Skip to content

窗口与导航 ​

主窗口是 Windows 端的外壳:窗口框架怎么搭、左侧导航怎么生成、点进去是哪个页面。

无边框自绘 + 自定义亚克力 ​

窗口没有系统标题栏。构造时做三件事:

csharp
SystemBackdrop = new TransparentAcrylicBackdrop();
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);

ExtendsContentIntoTitleBar 让内容延伸到标题栏高度,SetTitleBar 指定 AppTitleBar 那一格是拖拽区;系统 caption 用 SetBorderAndTitleBar(true, false) 藏掉但保留边框(边框留着,拖边缩放才有)。最小化/最大化/关闭是 XAML 里内联 SVG 路径自己画的——PathIcon.Data 不能放 ResourceDictionary 再 StaticResource 引用,会在运行期抛 XamlParseException。

亚克力是自己实现的 TransparentAcrylicBackdrop,用 DesktopAcrylicController 直接控 Tint:浅色 Tint 近白 #FCFCFC、深色近黑 #141414,默认不透明度 0.25 / 0.45。

内容区铺半透明层让壁纸透出来;侧栏和标题栏则刻意是不透明实色(浅 #F6F6F6 / 深 #161616)——内容区要透气,侧栏不能透,否则导航文字压在花壁纸上读不出来。两者之间不画分割线,靠明度分界。

导航完全由贡献点驱动 ​

这是外壳设计上最关键的一条:外壳只认贡献点,不认页面类型。

以前外壳里写死两处:XAML 的 NavigationViewItem 列表、代码里 tag → Page 类型的 switch。加一页要改两处,插件想挂页面还得另走一条路。现在内置页面自己也降级成了一个内置插件(BuiltinShellPlugin),Kind.Page 贡献点的 Payload 就是页面类型(内核不引用任何 UI 类型,只存 object)。

BuildNavFromContributions() 按贡献点重建整棵导航树,ResolvePageType(tag) 也只查贡献点:

csharp
private static Type ResolvePageType(string? tag) =>
    AppServices.Instance.Kernel.Contributions.Of(ContributionKind.Page)
        .FirstOrDefault(c => c.PageId == tag)?.Payload as Type
    ?? typeof(HomePage);

于是「加一个插件页」不用碰外壳一行代码——插件声明 contributes.navItems,宿主就多一个导航项,点进去落到统一的 PluginNavPage 按 (插件 id, 导航 id) 渲染声明式视图。停用内置外壳插件等于导航清空,这条有单测兜着。

插件导航有上限

插件一级导航项最多显示 3 个(MaxPluginNavItems),按加载顺序取前三个,其余不显示并写一行日志——一个插件塞满导航栏会把内置项挤没。

最小尺寸 900×620 的由来 ​

csharp
presenter.PreferredMinimumWidth = 900;
presenter.PreferredMinimumHeight = 620;

不是拍脑袋的整数。首页是「左版本库 + 右 360px 启动卡」的双栏,再窄右侧卡片就把版本列表挤到只剩几个字。版本页的行又是固定列结构(图标 / 260px 名称列 / 芯片 / 数据列),同样需要下限才能保证每一列都读得出来。

关窗必须停掉所有循环定时器 ​

DispatcherQueueTimer 的 Interval 必须显式设置。默认值是 0,含义是「尽最快速度反复触发」:

csharp
// Interval 必须显式设置:DispatcherQueueTimer 默认间隔为 0,会「尽最快速度」自旋。
_taskRefresh.Interval = TimeSpan.FromMilliseconds(300);

自旋本身无害(RefreshTaskRows 有脏标志挡着),但 tick 里只要出现毫秒级开销(读会话锁文件、探测 java 进程),UI 线程就被占满、再也泵不动 Dispatcher 队列——表现是「启动器一启动整个界面卡死」。

更要命的是关窗时不停掉它。WinUI 3 的退出流程要等 DispatcherQueue 停下来,而循环定时器会让它一直有活可干:窗口已关闭、消息循环永不返回,进程长期残留(没有窗口但内存还占着),还锁住 Starcloud.App.exe,导致后续 dotnet build 报 MSB3021。所以 Closed 里必须收尾:

csharp
private void StopBackgroundTimers()
{
    _taskRefresh.Stop();
    AppServices.Instance.NovaRuntime.StopTimers();
    var link = AppServices.Instance.DeviceLink;
    _ = Task.Run(() => link.Stop());   // 不等收尾:Stop 里有同步 Wait
}

设备链路的 Stop() 特意放后台线程:它内部有同步 Wait,在 UI 线程上等就是「关窗卡一下」,而 beacon / TCP 循环本来就在后台线程上,进程退出即随之结束。

定时器收尾别漏

常驻的循环定时器只有两处(主窗口的 300ms 调度中心刷新、Nova 的游戏内观察循环),但两处都要停。「不阻塞关闭」和「必须停掉」是两回事。

相关链接:外观与动效 · 自绘控件与交互行为 · 插件与内核

基于 MIT 许可证(有修改)开源