Skip to content

Microsoft.UI.Reactor(Reactor)的开发工作流围绕快速反馈构建。运行时的设计 目标是把它在做的事呈现出来 —— 每一次渲染、 每一次协调、每一个副作用、每一次调度器跳转 —— 通过一小组专门的工具, 让你不必借助性能分析器就能看见、归因并推断 你的应用在做什么。mur CLI、dotnet watch 下的预览 模式、MCP 服务器、VS Code 面板、应用内开发菜单,以及协调高亮 叠加层,都是同一个想法的变体:运行时公布它做的工作, 内循环让你读到它。代价在零售版中为零 —— 每个开发工具入口都位于一个构建期能力开关 (Reactor.DevtoolsSupport)加上一个会话期选择加入(--devtools app) 之后,因此最终用户永远看不到你不打算交付的东西。

开发工具

Reactor 的内循环以 dotnet watch 与预览模式为中心:你编辑 代码、保存,然后在正在运行的窗口里看到变化 —— 无需手动重启,大多数时候 也不用重置状态来重载类。围绕这个循环有 五个互补的面 —— mur CLIMCP 服务器VS Code 面板应用内开发菜单,以及 运行时叠加层 —— 每一个只在你要求时才亮起。

预览模式

dotnet watch 启动应用,即可使用 Reactor 对热重载友好的 预览工作流:

// Program entry point — this is the entire App.cs file:
// ReactorApp.Run<DevToolingApp>("Dev Tooling Demo",
//     width: 600, height: 450
// );
//
// Hot reload works when the app is launched under dotnet watch. Devtools
// screenshot capture is enabled by the app project's Reactor.DevtoolsSupport
// switch and activated by launching with --devtools.

应用入口就是一次普通的 ReactorApp.Run 调用;并没有 preview:devtools: 参数。开发工具专用的截图采集由应用项目的 Reactor.DevtoolsSupport 开关启用,并在启动时通过 --devtools 激活。

使用热重载运行

dotnet watch 启动应用:

dotnet watch run

它会启动应用并监视你的 .cs 文件变化。当你 保存时,dotnet watch 会重新编译,Reactor 会就地重渲染组件树 —— 同一个窗口、同一个进程。对于大多数编辑,你的实时 状态都能挺过这次重载。

下面是预览应用运行时的样子:

class DevToolingApp : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);
        var (message, setMessage) = UseState("Edit this code and save!");

        return VStack(16,
            Heading("Preview Mode Demo"),
            TextBlock(message).FontSize(16),
            HStack(8,
                Button("Click me", () => setCount(count + 1)),
                TextBlock($"Clicked {count} times").SemiBold()
            ),
            TextBox(message, setMessage, placeholderText: "Type something",
                header: "Message")
                .Width(300)
        ).Padding(24);
    }
}

预览模式演示

改一下 message 的默认值,或新增一个元素,保存文件, 然后看窗口更新。

重载后哪些状态会存活

Reactor 会把实时状态迁移过多次热重载,而不是重置它。 当你保存时,运行时会针对既有的 RenderContext 重跑 Render(),并把结果协调到活的 控件上。Hook 单元是按调用顺序匹配的,因此只要 Hook 序列不变, UseStateUseReducerUseRefUseMemoUsePersisted 都会保有自己的值。 当你编辑某个 Hook 所存储的记录或类时,运行时会逐字段把值迁移 到新的形状上;它无法映射的字段会被丢弃(并留下一行日志), 而不是抛异常。当你重命名组件或改变某组件的类型时, Reactor 会把该子树迁移到新的组件实例上,保住 它的 Hook 状态与底层 WinUI 控件。

你做的编辑 状态会发生什么
修改 Render() 方法体,增/删/重排元素 保留 —— 控件就地修补
编辑 Hook 中存储的值类型或记录 保留 —— 逐字段迁移;无法映射的字段被丢弃
重命名组件类型/改变其身份 保留 —— 子树迁移到新实例上
改变所存值的字段类型,例如 intOptional<int> 不迁移 —— 热重载复制器按名称与兼容类型匹配字段;要跨过这种模式边界请重启或重新挂载
增删 Hook 调用或改变其顺序 重置 —— Hook 列表被清空并重新挂载

最后一行是无法回避的情形:改变 Hook 的形状意味着 旧值无法重新键控到新布局上,因此 Reactor 会运行待处理的 清理函数并从零重新挂载 Hook,以保持循环存活, 而不是把你留在错误回退状态。

注意: 依赖在重载前后没有变化的副作用,会保留它从上个实例捕获的清理 闭包。这与 React 的身份语义一致,对捕获状态的副作用无害, 但如果某个副作用必须在每次重载时重跑,请给它一个会变化的依赖。对于你 明确希望连 Hook 形状变化都挺过去的状态,请使用 UsePersistedPersistedScope.Window,或把值存到 Observable<T> 字段中 —— 见 持久化

迁移行为异常时

状态迁移是尽力而为的。如果某次不寻常的编辑让组件进入 坏状态,恢复手段是完全重启:停掉 dotnet watch 再重新启动, 这会从零重新挂载每一个 Hook。运行时自己的 「丢掉一切、重新挂载」(HotReloadService.ResetAllContexts) 路径是框架内部的,在检测到 Hook 形状变化时自动运行 —— 它不是 你的应用会调用的 API。只有当上面那些有针对性的迁移没有产生 你期望的结果时,才动用重启。

NativeAOT 构建

状态迁移依赖 .NET Hot Reload,而它只在 JIT 调试构建中可用。在 NativeAOT(PublishAot=true)下 MetadataUpdater.IsSupportedfalse,因此整个迁移子系统 在静态上就是死代码并被裁剪掉 —— 发布的成品应用中既没有热重载循环, 也没有相应开销。

函数组件入口

做快速试验时,完全可以跳过类。给 ReactorApp.Run 传一个 lambda:

// Alternative: inline function component, no class needed
// ReactorApp.Run("Quick Test", ctx =>
// {
//     var (n, setN) = ctx.UseState(0);
//     return VStack(12,
//         TextBlock($"Count: {n}").FontSize(20),
//         Button("+1", () => setN(n + 1))
//     ).Padding(24);
// }, width: 400, height: 300);

这对于一次性的原型,或测试单个交互很有用。 你能得到同样的热重载行为 —— 编辑这个 lambda、保存、看结果。 实践范例目录 收集了更多符合这种形态的 例子。

mur CLI

mur 是 Reactor 感知仓库的命令行工具。它是文档流水线、本地化工作流、 开发工具服务器,以及若干仓库维护实用程序的 正统入口。各子命令与下面的工作流一一对应。

子命令 用途 常见调用
mur docs compile 编译文档集(模板 + 文档应用 → docs/guide/ mur docs compile
mur docs check-tier 只运行分级检查,不做交叉链接/引用/生成 mur docs check-tier --topic hooks
mur docs render-diagrams .mmd Mermaid 图渲染为 .svg,加快内循环迭代 mur docs render-diagrams --topic architecture-overview
mur docs new-diagram <topic> <id> 为某个主题脚手架出新的 Mermaid .mmd mur docs new-diagram hooks slot-table
mur loc 运行本地化流水线(extracttranslatevalidatestatusprune mur loc extract
mur devtools --devtools run 启动项目、监管重载,并托管 MCP 端点 mur devtools
mur check 仓库健康检查(cref 有效性、命名空间策略、「你是不是想写」建议) mur check
mur doctor 校验安装 —— SDK、mur、本地源、模板、插件 mur doctor
mur upgrade git pull 之后重新打包框架 + 模板并刷新插件 mur upgrade
mur figma watch 轮询 Figma 文件的设计变更 mur figma watch
mur pack-local / mur clean-local 为源码构建的框架冒烟测试打包/清理本地 NuGet 源;除非提供 --MSUIReactorVersion,否则应用模板默认使用公开的 Reactor 预览版 mur pack-local

除子命令之外,还有四个值得了解的顶层选项: mur --create <Name> 脚手架出新的 Reactor 项目,mur --skill 打印内嵌的 SKILL.md 智能体参考,mur --api 打印 reactor.api.txt 签名索引,mur --regen-api 从 源码检出重新生成该索引。

mur docs compile 是你最常动用的一条工作流。完整的 面以及让内循环变快的 --validate-only--no-screenshots (别名 --skip-screenshots)、--skip-diagrams--no-build--skip-reference--tier <stub|solid|comprehensive> 标志,参见 文档流水线贡献者指南

MCP 服务器

mur devtools--devtools run 启动你的项目、跨重载监管它, 并固定一个可供外部智能体或编辑器接入的 Model Context Protocol 端点。该端点 呈现的是正在运行的应用,而不是仓库:窗口列表、可视化树、 截图、Hook 状态、日志环形缓冲区,以及一组由 UIA 支撑的 动词(clicktypefocustoggleselectscrollwait), 用来驱动活的 UI。

mur devtools                     # 启动 + 监管;打印 MCP_ENDPOINT
mur devtools --mcp-port 9000     # 跨重载固定端口
mur devtools --print-config      # 输出适用于 Claude Code / VS Code / Copilot 的 MCP 配置

同样的动词也能不经智能体、直接从 CLI 使用 —— 每一个都 通过锁文件发现机制附着到正在运行的会话上:

mur devtools tree --view summary
mur devtools screenshot --out shot.png
mur devtools click "Button[Increment]"
mur devtools session list

没有 mur devtools serve 子命令,也不会由 dotnet watch 自动启动任何东西 —— mur devtools 本身就是启动器,因此当你想要 智能体集成时,请运行它而不是 dotnet watch run

更深层的协议面见 DevTools 内部机制

VS Code 面板

Reactor 的 VS Code 扩展位于 src/vscode-reactor/,提供一个 侧边面板,它可以:

  • 列出正在运行的应用的组件树。
  • 高亮你在树中点击的任何元素所对应的源码行。
  • 在编辑器旁渲染最新的截图(与文档流水线产出的 那些 PNG 相同)。
  • 切换一个 References 叠加层,用来绘制应用的响应式 引用关系图 —— descriptor.Reference/.ReferenceListbinding.Reference 桥接,以及 .LabeledBy.XYFocusDown 这类修饰符边 —— 并把引用环与长期为 null (无法解析)的引用标记为诊断项。参见 焦点与输入内部机制
  • 提供一个「compile docs」按钮,它会去调用 mur docs compile

该扩展与 MCP 端点(上文)通信,因此一个面板会话 不过是一个长期存活的 mur devtools 进程再加一层 UI。除了标准的 C# Dev Kit,Reactor 没有特殊的编辑器要求 —— 该面板是附加式的。想要内嵌真实 WinUI 表面(而不是流式传输截图)的可交互 Visual Studio 替代方案,见 Visual Studio 内嵌预览

当你针对一个已经在运行的预览进程使用 Reactor: Connect to Preview 时, 请把该进程输出中的 CAPTURE_PORT=...CAPTURE_TOKEN=... 两个值都填进去。采集服务器在任何端点探测(包括 /status)之前 都要求 bearer 令牌。

应用内开发菜单

对于活在运行中应用内部的开发面 —— 标题栏里的 「Dev」项、可切换的调试叠加层、一次性命令 —— Reactor 暴露三个小原语:UseDevtools()DevtoolsMenu(...)Observable<T>。两个彼此独立的信号 共同作用:

  1. 构建期能力 —— 给应用项目加上该功能开关:
<ItemGroup>
  <RuntimeHostConfigurationOption Include="Reactor.DevtoolsSupport"
                                  Value="true" Trim="true" />
</ItemGroup>

这是一个能力门 —— 它本身不会显示任何开发 UI。示例通常只在 Debug 构建中启用它,好让 Release/AOT 二进制继续裁剪掉开发工具代码。

  1. 会话期选择加入 —— 以 --devtools app 运行应用:
myapp.exe --devtools app

只有两者同时具备时,UseDevtools() 才返回 true。它是 RenderContext 的扩展方法,因此你从 函数组件ctx 调用它 —— 或者从一个你交给 ctx 的辅助方法里调用,前提是该辅助方法本身也以 Use* 命名,因为 REACTOR_HOOKS_005 只允许从 Render()Use* 方法中调用 Hook —— 并用 三元表达式给仅开发用的元素把关:

static class DevGate
{
    // UseDevtools() 是 RenderContext 的扩展方法,因此要从
    // 函数组件的 ctx(或任何你交给 ctx 的辅助方法)里访问 —— 并不存在
    // Component 级别的转发器。只有当 Reactor.DevtoolsSupport 构建开关与
    // `--devtools app` 两者同时具备时,它才返回 true。
    //
    // 该辅助方法命名为 Use*,因为它消耗一个 Hook 槽位:REACTOR_HOOKS_005
    // 只允许从 Render() 或 Use* 命名的方法中调用 Hook,因此若把它
    // 命名为 `Shell`,任何复制这段代码的项目都会收到警告。
    public static Element UseShell(RenderContext ctx)
    {
        var dev = ctx.UseDevtools();

        return VStack(8,
            MainContent(),
            dev ? DebugOverlay() : Empty()
        );
    }

    private static Element MainContent() => TextBlock("App content");

    // 仅在 dev 为 true 时才被*构造*。在零售版中这个三元表达式只需
    // 一次 bool 读取加一次分支 —— 没有元素树,也没有子元素被协调。
    private static Element DebugOverlay() =>
        TextBlock("debug overlay").Opacity(0.6);
}

DebugOverlay() 只在 dev 为 true 时才被构造。在零售版中 这一行只需一次 bool 读取加一次分支 —— 不分配元素树, 不协调子元素。同样的成本模型延续到 DevtoolsMenu,它只在 UseDevtools() 为 true 时 把自己渲染为标题栏项:

static class DevMenu
{
    public static Element UseTitleBar(RenderContext ctx)
    {
        // 在渲染期间订阅 —— 不要放在菜单构建器内部。构建器
        // lambda 在浮出打开时才运行,那不是一个渲染轮次,因此在里面
        // 调用 Hook 会破坏 Hook 顺序。
        //
        // 命名为 Use* 的理由与 DevGate.UseShell 相同:它消耗一个
        // Hook 槽位,而 REACTOR_HOOKS_005 要求这件事只能从 Render() 或
        // Use* 命名的辅助方法中做。
        var debugUI = ctx.UseObservable(AppFlags.DebugUI).Value;

        return HStack(8,
            TextBlock("My App"),
            // DevtoolsMenu 只在 UseDevtools() 为 true 时才把自己
            // 渲染为标题栏项,因此同样的成本模型得以延续。
            DevtoolsMenu(() => new MenuFlyoutItemBase[]
            {
                ToggleMenuItem("Debug UI", debugUI,
                    v => AppFlags.DebugUI.Value = v),
                MenuSeparator(),
                MenuItem("Clear cache", () => CacheService.Clear()),
                MenuItem("Slow mode off", () => AppFlags.SlowMode.Value = false),
            })
        );
    }
}

// 代替你的应用实际会缓存的东西。
static class CacheService
{
    public static void Clear() { }
}

Observable<T> 是支撑 AppFlags.DebugUI 这类标志的轻量 INPC 单元。把它们声明为 static readonly 字段:

// Observable<T> 是支撑仅开发用标志的轻量 INPC 单元。
// 把它们声明为 static readonly,好让每个组件共享同一个实例。
public static class AppFlags
{
    public static readonly Observable<bool> DebugUI = new(false);
    public static readonly Observable<bool> SlowMode = new(false);
    public static readonly Observable<bool> ForceDark = new(false);
}

任何想要响应变化的组件都在渲染期间通过 ctx.UseObservable(AppFlags.DebugUI).Value 订阅 —— 而不是在 DevtoolsMenu 的构建器 lambda 内部,那个 lambda 在浮出打开时才运行, 不在渲染轮次中。更完整的可观察量绑定故事见 高级模式

运行时叠加层

开发工具开启时,协调高亮叠加层会附着到正在运行的应用上:

  • 协调高亮叠加层。 在最近一次渲染中被协调器修补过的任何 元素周围闪出一个矩形。用于捕获意料之外的重渲染(Reactor 的规则 页覆盖了最常见的原因)。

该叠加层在 DevtoolsMenu 的切换组之下渲染。关闭时 零成本。

迭代循环

完整的内循环:

  1. 运行 在终端里执行 dotnet watch run
  2. 编辑 在编辑器里修改某个组件(如果你想让截图和组件树 并排显示,请同时打开 VS Code 面板)。
  3. 保存 文件 —— dotnet watch 检测到变化并重新编译。
  4. 查看 运行中窗口里更新后的 UI。

无需手动调用任何构建步骤。应用保持运行,窗口 停留在你离开时的位置。

class IterationDemo : Component
{
    public override Element Render()
    {
        var (items, updateItems) = UseReducer(new List<string>());
        var (input, setInput) = UseState("");

        return VStack(12,
            Heading("Iteration Cycle Demo"),
            TextBlock("Add items, then edit this code and save to see hot reload."),
            HStack(8,
                TextBox(input, setInput, placeholderText: "New item",
                    header: "New item")
                    .Width(200),
                Button("Add", () =>
                {
                    if (!string.IsNullOrWhiteSpace(input))
                    {
                        updateItems(list =>
                        {
                            var next = new List<string>(list) { input };
                            return next;
                        });
                        setInput("");
                    }
                })
            ),
            ForEach(items, (item, i) => TextBlock($"  - {item}").WithKey($"{i}-{item}"))
        ).Padding(24);
    }
}

迭代演示

模式

逐功能的调试标志 + Dev 菜单开关

在静态类里把一个标志声明为 Observable<bool>,通过 Dev 菜单的 ToggleMenuItem 暴露它,并在任何组件中通过 ctx.UseObservable(...) 读取它。翻转该标志会重渲染每一个 已订阅的组件 —— 无需任何事件接线样板。这个模式可以扩展到 十几个标志,而不需要配置 UI。

用于文档的无头截图采集

文档流水线所用的同一套开发工具管道,你自己的应用也能用: 在应用项目中启用 Reactor.DevtoolsSupport,并以 --devtools run 启动应用,好让截图工具链能采集该窗口。工具链契约见 docs/contributing/doc-pipeline.md。 这就是文档集中每一页都能自动获得截图、 而无需作者手动启动应用的方式。

常见错误

只启用开关而没有会话门

忘记 Reactor.DevtoolsSupport能力门、 而 --devtools app激活门,是最常见的 错误。启用开关构建出的二进制在用户面前不显示任何开发 UI —— 是会话期标志在起作用。如果你想让开发工具从零售版中被裁剪掉, 请把该开关保持为仅 Debug,而不是为 Release/AOT 启用它。

在内循环工作中用 UseState 承载真实应用状态

UseState 在每次热重载时都会重置。如果你的循环依赖一次 30 秒的登录流程才能到达你正在迭代的界面,请把 相关状态存进 UsePersisted(Window 作用域) 或某个 Observable<T> 字段。热重载会接上新的代码, 而不会重置你的状态。

把应用内开发菜单当作唯一的开发工具面

mur devtools 的 MCP 端点与 VS Code 面板是与应用内菜单 不同的面。它们从外部观察正在运行的应用; 而应用内菜单从应用自身内部观察运行时。构建期能力(Reactor.DevtoolsSupport) 加上 --devtools app 才会打开应用内菜单,而外部 MCP 端点需要通过 mur devtools 启动,而不是裸的 dotnet watch run —— 不要指望它会自动启动。

提示

让 dotnet watch 一直运行。 不要在两次编辑之间停掉再重启它。 它会自动处理重新编译与重连。

使用小组件。 更小的组件重载更快,因为需要重建的 树更少。尽早把片段抽出去。

留意终端。 热重载失败时(通常是语法 错误),dotnet watch 会打印错误。改好再保存 —— 它会重试。

做实验时用函数组件 ReactorApp.Run("Test", ctx => ...) 是尝试一个想法最快的方式。 没有类的样板代码。

基准测试用 ARM64 构建。 如果你在 ARM64 硬件上,请用 dotnet run -r win-arm64 构建,以取得原生的性能数字。

后续阅读

  • 快速上手 —— 上一篇:创建你的第一个应用并学习基础知识
  • 组件 —— 下一篇:组件类、props、函数组件、组合
  • Hook —— 学习组件内部使用的状态管理原语
  • 测试 —— 无头渲染器夹具、快照测试、异步测试模式
  • DevTools 内部机制 —— 开发菜单、叠加层与 MCP 服务器是如何实现的