WinUI 参考: 完整的属性面与设计指引见 Windowing Overview。
窗口¶
多数 Microsoft.UI.Reactor(Reactor)应用从 ReactorApp.Run 创建的那一个窗口开始。更大的桌面应用可以用 WindowSpec 与 ReactorApp.OpenWindow 打开多个原生 WinUI 顶层窗口,同时保持与页面内部相同的声明式组件模型。
生命周期基础¶
ReactorApp.Run<TRoot>(...) 打开主窗口。当主窗口需要完整的声明式属性面 —— 图标、最小/最大尺寸、背景材质、圆角样式或位置持久化 —— 请传入一个 WindowSpec 而非各个单独参数。ReactorApp.OpenWindow 在 UI 线程上打开一个次级窗口,并返回一个 ReactorWindow 句柄,用于命令式的生命周期操作。
public static void OpenSettings()
{
var settings = ReactorApp.OpenWindow(
new WindowSpec { Title = "Settings", Width = 520, Height = 420 },
() => new SettingsWindow());
settings.Activate();
settings.Close();
}
注意事项:
Close、Show、Hide、Activate、Update以及各改写方法仅限 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 |
CanResize、NoResize、CanMinimize |
AspectRatio |
double? 宽 / 高;在拖拽缩放期间生效 |
SizeToContent |
Manual、Width、Height、WidthAndHeight |
注意事项:
AspectRatio会拒绝ResizeMode.NoResize;没有拖拽就没有可施加的约束。AspectRatio与SizeToContent是互斥的布局驱动方式。SizeToContent在布局之后运行,所以第一帧可能短暂使用初始的Width/Height(未设置时则是操作系统选定的尺寸);最大化的窗口会忽略它并记录一条警告。- 最小/最大字段(
MinWidth、MaxHeight等)优先于基于内容与宽高比的尺寸计算。
移动与摆放¶
初始摆放用 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 序层级。ShowInTaskbar 与 ShowInSwitcher 是分开的,因为任务栏按钮与 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 |
Default、None、ToolWindow |
WindowCornerStyle |
Default、Square、Rounded、RoundedSmall |
BackdropKind |
None、Mica、MicaAlt、DesktopAcrylic、AcrylicThin、Transparent |
TitleBar(...) 是声明式的自定义标题栏。当 WindowSpec.ExtendsContentIntoTitleBar 为 null(默认)时,挂载一个 TitleBar(...) 元素会自动设置 Window.ExtendsContentIntoTitleBar = true。在 spec 上显式写 true 或 false 会压过推断。
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 |
Standard、Tall、Collapsed |
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(...)即可。 - 没有
IsMovableByBackground的WindowStyle.None会让用户无法移动窗口;Reactor 会警告但不抛异常。 - 除非显式设置
ShowInTaskbar,WindowStyle.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,看起来就完全像是「窗口图标没生效」。
因此,想要处处一致的图标,应用需要设置全部三处,并指向同一个 .ico:icon:(或 Assets\AppIcon.ico 约定)、csproj 中的 <ApplicationIcon>,以及 —— 打包时 —— 清单里的 logo。mur --create 会为你搭好前两个。
注意事项:
- 优先用真正的
.ico。它是AppWindow.SetIcon所记录的格式,而且 —— 对文件来源而言 —— 是托盘图标、任务栏覆盖图标与缩略图工具栏界面唯一能加载的格式,因为它们需要通过LoadImageW拿到裸HICON。Reactor 会把来源原样交给平台,而不预先校验扩展名。 - 打包应用不会从
Package.appxmanifest得到它的窗口图标。清单通过包标识驱动任务栏按钮与任务管理器,这完全绕过了窗口句柄 —— 因此没有显式图标时,即使任务栏按钮看起来正常,标题与 Alt-Tab 条目仍显示一个通用字形。 - 单独设置
<ApplicationIcon>会设定 Explorer 为.exe显示的图标,以及任务管理器在窗口归组所属的进程行上显示的图标。把它带到窗口上的是 Reactor 的回退逻辑;WinUI 自己不会这么做。 - 一个不存在的
FromPath来源会被上报为失败,这样回退逻辑仍会运行 —— 一个声明了却不存在的图标,绝不会让窗口比完全不声明更光秃。FromResourceURI 在到达平台之前会被映射到应用旁对应的文件,因为AppWindow.SetIcon要的是文件系统路径:如果直接把 URI 交出去,打包应用会静默地得到一个默认图标,而不是该资产。 - 二进制图标会在
WindowIcon的整个生命周期内持有其字节。这正是该 API 的意义,但对于一个长期存活、携带大型多分辨率.ico的 spec,请把这点记在心上。
托盘图标¶
ReactorApp.OpenTrayIcon 为进程注册一个通知区域图标;UseTrayIcon 把一个图标限定到某个组件,并在卸载时关闭它。两者都接受 TrayIconSpec,而改写 Icon、Tooltip 或 IsVisible 会通过 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 |
Task、Custom(需要 GroupCategory)、Separator |
注意事项:
- 参数字符串会经过外壳往返到下一个进程启动。 Reactor 从不自动执行它们。行动之前先用
DeepLinkMap校验,并且承载非字面数据的条目要用JumpListItem.ForCommandLine构建,这样子恶意值就无法越界窜到相邻的 argv 槽位。 - 跳转列表条目、托盘「打开」与缩略图工具栏按钮在 WinUI 激活界面上无法区分 —— 三者都以
LaunchKind.JumpList到达。更细的区分请编码在 URI 本身里。 - 打包路径上的图标需要
WindowIcon.FromResource(ms-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 |
Position、SetPosition、UseWindowPosition |
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
}
对于任何你可能想再次找到的窗口,都使用 WindowKey。UseOpenWindow 让组件声明式地拥有一个次级窗口的存在;托盘图标则用 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 序。 应用面板优先用 Floating;AlwaysOnTop 留给全局覆盖层。
被拒绝的原语用进阶实践。 如果你需要真正的分层窗口透明或任意形状的圆角,见进阶窗口。