WinUI 参考: 完整的属性面与设计指导,参见 Introduction。
Reactor 是一个保留式 UI 框架:组件 渲染不可变的元素,协调器对其做差异比对,WinUI 让控件保持存活。Win2D 则是像素层面的即时模式逃生通道 —— 适用于那些对保留式控件来说过于动态或数量过于庞大的像素:粒子场、绘图表面、频谱图、图像编辑器,以及重度依赖 GPU 的模拟。Microsoft.UI.Reactor.Advanced 把这份能力做成按需引入,因此简单的应用不必背负 Win2D 的原生负载;同时画布元素依然处在与普通控件相同的 Hook、副作用 与协调模型之中。可以把画布看作一个原生 WinUI 孤岛:它的像素由 Win2D 回调拉取,而它的生命周期、props 与失效由 Reactor 驱动。
Win2D 画布¶
Reactor.Advanced 把三个 Win2D 画布控件以 Reactor 元素的形式暴露出来:Win2DCanvas、Win2DAnimatedCanvas 与 Win2DVirtualCanvas。当你需要即时模式绘图时引入该包;保留式 UI 仍请继续使用普通的 组件、动画 以及扩展 Reactor 控件模型。
三种画布,三类工作负载¶
| Win2D 控件 | 何时使用 | 线程 | Reactor 元素 |
|---|---|---|---|
CanvasControl |
一次性绘制,或数据变化才失效的场景:仪表、类图表可视化、绘画工具。 | UI 线程 Draw。 |
Win2DCanvas |
CanvasAnimatedControl |
游戏循环式:以目标帧率稳定地先 Update(args) 再 Draw(session)。 |
Win2D 游戏线程。 | Win2DAnimatedCanvas |
CanvasVirtualControl |
极大或可滚动的表面:白板、图像编辑器、多百万像素的画板。 | UI 线程按区域绘制。 | Win2DVirtualCanvas |
三者都用 CanvasDrawingSession 来绘制。它们的差别在于由谁触发失效以及回调发生在哪个线程。
手动画布(Win2DCanvas)¶
当像素只应在应用状态变化后更新时,使用 Win2DCanvas(...)。把绘制回调依赖的每一个状态值都作为 redrawKey 传入;当该键在协调过程中发生变化时,处理函数会调用 CanvasControl.Invalidate()。
class ManualCanvasDemo : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
return VStack(12,
SubHeading("Manual canvas"),
Button($"Redraw with count {count}", () => setCount(count + 1))
.AutomationName("Redraw manual canvas"),
Win2DCanvas((session, _) =>
{
session.Clear(Colors.White);
session.DrawText($"Count = {count}", 24, 24, Colors.DarkSlateBlue);
session.DrawCircle(90, 96, 20 + count * 3, Colors.DeepSkyBlue, 4);
}, redrawKey: count)
.ClearColor(Colors.White)
.Width(360)
.Height(150)
).Padding(20);
}
}

RedrawKey 被有意设计为显式的。它可以防止隐蔽的重绘循环,并让「保留式到即时模式」的边界在代码中可见:状态变化 → 元素重新渲染 → 键变化 → 画布的下一帧使用新捕获的状态。
注意: 对于 Reactor 看不到的任意变更,
Win2DCanvas不会重绘。如果OnDraw读取的是可变字段、计时器,或通过 Hook setter 之外的方式更新的数据,你就必须在元素中加入一个会变化的RedrawKey,或者通过刻意的控件引用/命令路径去触发失效。对于普通的 Reactor 状态,优先把状态值(或由它派生的版本号)作为键。
动画画布(Win2DAnimatedCanvas)¶
当需要稳定逐帧刷新的场景时,使用 Win2DAnimatedCanvas(...)。绘制循环独立于 Reactor 的重渲染运行,但你通过 drawState 传入的状态对象能跨渲染存活,因为它来自 UseDrawState。
class AnimatedCanvasDemo : Component
{
public override Element Render()
{
return Memo(ctx =>
{
var dots = ctx.UseDrawState(() => DotField.Create(count: 180, width: 420, height: 220));
var sprite = ctx.UseCanvasResources<CanvasBitmap>(device =>
{
byte[] pixels =
[
0x00, 0x78, 0xD4, 0xFF,
0x50, 0xC8, 0x78, 0xFF,
0xFF, 0xB9, 0x00, 0xFF,
0xD8, 0x3B, 0x01, 0xFF,
];
var bitmap = CanvasBitmap.CreateFromBytes(
device,
pixels,
widthInPixels: 2,
heightInPixels: 2,
Windows.Graphics.DirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized);
return ValueTask.FromResult(bitmap);
});
return VStack(12,
SubHeading("Animated canvas"),
Win2DAnimatedCanvas(
onUpdate: (args, state) => ((DotField)state!).Step(args.Timing.ElapsedTime),
onDraw: (session, _, state) =>
{
var field = (DotField)state!;
session.Clear(Color.FromArgb(255, 12, 16, 28));
field.Draw(session);
if (sprite.Current is { } bitmap)
session.DrawImage(bitmap, 16, 16, new Rect(0, 0, 2, 2), 0.85f);
},
drawState: dots.Current)
.ClearColor(Color.FromArgb(255, 12, 16, 28))
.TargetFps(60)
.Width(420)
.Height(220)
// UseCanvasResources builds the sprite on Win2D's shared device, so the
// canvas must draw with that same device — otherwise the cross-device
// DrawImage raises a fatal stowed exception.
.UseSharedDevice()
).Padding(20);
});
}
}

在把真正的工作放进 onUpdate 或 onDraw 之前,请先读线程。这两个回调都运行在 Win2D 游戏线程上,因此它们可以读取 UseDrawState 对象,但绝不能触碰 WinUI 控件。如果循环需要把状态回传给 Reactor 外框,请使用线程安全的 Hook setter,或使用由 UI 稍后读取的无锁缓冲区。
虚拟画布(Win2DVirtualCanvas)¶
当逻辑内容远大于视口时,使用 Win2DVirtualCanvas(...)。Win2D 要求你只绘制被标记为失效的区域;Reactor 把 InvalidateRegions 暴露为一个不可变的命令 prop。状态变化后要失效特定分块时,请传入一个新的列表实例。
class VirtualCanvasDemo : Component
{
public override Element Render()
{
var (stamp, setStamp) = UseState(0);
var highlightedTile = new Rect(1024, 512, 360, 360);
var canvas = Win2DVirtualCanvas((session, region) =>
{
const double tile = 512;
session.Clear(Colors.WhiteSmoke);
for (double y = Math.Floor(region.Y / tile) * tile; y < region.Y + region.Height; y += tile)
{
for (double x = Math.Floor(region.X / tile) * tile; x < region.X + region.Width; x += tile)
{
var rect = new Rect(x, y, tile, tile);
var color = ((int)(x / tile + y / tile) % 2) == 0
? Color.FromArgb(255, 232, 244, 255)
: Color.FromArgb(255, 245, 235, 255);
session.FillRectangle(rect, color);
session.DrawRectangle(rect, Colors.SlateGray, 2);
session.DrawText($"tile {x / tile:0},{y / tile:0}", (float)x + 24, (float)y + 28, Colors.DarkSlateGray);
}
}
session.FillRectangle(new Rect(0, 0, 420, 260), Color.FromArgb(255, 0, 120, 212));
session.DrawText("origin tile", 32, 32, Colors.White);
session.FillRectangle(highlightedTile, Color.FromArgb(255, 255, 185, 0));
session.DrawText($"invalidated {stamp}", 1052, 560, Colors.Black);
}, new Size(4000, 4000)) with
{
InvalidateRegions = stamp == 0 ? null : [highlightedTile]
};
return VStack(12,
SubHeading("Virtual canvas"),
Button("Invalidate highlighted tile", () => setStamp(stamp + 1)),
ScrollView(canvas)
.Width(620)
.Height(320)
).Padding(20);
}
}

请让分块的计算保持确定性。区域回调可能以任意可见分块顺序到达,因此分块的背景与标签应由坐标推导,而不是依赖可变的迭代状态。
Hook¶
| Hook | 用途 | 典型画布 |
|---|---|---|
UseDrawState<T>(Func<T>) |
能跨组件重渲染存活的、稳定的可变逐帧状态。 | 动画画布、手动画布的热路径 |
UseCanvasResources<T>(create, dispose?) |
对设备丢失安全的资源获取与清理。 | 任何使用位图、几何对象、渲染目标的画布 |
UseDrawCommand<TState>(state, draw, deps) |
为手动画布提供记忆化的绘制委托。 | 手动画布 |
UseDrawState¶
UseDrawState 是面向「帧自有状态」的一种更易发现的 UseRef 形态。Current 中的对象就是作为 drawState 传入的那个对象,因此动画回调能在 Reactor 重渲染周边控件的同时保住自己的粒子数组或物理缓冲区。
class AnimatedCanvasDemo : Component
{
public override Element Render()
{
return Memo(ctx =>
{
var dots = ctx.UseDrawState(() => DotField.Create(count: 180, width: 420, height: 220));
var sprite = ctx.UseCanvasResources<CanvasBitmap>(device =>
{
byte[] pixels =
[
0x00, 0x78, 0xD4, 0xFF,
0x50, 0xC8, 0x78, 0xFF,
0xFF, 0xB9, 0x00, 0xFF,
0xD8, 0x3B, 0x01, 0xFF,
];
var bitmap = CanvasBitmap.CreateFromBytes(
device,
pixels,
widthInPixels: 2,
heightInPixels: 2,
Windows.Graphics.DirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized);
return ValueTask.FromResult(bitmap);
});
return VStack(12,
SubHeading("Animated canvas"),
Win2DAnimatedCanvas(
onUpdate: (args, state) => ((DotField)state!).Step(args.Timing.ElapsedTime),
onDraw: (session, _, state) =>
{
var field = (DotField)state!;
session.Clear(Color.FromArgb(255, 12, 16, 28));
field.Draw(session);
if (sprite.Current is { } bitmap)
session.DrawImage(bitmap, 16, 16, new Rect(0, 0, 2, 2), 0.85f);
},
drawState: dots.Current)
.ClearColor(Color.FromArgb(255, 12, 16, 28))
.TargetFps(60)
.Width(420)
.Height(220)
// UseCanvasResources builds the sprite on Win2D's shared device, so the
// canvas must draw with that same device — otherwise the cross-device
// DrawImage raises a fatal stowed exception.
.UseSharedDevice()
).Padding(20);
});
}
}
UseCanvasResources¶
UseCanvasResources 创建由设备支撑的资源,在 CanvasDevice.DeviceLost 之后重跑工厂方法,并在卸载时释放旧资源。下面这段片段加载一张极小的 CanvasBitmap 并在动画画布中绘制它。
var sprite = ctx.UseCanvasResources<CanvasBitmap>(device =>
{
byte[] pixels =
[
0x00, 0x78, 0xD4, 0xFF,
0x50, 0xC8, 0x78, 0xFF,
0xFF, 0xB9, 0x00, 0xFF,
0xD8, 0x3B, 0x01, 0xFF,
];
var bitmap = CanvasBitmap.CreateFromBytes(
device,
pixels,
widthInPixels: 2,
heightInPixels: 2,
Windows.Graphics.DirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized);
return ValueTask.FromResult(bitmap);
});
必须使用共享设备。
UseCanvasResources在 Win2D 的进程级共享设备上构建资源。任何绘制这些资源的画布都必须通过.UseSharedDevice()选择同一个设备 —— 参见共享设备。
共享设备¶
Win2D 资源(位图、几何对象、渲染目标)是与设备绑定的:在某个 CanvasDevice 上创建的资源只能由来自同一设备的绘制会话绘制。默认情况下每个画布控件拥有一个专用设备,而 UseCanvasResources 在 CanvasDevice.GetSharedDevice() 返回的共享设备上构建资源。用拥有不同设备的控件去绘制共享设备上的资源会引发跨设备错误,其表现是致命的隐藏异常(应用会崩溃,而不是抛出可捕获的异常)。
只要画布绘制 UseCanvasResources 的输出(或任何通过 CanvasDevice.GetSharedDevice() 构建的资源),就用声明式的 .UseSharedDevice() 修饰符把该画布切换到共享设备:
class SharedDeviceDemo : Component
{
public override Element Render()
{
return Memo(ctx =>
{
// Built on Win2D's process-wide shared device.
var sprite = ctx.UseCanvasResources<CanvasBitmap>(device => ValueTask.FromResult(
CanvasBitmap.CreateFromBytes(
device,
new byte[] { 0x00, 0x78, 0xD4, 0xFF },
widthInPixels: 1,
heightInPixels: 1,
Windows.Graphics.DirectX.DirectXPixelFormat.B8G8R8A8UIntNormalized)));
return Win2DAnimatedCanvas(
onUpdate: (_, _) => { },
onDraw: (session, _, _) =>
{
if (sprite.Current is { } bitmap)
session.DrawImage(bitmap, 16, 16, new Rect(0, 0, 1, 1));
})
.TargetFps(60)
// Required: the canvas draws a shared-device resource, so it must
// stop using its own dedicated device.
.UseSharedDevice();
});
}
}
该修饰符在三种画布元素上都可用(Win2DCanvas、Win2DAnimatedCanvas、Win2DVirtualCanvas)。完全在某个画布自己的 OnCreateResources 中创建并绘制(使用该控件的设备)的资源不需要它。
UseSharedDevice 是构建设备时的设置:Win2D 在控件首次落实其设备时求值一次。它在控件的整个生命周期内固定不变 —— 不支持在活跃画布上跨重渲染切换 .UseSharedDevice()(那会强制进行容易崩溃的就地设备重建,因此 Reactor 在 Release 构建中忽略该变更,在 Debug 构建中抛出明确的错误)。要切换设备,请用不同的 key 重新挂载该画布。
在 UseCanvasResources 与 OnCreateResources 之间选择¶
两者都会创建由设备支撑、具备设备丢失恢复能力的资源;差别在于资源归哪个设备所有:
UseCanvasResources Hook |
画布的 OnCreateResources 回调 |
|
|---|---|---|
| 设备 | Win2D 的进程级共享设备 | 画布自己的设备(ctrl.Device) |
是否必需 .UseSharedDevice() |
是,每个绘制该资源的画布都要 | 否 |
| 可否跨多个画布复用 | 可以 —— 一个 Hook 供给多个画布 | 不可以 —— 每个画布各自一份 |
| 资源生命期 | 由 Hook 持有(引用 + 卸载时自动释放) | 由你自己保存/释放 |
| 最适合 | 多个画布共用的精灵图/图集,或组件级资源状态 | 只有一个画布绘制的资源 |
传给 UseCanvasResources 的 create 回调已经接收了用于构建的 CanvasDevice —— 该 Hook 有意提供共享设备,好让单个资源可以供给任意数量的画布。把某个画布自己的设备传进该 Hook 是不受支持的:控件会惰性创建设备(Hook 的副作用执行时它通常尚未落实),并且在设备丢失时替换它,因此不存在稳定的、属于单个画布的设备可以交给该 Hook。当你希望某个资源绑定到某个画布的设备时,请在该画布的 OnCreateResources 中创建 —— 设备丢失后 Win2D 会用新设备重新触发该回调:
class CanvasOwnedResourceDemo : Component
{
private static readonly Uri SpriteUri = new("ms-appx:///Assets/sprite.png");
// Owned by this component and built on the CANVAS's own device — no
// .UseSharedDevice() needed, and none wanted.
private CanvasBitmap? _sprite;
public override Element Render()
{
return Win2DAnimatedCanvas(
onUpdate: (_, _) => { },
onDraw: (session, _, _) =>
{
if (_sprite is { } s)
session.DrawImage(s, 16, 16);
}) with
{
// Win2D re-raises this callback with the fresh device after a loss.
OnCreateResources = async ctrl =>
_sprite = await CanvasBitmap.LoadAsync(ctrl.Device, SpriteUri),
};
}
}
UseDrawCommand¶
UseDrawCommand 是 Win2D 场景下的 UseCallback:当每次渲染都重建手动画布的绘制委托会产生过多分配或捕获时,把它记忆化。与另外两个 Win2D Hook 一样,它是 RenderContext 的扩展方法,因此在 Memo(ctx => …) 方法体内以 ctx.UseDrawCommand(...) 的形式调用:
class DrawCommandDemo : Component
{
public override Element Render()
{
return Memo(ctx =>
{
var (count, setCount) = ctx.UseState(0);
// Memoized like UseCallback: the delegate is rebuilt only when a dep changes.
var draw = ctx.UseDrawCommand(
state: count,
draw: static (session, _, value) =>
session.DrawText($"Count = {value}", 16, 16, Colors.Black),
deps: [count]);
return VStack(12,
Button($"Redraw with count {count}", () => setCount(count + 1))
.AutomationName("Redraw draw command canvas"),
Win2DCanvas(draw, redrawKey: count)
.ClearColor(Colors.White)
.Width(240)
.Height(80)
).Padding(20);
});
}
}
线程¶
| 回调 | 线程 |
|---|---|
Win2DCanvas.OnDraw |
UI 线程。在其中访问 Reactor 状态是安全的。 |
Win2DCanvas.OnCreateResources |
由 Win2D 管理的工作线程。 |
Win2DAnimatedCanvas.OnUpdate / .OnDraw |
Win2D 游戏线程。 仅当 T 对该访问模式安全时,通过 Ref<T>.Current 读取 Hook 状态才是安全的。触碰 WinUI 控件不安全。 |
Win2DAnimatedCanvas.OnCreateResources |
循环启动前的游戏线程。 |
Win2DVirtualCanvas.OnRegionDraw |
UI 线程。 |
UseCanvasResources 的 create |
视宿主画布而定,可能是工作线程或游戏线程。 |
请把 Ref<T> 当作一个稳定的、非 volatile 的槽位:来自 UI 线程的写入最终会被 Win2D 游戏线程看到,但 Ref<T> 本身不插入内存屏障,也不保护复合型变更。当两个线程都可能修改被引用的对象时,请让该对象自身线程安全(锁、Interlocked、Volatile,或一个在下一帧开始时清空的生产者/消费者队列)。Reactor 不会把动画回调编组到 UI 线程,因为 CanvasAnimatedControl 的意义正是避免 UI 线程成为瓶颈。各形态的具体指引见 UseDrawState 的 XML 备注。
调试哨兵: Debug 构建会包装动画的
OnUpdate/OnDraw,并在疑似 WinUI 线程关联性异常逃逸时附加指向本节的提示。该哨兵是诊断辅助,不是同步模型。
设备丢失¶
当 GPU 重置、显示器变更或驱动更新时,CanvasDevice 可能丢失。绑定到旧设备的资源必须在替换后的设备上重建。UseCanvasResources 负责这个循环:释放旧资源、再次调用你的 create(CanvasDevice) 工厂方法,并把新值留在返回的引用里。在 OnDraw 中临时分配的资源由你自行负责,而且也是性能上的坏味道。
性能:粒子风暴¶
粒子风暴示例 是 Reactor.Advanced 的经典完整范例:热路径用 Win2DAnimatedCanvas,外框用纯 Reactor 控件(滑块、调色板选择、暂停/继续、实时 FPS),并用一个生产者/消费者队列把来自 UI 线程的跨线程变更送入游戏线程的模拟中。请运行该示例,并在你自己的硬件上针对目标粒子数采集 FPS;该示例的 README 记录了基线测量方法。
提示¶
- 先按工作负载选画布:数据变化重绘、稳定循环,还是分块表面。
CanvasBitmap、CanvasGeometry、渲染目标和精灵表请优先用UseCanvasResources;不要在OnDraw中重建它们。- 绘制数以万计的精灵时,使用
CanvasSpriteBatch之类的 Win2D 批处理 API。 - 画布周围保留 Reactor 的保留式 UI,用于控件、布局、副作用 与应用状态。
模式¶
- 手动失效模式。 从手动场景读取的每个值推导出
RedrawKey,让协调恰好安排一次失效。 - 响应式游戏循环模式。 把粒子/物理缓冲区放进
UseDrawState;参数由 Hook 驱动,让OnUpdate在下一帧读取最新值。 - 对设备丢失安全的资源模式。 通过
UseCanvasResources获取位图与渲染目标,只在引用非空时绘制,必要时在该 Hook 的dispose回调中释放自定义资源。给绘制它们的画布加上.UseSharedDevice()(见共享设备)。 - 虚拟分块模式。 用坐标推导绘制内容,并在分块变化时传入一个新的
InvalidateRegions列表。
常见错误¶
- 在
Win2DAnimatedCanvas.OnUpdate或.OnDraw中触碰 WinUI 控件;应改用线程安全的状态交接。 - 值驱动的手动场景漏掉
RedrawKey,导致像素一直陈旧,直到发生缩放或 DPI 变化。 - 在
OnDraw中逐帧分配位图、画笔、数组或字符串,而不是复用绘制状态与资源。 - 修改后仍传入同一个
InvalidateRegions列表实例;Reactor 以引用变化作为失效的判据。 - 在未选择
.UseSharedDevice()的画布上绘制UseCanvasResources的输出(或任何CanvasDevice.GetSharedDevice()的资源);跨设备绘制会以隐藏异常使应用崩溃。见共享设备。