WinUI 参考: 完整的属性面与设计指导,参见 Xaml Islands。
XamlIslandControl 是一个 WinForms 控件,用于托管一整棵
Microsoft.UI.Reactor(Reactor)组件树。表单的其余部分仍是
WinForms —— 标签、面板、菜单、既有的消息循环 —— 而
你的 Reactor 子树渲染进一个以该控件客户区为根的
DesktopWindowXamlSource。跨边界的数据流是
显式的:WinForms 事件处理函数通过设置组件的 props,或调用
ReactorHostControl 句柄上的方法,来调入 Reactor;Reactor 则通过调用
你的表单在构造时交给它的委托回调进 WinForms。这里没有隐式的双向
绑定、没有共享的 DataContext、也没有 XAML 加载器 —— 纯 Reactor 窗口中成立的那套
声明式模型在这里同样成立,
只是嵌套在 System.Windows.Forms.Form 里面。线程模型
是承重的细节:WinForms 拥有消息泵,而 WinUI 跑在引导程序在同一线程上安装的
DispatcherQueue 之上,但对任意后台线程回调,两者并不同步。
下面的注意事项点出了具体的失败模式。本页覆盖引导、控件插入、数据
流模式、线程约束、设计器集成、
键盘与无障碍桥接,以及供三者混用项目参考的 WPF 姊妹篇
wpf-interop。
WinForms 互操作¶
Reactor 组件可以借助 XAML Islands 运行在 WinForms 应用里。
Reactor.Interop.WinForms 包提供 XamlIslandControl —— 一个
标准的 WinForms 控件,可托管 Reactor 组件树,并具备完整的
键盘、无障碍与主题支持。
引导¶
每个 WinForms + Reactor 应用都从 XamlIslandBootstrap.Run() 开始。它
初始化 WinAppSDK/WinUI 运行时,然后调用你的回调来
显示 WinForms UI:
XamlIslandBootstrap.Run(() =>
{
var form = new SWF.Form
{
Text = "My WinForms + Reactor App",
Width = 800,
Height = 500
};
var island = new XamlIslandControl
{
ComponentType = typeof(WinFormsHostDemo),
Dock = SWF.DockStyle.Fill
};
form.Controls.Add(island);
form.Show();
});
引导程序负责:
- 为 WinUI 创建 DispatcherQueue
- 加载主题资源
- 键盘消息过滤(让 WinUI 控件能收到按键事件)
- DPI 感知配置
- 调用
Application.Exit()时干净关闭
在你应用的起始处调用一次 XamlIslandBootstrap.Run()。
WinForms 拥有消息循环 —— Reactor 在它内部运行。
XamlIslandControl¶
XamlIslandControl 是一个包装 DesktopWindowXamlSource 的
System.Windows.Forms.Control。把它加到任意 WinForms 表单或面板里:
class WinFormsHostDemo : Component
{
public override Element Render()
{
// This component is hosted via XamlIslandControl.ComponentType
var (count, setCount) = UseState(0);
return VStack(12,
Heading("Reactor in WinForms"),
TextBlock($"Count: {count}").FontSize(24),
Button("+1", () => setCount(count + 1))
).Padding(24).Background(SolidBackground);
}
}
有三种方式设置内容:
| 属性 | 使用场景 |
|---|---|
ComponentType |
设置一个 Reactor Component 类型 —— 在设计器中可用 |
ContentFactory |
提供一个工厂函数以做自定义初始化 |
XamlContent |
直接设置原生 WinUI UIElement 内容 |
ComponentType 是最简单的路径:把它设为 typeof(MyComponent),
控件就会自动创建并托管该组件。
设计器支持¶
XamlIslandControl 具备完整的 WinForms 设计器支持。把它拖到表单上,
然后在属性网格中设置 ComponentType —— 下拉列表会列出所有具有无参构造函数的具体
Component 子类:
// In the form's Designer.cs file:
//
// this.reactorIsland = new XamlIslandControl();
// this.reactorIsland.ComponentType = typeof(DashboardComponent);
// this.reactorIsland.Dock = DockStyle.Fill;
// this.panel1.Controls.Add(this.reactorIsland);
//
// The Properties grid shows a dropdown of all Component subclasses
// with parameterless constructors. Select your component and the
// designer serializes it as typeof(DashboardComponent).
设计时,该控件会渲染一个带边框的占位符,显示 组件名。在应用运行之前不会创建任何 WinUI 对象,因此 设计器保持轻量。
ReactorComponentTypeConverter 支撑着这套集成。它在 Type 对象与类型名字符串之间
转换,并为下拉列表枚举可用的
组件。
键盘与 Tab 导航¶
XAML Islands 需要显式的键盘桥接才能与 WinForms
的 Tab 顺序互操作。XamlIslandControl 会自动处理这一点:
class KeyboardDemo : Component
{
public override Element Render()
{
var (text, setText) = UseState("");
// Tab 会把焦点从 WinForms 控件移进这棵 Reactor 树。
// Tab/Shift+Tab 在 Reactor 控件之间正常循环。
// 从最后一个 Reactor 控件 Tab 出去会把焦点交还 WinForms。
return VStack(12,
TextBox(text, setText, placeholderText: "Type here...", header: "Message")
.TabIndex(0),
Button("Submit", () => { })
.TabIndex(1)
.AccessKey("S")
).Padding(24).Background(SolidBackground);
}
}
- Tab/Shift+Tab 在 WinForms 控件之间、以及进出 Reactor 组件树时移动焦点
- 方向键、回车、Esc 会被路由到孤岛内的 WinUI 控件
- Alt+键快捷键 对 WinForms 菜单与 Reactor 的
AccessKey修饰符都有效
引导程序的 ContentPreTranslateMessage 钩子确保键盘消息
能到达 WinUI 控件。无需额外配置。
无障碍¶
屏幕阅读器把 XAML Islands 内的 Reactor 组件视为 WinForms 自动化树的一部分。互操作层做了这些桥接:
class AccessibleIslandComponent : Component
{
public override Element Render()
{
var (name, setName) = UseState("");
// All accessibility modifiers work inside XAML Islands
return VStack(12,
Heading("Registration")
.HeadingLevel(AutomationHeadingLevel.Level1),
TextBox(name, setName, header: "Full Name")
.AutomationName("Full name")
.Required()
.TabIndex(0),
Button("Register", () => { })
.AutomationName("Submit registration")
.TabIndex(1)
).Padding(24)
.Landmark(AutomationLandmarkType.Form)
.Background(SolidBackground);
}
}
- AutomationName、HeadingLevel 以及其他无障碍 修饰符的工作方式与纯 Reactor 应用完全相同
UseAnnounce实时区域会穿过孤岛边界转发- Tab 顺序 在 WinForms 与 Reactor 控件之间正确流动
- 通过
UseFocusTrap实现的焦点陷阱在 Reactor 子树内有效
完整的修饰符参考见 无障碍。
跨边界的数据流¶
跨 WinForms ⇄ Reactor 边界的数据通过三种 机制流动 —— 请挑选与方向和频率相匹配的那一种:
| 方向 | 机制 | 何时使用 |
|---|---|---|
| WinForms → Reactor(一次性) | XamlIslandControl.ComponentType = typeof(MyComponent) 并重建 |
每个表单设置一次初始内容。重新赋值会重建子树。 |
| WinForms → Reactor(实时) | ContentFactory 返回一个 ReactorHostControl;保留对宿主根组件句柄的引用 |
宿主只构造一次;你从事件处理函数中对组件调用方法。 |
| WinForms → Reactor(可观察视图模型) | 把一个既有的 INPC 视图模型传进组件构造函数;组件调用 UseObservable 订阅 |
你已经有 WinForms 形态的视图模型,想让 Reactor 响应它的变更事件重渲染。 |
| Reactor → WinForms | 把一个 Action<T> 回调注入组件的 props;组件从事件处理函数里调用它 |
只要 Reactor 子树需要把值推回宿主。 |
除非你在 WinForms 侧的 INotifyPropertyChanged
源之上接了 UseObservable,否则数据流是显式且单向的。
这里没有隐式的 DataContext,也没有全局事件总线 ——
每一次跨越都是你自己写的一次方法调用。
线程约束¶
引导程序在 WinForms UI 线程(也就是 Application.Run 正在调度的那个线程)
上创建 DispatcherQueue,并把 WinUI 钉在
该线程上。WinForms 消息泵回调(Click、Load、
Paint)本来就在正确的线程上 —— 在 Click
处理函数内部调用 element.SetValue(...) 或调用 setter 是安全的,自动编组
路径此时是空操作。
危险在于后台线程。BackgroundWorker.ProgressChanged、
不带 ConfigureAwait 的 Task.Run(...) 续体,以及
HttpClient 异步回调,默认都落在
线程池上。如果你的处理函数随后试图直接设置 Reactor 的 UseState,
Reactor 在 RenderContext.SetState 中的自动编组会把这个写入排队到
ReactorApp.UIDispatcher —— 这在纯 Reactor
窗口里可行,但在 WinForms 宿主内部,只有当你显式桥接过它时,
那才是正确的调度器。安全的做法是在后台回调中先调用
XamlIslandControl.Dispatcher.TryEnqueue(...),再去碰
Reactor 状态。下面的注意事项列出了你忘记时会看到的
具体异常。
注意: WinForms 的
Application.Idle与 WinUI 的DispatcherQueue不是 同步的。从 WinForms 的Button.Click处理函数调用Element.SetValue(...)没问题 —— 点击本来就在 WinForms UI 线程上运行,那也正是 WinUI 调度器所指向的同一个线程, Reactor 的自动编组看不到线程变化。但从由工作线程引发的BackgroundWorker.ProgressChanged处理函数里调用它, 底层IXamlObjectsetter 会抛COMException: 0x8001010E (RPC_E_WRONG_THREAD)—— Reactor 的自动编组看的是ReactorApp.UIDispatcher,而在 WinForms 宿主内部,它可能 压根没有被初始化(互操作引导把调度器安装在宿主控件上,而不是全局应用上)。 修法是先手工编组:在后台处理函数里xamlIsland.Dispatcher.TryEnqueue(() => setState(value))。对应的分析器是REACTOR_INTEROP_001(「在 WinForms 宿主中从非 UI 线程调用 Reactor 状态 setter」)—— Warning 级 —— 但只有在分析器 能证明该调用点在非 UI 线程上时才会触发,而它无法 跨Task.Run的 lambda 边界做这种证明。
背景与尺寸¶
XAML Islands 不提供隐式背景或拉伸行为。 托管在 WinForms 中的 Reactor 组件必须自己管理背景:
class BackgroundDemo : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
// Always set an explicit background on root content.
// XAML Islands have no default background — without this,
// content renders on a transparent surface.
return Grid([GridSize.Star()], [GridSize.Star()],
VStack(12,
TextBlock("Theme-aware background").Bold(),
TextBlock($"Count: {count}"),
Button("Increment", () => setCount(count + 1))
).Padding(24)
).Background(SolidBackground);
}
}
把组件的根包在带 .Background(...) 的 Grid(...) 里,以填满
孤岛区域。否则组件会渲染在透明
背景上,并且可能不会拉伸以填满 WinForms 控件的边界。
进阶:ContentFactory¶
对于需要构造函数参数或自定义宿主配置的组件,
请用 ContentFactory 代替 ComponentType:
// ContentFactory 在孤岛就绪后于 UI 线程上运行。返回任意
// UIElement —— 通常是一个包装你组件的 ReactorHostControl。
// ReactorHostControl 没有 SetComponent<T>() 方法:无参组件请用
// ComponentFactory,组件需要参数时请用 Mount(...)。
static class ConfigurableIsland
{
public static XamlIslandControl Create(string title) => new()
{
ContentFactory = () =>
{
var host = new ReactorHostControl();
host.Mount(new ConfigurableComponent(title));
return host;
},
Dock = SWF.DockStyle.Fill,
};
}
class ConfigurableComponent(string title) : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
return VStack(12,
Heading(title),
TextBlock($"Value: {count}"),
Button("+1", () => setCount(count + 1))
).Padding(24).Background(SolidBackground);
}
}
该工厂函数在 XAML Island 就绪后于 UI 线程上运行。
返回任意 UIElement —— 通常是一个包装你组件的
ReactorHostControl。注意 ComponentType 在 XamlIslandControl 上,而不在
ReactorHostControl 上:在工厂内部,无参组件请设置 ComponentFactory,
组件需要参数时则调用 Mount(component)。
模式¶
通过 UseObservable 把 WinForms 视图模型桥接进 Reactor 组件¶
当既有的 WinForms 应用已经有实现了
INotifyPropertyChanged 的视图模型时,迁移路径是托管一个
订阅同一个视图模型的 Reactor 组件,而不是把状态
重写进 Hook。Reactor 组件通过构造函数 props 接收该视图模型,
并调用 UseObservable 把订阅绑定到自己的生命周期;
WinForms 表单保留同一个视图模型引用,并通过它一直使用的
INPC 管道响应属性变化。结果是:增量采用、
一次一个表单、无需大爆炸式重写。
在孤岛与宿主之间共享同一棵无障碍树¶
引导程序把 WinForms 的无障碍树接入以孤岛为根的
UIAutomation 树。屏幕
阅读器看到的是一个逻辑应用,而不是两个:表单的 Label 控件、
孤岛的 Reactor Text 元素,以及表单其余部分
在一次讲述人朗读中一起出现。要让这一点成立,请在
WinForms 外围外框(标签、分组框、表单自身)上设置
AutomationName —— 否则讲述人会为 WinForms 那一半读出「窗格」,
体验就是割裂的。
在 Tab 上往返焦点¶
从 WinForms 的 TextBox 按 Tab,应进入 Reactor 子树的
第一个可聚焦元素;再按 Tab 应在 Reactor 内前进;
从最后一个 Reactor 元素 Tab 出去,则应回到下一个
WinForms 控件。XamlIslandControl 通过 WinForms 的
Control.PreviewKeyDown 与引导程序的 PreTranslateMessage
筛选器处理这一点;简单场景无需额外接线。对于
显式混用孤岛与宿主的复杂 Tab 顺序,请在两侧都设置 TabIndex ——
WinForms 的 TabIndex 与 Reactor 的 .TabIndex(n) 使用
同一套整数空间。
常见错误¶
没有先调用 XamlIslandBootstrap.Run()¶
引导程序会初始化 WinAppSDK、安装调度器,
并注册键盘消息筛选器。没有它,
XamlIslandControl.Load 要么静默什么都不做(控件渲染为空),
要么抛 InvalidOperationException: 'WindowsAppSDK was not
bootstrapped on this thread.' —— 取决于哪个属性
先触发惰性初始化。修法就是上面引导片段中展示的那个正统
入口点:每个 WinForms +
Reactor 的 Main 方法都以 XamlIslandBootstrap.Run(() => { … }) 开头。
把孤岛放在 DoubleBuffered = true 的 Panel 里¶
WinForms 的双缓冲在每次绘制时把子控件合成到一块
离屏位图上。XAML Islands 通过 DirectX
渲染到一块不参与 GDI 双缓冲的独立合成表面上;
结果就是严重的闪烁、调整大小时的白闪,
以及间歇性的陈旧帧残影。修法是
把孤岛上方任何容器上的 DoubleBuffered = true 去掉。
只在那些不含孤岛的兄弟 WinForms 控件上设置它。
忽略 WinForms 外围外框的无障碍¶
一个在每个 Element 上都用了 AutomationName 的
Reactor 子树,本身没问题。把它丢进一个
Label 控件没有 AccessibleName、GroupBox 标题为空的 WinForms 表单里,
讲述人在到达孤岛之前会读出「窗格、窗格、
窗格」。完整的应用级无障碍故事
需要两半都做好。请在孤岛内部运行无障碍扫描器,
并在宿主表单上运行 axe-windows / Accessibility Insights。
提示¶
先调用 XamlIslandBootstrap.Run()。 在显示任何 WinForms 表单之前。
引导程序必须在创建任何
XamlIslandControl 实例之前初始化 WinUI 运行时。
简单场景用 ComponentType。 它对设计器友好,并且
自动处理生命周期。把 ContentFactory 留给那些需要参数或
自定义宿主搭建的组件。
在根组件上设置显式背景。 XAML Islands 没有
默认背景。主题感知的背景请用
.Background(Theme.SolidBackground)。
给孤岛控件设置 Dock 或锚定。 XamlIslandControl 支持
标准 WinForms 布局:Dock = DockStyle.Fill 填满面板,
或用锚定实现按比例缩放。
端到端测试 Tab 导航。 在 WinForms 控件与 Reactor
控件之间反复按 Tab,验证焦点流动正确。桥接层会自动处理大多数
情况,但复杂的 Tab 顺序可能需要 .TabIndex() 提示。