Source generators are often introduced as a way to remove boilerplate. That is useful, but it was not the main architectural reason FUI moved more of its Unity UI pipeline into Roslyn.
The harder question begins after binding code has already been generated: does the runtime still need to scan assemblies, inspect attributes, resolve types, and reconstruct the relationship between a View, ViewModel, BindingContext, and Presenter?
FUI's answer is to move that composition step to compile time. The generator does not stop at property notifications and binding callbacks. It also emits binding factories and strongly typed routes, so the Player runtime executes an already-validated object graph instead of rediscovering it.
This article explains why that distinction matters, how the design evolved, and what the final architecture gains beyond the vague promise of “less reflection.”
The original problem was repetitive protocol code
Consider a settings screen with a title, a volume slider, a vibration toggle, and a close button. The ViewModel is small, but connecting it to the UI requires a surprisingly large protocol:
- propagate property changes to UI elements;
- propagate control changes back to the ViewModel;
- connect UI events to commands;
- perform initial synchronization;
- unsubscribe every handler during unbinding;
- construct the matching BindingContext and Presenter.
None of these steps is individually difficult. The risk comes from repetition. A missing unsubscribe, an incompatible target member, or an incorrect string may remain invisible until that specific screen opens.
The earliest code-generation experiment preserved in FUI's repository was an external FUICompiler executable. It targeted .NET 6, was published as a self-contained win-x64 tool, walked Roslyn syntax nodes, extracted binding attributes, and emitted BindingContext source.
The central idea was already present:
var classDeclarations = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>();
foreach (var classDeclaration in classDeclarations)
{
if (!Utility.TryGetClassBindingAttribute(
classDeclaration, out var attributes))
continue;
// Build a binding configuration and emit a BindingContext.
}
This prototype solved the most visible problem: repetitive binding code. But an external compiler also introduced orchestration work. It had to find project files, decide where generated files belonged, coordinate with Unity's import cycle, and keep its own platform and version assumptions in sync with the project.
Some information was also read as syntax text, such as property.Type.ToString(), instead of relying fully on the compiler's semantic type system.
The prototype was removed in the next commit on the same day, so it should not be presented as a long-lived production system. It is better understood as an early design probe: code generation was useful, but the generator belonged inside the compilation pipeline.
Moving generation into Roslyn solved only the first half
FUI later moved the work into C# Source Generators. That immediately improved the build boundary.
The input was now the active Roslyn Compilation, not a separate tool's interpretation of a directory. The generator could use SemanticModel and INamedTypeSymbol to reason about actual types, inheritance, and attributes. Generated source then participated in the same compilation as user code, allowing ordinary C# errors to expose invalid generated relationships.
Before the latest architectural refactor, FUI had three generator entry points:
-
ObservableObjectGeneratoremitted property-change support; -
BindingContextGeneratoremitted binding and unbinding logic; -
BindingContextInfoGeneratoremitted metadata for Editor tooling.
That system already removed substantial boilerplate. It also grew to support two-way binding, event commands, descriptor metadata, and generic BindingContexts.
However, generating a BindingContext did not yet make compile time the source of truth for composition.
The intermediate architecture had two sources of truth
The older runtime still contained a BindingContextTypeResolver. Its static initialization scanned every loaded assembly and type:
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
ResolveAllBindingContext(type);
ResolveViewModelDefaultPresenter(type);
}
}
The resolver read attributes from generated BindingContext types, discovered Presenter implementations, and built several lookup tables:
View name -> BindingContext type
ViewModel -> BindingContext type
ViewModel -> Presenter type
Derived VM -> reusable base BindingContext
When a screen was created, the old UIEntity.Create path queried those tables and then used reflection-based construction:
var viewModel = Activator.CreateInstance(resultViewModelType);
var bindingContext = Activator.CreateInstance(
contextType, view, viewModel);
var presenter = Activator.CreateInstance(presenterType);
This reveals a more important problem than the raw cost of reflection: the type relationship was known during generation and then discovered again at runtime.
The generator decided which BindingContext belonged to a ViewModel. The runtime independently reconstructed that answer through attributes and type scanning. Both rule sets had to remain aligned. If they diverged, the failure appeared when a screen was first instantiated rather than where the relationship was declared.
Even if the rules never diverged, the runtime still repeated work that compilation had already completed.
This is a useful distinction for any source-generated framework:
Compile time: generate an implementation.
Runtime: rediscover and compose the generated implementation.
Using a source generator does not automatically create a compile-time architecture. If runtime metadata interpretation is still required, generation has removed local boilerplate but has not become the system's single source of truth.
Feature growth turned the resolver into a second compiler
With simple one-way binding, composition answers only a few questions: which View belongs to a ViewModel, and which BindingContext should be constructed?
As the framework grows, the relationship matrix expands:
- two-way binding needs a reverse event path and optional
ConvertBack; - commands connect methods, parameters, elements, and events;
- one ViewModel may project to multiple Views;
- Presenter selection may be explicit or inferred;
- routes carry layer, history, cache, transition, and dependency policies;
- list items, prototypes, and dynamic Views need the same binding factories.
Each feature creates a choice. The runtime resolver can learn another rule, or the generator can emit the final answer.
Extending the resolver is often the smaller short-term change. Over time, however, it becomes a second compiler: it discovers candidates, applies precedence, resolves ambiguity, handles inheritance, and requires preservation rules for reflection-reachable types in AOT and managed-stripping environments.
The architectural turning point was therefore not simply replacing ISourceGenerator with IIncrementalGenerator. It was changing what the generator produced.
FUI began generating the complete executable composition relationship.
The generator now emits the runtime entry points
The current package has one [Generator] entry point: BindingSourceGenerator. It uses a syntax provider to identify class declarations, confirms observable types through the semantic model, and produces five categories of source in a single pipeline:
ViewModel + attributes
|
+-- observable properties: PropertyChanged.g.cs
+-- binding execution: <VM>.<View>.BindingContext.g
+-- Editor metadata: <VM>.<View>.BindingInfo.g
+-- type factories: BindingRegistry.g.cs
+-- navigation entry points: Routes.g.cs
The first three outputs answer how data moves. The final two answer how objects are constructed and screens are opened. Both sets are derived from the same compilation input.
The Settings Sample shows the intended developer-facing input:
[ViewContract("SettingsView")]
[RoutePolicy(Layer.Popup,
CoverageMode = CoverageMode.KeepVisible)]
public partial class SettingsViewModel : ViewModel
{
[ObservableProperty]
[Bind("Volume", nameof(SliderElement.Value),
bindingMode: BindingMode.TwoWay)]
float volume = 0.5F;
[Command("Close", nameof(ButtonElement.OnClick))]
public void Close()
{
SettingsSampleRuntime.Close();
}
}
These attributes are not runtime scripts. They form a small declarative language embedded in C#. The generator compiles that language into three layers of executable behavior.
1. Property changes become ordinary calls
For an [ObservableProperty] field, FUI emits the property setter, change delegate, and notification path. Updating the value uses compiled field access and delegate calls rather than PropertyInfo.SetValue.
2. Binding descriptions become symmetric lifecycle code
The generated BindingContext caches target elements and subscribes to ViewModel changes. A two-way binding also subscribes to control changes and writes values back to the ViewModel. OnUnbinding contains the corresponding unsubscribe path.
That symmetry is more valuable than saving keystrokes. A common manual error—adding a listener without removing it—becomes a property of one generator template. Once that template is verified, every generated screen follows the same lifecycle protocol.
3. Type discovery becomes direct construction
BindingFactoryGenerator emits explicit registration code:
GeneratedBindingRegistry.Register<SettingsViewModel>(
static (view, viewModel) =>
new SettingsViewModel_SettingsView_BindingContext(
view, viewModel),
static () => new SettingsPresenter());
The runtime registry still uses Type as a key because dynamic items may need to resolve a factory from an actual ViewModel type. The value, however, is a compiled Func. The runtime no longer scans every assembly to discover implementations and no longer asks Activator.CreateInstance to infer how they should be constructed.
RouteGenerator performs the same transformation for navigation. It emits a Route<TViewModel> that contains the resource key, ViewModel factory, BindingContext factory, Presenter factory, and route policy:
public static Route<SettingsViewModel> SettingsView { get; } =
GeneratedRouteFactory.Create<SettingsViewModel>(
"SettingsView",
typeof(SettingsPresenter),
static () => new SettingsViewModel(),
static (view, vm) =>
new SettingsViewModel_SettingsView_BindingContext(
view, vm),
static () => new SettingsPresenter(),
/* generated route policy */);
The application receives an executable, strongly typed screen contract rather than a set of strings and types that still need to be interpreted. Routes.Initialize() also performs idempotent binding-registry initialization, so navigation and binding no longer maintain separate startup protocols.
What the final architecture actually gains
“Less reflection means faster” is too narrow and would require benchmarks to quantify. FUI's repository does not publish a benchmark that supports a percentage claim.
What the code does establish is that several categories of Player work have been removed from the main binding and navigation path: whole-assembly GetTypes(), runtime attribute discovery, and reflection-based BindingContext/Presenter construction.
The broader benefit appears at four stages:
| Stage | Older responsibility | Generated architecture | Practical effect |
|---|---|---|---|
| Development | Maintain scattered composition code | Express intent with attributes and symbol-backed names | Renames and reviews can follow more real references |
| Compilation | Generate local binding implementation | Generate properties, bindings, factories, and routes together | One input produces the complete relationship |
| Screen creation | Scan types, inspect attributes, invoke reflection constructors | Look up and call compiled delegates | The execution path is shorter and deterministic |
| Framework evolution | Teach the runtime resolver every new dimension | Extend the compile-time model and generated output | Complexity remains inspectable and testable before Player execution |
Failures move earlier
Reflection can preserve an incomplete relationship until the screen opens. Generated code must compile. Invalid types, missing constructors, and unresolved route dependencies can surface during the build instead.
The current generator also reports some architecture-level diagnostics, including missing route dependencies, static dependency cycles, and auto-properties that are incompatible with Pure mode.
This is not complete compile-time validation. Several syntax actions in the current AttributeBindingAnalyzer remain disabled, so it would be inaccurate to claim that every binding error is detected by the compiler. The defensible claim is that the failure boundary has moved substantially earlier.
AOT and stripping tools can see more of the path
Unity Player builds commonly involve IL2CPP and managed-code stripping. Reflection is possible in that environment, but types reached only through runtime scanning often require additional preservation metadata.
Generated generic references, new expressions, and direct delegates make more of the real call graph visible to the toolchain. This reduces uncertainty; it does not guarantee that all AOT or stripping concerns disappear.
FUI also does not eliminate reflection everywhere. Editor-side catalogs, validators, and IL post-processing coordination still inspect loaded assemblies. Those tools serve authoring and build-time workflows, not the Player's primary binding/navigation path. That boundary is more useful than a marketing claim of “zero reflection.”
Strongly typed routes turn page identity into a contract
In the older design, View names, ViewModel types, Presenter types, and screen policies lived in different places and were assembled at runtime. A generated Route<TViewModel> brings them into one object.
This is not only an autocomplete improvement. Dependencies, synchronous-open support, caching, history, and transition policy can all be decided while generating the route. Navigation receives an executable contract rather than a bag of parameters.
Generated code is inspectable architecture output
A reflection resolver's result lives in runtime dictionaries and is usually visible only after the application starts. A generator's result is ordinary C#.
When behavior is unexpected, the investigation path becomes concrete: inspect the attribute input, inspect the generated source, then inspect runtime execution. There is less need to guess which types an assembly scan found or which precedence rule ran first.
Why FUI has Pure and Mixed property modes
A source generator can add source to a Compilation; it cannot rewrite an existing method body.
If the developer declares a field:
[ObservableProperty]
float volume;
the generator can add a property in another part of the same partial type. That is Pure mode.
If the developer has already written an auto-property:
[ObservableProperty]
public float Volume { get; set; }
the generator cannot redeclare that property or inject notification logic into its setter. Mixed mode therefore uses optional IL Post Processing with Mono.Cecil to rewrite the setter after compilation, while the source generator still emits the notification entry point and binding code.
This is not a cosmetic style choice. It follows directly from the additive nature of Source Generators. Pure mode is easier to inspect for new projects; Mixed mode reduces migration cost for codebases that already rely heavily on auto-properties.
The incremental pipeline still has room to improve
The current entry point uses the Incremental Generator API, but it collects all candidate types and combines them with the complete Compilation before producing assembly-level output.
That is a legitimate incremental architecture, but not the most granular possible cache graph. Some changes can still cause the aggregated stage to regenerate an entire assembly's outputs.
This is an implementation detail worth improving, but it does not change the main architectural result: the runtime no longer maintains a second type-discovery system.
Source generation is also not automatically the best choice for every UI project. A small application with a handful of screens may be clearer with direct handwritten wiring. A system whose relationships truly exist only at runtime may still need reflection or data-driven registration.
Compile-time generation becomes compelling when the rules are stable, the relationships are repetitive, and the project benefits from validating them before a screen opens.
The real choice is when uncertainty should be resolved
FUI's evolution can be summarized in three stages:
External compiler: remove repetitive binding code.
Early Source Generators: generate inside compilation, but rediscover composition at runtime.
Compile-time composition: emit properties, bindings, factories, and routes; execute them directly at runtime.
The reason to use Source Generators is therefore not that reflection is inherently bad, nor simply that attributes are shorter to write.
Most relationships between a View, ViewModel, BindingContext, Presenter, and Route are already known when the code is written. Once those relationships are stable, there is little value in waiting until a screen opens to infer them again.
Moving that work into compilation produces a shorter runtime path, a clearer type contract, and an earlier failure boundary. Reduced boilerplate is the visible result. Making generated code the single composition truth is the deeper architectural change.
Source references
- Early external FUICompiler project
- Early attribute-based BindingContext generator
- Pre-refactor BindingContextTypeResolver
- Pre-refactor reflection construction path
- Current incremental generator entry point
- Current binding factory generator
- Current strongly typed route generator
- Settings Sample input
Repository: fujisheng/FUI
Top comments (0)