用途
该技能解决桌面应用启动顺序问题:单实例守卫必须运行在依赖容器启动之前,也必须在任何打开磁盘状态的操作之前。桌面应用第二次启动就是第二个进程,它在发现自己冗余之前所做的一切,都会作用于第一个进程已经拥有的文件。
主要能力
- 明确单实例守卫应位于容器之上,而不是 UI 内部。
- 解释依赖容器为何容易出问题:容器是惰性的,定义在被首次请求时才构建,急切加载需按定义显式开启(如 single<SettingsStore>(createdAtStart = true))。构造存储通常不打开文件,文件由首次读取打开。
- 指出容器启动与设置文件打开之间往往只隔一行(例如为选择 UI 语言而阻塞读取已保存语言),因此守卫必须位于容器之上。
- 说明两个进程持有同一偏好文件时,存储的写入路径(先写临时文件再重命名覆盖)在拒绝替换已打开文件的操作系统上会失败;在 Windows 上已证实为启动时重命名步骤失败并报输入/输出错误,且发生在任何窗口出现之前。
- 提供桥接回运行中实例的方式:守卫运行在 UI 框架之外,其恢复回调不能直接触碰组合状态,可用一个带缓冲热流的小对象跨过去。
- 说明 tryEmit 配合备用缓冲容量可让守卫回调永不挂起、永不阻塞锁库调用它的线程;窗口收集请求后取消隐藏、取消最小化、提升自身并消费转发的参数。
使用方式
守卫放在容器之上,而非 UI 内部。参考代码顺序为:安装崩溃处理器、预热平台探测、设置渲染器属性、解析深链参数,然后获取单实例守卫;若不是单实例,则把参数写入待处理位置后直接返回,此时尚未触碰磁盘;若是首个实例,才启动容器。
桥接对象使用 MutableSharedFlow<Unit>(extraBufferCapacity = 1),对外暴露 SharedFlow,request() 调用 tryEmit。
转发参数必须在退出前写入、在提升窗口后读取;第二个进程的唯一职责是把值留在第一个进程能找到的地方,第一个进程在恢复收集器中、在使自己可见之后消费它。
argv 应按形状过滤,而非对照固定协议列表:使用正则 ^[A-Za-z][A-Za-z0-9+.-]*://.+ 匹配任意 scheme://…。
适用场景
适用于需要单实例行为的桌面应用,尤其是使用依赖容器、偏好存储、深链或 URL scheme 的应用。
限制与陷阱
- 守卫之前“顺手”做的任何事都是第二个进程触碰过的状态:读取设置值选语言、初始化数据库、打开日志文件、预热缓存,都是在第一个实例拥有的目录中留下立足点。守卫前区域应只保留不触碰任何东西的操作:安装崩溃处理器、设置系统属性、解析 argv。
- macOS 上打开已注册 URL scheme 或点击运行中应用的 Dock 图标会把应用事件投递给现有进程,不会发生第二次启动,因此守卫的恢复回调永远不会为其触发。需单独注册这些事件处理器并路由到同一恢复信号,否则应用会为第二次启动前置,却不会为 Dock 点击前置。
- 放在 UI 内的守卫即使看起来早,也运行得太晚——那时框架、容器和窗口状态都已初始化。判断信号是“第二实例”检查出现在 composable 或窗口回调中。
- 守卫自身状态必须释放:锁文件、绑定的本地端口等在某些系统上会存活于突然终止之后,下次启动会误以为已有实例运行而立即退出。需测试硬杀后重启。
验证方法
- 应用运行时启动两次,确认第一个窗口前置且第二个进程退出,覆盖所有发布平台。
- 自上而下阅读入口函数,检查守卫之前没有任何触碰磁盘的操作。可先去掉 import 块和注释,再执行 grep -n "<container-start-fn>|DataStore|openDatabase|File(" <entry-file> | grep -vE "^[0-9]+: *(import|//|*)",所有存活命中都必须位于守卫行号之下。
- 带参数第二次启动,确认参数到达运行中的实例。
- 突然杀死进程后重启,必须能启动,而不是以“已在运行”静默退出。
- 在 macOS 上专门测试 Dock 图标点击和运行中应用的 scheme 启动,它们走应用事件路径而非守卫路径,会独立失效。