Skip to content

WinUI 参考: 完整的属性面与设计指导,参见 Introduction

Reactor 是一个保留式 UI 框架:组件 渲染不可变的元素,协调器对其做差异比对,WinUI 让控件保持存活。Win2D 则是像素层面的即时模式逃生通道 —— 适用于那些对保留式控件来说过于动态或数量过于庞大的像素:粒子场、绘图表面、频谱图、图像编辑器,以及重度依赖 GPU 的模拟。Microsoft.UI.Reactor.Advanced 把这份能力做成按需引入,因此简单的应用不必背负 Win2D 的原生负载;同时画布元素依然处在与普通控件相同的 Hook副作用 与协调模型之中。可以把画布看作一个原生 WinUI 孤岛:它的像素由 Win2D 回调拉取,而它的生命周期、props 与失效由 Reactor 驱动。

Win2D 画布

Reactor.Advanced 把三个 Win2D 画布控件以 Reactor 元素的形式暴露出来:Win2DCanvasWin2DAnimatedCanvasWin2DVirtualCanvas。当你需要即时模式绘图时引入该包;保留式 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);
    }
}

手动 Win2D 画布

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);
        });
    }
}

动画 Win2D 画布

在把真正的工作放进 onUpdateonDraw 之前,请先读线程。这两个回调都运行在 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);
    }
}

虚拟 Win2D 画布

请让分块的计算保持确定性。区域回调可能以任意可见分块顺序到达,因此分块的背景与标签应由坐标推导,而不是依赖可变的迭代状态。

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 上创建的资源只能由来自同一设备的绘制会话绘制。默认情况下每个画布控件拥有一个专用设备,而 UseCanvasResourcesCanvasDevice.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();
        });
    }
}

该修饰符在三种画布元素上都可用(Win2DCanvasWin2DAnimatedCanvasWin2DVirtualCanvas)。完全在某个画布自己的 OnCreateResources 中创建并绘制(使用该控件的设备)的资源不需要它。

UseSharedDevice构建设备时的设置:Win2D 在控件首次落实其设备时求值一次。它在控件的整个生命周期内固定不变 —— 不支持在活跃画布上跨重渲染切换 .UseSharedDevice()(那会强制进行容易崩溃的就地设备重建,因此 Reactor 在 Release 构建中忽略该变更,在 Debug 构建中抛出明确的错误)。要切换设备,请用不同的 key 重新挂载该画布。

UseCanvasResourcesOnCreateResources 之间选择

两者都会创建由设备支撑、具备设备丢失恢复能力的资源;差别在于资源归哪个设备所有:

UseCanvasResources Hook 画布的 OnCreateResources 回调
设备 Win2D 的进程级共享设备 画布自己的设备(ctrl.Device
是否必需 .UseSharedDevice() 是,每个绘制该资源的画布都要
可否跨多个画布复用 可以 —— 一个 Hook 供给多个画布 不可以 —— 每个画布各自一份
资源生命期 由 Hook 持有(引用 + 卸载时自动释放) 由你自己保存/释放
最适合 多个画布共用的精灵图/图集,或组件级资源状态 只有一个画布绘制的资源

传给 UseCanvasResourcescreate 回调已经接收了用于构建的 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 线程。
UseCanvasResourcescreate 视宿主画布而定,可能是工作线程或游戏线程。

请把 Ref<T> 当作一个稳定的、非 volatile 的槽位:来自 UI 线程的写入最终会被 Win2D 游戏线程看到,但 Ref<T> 本身不插入内存屏障,也不保护复合型变更。当两个线程都可能修改被引用的对象时,请让该对象自身线程安全(锁、InterlockedVolatile,或一个在下一帧开始时清空的生产者/消费者队列)。Reactor 不会把动画回调编组到 UI 线程,因为 CanvasAnimatedControl 的意义正是避免 UI 线程成为瓶颈。各形态的具体指引见 UseDrawState 的 XML 备注。

调试哨兵: Debug 构建会包装动画的 OnUpdate / OnDraw,并在疑似 WinUI 线程关联性异常逃逸时附加指向本节的提示。该哨兵是诊断辅助,不是同步模型。

设备丢失

当 GPU 重置、显示器变更或驱动更新时,CanvasDevice 可能丢失。绑定到旧设备的资源必须在替换后的设备上重建。UseCanvasResources 负责这个循环:释放旧资源、再次调用你的 create(CanvasDevice) 工厂方法,并把新值留在返回的引用里。在 OnDraw 中临时分配的资源由你自行负责,而且也是性能上的坏味道。

性能:粒子风暴

粒子风暴示例Reactor.Advanced 的经典完整范例:热路径用 Win2DAnimatedCanvas,外框用纯 Reactor 控件(滑块、调色板选择、暂停/继续、实时 FPS),并用一个生产者/消费者队列把来自 UI 线程的跨线程变更送入游戏线程的模拟中。请运行该示例,并在你自己的硬件上针对目标粒子数采集 FPS;该示例的 README 记录了基线测量方法。

提示

  • 先按工作负载选画布:数据变化重绘、稳定循环,还是分块表面。
  • CanvasBitmapCanvasGeometry、渲染目标和精灵表请优先用 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() 的资源);跨设备绘制会以隐藏异常使应用崩溃。见共享设备

后续阅读

  • 关于逃生通道的通用纪律,请阅读高级模式
  • 想了解可选控件如何接入 V1 处理程序模型,请参阅扩展 Reactor 控件
  • 当游戏循环回调需要把数据交回 UI 状态时,请使用线程与调度
  • 想看到一个完整的 5 万粒子应用,请探索粒子风暴示例
  • 在决定引入这个重量级依赖之前,请把 Win2D 的即时模式工作与保留式的动画图表做一番比较。