WinUI 参考: 完整的属性与设计指南,参见 Winui3。
如果你在 XAML 里浸润多年,那个反复冒出来的问题一定是"我的绑定去哪儿了?"Microsoft.UI.Reactor(以下简称 Reactor)渲染的 WinUI 控件与你的 XAML 页面所渲染的完全相同——Button、TextBox、TabView、
ItemsRepeater——并且通过同样的面板来布局。变化的是属性值的真相来源。在 XAML 中,控件拥有自己的依赖属性,一个绑定表达式监听视图模型属性,并在 PropertyChanged 触发时拉入新值。在 Reactor 中,Hook 拥有状态,组件重新渲染以产出一棵新的元素树,再由协调器把属性值写入既有的控件。两种模型解决的是同一个问题(让 UI 与状态保持同步),却把工作放在了相反的位置上。把本页读一遍,其余代码库就都顺理成章了:架构概览、Hook 内部机制、
协调(Reconciliation)里都没有藏着一套绑定系统。
Reactor 与 XAML 的对比¶
本页写给想要知道为什么的 XAML 开发者。面向 XAML 开发者的 Reactor那份手册讲的是常见改写任务的怎么做("我有一个带绑定的 Page,它该变成什么?")。它被索引在两个位置:第 1 节(快速上手),这样初次接触文档集的 XAML 开发者能早早发现它;以及第 9 节(底层原理),让它紧挨着那些与之互补的架构页面。
拉与推,一张图说清¶
| 关注点 | XAML | Reactor |
|---|---|---|
| 状态所在 | 视图模型属性 / 依赖属性 | RenderContext 内的 Hook 槽位 |
| 状态信号 | INotifyPropertyChanged.PropertyChanged |
UseState 返回的 setter 闭包 |
| 属性写入 | BindingExpression.UpdateTarget 拉取 VM 的 getter |
协调器在渲染后写入 |
| 样式应用 | Style + Setter + 触发器 |
元素/修饰符组合,或 Use* 命名样式 Hook |
| 动画 | 在 XAML 中定义 Storyboard,再命令式启动 |
由协调器解析的 .Animate(...) / .Transition(...) 修饰符 |
| 布局状态 | VisualStateManager + VisualState/Group 的 XAML |
元素上的 .InteractionStates(...) 修饰符 |
| 资源 | ResourceDictionary / ThemeDictionary |
ThemeRef 只读记录结构 |
| 列表 | ItemsControl + DataTemplate |
ForEach(items, item => …) 为每个项构建元素 |
| MVVM | 带 INPC 属性的视图模型类 | 带 Hook 的函数组件或类组件 |
下面每一行都会把映射讲得足够深入,让机械式的翻译不再靠猜。
注意: 拉/推之分是运行时的区别,不是一份功能清单。Reactor 依然在使用依赖属性——它具象化出的每一个 WinUI 控件,其 DP 都由协调器设置。消失的是那个监听 VM 并拉取值的绑定表达式。如果你往一个由 Reactor 具象化的控件上挂
{Binding}(通过 ElementRef 的强制转换,或在副作用中命令式地设置),你就会创建出一条协调器并不知晓的并行响应式边,而下一轮协调会直接覆盖你的属性写入。请通过 Hook 状态驱动属性、让协调器来写入;不要往 Reactor 拥有的控件上做绑定。
依赖属性 → 元素记录 + 修饰符¶
XAML 的依赖属性系统是 WinUI 中一切响应式能力的基座:绑定挂在 DP 上,样式设置 DP,动画驱动 DP,VisualStateManager 翻转 DP。DP 本身是控件上的一个槽位,带有由元数据驱动的默认值、类型强制转换、变更通知,以及沿可视化树的继承。Button.Content 的值是一个 DP;Grid.Row 附加属性是一个 DP;主题颜色则来自一套带有 DP 风味的资源系统。
public abstract record Element
{
/// <summary>
/// Optional key for stable identity across re-renders (like React's key prop).
/// When set, the reconciler uses it to match elements across list reorderings.
/// </summary>
public string? Key { get; init; }
/// <summary>
/// Layout modifiers (margin, padding, size, alignment, etc.) applied to this element.
/// Set via fluent extension methods: TextBlock("hi").Margin(10).Width(200)
/// Modifiers are stored inline so the concrete element type is preserved through chaining.
/// </summary>
public ElementModifiers? Modifiers { get; init; }
Reactor 用 Element 记录取代了"控件上的 DP 槽位"。ButtonElement(一个派生自
Element 的 record)把 Content、OnClick 等作为记录字段携带。修饰符系统则把布局与样式关注点折叠进一个独立的
ElementModifiers 记录,通过记录的 with 更新一路串联。附加属性(Grid.Row、Canvas.Left)存放在同一条记录上一个以类型为键的 Attached 字典里。
private static T Modify<T>(T el, ElementModifiers mods) where T : Element =>
el with { Modifiers = el.Modifiers is not null ? el.Modifiers.Merge(mods) : mods };
// #165/#157 — bucket-level merge entry points for pure-layout / pure-visual
// fluent modifiers. Routing through these avoids allocating a throwaway parent
// ElementModifiers (and the bucket sub-record its init shim builds) on every
// chained call: `.Margin().Width().Foreground()` merges the delta straight into
// the Layout / Visual slot instead of constructing a temporary ElementModifiers
// per step. Semantics are identical to Modify(el, new ElementModifiers { <field> }):
// ElementModifiers.Merge copies every non-bucket field as `other.X ?? X` (i.e.
// unchanged) when only a bucket is supplied, and merges the bucket with the same
// `other ?? this` precedence used here.
private static T ModifyLayout<T>(T el, LayoutModifiers delta) where T : Element
{
var mods = el.Modifiers;
if (mods is null)
return el with { Modifiers = new ElementModifiers { Layout = delta } };
var merged = mods.Layout is not null ? mods.Layout.Merge(delta) : delta;
return el with { Modifiers = mods with { Layout = merged } };
}
private static T ModifyVisual<T>(T el, VisualModifiers delta) where T : Element
{
var mods = el.Modifiers;
if (mods is null)
return el with { Modifiers = new ElementModifiers { Visual = delta } };
var merged = mods.Visual is not null ? mods.Visual.Merge(delta) : delta;
return el with { Modifiers = mods with { Visual = merged } };
}
private static T ModifyA11y<T>(T el, AccessibilityModifiers a11y) where T : Element
{
var existing = el.Modifiers?.Accessibility;
var merged = existing is not null ? existing.Merge(a11y) : a11y;
return Modify(el, new ElementModifiers { Accessibility = merged });
}
private static T ModifyTheme<T>(T el, string property, ThemeRef theme) where T : Element
{
var bindings = el.ThemeBindings is not null
? new Dictionary<string, ThemeRef>(el.ThemeBindings) { [property] = theme }
: new Dictionary<string, ThemeRef> { [property] = theme };
return el with { ThemeBindings = bindings };
}
像 TextBlock("hi").FontSize(24).Margin(8) 这样的链,最终产出一个 Modifiers 已合并的
TextBlockElement 记录。协调器在给控件打补丁时,会同时读取类型化字段与修饰符。这里没有逐属性的变更通知:比较发生在下一次渲染时的记录层面,只有差异才会变成属性写入。
绑定 → 状态之上的闭包¶
XAML 中的 {Binding Name} 会装入一个 BindingExpression,它持有对视图模型和属性路径的引用,订阅
INotifyPropertyChanged,并在每次 PropertyChanged 触发时读取源、写入目标 DP。绑定是一个长生命周期的对象;模式(OneWay、TwoWay、OneTime)、更新触发器、转换器与回退值全都挂在它身上。
public (T Value, Action<T> Set) UseState<T>(T initialValue, bool threadSafe = false)
{
if (_hookIndex >= _hooks.Count)
{
_hooks.Add(new ValueHookState<T>(initialValue, threadSafe));
}
var currentIndex = _hookIndex;
_hookIndex++;
if (_hooks[currentIndex] is not ValueHookState<T> hook)
throw new HookOrderException(
$"Hook at index {currentIndex} is {_hooks[currentIndex].GetType().Name}, expected ValueHookState<{typeof(T).Name}> (UseState). " +
"Hooks must be called in the same order every render.");
Reactor 不装入任何东西。TextBlock(name) 读取 name 的当前值(一个恰好来自
UseState 的普通 C# 变量),产出一个 Content = name 作为记录字段的 TextBlockElement。当 name 变化时,setter 写入槽位,组件重新渲染,新的 TextBlockElement 与旧的不同,协调器便把新的 Text 值写到既有的
TextBlock 上。这里没有绑定对象——生成元素的那个闭包本身就是绑定。
TwoWay 则以受控输入模式的形式出现。TextBox 接收当前值与一个 setter 回调;用户的输入触发回调,回调写入槽位,槽位引发重新渲染,渲染又把新值写回 TextBox。模式是隐式的,取决于你是否传了 setter。
DataTemplate → 函数组件¶
DataTemplate 是一份配方:给定一个数据项,产出一棵控件树。它用 XAML 编写,由框架在项出现在 ItemsControl 中时实例化。模板可以为元素命名、绑定到项上的属性;运行时维护一个池并做回收。
// A XAML DataTemplate is a recipe the framework instantiates per item.
// In Reactor the recipe is just a closure — `Card` is an ordinary method
// returning an Element, and `ForEach` runs it once per item per render.
static Element ProductList(IReadOnlyList<Product> items) =>
VStack(8, ForEach(items, item => Card(item).WithKey(item.Id.ToString())));
static Element Card(Product item) =>
CardSurface(VStack(4,
TextBlock(item.Name).FontSize(16).SemiBold(),
TextBlock(item.Category).Foreground(Theme.SecondaryText)));
item => Card(item) 这个闭包就是模板。它在每次渲染中为每个项执行一次,产出一棵元素树,再由子节点协调器把这些元素与上一次渲染的结果做匹配。身份标识默认来自位置;在项元素上加 .WithKey(id),就能在重排时保住身份。
ItemsRepeater 依旧在底层——ForEach 通过回收真实 WinUI 控件的 ElementFactory 完成具象化。
Style → 修饰符组合 / 命名样式¶
XAML 中的 Style 把一袋属性设置器应用到每一个匹配 TargetType(或带有该样式键)的控件上。这袋内容以 DP 为键,BasedOn 则通过继承把样式串成链。
Reactor 有两个对应物。最常见的是一个组合元素与修饰符的普通方法:
// A XAML `Style` is a keyed bag of setters matched by TargetType. The
// Reactor analogue is a plain method that composes a wrapper element — no
// static registration, no runtime TargetType check, just composition.
static Element CardSurface(Element child) =>
Border(child)
.Background(Theme.CardBackground)
.CornerRadius(8)
.Padding(16);
调用 CardSurface(TextBlock("hi")) 会返回一个包住子元素的 Border,并已应用卡片修饰符。这就是纯粹的组合——不需要框架介入,没有静态注册,也没有运行时的 Style.TargetType 检查。
对于少数几个规范的 WinUI 命名样式,样式一页记录了命名样式流畅方法——按钮上的 .AccentButton()、
.SubtleButton() 与 .TextLink(),InfoBar 上的 .Informational() /
.Success() / .Warning() / .Error()——它们把一个 StaticResource 样式键烘焙进一次调用。比这更宽泛的需求,就交给类似上面 CardSurface 的辅助方法:Reactor 没有样式注册表,也没有 BasedOn 链。感知主题的画刷属性在协调时通过
ThemeRef 解析。
ResourceDictionary → ThemeRef + 主题令牌¶
public readonly record struct ThemeRef(string ResourceKey)
{
public override string ToString() => $"ThemeRef({ResourceKey})";
/// <summary>
/// Resolves this theme reference using the element's actual theme.
/// Walks the ThemeDictionaries in Application.Resources and MergedDictionaries
/// to find the brush matching the element's effective theme (which respects
/// per-element RequestedTheme overrides, not just the app-level theme).
/// </summary>
internal static Brush? Resolve(string resourceKey, FrameworkElement fe)
{
var themeName = GetEffectiveThemeName(fe);
return ResolveForTheme(resourceKey, themeName);
}
ResourceDictionary(尤其是合并进 Application.Resources 的那些)是 XAML 声明命名资源——画刷、double、样式——并通过 {StaticResource Key} 或 {ThemeResource Key} 引用它们的方式。后者会通过当前激活的 ThemeDictionaries[name] 解析。
Reactor 通过 ThemeRef——一个持有资源键的只读记录结构——暴露同一套 WinUI 主题字典。静态的 Theme 类为最常用的令牌命名(Theme.Accent、Theme.CardBackground 等);Theme.Ref(key)
则是对其他任意键的逃生舱。协调器遍历 Application.Resources.ThemeDictionaries 的方式与 {ThemeResource} 完全一致,并在 ActualThemeChanged 时重新解析——因此切换系统主题会更新每一个 Theme.Accent 画刷,而无需重新渲染。
VisualStateManager → InteractionStates 修饰符¶
XAML 中的 VisualStateManager 驱动状态机式的动画:一个控件声明若干组具名的 VisualState(Normal / PointerOver /
Pressed / Disabled),一次状态转换会触发挂在该 VisualState 上的故事板。开发者通过调用
VisualStateManager.GoToState 来切换状态。
Reactor 在元素上提供的 .InteractionStates(...) 修饰符,则直接声明每个状态下的属性覆盖。协调器会在具象化出的控件上安装 WinUI 可视化状态组的管道,并在可视化状态变化时翻转属性。这里没有独立的故事板编写过程;过渡与元素一起用 C# 描述。完整的接口见动画一页,协调器一侧的细节见
animation-pipeline。
Storyboard → Animate / Transition 修饰符¶
Storyboard 是 WinUI 的动画原语:一棵随时间驱动依赖属性的时间线树,可以命令式启动,也可以经由 VisualState 声明式启动。其结果是一个挂在控件上的长生命周期 AnimationCollection。
Reactor 元素上的 .Animate(...) 与 .Transition(...) 修饰符把同样的动画描述成元素上的一条记录。协调器会把该记录展开为
ImplicitAnimationCollection(合成层的过渡)或由关键帧驱动的属性动画,具体取决于模式。要停止动画,就在下一次渲染时移除该修饰符,协调器会解绑该集合。若是一次性的批量动画、由事件而非持久修饰符驱动,则把状态变化包进环境式的
AnimationScope.WithAnimation(curve, action) 作用域——完整的决策表见动画一页。
INotifyPropertyChanged → Hook 重新渲染¶
public sealed class Observable<T> : INotifyPropertyChanged
{
private T _value;
public Observable() : this(default!) { }
public Observable(T initial) => _value = initial;
public T Value
{
get => _value;
set
{
if (EqualityComparer<T>.Default.Equals(_value, value)) return;
_value = value;
PropertyChanged?.Invoke(this, _valueChangedArgs);
}
}
public event PropertyChangedEventHandler? PropertyChanged;
INotifyPropertyChanged 是 XAML 用来表示"这个属性是可观察的"的契约。绑定订阅 PropertyChanged;触发它就重新拉取。Reactor 的 UseObservable Hook 则是迁移桥接:把一个已有的 INotifyPropertyChanged 对象交给它,组件就会在该 Hook 的生命周期内订阅它,并在 PropertyChanged 时重新渲染。对于纯 Reactor 状态,你永远不需要声明
INotifyPropertyChanged——Hook 的 setter 就是信号。
MVVM 的 ViewModel → 组件状态 + UseObservable 桥接¶
MVVM 的 ViewModel 是 XAML 开发者放置"页面需要显示的东西"与"页面可以发出的命令"的地方。它实现
INotifyPropertyChanged,持有 ICommand 实例,并在多次导航之间保持存活。
Reactor 不把 VM 与 View 分开。状态就在组件里、在 Hook 里。命令就是你闭包捕获的函数。页面通过 UsePersisted 持久化,而不是靠一个长生命周期的 VM。当你确实需要一个 VM 形态的对象——为了在组件间共享逻辑,或为了对接既有的服务层模型——那就写一个普通类,触发
PropertyChanged(或把值包进 Observable<T>),再让每个消费方组件通过 UseObservable 订阅。组件依然拥有自己渲染的那一份状态切片;VM 是数据源。
模式¶
移植一个 XAML 页面¶
机械式翻译有四个步骤:
- 识别页面中使用到的 VM 属性。 每一个都变成一个
UseState(组件局部)或一个针对已有 INPC 数据源的UseObservable。 - 翻译 XAML 标记树。 外层面板 →
VStack/HStack/Grid;命名元素在渲染中保持按位置排列;样式变成修饰符链。 - 把绑定翻译成闭包。
{Binding Name}→ 直接读取变量。Mode=TwoWay→ 受控输入对(value, setter)。 - 翻译命令。 一个
ICommand变成一个普通函数,通常由点击处理器的闭包捕获。如果该命令必须可共享,命令(Commanding)一页记录了显式的写法。
public (T Value, Action<T> Set) UseState<T>(T initialValue, bool threadSafe = false)
{
if (_hookIndex >= _hooks.Count)
{
_hooks.Add(new ValueHookState<T>(initialValue, threadSafe));
}
var currentIndex = _hookIndex;
_hookIndex++;
if (_hooks[currentIndex] is not ValueHookState<T> hook)
throw new HookOrderException(
$"Hook at index {currentIndex} is {_hooks[currentIndex].GetType().Name}, expected ValueHookState<{typeof(T).Name}> (UseState). " +
"Hooks must be called in the same order every render.");
面向 XAML 开发者的 Reactor手册为最常见的页面形态提供了可用的示例:设置页、列表-详情、模态对话框。把它当作做法层面的参考;当你想弄清底层的来龙去脉时,再回到本页。
常见错误¶
出于习惯在局部状态上实现 INPC¶
// Don't:
public class ProfilePage : Component, INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set { _name = value; PropertyChanged?.Invoke(this, new(nameof(Name))); }
}
public event PropertyChangedEventHandler? PropertyChanged;
}
public (T Value, Action<T> Set) UseState<T>(T initialValue, bool threadSafe = false)
{
if (_hookIndex >= _hooks.Count)
{
_hooks.Add(new ValueHookState<T>(initialValue, threadSafe));
}
var currentIndex = _hookIndex;
_hookIndex++;
if (_hooks[currentIndex] is not ValueHookState<T> hook)
throw new HookOrderException(
$"Hook at index {currentIndex} is {_hooks[currentIndex].GetType().Name}, expected ValueHookState<{typeof(T).Name}> (UseState). " +
"Hooks must be called in the same order every render.");
框架并不会订阅组件上的 PropertyChanged——重新渲染只来自 UseState 的 setter 或父组件的重新渲染。那个字段对渲染循环是不可见的,因此更新 Name 什么也不会发生。把这个值挪进 UseState,setter 就是信号;INPC 的一整套仪式也就不复存在了。
小贴士¶
绑定并不存在。 当你开始琢磨"这个该怎么绑定"时,停下来改问"这个属性是从什么状态读取的?"答案是一个 UseState、一个 UseObservable 或一个 prop——而控件只是读取那个变量。
ThemeRef 就是你的 {ThemeResource}。 任何你想从 Application.Resources 取主题画刷的地方,改用 Theme.Accent 或
Theme.Ref(key)。协调器会按元素解析画刷,并在主题变化时重新解析。
不要把 XAML 做 1:1 的翻译。 一个带有七个 UserControl 和十二个绑定的页面,往往会变成一个带四个 Hook 的组件。从标记到代码的翻译会坍缩掉大量 XAML 结构,因为 C# 函数体把它们全放在了一处。
下一步¶
- 面向 XAML 开发者的 Reactor — 附页面改写实例的手册。
- 架构概览 — 把这里的一切各就各位的运行时图示。
- 主题令牌 — 完整的
ThemeRef接口。 - Hook — 状态处理相关的 API。
- 响应式模型 — 为什么"推"的方向是这个样子。