Skip to content

WinUI 参考: 完整的属性面与设计指引见 Windowing Overview

窗口

多数 Microsoft.UI.Reactor(Reactor)应用从 ReactorApp.Run 创建的那一个窗口开始。更大的桌面应用可以用 WindowSpecReactorApp.OpenWindow 打开多个原生 WinUI 顶层窗口,同时保持与页面内部相同的声明式组件模型。

从 WindowSpec 到 ReactorWindow 宿主的窗口生命周期:原生 AppWindow 外壳、显示器与任务栏集成,直至关闭/释放清理

生命周期基础

ReactorApp.Run<TRoot>(...) 打开主窗口。当主窗口需要完整的声明式属性面 —— 图标、最小/最大尺寸、背景材质、圆角样式或位置持久化 —— 请传入一个 WindowSpec 而非各个单独参数。ReactorApp.OpenWindow 在 UI 线程上打开一个次级窗口,并返回一个 ReactorWindow 句柄,用于命令式的生命周期操作。

ReactorApp.Run<WindowsApp>("Windows Demo", width: 640, height: 520
);
public static void OpenSettings()
{
    var settings = ReactorApp.OpenWindow(
        new WindowSpec { Title = "Settings", Width = 520, Height = 420 },
        () => new SettingsWindow());

    settings.Activate();
    settings.Close();
}

注意事项:

  • CloseShowHideActivateUpdate 以及各改写方法仅限 UI 线程调用。
  • Close() 是幂等的:多次调用它(或者在所有者关闭级联已经在拆除窗口的过程中调用)只会执行一次原生关闭。冗余的关闭是安全的空操作,因此汇合的拆除路径不会重入原生窗口销毁。
  • ReactorApp.PrimaryWindow 是第一个符合条件的已打开窗口;关闭它是否导致进程退出由关闭策略决定。选择退出关闭策略的辅助窗口 —— 尤其是停靠系统撕离出的浮动窗口 —— 被排除在主窗口选举之外:它们永远不会成为兜底主窗口,也不会在真正的主窗口关闭时被提升为主窗口(重新选举会跳过它们,如果只剩被排除的窗口,PrimaryWindow 就保持 null)。这就避免了关闭一个临时浮动窗口时触发 OnPrimaryWindowClosed 并退出应用。
  • UseWindow() 在窗口组件内部返回所属的 ReactorWindow,在窗口之外(例如托盘浮出)返回 null

尺寸与缩放

初始的 Width / Height 单位是 DIP,二者都可选 —— 不设置(即默认)就让操作系统选择初始窗口尺寸,与一个普通的 XAML Window 完全一致。只设置其中一个轴就只应用该轴,另一个交给操作系统。运行时尺寸由 SetSize 控制,外壳缩放策略由 ResizeMode 控制,交互式的宽高比锁定由 AspectRatio 控制,内容驱动的尺寸由 SizeToContent 控制。

class PreviewWindow : Component
{
    public static WindowSpec Spec => new()
    {
        Title = "Preview",
        Width = 640,
        Height = 360,
        ResizeMode = WindowResizeMode.CanMinimize,
        AspectRatio = 16.0 / 9.0,
    };

    public override Element Render()
    {
        var window = UseWindow();

        UseWindowAspectRatio(1.0); // 绑定生命周期的 Hook;卸载即清除

        return Button("Widescreen", () => window?.SetAspectRatio(4.0 / 3.0));
    }
}
API 取值 / 行为
ResizeMode CanResizeNoResizeCanMinimize
AspectRatio double? 宽 / 高;在拖拽缩放期间生效
SizeToContent ManualWidthHeightWidthAndHeight

注意事项:

  • AspectRatio 会拒绝 ResizeMode.NoResize;没有拖拽就没有可施加的约束。
  • AspectRatioSizeToContent 是互斥的布局驱动方式。
  • SizeToContent 在布局之后运行,所以第一帧可能短暂使用初始的 Width / Height(未设置时则是操作系统选定的尺寸);最大化的窗口会忽略它并记录一条警告。
  • 最小/最大字段(MinWidthMaxHeight 等)优先于基于内容与宽高比的尺寸计算。

移动与摆放

初始摆放用 StartPosition,命令式移动用 SetPosition,回读用 Position,响应实时移动用 PositionChanged / UseWindowPosition()

class CommandPalette : Component
{
    public static WindowSpec Spec => new()
    {
        Title = "Command Palette",
        StartPosition = WindowStartPosition.CenterOnCurrent,
        IsMovableByBackground = true,
    };

    public override Element Render()
    {
        var (x, y) = UseWindowPosition();
        var drag = UseWindowDragMove();

        return VStack(8,
            TextBlock($"at {x}, {y}"),
            Button("Drag window", drag));
    }
}

当根元素的非交互区域被按下时,IsMovableByBackground 会启动操作系统的移动循环。用 .Drag(false) 标记自定义的交互区域:

class PaletteChrome : Component
{
    public override Element Render() =>
        HStack(
            TextBlock("Palette"),
            Button("Settings").Drag(false));
}

摆放选项:

WindowStartPosition 含义
Default 由 WinUI / 外壳决定摆放
CenterOnPrimary 居中于主显示器
CenterOnOwner 居中于所有者窗口所在的显示器
CenterOnCurrent 居中于光标所在的显示器
Manual 使用 ManualPosition 指定的 DIP 左上角

持久化是显式开启的:

public static WindowSpec ShellSpec { get; } =
    new WindowSpec { Title = "Shell" }
        .WithPersistence("main-window", fallback: WindowStartPosition.CenterOnCurrent);

public static void FlushPlacement(ReactorWindow window)
{
    window.SavePlacement(); // 手动尽力刷写
}

注意事项:

  • 位置值的单位是 DIP;混合 DPI 桌面上不存在单一的全局 DIP 网格。
  • PositionChanged 在拖拽期间会高频触发;需要时在应用代码里做去抖。
  • PersistenceId 本身只是身份标识。摆放位置的恢复/保存需要 PersistPlacement = true.WithPersistence(...)

摆放位置存在哪里

Reactor 在首次 OpenWindow 时选定存储:打包应用使用 ApplicationData.Current.LocalSettings,非打包应用使用 %LOCALAPPDATA%/<ProcessName>/ 下的一个 JSON 文件。由于该默认值以进程名为键,重命名可执行文件会让此前保存的布局成为孤儿。

非打包应用可以改为选择一种稳定的身份标识 —— 在首次 OpenWindow 之前指定存储:

public static void UseStableIdentity()
{
    ReactorApp.WindowPersistenceStore =
        new UnpackagedAppDataStore(publisher: "Contoso", product: "TimeTracker");
}

UnpackagedAppDataStore 以你提供的 publisher/product 组合为键,而不是进程名,因此保存的布局在改名后依然可用。它是显式开启而非默认值,因为两种存储的数据键方式不同,切换也不会迁移既有布局。多个应用实例共享同一个 publisher/product 是安全的:写入跨进程串行化,所以为某个窗口保存不会丢掉另一个窗口的条目。共享同一个 PersistenceId 的两个窗口仍会按设计互相覆盖 —— 串行化消除的是丢失更新竞态,而不是单个键上刻意的「后写者胜」。

Z 序与可见性

WindowLevel 选择 Z 序层级。ShowInTaskbarShowInSwitcher 是分开的,因为任务栏按钮与 Alt-Tab 可见性是外壳的两个独立概念。

class FloatingPalette : Component
{
    public static WindowSpec Spec => new()
    {
        Title = "Palette",
        Level = WindowLevel.Floating,
        ShowInTaskbar = false,
        ShowInSwitcher = true,
    };

    public override Element Render()
    {
        var isCovered = UseIsCovered(); // 来自 ZOrderChanged 的提示
        return TextBlock(isCovered ? "(covered)" : "(visible)");
    }
}
WindowLevel 行为
Normal 常规 Z 序
Floating 在所有者及其他 Reactor 应用窗口激活时仍保持在其上方
AlwaysOnTop Win32 置顶层
ShowInTaskbar ShowInSwitcher 结果
true true 普通应用窗口
true false 有任务栏按钮,无 Alt-Tab 条目
false true 工具面板形态
false false 临时 / 启动器 / 覆盖层形态

注意事项:

  • ZOrderChanged.IsCovered 是基于 HWND 插入顺序的遮盖提示,不是像素级精确的遮挡判断。
  • Floating 是应用内局部的。只有需要全局置顶时才用 AlwaysOnTop
  • 运行时切换任务栏可见性会隐藏/显示一次 HWND,以便外壳刷新。

外壳与外观

WindowStyle 控制原生外壳。WindowCornerStyle 映射到 Windows 11 的 DWM 圆角偏好。背景材质既可以在 WindowSpec.Backdrop 上指定,也可以用根级 .Backdrop(...) 修饰符。

public static WindowSpec HudSpec { get; } = new()
{
    Title = "HUD",
    Style = WindowStyle.None,
    IsMovableByBackground = true,
    CornerStyle = WindowCornerStyle.Rounded,
    Backdrop = BackdropChoice.Of(BackdropKind.DesktopAcrylic),
};
API 取值
WindowStyle DefaultNoneToolWindow
WindowCornerStyle DefaultSquareRoundedRoundedSmall
BackdropKind NoneMicaMicaAltDesktopAcrylicAcrylicThinTransparent

TitleBar(...) 是声明式的自定义标题栏。当 WindowSpec.ExtendsContentIntoTitleBarnull(默认)时,挂载一个 TitleBar(...) 元素会自动设置 Window.ExtendsContentIntoTitleBar = true。在 spec 上显式写 truefalse 会压过推断。

class TitleBarWindow : Component
{
    public override Element Render() =>
        VStack(
            TitleBar("My app"),
            TextBlock("Body"));
}

TitleBar(...) 接受自定义 Content(以及尾部的 RightHeader)。content 内部的交互控件会自动被排除在窗口拖拽区域之外(WinApp SDK ≥ 2.1.3)。可以用 .IsDragRegion(false) 按元素强制为可点击视觉元素,或用 .IsDragRegion(true) 强制可拖拽;当 content 在不同渲染之间变化时,在标题栏上设置 .AutoRefreshDragRegions()

(TitleBar("Gallery") with
{
    Content = HStack(8,
        AutoSuggestBox("", _ => {})
            .AutomationName("Search gallery")
            .Width(200),
        Button(Icon(FontIcon("\uE713", fontSize: 16)), OnSettings)
            .AutomationName("Settings").IsDragRegion(false)),
}).AutoRefreshDragRegions();

标题栏图标

一个没有 .Icon(...)TitleBar(...) 会显示窗口的图标:如果声明了 WindowSpec.Icon 就用它,否则走 Assets\AppIcon.ico 约定。已经自带图标的应用不必再重复声明一遍:

// A TitleBar(...) with no .Icon(...) inherits the window's icon, so an app that
// already ships one does not restate it.
static class TitleBarIconSetup
{
    public static void Run() =>
        ReactorApp.Run<InheritedIconApp>("My app",
            icon: WindowIcon.FromPath("Assets/AppIcon.ico"));
}

class InheritedIconApp : Component
{
    public override Element Render() =>
        VStack(
            TitleBar("My app"),          // shows Assets/AppIcon.ico, nothing to declare
            TextBlock("Body"));
}

// Opt out where a bare title bar is what you want:
class BareTitleBarApp : Component
{
    public override Element Render() =>
        VStack(
            TitleBar("My app").NoIcon(),
            TextBlock("Body"));
}

WinUI 控件本身并不这么做。有两个限制值得了解:

  • 以可执行文件 PE 资源形式存在的图标(<ApplicationIcon>不会被继承。窗口自身图标链的该阶段产出一个没有路径的裸 HICON,而 XAML 的 IconSource 需要一个图像源。窗口标题与 Alt-Tab 仍会显示它;窗口内的标题栏不会。
  • 内嵌窗口(WindowSpec.Embed)永远拿不到窗口图标,因此它的标题栏也没有可继承的东西。

当你想要一个不同的标记时,.Icon(...) 仍然优先 —— 比如说标题栏里用单色字形,而窗口标题用全彩 .ico.NoIcon() 是给「自带图标却刻意想要一条光秃标题栏」的应用准备的退出选项。

行为变更。 在此之前,没有 .Icon(...)TitleBar(...) 总是不渲染图标。一个自带窗口图标(或 Assets\AppIcon.ico)却刻意想要光秃标题栏的既有应用,应当加上 .NoIcon() 来保持原样。显式设置了 .Icon(...) 的应用不受影响。

高标题栏

承载导航外壳(返回按钮、侧边栏切换按钮)的标题栏使用高(48 DIP)标题。用 .Tall() 声明:

var titleBar = TitleBar("My app")
    .WithNavigation(nav)
    .PaneToggleButtonVisible(true)
    .Tall();                                  // or .HeightOption(WindowTitleBarHeight.Tall)

这会同时设置两半,而这正是手工操作最容易出错的地方:系统标题(AppWindow.TitleBar.PreferredHeightOption以及 WinUI 标题栏控件自身的高度。该控件不会从系统标题推导自己的高度,所以只抬高系统标题会留下「48 DIP 的系统标题压在 32 DIP 的标题栏上」。元素上显式的 .Height(...) 仍优先于隐含的 48。

这个开关在 spec 上同样存在,供那些不用 TitleBar(...) 元素却也需要它的窗口使用(两种方式都需要内容扩展,且两者同时设置时 spec 优先于元素上的声明):

public static WindowSpec Spec { get; } = new()
{
    Title = "My app",
    ExtendsContentIntoTitleBar = true,
    TitleBarHeight = WindowTitleBarHeight.Tall,
};
API 取值
WindowTitleBarHeight StandardTallCollapsed

Reactor 会在把窗口切换到内容扩展模式之后再应用高度,因此不存在顺序隐患。你自己设置 AppWindow.TitleBar.PreferredHeightOption 依然可行,但在一个未内容扩展的窗口上它会抛出 ERROR_INVALID_STATE —— 这正是命令式写法在副作用函数体里显得脆弱的原因。

从命令式绕法迁移

早期代码 —— 包括 Windows App SDK 的 reactor-navview 模板 —— 会把赋值重新投递到调度器队列上:

class LegacyTallTitleBar : Component
{
    public override Element Render()
    {
        // Don't do this any more.
        var window = UseWindow();
        UseEffect(() =>
        {
            if (window is not { } win) return;
            win.NativeWindow?.DispatcherQueue.TryEnqueue(() =>
                win.AppWindow.TitleBar.PreferredHeightOption =
                    Microsoft.UI.Windowing.TitleBarHeightOption.Tall);
        });

        return TitleBar("My app");
    }
}

把整个副作用删掉,改为声明 .Tall()

那次调度器跳转基于一个误诊(issue #917)。TitleBar(...)ExtendsContentIntoTitleBar 推断从未破坏过 PreferredHeightOption —— 在活动窗口上实测,从副作用函数体直接写入产生的几何结果与跳转写入完全一致。原报告实际遇到的是:系统标题移动了,而 WinUI 标题栏控件仍停在 32 DIP,回读是 Tall,但看起来什么都没发生。延迟写入从未解决那个问题;把两个高度配对才行,而 .Tall() 应用的正是这一点。

注意事项:

  • 在仍然渲染 TitleBar(...) 元素的同时设置 ExtendsContentIntoTitleBar = false 是允许的(那种情况下 Reactor 会跳过 SetTitleBar),但在 #537 修复之前,这种组合会在窗口关闭时以 STATUS_HEAP_CORRUPTION 让进程崩溃 —— WinUI 标题栏控件只有在内容扩展模式下才能安全拆除。现在 Reactor 会在原生关闭前把窗口切回内容扩展模式,因此关闭是安全的;窗口存活期间你观察到的值不变。新代码在确实想要系统标题栏时,直接省掉 TitleBar(...) 即可。
  • 没有 IsMovableByBackgroundWindowStyle.None 会让用户无法移动窗口;Reactor 会警告但不抛异常。
  • 除非显式设置 ShowInTaskbarWindowStyle.ToolWindow 默认从任务栏隐藏。
  • WindowCornerStyle 是 Windows 11 的 DWM 偏好;Windows 10 会忽略它。
  • 当所引用的 Windows App SDK 未暴露透明背景材质类型时,BackdropKind.Transparent 会退回为无背景材质。
  • TitleBarHeight / .Tall() 需要内容扩展的窗口。在永不扩展的窗口上,Reactor 会警告并跳过写入而不抛异常 —— 而且如果窗口之后扩展了,会自动重新应用所声明的高度。

窗口图标

窗口图标就是 Windows 在窗口标题与 Alt-Tab 切换器中显示的 Win32 HICON。可以在 ReactorApp.Run 上用 icon: 声明式设置,次级窗口则用 WindowSpec.Icon

// The window icon is the Win32 HICON shown in the window caption and Alt-Tab —
// distinct from TitleBar(...).Icon(...), which draws a mark inside the window.
// Use an .ico. Unpackaged, this also drives the taskbar button; packaged, the
// taskbar comes from the manifest's Square44x44Logo instead.
static class WindowIconSetup
{
    // Unpackaged: a file deployed beside the app.
    public static void RunWithFileIcon() =>
        ReactorApp.Run<WindowsApp>("Windows Demo",
            icon: WindowIcon.FromPath("Assets/AppIcon.ico"));

    // Packaged: an .ico shipped with Build Action = Content.
    public static void RunWithPackagedIcon() =>
        ReactorApp.Run<WindowsApp>("Windows Demo",
            icon: WindowIcon.FromResource("ms-appx:///Assets/AppIcon.ico"));

    // A full WindowSpec reaches the fields the flat arguments cannot.
    public static void RunWithSpec() =>
        ReactorApp.Run<WindowsApp>(new WindowSpec
        {
            Title = "Windows Demo",
            Width = 640,
            Height = 520,
            MinWidth = 400,
            Icon = WindowIcon.FromPath("Assets/AppIcon.ico"),
        });
}

等同于 TitleBar(...).Icon(...),后者是在窗口客户区内部绘制一个应用标记。一个窗口完全可以同时拥有两者,而且它们可以不同 —— 标题栏里是单色标记,任务栏里是全彩 .ico

未声明图标时,Reactor 依次回退到应用旁的 Assets\AppIcon.ico,再到由 <ApplicationIcon> 嵌入可执行文件的图标。

图标可以来自哪里

WindowIcon 在三种来源类型下共有四个工厂方法,而并非每个界面都接受每一种:

工厂方法 来源
WindowIcon.FromPath(path) 应用旁的一个文件 —— 非打包场景的常见写法。
WindowIcon.FromResource(uri) 一个 ms-appx:///Assets/App.ico 包资源。
WindowIcon.FromBytes(data) 已在内存中的编码 .ico 或 PNG 数据。
WindowIcon.FromRgba(pixels, w, h) 一块裸的直接 Alpha RGBA8 缓冲区,自上而下排列。
界面 FromPath FromResource FromBytes / FromRgba
窗口标题 / Alt-Tab
托盘图标
任务栏覆盖图标
缩略图工具栏按钮
跳转列表条目 仅非打包 仅打包

表格背后的规律是每个界面需要哪种原语。三个外壳界面需要裸 HICON,它要么来自对文件调用 LoadImageW,要么来自对内存数据调用 CreateIconFromResourceEx —— 两者都无法读取打包资源 URI。跳转列表需要一个 Uri。窗口标题需要一个文件系统路径,因为 AppWindow.SetIcon 接受的就是路径。

界面无法使用的来源会被跳过并给出诊断信息,而不是抛异常。对窗口而言,这意味着回退到 Assets\AppIcon.ico 或 PE 图标,所以一个二进制形式的 icon: 不会让窗口比完全不声明更光秃。

哪个界面显示哪个图标

这一点常让人栽跟头,所以值得说清楚。三种不同的资产喂给四个不同的外壳界面,谁胜出取决于界面以及你的应用是否具有包标识:

界面 非打包 打包(MSIX)
窗口标题 窗口图标 窗口图标
Alt-Tab 窗口图标 窗口图标
任务栏按钮 窗口图标 清单中的 Square44x44Logo
任务管理器,窗口行 窗口图标 清单中的 Square44x44Logo
任务管理器,进程行 <ApplicationIcon> 清单中的 Square44x44Logo
Explorer 中 .exe 本身 <ApplicationIcon> <ApplicationIcon>

有两点结论值得内化:

  • 只靠 icon: 永远覆盖不了一切。 它设置的是窗口句柄的 HICON,也就是标题与 Alt-Tab。任务管理器用来归组窗口的进程行、以及 Explorer 里的 .exe,都来自可执行文件内嵌的 PE 图标 —— 一个构建期资源,只有 <ApplicationIcon> 能设置它。Reactor 无法在运行时更改。
  • 打包应用还需要清单里匹配的 logo。 外壳通过包标识解析任务栏按钮,从不查看窗口句柄,所以一个正确的 icon: 配上不匹配的 Square44x44Logo,看起来就完全像是「窗口图标没生效」。

因此,想要处处一致的图标,应用需要设置全部三处,并指向同一个 .icoicon:(或 Assets\AppIcon.ico 约定)、csproj 中的 <ApplicationIcon>,以及 —— 打包时 —— 清单里的 logo。mur --create 会为你搭好前两个。

注意事项:

  • 优先用真正的 .ico。它是 AppWindow.SetIcon 所记录的格式,而且 —— 对文件来源而言 —— 是托盘图标、任务栏覆盖图标与缩略图工具栏界面唯一能加载的格式,因为它们需要通过 LoadImageW 拿到裸 HICON。Reactor 会把来源原样交给平台,而不预先校验扩展名。
  • 打包应用不会Package.appxmanifest 得到它的窗口图标。清单通过包标识驱动任务栏按钮与任务管理器,这完全绕过了窗口句柄 —— 因此没有显式图标时,即使任务栏按钮看起来正常,标题与 Alt-Tab 条目仍显示一个通用字形。
  • 单独设置 <ApplicationIcon> 会设定 Explorer 为 .exe 显示的图标,以及任务管理器在窗口归组所属的进程行上显示的图标。把它带到窗口上的是 Reactor 的回退逻辑;WinUI 自己不会这么做。
  • 一个不存在的 FromPath 来源会被上报为失败,这样回退逻辑仍会运行 —— 一个声明了却不存在的图标,绝不会让窗口比完全不声明更光秃。FromResource URI 在到达平台之前会被映射到应用旁对应的文件,因为 AppWindow.SetIcon 要的是文件系统路径:如果直接把 URI 交出去,打包应用会静默地得到一个默认图标,而不是该资产。
  • 二进制图标会在 WindowIcon 的整个生命周期内持有其字节。这正是该 API 的意义,但对于一个长期存活、携带大型多分辨率 .ico 的 spec,请把这点记在心上。

托盘图标

ReactorApp.OpenTrayIcon 为进程注册一个通知区域图标;UseTrayIcon 把一个图标限定到某个组件,并在卸载时关闭它。两者都接受 TrayIconSpec,而改写 IconTooltipIsVisible 会通过 Shell_NotifyIcon 重新应用。

class TrayHost : Component
{
    public override Element Render()
    {
        var icon = UseMemo(() => WindowIcon.FromPath("Assets/TrayIcon.ico"));
        var tray = UseTrayIcon(new TrayIconSpec(
            Icon: icon,
            Tooltip: "My App",
            Key: WindowKey.Of("main-tray")));

        UseEffect(() =>
        {
            if (tray is null) return () => { };
            void onClick(object? s, EventArgs e)
                => ReactorApp.PrimaryWindow?.Activate();
            tray.Click += onClick;
            return () => tray.Click -= onClick;
        }, tray ?? (object)"no-tray");

        return TextBlock("Tray icon registered while this component is mounted.");
    }
}

图标不必来自文件。WindowIcon.FromBytes 接受编码的 .ico 或 PNG 数据,WindowIcon.FromRgba 接受裸像素缓冲区 —— 因此嵌入资源、下载来的资产,或你在运行时绘制的角标,都能不落临时文件就到达外壳:

// A tray icon can also come from bytes already in memory — no temporary file.
class BinaryTrayHost : Component
{
    public override Element Render()
    {
        // Encoded .ico or PNG data: an embedded resource, a download, a database blob.
        var embedded = UseMemo(() => WindowIcon.FromBytes(LoadEmbeddedIcon()));

        // Or a raw RGBA8 buffer you drew yourself, for a badge that changes at runtime.
        var drawn = UseMemo(() => WindowIcon.FromRgba(UnreadBadge(16, 16), 16, 16));

        var tray = UseTrayIcon(new TrayIconSpec(
            Icon: embedded,
            Tooltip: "My App",
            Key: WindowKey.Of("binary-tray")));

        UseEffect(() =>
        {
            // Swapping the source reloads the shell bitmap.
            if (tray is not null) tray.Icon = drawn;
            return () => { };
        }, drawn);

        return TextBlock("Tray icon built from in-memory data.");
    }

    static byte[] LoadEmbeddedIcon()
    {
        var assembly = typeof(BinaryTrayHost).Assembly;
        using var stream = assembly.GetManifestResourceStream("MyApp.TrayIcon.ico")
            ?? throw new InvalidOperationException(
                "Embedded resource 'MyApp.TrayIcon.ico' not found — check the file's Build Action.");
        using var buffer = new MemoryStream();
        stream.CopyTo(buffer);
        return buffer.ToArray();
    }

    // width * height * 4 bytes, top-down, one pixel as R, G, B, A.
    static byte[] UnreadBadge(int width, int height)
    {
        var pixels = new byte[width * height * 4];
        for (int i = 0; i < pixels.Length; i += 4)
        {
            pixels[i] = 0xE8;     // R
            pixels[i + 1] = 0x11; // G
            pixels[i + 2] = 0x23; // B
            pixels[i + 3] = 0xFF; // A
        }
        return pixels;
    }
}

注意事项:

  • 尽量提供多尺寸的 .ico(16、20、24、32、40 px)。托盘请求的是 DPI 感知的小图标尺寸,而文件加载器与内存加载器都会挑选最接近的帧,而不是对单一帧做重缩放。
  • ms-appx:/// 资源在这里不可用 —— 见上表。打包应用应当附带一个旁置 .ico 或嵌入字节。
  • 工具提示会被截断到外壳的 127 字符 szTip 缓冲区,并作为图标在「讲述人」中的可访问名称呈现。
  • 托盘点击未经身份验证。 该回调以 Win32 消息形式到达,同完整性级别的任何进程都能合成它。点击处理程序请用于可逆的 UI 操作;任何破坏性动作都要用应用内确认把关。

任务栏集成

TaskbarItem 把逐窗口的任务栏功能归拢在一起,同时为兼容性在 ReactorWindow 上保留了旧快捷方式。跳转列表是唯一不在 TaskbarItem 上的任务栏界面,因为它是逐进程而非逐窗口的 —— 见下文跳转列表

var taskbar = UseWindow()!.TaskbarItem;
taskbar.Description = "Build in progress";
taskbar.Progress.State = TaskbarProgressState.Normal;
taskbar.Progress.Value = 0.42;
taskbar.SetThumbnailToolbar([
    new ThumbnailToolbarButton("pause", WindowIcon.FromPath("pause.ico"), "Pause", () => Pause())
]);

外观成员:

  • Progress —— 与 ReactorWindow.Progress 是同一实例。
  • Overlay —— 与 ReactorWindow.Overlay 是同一实例。
  • Description —— 转发到 ITaskbarList3.SetThumbnailTooltip
  • SetThumbnailToolbar / ClearThumbnailToolbar —— 与 ReactorWindow 上那些快捷方法使用同一条工具栏管线。

注意事项:

  • 外壳 COM 调用是尽力而为的;Reactor 在相关处保留最后设置的托管状态。
  • 缩略图工具栏最多支持七个按钮。
  • 覆盖图标与缩略图图标需要 HICON 兼容的来源:FromPath 文件,或 FromBytes / FromRgba 数据。资源 URI 不是覆盖图标 HICON。

跳转列表

JumpList 是进程范围的静态对象:外壳为每个应用标识挂一个列表,而不是每个窗口一个。JumpList.UpdateAsync 替换整个列表,JumpList.ClearAsync 移除它。

激活某个条目会带着该条目的 Arguments 字符串重新启动进程。Reactor 把它作为 LaunchKind.JumpList 呈现给传给 ReactorApp.Run(Action<ReactorAppContext>) 启动回调的 ReactorAppContext。推荐的约定是在 Arguments 里放一个深链接 URI(JumpListItem.ForUri 就是干这个的),并通过 DeepLinkMap<TRoute> 解析它:

// Unpackaged apps must set an AppUserModelId once, before the first
// UpdateAsync — the shell has no other stable identity to hang the
// jump list off. Packaged apps inherit it from the manifest.
public static async Task PublishAsync()
{
    JumpList.AppUserModelId = "Contoso.Reactor.Demo";
    JumpList.ShowRecent = true;

    await JumpList.UpdateAsync([
        JumpListItem.ForUri("New document", "contoso://new"),
        JumpListItem.ForUri("Open dashboard", "contoso://dashboard",
            description: "Jump straight to the dashboard"),
        new JumpListItem("Report a bug", "contoso://bug",
            Kind: JumpListItemKind.Custom, GroupCategory: "Help"),
    ]);
}

// Entries come back as a plain process re-launch. Resolve the argument
// string through the same DeepLinkMap the app already uses for routes;
// never act on it unvalidated. DeepLinkResult.Routes is the resolved
// back stack, deepest route last.
public static void Start(DeepLinkMap<string> routes) =>
    ReactorApp.Run(ctx =>
    {
        if (ctx.LaunchActivation.Kind == LaunchKind.JumpList &&
            ctx.LaunchActivation.TryResolve(routes, out var deepLink))
        {
            ReactorApp.OpenWindow(
                new WindowSpec { Title = deepLink.Routes[^1] },
                () => new SettingsWindow());
        }
    });
API 用途
JumpList.AppUserModelId 外壳身份标识。非打包应用在首次 UpdateAsync 之前必需;MSIX 下会被忽略。
JumpList.ShowRecent / ShowFrequent 开关操作系统管理的分类。内容归外壳所有。
JumpList.UpdateAsync(items) 替换整个列表。仅限 UI 线程。
JumpList.ClearAsync() 移除该应用的条目。
JumpListItem.ForUri(...) 深链接条目 —— Arguments 就是该 URI。
JumpListItem.ForCommandLine(...) argv 风格条目;为 CommandLineToArgvW 转义每个值。
JumpListItemKind TaskCustom(需要 GroupCategory)、Separator

注意事项:

  • 参数字符串会经过外壳往返到下一个进程启动。 Reactor 从不自动执行它们。行动之前先用 DeepLinkMap 校验,并且承载非字面数据的条目要用 JumpListItem.ForCommandLine 构建,这样子恶意值就无法越界窜到相邻的 argv 槽位。
  • 跳转列表条目、托盘「打开」与缩略图工具栏按钮在 WinUI 激活界面上无法区分 —— 三者都以 LaunchKind.JumpList 到达。更细的区分请编码在 URI 本身里。
  • 打包路径上的图标需要 WindowIcon.FromResourcems-appx:///…);FromPath 值在那里会被静默忽略。
  • UpdateAsync 在触碰外壳之前会校验整批数据,因此一个坏条目绝不会留下一个半填充的列表。非分隔符条目必须有非空的 Title

显示器

ReactorDisplay 以 Reactor 面向 DIP 的形状暴露当前显示器布局,并在 Windows 报告布局变化时触发 DisplayLayoutChanged

var displays = UseDisplays();
var nearest = ReactorDisplay.NearestTo(window.Position.X, window.Position.Y);

DisplayInfo 包含:

属性 含义
Id Win32 显示器 id(例如 \\.\DISPLAY1
IsPrimary 是否主显示器
WorkAreaDip 近似 DIP 的工作区
BoundsDip 近似 DIP 的完整边界
Dpi 显示器有效 DPI

注意事项:

  • 混合 DPI 下的虚拟屏幕 X/Y 值是近似的,因为 Windows 暴露的是物理像素,而不是一套全局 DIP 坐标系。
  • ReactorDisplay.Displays 是一份快照。要在变化时重新渲染,请用 UseDisplays()
  • NearestTo 接受 Reactor 近似显示空间中的 DIP 坐标。

选择器

选择器 Hook 创建 WinUI 存储选择器,并用所属窗口的 HWND 初始化它们,因此选择器能正确地对目标窗口呈现模态,而无需应用代码做 HWND 互操作。

// Both helpers return null when the user cancels the dialog.
var pickFile = UseFilePickerAsync;
var pickFolder = UseFolderPickerAsync;

return Button("Open...", async () =>
{
    var file = await pickFile(new FilePickerOptions(
        FileTypeFilter: [".txt", ".md"]));
    if (file is null) return;

    var folder = await pickFolder(new FolderPickerOptions());
    if (folder is null) return;
});

注意事项:

  • 选择器 Hook 必须在所属窗口的 UI 线程上调用。
  • Reactor 从不接受任意的 HWND;它总是使用 UseWindow().NativeWindow
  • 测试应注入选择器服务,而不是打开原生对话框。

WPF / UWP 迁移对照

来自 054 之前的 Reactor 应用?被移除的字段及其替代者见迁移:窗口体系演进

既有技术栈概念 Reactor 054 形态 说明
WPF ResizeMode WindowResizeMode 有意省略了 CanResizeWithGrip
WPF SizeToContent WindowSizeToContent 同样是那四个值。最小/最大仍然优先。
WPF Topmost WindowLevel.AlwaysOnTop Floating 增加了应用内局部的所有者/同级行为。
WPF WindowStyle.None WindowStyle.None IsMovableByBackground 搭配。
WPF WindowStartupLocation.CenterScreen CenterOnCurrent 优先光标所在显示器。
WPF 手动 Top / Left PositionSetPositionUseWindowPosition DIP,有混合 DPI 的注意事项。
WPF 任务栏可见性 ShowInTaskbar ShowInSwitcher 拆开。
手动设置持久化 .WithPersistence(id) 显式开启,一行。
TaskbarItemInfo TaskbarItem 覆盖进度、覆盖图标、描述、缩略图按钮的外观层。
WPF JumpTask / JumpList JumpListItem / JumpList 进程范围而非逐窗口;UpdateAsync 替换整个列表。
UWP/WinUI 选择器 HWND 设置 UseFilePickerAsync / UseFolderPickerAsync 所属 HWND 自动接线。

查找与枚举窗口

public static void Inspect(WindowKey key)
{
    IReadOnlyList<ReactorWindow> all = ReactorApp.Windows; // snapshot
    ReactorWindow? primary = ReactorApp.PrimaryWindow;     // null after it closes
    ReactorWindow? found = ReactorApp.FindWindow(key);     // look up by WindowKey
}

对于任何你可能想再次找到的窗口,都使用 WindowKeyUseOpenWindow 让组件声明式地拥有一个次级窗口的存在;托盘图标则用 UseTrayIcon,并在卸载时自动关闭。

class SettingsHost : Component
{
    public override Element Render()
    {
        // While this component is mounted, ensure a settings window keyed
        // to "settings" is open. Re-renders that pass the same WindowKey
        // reuse the same handle; the hook dedupes against the live window
        // registry via FindWindow.
        var settings = UseOpenWindow(
            key: "settings",
            spec: new WindowSpec { Title = "Settings", Width = 480, Height = 360 },
            factory: () => new SettingsWindow());

        return TextBlock(settings is null
            ? "(no UI dispatcher)"
            : $"Settings open — id={settings.Id}");
    }
}

关闭策略

// Call once at startup, before ReactorApp.Run. With OnLastSurfaceClosed the
// process keeps running while a tray icon or any window is alive; with
// Explicit you must call ReactorApp.Exit() yourself.
static class Startup
{
    public static void ConfigureShutdown()
    {
        ReactorApp.ShutdownPolicy = ShutdownPolicy.OnLastSurfaceClosed;
    }
}
策略 进程在此时退出……
OnPrimaryWindowClosed (默认) 主窗口关闭时
OnLastSurfaceClosed 最后一个窗口与最后一个托盘图标都关闭时
Explicit 从不自动退出;需要调用 ReactorApp.Exit()

OnPrimaryWindowClosed 下,只有被选出的 PrimaryWindow 会触发退出。选择退出关闭策略的辅助窗口(例如停靠系统撕离出的浮动窗口)永远不会被选为主窗口,因此关闭其中一个不会让应用退出,即使它是最后一个可见窗口。

Reactor 在启动时接管 WinUI 的 Application.DispatcherShutdownMode,把平台切换到 OnExplicitShutdown。否则 WinUI 会在最后一个窗口关闭时结束进程,而不去征询策略、托盘图标或 ExcludeFromShutdownPolicy。这一接管是无条件的 —— 它不随策略变化 —— 所以上表对界面关闭而言是穷尽的:当某个窗口或托盘图标关闭时,只由策略决定。进程还可以有两种其他结束方式,二者都是直接要求 WinUI 退出,而不是让它自行判断:一个完全不打开任何界面的启动回调在 OnPrimaryWindowClosed 下会立即退出,而 ReactorApp.Exit() 总是有效。

请在 ReactorApp.Run 之前设置策略,或在 UI 线程上、你的启动回调返回之前设置。界面关闭会在发生的那一刻读取它,所以此前的任何时候做出的更改都算数 —— 但零界面的启动会在回调返回的瞬间被判定,因此启动期间从另一个线程投递的写入可能来得太晚,未被看到。更改策略永远不会移动 DispatcherShutdownMode

Reactor 只在它拥有 Application 时才做这项接管。一个内嵌 ReactorHostControl 的 WinUI 应用运行自己的 Application 并管理自己的窗口,因此 Reactor 不碰它的关闭模式与生命周期。

提示

记忆化 spec。 稳定的 WindowSpec 可避免不必要的外壳更新。

单位统一用 DIP。 窗口尺寸与位置 API 使用 DIP;外壳样式位与 DWM API 内部使用物理像素。

选择最窄的 Z 序。 应用面板优先用 FloatingAlwaysOnTop 留给全局覆盖层。

被拒绝的原语用进阶实践。 如果你需要真正的分层窗口透明或任意形状的圆角,见进阶窗口

下一步