Electron三端统一架构:运行时Adapter、IPC能力边界与分层设计

第二章:地基 ------ 三端统一的应用架构

建筑的地基决定了上面能盖多高。软件也一样。本章讲五个地基层的设计思想------运行时 Adapter、声明式能力边界、分层与依赖方向、预算约束驱动的性能架构、命名空间迁移------它们不是 AI 相关的技术,但没有它们,后面所有 AI 能力都无处安放。


2.1 一套代码三端运行:运行时 Adapter

问题:多一个平台,多一倍麻烦

假设你写了一个聊天应用。React 前端,Electron 桌面壳。跑起来了,产品说:出个手机版。

最直觉的做法是什么?再开一个仓库,React Native 重写一套 UI。界面长得差不多,网络请求抄一遍,两三周出个能用的版本。

第一个月没问题。第二个月,桌面版加了"消息已读回执"。你在移动版也要加------两边各写一遍,各调一遍。第三个月,回执逻辑改了,桌面版改完,移动版忘了。用户投诉:手机上已读状态不对。

这不是 bug,是架构问题。

两套代码意味着每个功能的维护成本不是 2 倍------写代码可能只多一倍,但"记得去改第二个地方"的认知负担是指数级的。因为遗漏不会立刻报错。编译通过,测试通过,运行也不崩溃,只是行为不一致。这类 bug 往往在用户投诉后才发现,定位时间远超修复时间。

三个平台就更不用说了。三个仓库、三条构建管线、三倍的发版流程。每个功能改三遍、调三遍、测三遍。如果你是一个五人团队,这个成本也许能扛。如果你是一个人------不管你多勤奋,乘法会杀死你。

那怎么办?能不能只写一份代码,让它在不同平台上跑?

矛盾:一份代码,但底层不同

问题在于,不同平台的底层通信机制不一样。

Electron 桌面应用的前端(渲染进程)和后端(主进程)之间用 IPC(进程间通信)------这是操作系统级的通道,快,但只在本机有效。手机 App 和 Web 浏览器没有这个通道,只能用 HTTP 请求------通过网络发到服务器。

这是一个根本性的差异。你的 React 组件调用"获取对话列表"这个操作,在桌面上应该走 IPC,在手机上应该走 HTTP。如果你把这个判断写在每个组件里------

typescript 复制代码
// 不要这样写
function ConversationList() {
  if (isDesktop) {
    data = ipc.invoke('getConversations')
  } else {
    data = fetch('/api/conversations')
  }
}

每个组件都要写一遍 if。50 个 API 调用点,50 个 if。改一个通信逻辑,改 50 个地方。这比两套代码还糟------你把平台差异像撒胡椒面一样撒遍了整个项目。

方案:Adapter 模式

1994 年,GoF 在《设计模式》里描述了一个叫 Adapter 的模式。原始定义比较抽象,但核心思想很简单:

把差异关在一个房间里,不让它跑出来。

具体做法是在业务层和平台层之间插入一个薄层------适配层。业务层只跟适配层的统一接口对话,不知道底层是 IPC 还是 HTTP。适配层内部做判断和转换,把请求转发到正确的通道。

markdown 复制代码
业务层(React 组件)
    ↓ 调用统一接口
适配层(transport + api)
    ↓ 内部判断平台
IPC / HTTP / WebSocket

这个模式的价值不在于它多聪明------它很朴素。价值在于它划了一条线:线以上的代码永远不需要知道自己跑在什么平台上。新增一个平台,只改线以下。新增一个功能,线以下不用动。两个方向的变化互不干扰。

这就是软件设计里反复出现的一个原则:把变化的原因隔离开。平台会变(今天三个,明天可能四个),功能也会变(每周加新 API)。如果两种变化搅在一起,任何一个改动都可能波及全局。Adapter 把它们分开了。

实战:470 行撑起三个平台

回到 Halo。它需要跑在三个环境上:Electron 桌面、Capacitor 移动壳(iOS/Android)、任意浏览器(远程访问)。这三个环境的检测逻辑集中在一个文件 transport.tssrc/renderer/api/transport.ts,470 行),判断条件各不相同:

环境 怎么检测 走什么通道
Electron 桌面 window 上有 halo 对象(preload 注入的) IPC
Capacitor 移动 编译时注入 __CAPACITOR__ 标志 HTTP 到用户配置的服务器地址
浏览器远程 以上都不是 HTTP 到当前页面的域名

然后,api/index.ts(约 2000 行,100 多个方法)里每个 API 都是同一个写法:

typescript 复制代码
// src/renderer/api/index.ts
listConversations: async (spaceId: string) => {
  if (isElectron()) return window.halo.listConversations(spaceId)
  return httpRequest('GET', `/api/spaces/${spaceId}/conversations`)
},

上层 React 组件调 api.listConversations(spaceId),不知道也不需要知道底层走的是什么。桌面上走 IPC,手机和浏览器上走 HTTP。100 多个方法,全部同一个模式。

这就是 Adapter。差异被关在 transport.tsapi/index.ts 这两个文件里,项目里其他几万行 React 代码完全不知道平台的存在。

一个关键抉择:运行时检测 vs 编译时分支

Adapter 模式确定了之后,还有一个选择:什么时候判断平台?

编译时分支 是一种做法。C/C++ 里的 #ifdef 就是这个思路------编译时决定走哪条路,打出不同的产物。桌面版编译出 bundle-desktop.js,移动版编译出 bundle-mobile.js,各走各的。

好处是运行时零开销,坏处是三份产物。三条构建管线、三个 CI 配置、三个版本号。构建配置是 Electron 项目里最脆弱的部分------改一个 Vite 配置就可能炸掉整条线。三份配置意味着三倍的炸裂概率。

运行时检测 是另一种做法。只编译一份代码,运行时通过 if 判断当前环境。多了一个 if 的开销(可以忽略),但只维护一份产物、一条构建管线、一个版本号。

Halo 选了运行时检测。原因很具体:一个人加 AI 的团队,维护三条构建管线是不可承受的。运行时多一个 if,换来的是只需要管一份代码------这个交换在 Halo 的约束下是不需要犹豫的。

但这个选择不是在所有场景下都成立。如果三个平台的 UI 差异很大(比如手表和桌面),共享代码的收益就小了,编译分支可能更合适。如果团队有专门的构建工程师,三条管线的成本可以被分摊。Adapter 的"运行时 vs 编译时"不是对错问题,是团队规模和平台差异程度决定的取舍。

代价:差异处理集中在适配层

一套代码跑三个平台,不意味着三个平台的行为完全一样。差异是真实存在的------它们只是被集中管理了,而不是消失了。

举两个例子。移动端用户需要手动填入桌面端的 IP 地址才能连接。但"添加服务器"这个流程有一个微妙需求:用户填了地址,系统要先测试连通性,通了才持久化;取消则丢弃。transport 层用一个临时变量和一个持久变量解决这个问题------设计上很轻,但不处理的话,用户取消连接后会莫名连到一个错误地址。

再比如令牌过期。同样是 401 错误,移动端没有服务器渲染的登录页,需要触发一个 DOM 事件把用户导航回连接页面;浏览器端有服务器兜底,直接刷新让服务器重定向到登录页。两种行为都在同一个 httpRequest 函数里按环境判断,上层调用者不需要知道。

这种细节是 Adapter 模式最容易出问题的地方。宏观架构画得再清楚,一个 401 处理不对,用户就卡在白屏上。模式给你的是结构------怎么在结构里处理每一个边界条件,仍然是工程活。

设计的复利

渲染层共享带来的最大价值不是省了多少代码------470 行 transport 加 2000 行 API,不到 2500 行撑起三端通信。真正的价值是每个功能只写一次

新增一个 API,改适配层的两个文件加主进程 handler,三个文件。如果是三套代码,要在三个仓库各做一遍------写三次、调三次、发三次版。对一个人加 AI 的团队来说,这种乘法是致命的。约束逼出了更简单的架构。

而且这个决策有一个当时没预料到的长远回报:Halo 后来做远程访问功能时,因为渲染层已经和传输层解耦,加 HTTP 支持几乎是零成本------transport 层加一个 else 分支就行。如果当初选了编译分支,远程访问意味着新增第三条构建管线和第三个仓库。

好的架构决策,当时看是"够用就好",回头看是"提前关闭了一类问题"。


2.2 IPC 安全桥:声明式能力边界

问题:谁能调什么

假设你在做一个桌面应用,前端是 Web 技术(HTML/CSS/JS),后端是 Node.js。前端想读一个文件,怎么办?直接调 fs.readFile()

如果你用的是 Electron,默认配置下前端(渲染进程)确实可以直接调用 Node.js 的所有 API------文件系统、网络、子进程,什么都行。开发起来非常顺畅。

但想一下:如果你的应用会加载外部网页呢?一个 AI 浏览器功能,会在渲染进程里打开任意 URL。如果渲染进程能直接调 Node.js API,那任何一个被注入了恶意脚本的网页,都能通过你的应用读取用户的文件系统。一个 XSS 漏洞就能变成完整的系统入侵。

这不是假设性的风险。业界太多 Electron 应用把 nodeIntegration 设成 true(允许渲染进程直接调 Node.js),出过安全事故。Electron 官方文档在安全章节的第一条建议就是:关掉它。

关掉之后,渲染进程和 Node.js 之间就有了一道墙。渲染进程不能直接调用任何 Node.js API。那合法的操作怎么办?------比如"获取对话列表"这种你自己的业务逻辑?

你需要在墙上开洞。问题是:怎么开,在哪开,谁来管?

两种开洞方式

方式一:在每个调用点开。 渲染进程要调什么,就在那个地方单独处理权限。组件 A 需要读文件,就在组件 A 里写权限检查;组件 B 需要发消息,就在组件 B 里写另一个检查。

问题是:权限检查散落在整个项目里。你有 100 个组件,就有 100 个潜在的权限漏洞点。新来的开发者(或 AI)加了一个组件,忘了写权限检查?没有任何机制能提醒你。更糟糕的是,"漏了一个检查"不会报错------功能正常工作,安全缺口静默存在。

方式二:在一个地方声明所有能力。 写一份清单,列出渲染进程能做的所有事情。不在清单上的,做不了。清单就是能力的边界。

Electron 提供了实现第二种方式的机制:contextBridge。它要求你在一个叫 preload 的脚本里,显式声明渲染进程能调用的每一个方法,然后把它们注入到 window 对象上。没声明的方法,渲染进程连函数名都看不到。

这就是声明式能力边界------用一份白名单定义"能做什么",而不是用散落各处的黑名单防止"不能做什么"。安全领域的经验是:白名单几乎总是比黑名单安全。黑名单的问题是你得预见所有攻击方式,漏一个就输了;白名单只需要确保清单是完整的。

实战:954 行白名单

在 Halo 里,src/preload/index.ts 有 954 行,声明了大约 215 个方法。这个文件的内容极其机械:

typescript 复制代码
// src/preload/index.ts
const api: HaloAPI = {
  listConversations: (spaceId) =>
    ipcRenderer.invoke('conversation:list', spaceId),
  sendMessage: (request) =>
    ipcRenderer.invoke('agent:send-message', request),
  // ... 还有两百多个
}
contextBridge.exposeInMainWorld('halo', api)

每一行做的事情都一样:把一个 ipcRenderer.invoke 调用包一层,暴露到 window.halo 上。没有业务逻辑,没有条件判断,纯声明。

这 954 行就是渲染进程的完整能力边界。如果你想知道渲染进程能做什么、不能做什么,读这一个文件就够了。不在这个文件里的操作,渲染进程做不到------不是"不应该做",是"做不到",API 入口根本不存在。

代价:N 文件同步

声明式能力边界的设计很清晰,但它带来了一个工程上的痛苦:每加一个功能,要同步改多个文件。

新增一个从渲染进程到主进程的调用,需要改三个文件:

css 复制代码
1. src/main/ipc/<module>.ts    --- 主进程 handler(实际执行逻辑)
2. src/preload/index.ts         --- 安全桥声明(白名单注册)
3. src/renderer/api/index.ts    --- 渲染层调用(业务层入口)

如果这个功能还要支持远程访问(手机或浏览器通过 HTTP 调用),还需要改第四个文件:HTTP 路由。

少改一处会怎样?编译不会报错,TypeScript 不会警告,运行时也不崩溃。它只是静默失效------你点一个按钮,什么都没发生,没有错误日志,没有异常弹窗。第一次碰到这种 bug,定位花了 40 分钟,最后发现只是 preload 里漏了一行声明。

这是一个典型的"多文件协同一致性"问题。解决思路不止一条,而且它们构成一个从轻到重的光谱:

Level 1:规范约束。 把"三文件同步"写成规范,放在团队成员(或 AI)每次写代码时能看到的地方。好处是零基础设施成本;代价是依赖纪律,漏了不报错。

Level 2:共享接口 + 编译器拦截。 VS Code 用的就是这种方案。它在公共层(common/)定义一个 TypeScript 接口(比如 IMyService),服务端和客户端各写一个适配器------服务端的 IServerChannel 把 IPC 调用映射到真实方法,客户端的 Proxy 类把方法调用转发到 IPC 通道。两端都被同一个接口约束。改了方法签名但忘了更新另一端?TypeScript 编译直接报错。代价是每个服务多一层抽象(Channel + Proxy),样板代码更多;收益是从"靠纪律发现遗漏"升级到"靠编译器发现遗漏"。而且同一个接口可以透明切换底层传输------Electron IPC、Node Socket、浏览器 MessagePort 都走同一套代码,这就是 VS Code 的 Remote 架构能那么干净的原因之一。

Level 3:代码生成。 写一个生成器,从一份定义文件自动生成 handler、preload 声明、渲染层调用。好处是彻底消除遗漏和样板代码;代价是生成器本身要维护,生成时机要管理。gRPC 的 .proto → 生成客户端/服务端代码就是这个思路。

在 Halo 里选了 Level 1------新增 IPC 的频率大约一周一两个,215 个方法的规模还不到值得引入 Channel/Proxy 抽象的地步。规范写在 CLAUDE.md(项目指令文件,AI 每次启动时自动读取)里:"新增 IPC 必须同步修改三个文件"。AI 看见规范就会遵守,漏改的频率从大概三次里错一次降到了几乎不再犯。

如果 Halo 的 IPC 方法数量增长到 500+ 或者有多人协作,Level 2 就值得迁移了------用共享接口换掉现在三个文件各自独立声明的状态。这是一个"什么时候引入抽象层"的判断题,不是"该不该用"的对错题。判断标准很具体:当"漏改导致的调试时间"开始超过"维护抽象层的时间"时,就是升级的信号。

模块组织:按业务切,不按技术切

主进程那边,handler 分散在 src/main/ipc/ 下 27 个文件里,每个文件对应一个业务领域------对话、配置、浏览器、远程访问、App 管理等。

这个组织方式影响的不只是代码整洁度,还有 AI 协作的效率。当你对 AI 说"加一个对话相关的 handler",它知道去 conversation.ts 里找。如果所有 handler 堆在一个文件里,AI 要在几千行代码里定位插入点,出错概率翻倍。模块边界是给人看的,也是给 AI 看的。

所有 handler 遵循同一个响应格式 { success, data?, error? }。渲染层用统一的方式处理成功和失败,不用每个调用点写不同的错误解析。这个约定写在提示词里,AI 从第一天起就没偏离过。

局部重复 vs 全局 DRY

HTTP 路由和 IPC handler 几乎调用同一个 service 层,只是入口不同------IPC 通过 event 对象传参,HTTP 通过 req/res。这种近乎 1:1 的重复,第一反应是抽一个 controller 层去重。

但 IPC 和 HTTP 的签名不同,强行统一需要一个适配层,适配层又引入新的间接性。最终的代码量可能差不多,但可读性下降了------你需要理解适配层才能理解调用链。

"局部重复但一目了然"和"全局 DRY 但需要理解间接层",是软件设计中反复出现的取舍。DRY 原则(Don't Repeat Yourself)是对的,但它的适用前提是"重复的部分真的是同一个东西"。IPC handler 和 HTTP route 看起来像同一个东西,但它们的变化原因不同------IPC 可能要处理 Electron 特有的 event 对象,HTTP 可能要处理中间件和认证。它们碰巧现在长得一样,不代表以后也一样。DRY 消除的应该是"同一个知识的重复",不是"碰巧相似的代码的重复"。


2.3 分层与依赖方向:为什么坏代码不会拖垮整栋楼

问题:一个文件改坏了,半个项目跟着崩

假设你在做一个中等规模的项目------十几个模块,几万行代码。你改了数据库模块里的一个查询函数,结果 UI 页面崩了。

你困惑了一下,然后发现:UI 组件直接 import 了数据库模块的一个内部函数。你改了那个函数的返回格式,UI 组件没跟着改,崩了。

这不是你的错,是代码结构的错。UI 层不应该直接依赖数据库层。 它们之间应该隔着一个 service 层------UI 调 service,service 调数据库。这样你改数据库的内部实现,只要 service 的接口没变,UI 就不会受影响。

这就是"分层"------软件架构里最古老、最基本的组织原则之一。几乎每本架构书都会讲,但它在 AI 编程时代变得比以往更重要。原因很简单:

AI 写出坏代码是常态。

不是偶尔,是常态。一个函数里逻辑绕弯、一个模块内重复实现、一个变量名起得莫名其妙------这些事天天发生。你不可能靠 review 全部拦下,量太大了。

那怎么办?答案不是"让 AI 写出更好的代码"(这取决于模型能力,你控制不了),而是让坏代码的影响范围可控

设计思想:单向依赖 = 风险隔离

分层的核心不是"把代码分成几个文件夹"------那是形式。核心是依赖方向的约束:上层可以依赖下层,下层不能依赖上层。

vbnet 复制代码
UI 层(渲染进程)
  ↓ 可以调用
Service 层(业务逻辑)
  ↓ 可以调用
Platform 层(数据库、文件系统)
  ↓ 可以调用
Shared 层(类型定义、常量)

箭头只能朝下,不能朝上。Shared 层不知道 Platform 层的存在,Platform 层不知道 Service 层的存在。这个约束有两个直接后果:

后果一:损坏被隔离在局部。 如果 AI 在 Platform 层写了一段烂代码,它只可能影响 Platform 层本身和调用它的 Service 层。UI 层不受影响------因为 UI 层不直接依赖 Platform 层。就像一栋楼的承重结构:一角被砸坏,受力被楼板和承重墙分摊到局部,整栋楼不会倾倒。

后果二:懒加载和代码拆分成为可能。 这一点经常被忽略。如果两个模块互相依赖(A import B,B 也 import A),打包工具没法把它们拆成独立的 chunk------它们被绑死在一起。你想对 A 做 dynamic import() 实现懒加载?做不到,因为 B 已经在启动时把 A 拉进来了。循环依赖是性能优化的天敌。 我在在线文档系统里见过这个问题------几个核心模块互相引用,导致主 bundle 怎么拆都拆不小,首屏加载时间降不下去。根源不是打包配置的问题,是依赖方向的问题。

所以分层不是"代码整洁"的审美追求,它是两个非常实际的工程需求的前提:风险隔离和性能优化。

实战:哪些层是干净的,哪些不是

一个真实项目的分层不会是完美的。看一下 Halo 的实际情况:

css 复制代码
src/shared/      → 0 个外部依赖。完全干净。
src/renderer/    → 0 个对 src/main/ 的依赖。靠 Electron 进程模型硬隔离。
src/sdk/         → 0 个对 main/renderer 的依赖。完全独立。
src/worker/      → 0 个对 Electron 的依赖。纯 Node.js 进程。

这四个层的边界是硬的------不是靠 lint 规则,是靠进程隔离和构建配置。renderer/main/ 在不同的进程里运行,物理上就不可能互相 import。worker/ 的代码注释里写着"不得导入 Electron"------因为它跑在独立的子进程里,导入 Electron 会直接崩溃。

但在 src/main/ 内部,情况没那么干净:

  • services/ 和 apps/ 存在双向依赖。 apps 依赖 services 是正常的(57 处 import)。但 services 也反向依赖了 apps(12 处 import)。这意味着你没法把 apps 模块独立拆出去做懒加载------它和 services 绑在一起了。
  • platform/ 反向依赖了 services/(7 处 import)。platform 应该是更底层的基础设施,它不应该知道 services 的存在。

这不是"Halo 的代码多好"的展示------恰恰相反,这是一个真实项目里"80% 干净 + 20% 务实妥协"的典型状态。那 20% 的不干净有具体代价:apps 和 services 的循环依赖让数字员工系统没法被独立懒加载,启动时必须一起加载。如果依赖方向是干净的,数字员工系统可以推迟到用户第一次使用时再加载------这正是下一节(2.4)讲的性能优化手段的前提。

分层对 AI 编程为什么特别重要

传统团队开发里,分层的价值是"代码可维护性"------一个有经验的工程师会自觉遵守依赖方向,偶尔犯错同事也能在 review 里拦住。

AI 编程里,分层的价值变成了损伤控制

AI 不会"自觉遵守"依赖方向。你不在提示词里明确写"这个模块不能 import services/",它就可能从 platform 层直接调 service 层的函数------因为那是完成任务最快的路径。AI 优化的是"当前任务完成",不是"长期架构健康"。

这意味着两件事:

第一,依赖方向必须写在 AI 能看到的地方。 不是写在架构文档的某个角落,而是写在 AI 每次启动时自动加载的项目指令文件里。"src/shared/ 不得 import 任何其他层""platform/ 不得 import services/"------这种规则越具体越好。AI 遵守明确的禁令比遵守模糊的原则可靠得多。

第二,进程级别的硬隔离比 lint 规则更可靠。 Halo 的 renderer/ 和 main/ 之间零违规,不是因为 AI 更守规矩,是因为进程模型让违规在物理上不可能。如果你的架构允许用进程或构建配置来强制执行层级边界,优先用它们------它们不依赖任何人(或 AI)的纪律。

第一章里提到过一个判断:"给 AI 设计合理的模块层次和依赖方向,是纪律能落地的前提。" 本节展开了这个判断的技术含义。分层不保证 AI 写出好代码------但它保证坏代码的爆炸半径是有限的。


2.4 首屏性能:预算、懒加载、Worker 隔离

问题:为什么你的应用越来越慢

假设你在做一个 Electron 桌面应用。第一个月,功能不多,启动飞快------0.5 秒。你很满意。

第三个月,加了用户系统、文件管理、通知中心。启动变成 2 秒。你想"还好,可以接受"。

半年后,加了插件系统、AI 功能、自动更新、数据库初始化。启动变成 5 秒。用户开始投诉。你想优化,打开启动流程的代码------几十个初始化模块排在 app.on('ready') 回调里,互相之间有隐含的依赖关系,没人知道哪个能安全移走。

你不敢动。

这个过程我在大型在线文档系统里亲眼见过。几十个初始化模块塞进启动回调,功能堆到后面,启动时间从 800ms 膨胀到 4 秒。不是因为哪一个模块特别慢------每个模块单独看都只要几十毫秒。问题是没有人在功能加入时问过一个问题:"这个东西是首屏必须的吗?"

VS Code 也面临同样的问题。它的解决方案是把启动拆成 5 个 Phase------Phase 0 是窗口壳,Phase 1 是核心编辑器,Phase 2 以后才是插件系统。这套 Phase 模型后来成了 Electron 应用启动优化的标准参考。

但无论是 VS Code 的 5 阶段还是其他任何分阶段方案,背后的思想是同一个:性能不是事后优化的结果,而是事前设定的约束。

设计思想:预算约束

"优化"和"约束"是两种完全不同的思维方式。

优化是事后的。功能先上,等慢了再找瓶颈、做 profiling、逐个优化。问题是:等你发现慢的时候,技术债已经堆了一层又一层,优化的成本远高于当初做对的成本。而且优化往往是局部的------你优化了一个模块,下个月又加了三个新模块,启动时间又回去了。

约束是事前的。在动第一行代码之前就设定一个预算------比如"启动时间不超过 500ms"------然后每个新功能加入时都必须在预算内。超了,不是"以后优化",而是"现在就不能放在启动阶段"。

这和家庭财务的道理一样。"月底看看花了多少,超了就少花点"是优化思维;"月初设好预算,每笔支出都要在预算内"是约束思维。哪个更有效,不言自明。

预算约束的价值不在于数字本身------500ms、300ms、1 秒都可以,取决于你的产品。价值在于它强迫每个新功能在加入时回答一个问题:你是首屏必须的吗?

大部分功能的答案是"不是"。

实战:三阶段启动

Halo 把启动拆成三个阶段,写在 src/main/bootstrap/ 下。

阶段一:Essential(500ms 红线)。 只注册首屏必须的 9 个 IPC handler。顺序有讲究------Config 必须第一个,因为后面的服务可能读配置。这 9 个服务决定了用户打开应用后第一眼看到的东西:空间列表、对话列表、发送消息的能力。

这个约束写在代码注释里:

typescript 复制代码
// src/main/bootstrap/essential.ts
// GUIDELINES:
//   - Each service here directly impacts startup time
//   - Total initialization should be < 500ms
//   - New additions require architecture review

三行注释,但它形成了一道心理屏障------任何人(包括 AI)想往首屏塞新东西,都会先看到这三行。Remote Access 想进来过,被拒了:远程访问不是首屏必须的。AI Browser 想进来过,也被拒了:浏览器功能可以等用户第一次点击时再初始化。

阶段二:Extended(窗口显示后)。 窗口已经画出来了,用户已经能看到界面了。这时候开始注册非首屏功能:远程访问、AI 浏览器、搜索、健康监控等十几个模块。关键特征是只注册,不执行重逻辑。AI Browser 的初始化是懒加载的------注册 handler 时什么都不做,等用户第一次用到浏览器功能时才真正启动。

最重的活------数据库、调度器、记忆系统、App Runtime------全部丢进一个后台异步函数,UI 不等它。

阶段三:Background(后台执行)。 数据库初始化完成后,调度器、App 管理器、App Runtime 按依赖顺序启动。这里有一个关键的顺序约束:调度器必须最后启动。原因是调度器一旦启动就会触发定时任务,如果事件路由还没注册完,定时事件会被触发但没有监听器接收------然后丢失。

"先订阅,后发布"------在消息系统设计里这是常识,但在启动流程里很容易忘。急着让系统跑起来,忘了有些组件还没准备好接收。

Push + Pull:一个实践中发现的状态同步问题

阶段二完成后,需要通知渲染进程"扩展服务已就绪"。最直觉的做法是发一个事件:

typescript 复制代码
sendToRenderer('bootstrap:extended-ready', { timestamp, duration })

开发时,每次改一行 React 代码,Vite 会热重载渲染进程,但主进程不重启。这个事件在 3 秒前就发过了,新的渲染进程没收到。结果:HMR 之后整个 App Store 面板变灰------渲染进程认为扩展服务还没就绪。

解决方案是同时用两种机制:事件推送(Push)和状态查询(Pull)。主进程发完事件后还维护一个布尔标志位,渲染进程启动时主动查一下------如果已经 ready 就不等事件了。

VS Code 用了更复杂的 Barrier + LifecyclePhase 状态机来解决同样的问题。两个变量和一个 IPC handler 对单窗口应用足够。方案的复杂度应该匹配问题的复杂度。

分层懒加载:同一个原则的多层应用

"推迟到需要时再加载"这个原则不只适用于服务初始化,数据加载也一样。

Halo 的对话存储有一个设计:AI 的思考链(thinking)单独存成 .thoughts.json,不跟消息本体混在一起。原因是思考链占整个对话文件大小的约 97%------一条消息可能只有 200 字,但思考过程可能有 8000 字。如果每次列出对话列表时都把思考链加载进内存,首屏就别想快了。

所以对话数据也分三层:列表只读 index(极轻量),进入对话读 messages(中等),展开思考过程才读 thoughts(重量级)。

同一个思维模型------按需加载,而不是预先加载------从服务层贯穿到数据层。你在自己的项目里也可以用同样的判断:这个数据/服务/模块,用户此刻真的需要看到吗?如果不需要,推迟。

重 I/O 剥离主线程:Worker 隔离

分层解决了"什么时候加载"的问题,但还有一个问题:"加载过程本身如果很重怎么办?"

文件系统监控就是这类问题。桌面应用需要监控工作空间里的文件变化(新增、修改、删除),这涉及大量的文件系统调用。如果在主进程的事件循环里做这件事,每次文件扫描都会阻塞 UI 响应------用户点一个按钮,要等文件扫描完才有反应。

解决方案是把重 I/O 操作剥离到独立进程。Halo 有一个 file-watcher worker(src/worker/file-watcher/),通过 child_process.fork() 运行在独立的 OS 进程里,和主进程通过消息通信。主进程的事件循环完全不受文件扫描的影响。

这个模式的关键约束是:worker 进程不能导入 Electron。它是一个纯 Node.js 进程,只做计算和 I/O,通过类型化的消息协议和主进程交换数据。这种隔离确保了 worker 崩溃不会拖垮主进程------主进程检测到 worker 崩溃后会自动重启它。

什么时候该用 Worker?一个简单的判断标准:如果一个操作可能阻塞事件循环超过 50ms,把它移出去。 50ms 是一帧的时间(按 20fps 算)。超过这个时间,用户能感知到界面卡顿。

这些手段的共同思想

三阶段启动、懒加载、Worker 隔离------这些手段表面上各不相同,但底层是同一个思想:把"什么时候做"和"做什么"分开设计。

不加约束的默认行为是"启动时全部做完"。这在功能少的时候没问题,功能多了就是灾难。分层启动把"做什么"拆成了几批,按优先级分配到不同时间点。懒加载进一步推迟到"用户真正需要时"。Worker 隔离甚至把"在哪里做"也分开了------重活丢到另一个进程,主进程只负责协调。

这套思维方式不限于 Electron。Web 应用的 code splitting、移动端的按需加载、后端服务的延迟初始化------本质上都在回答同一个问题:这件事必须现在做吗?必须在这里做吗?


2.5 SQLite 命名空间迁移:并行开发的版本隔离

问题:两个人同时改数据库

假设你的项目用了 SQLite。你和同事各自在做一个新功能,都需要给数据库加新表。

你们用的是 Rails 风格的全局迁移:每次改 schema 就写一个迁移文件,文件名带版本号(001、002、003...),启动时按顺序执行。

你写了 003 号迁移,加了一张 tasks 表。同事写了 003 号迁移,加了一张 logs 表。你们都在自己的分支上开发,各自的代码跑得好好的。合并代码的时候------版本号撞了。两个 003,系统不知道先跑哪个。

这是一个协调问题。通常的解决方式是:约定由一个人分配版本号,或者用时间戳代替序号。但不管怎么约定,核心矛盾不变------全局版本号要求所有开发者在一条线上排队。

如果你的"开发者"是几个并行运行的 AI 实例呢?它们互相不知道对方的存在,不能在 Slack 上喊一声"003 我用了"。

设计思想:版本隔离

解决版本号冲突有两条路。

路一:每个模块独立数据库。 调度器用 scheduler.db,App 管理器用 apps.db,各管各的版本号,互不干扰。好处是隔离彻底;代价是每个模块都要写一遍数据库打开、PRAGMA 配置、连接管理的样板代码。如果你有五个模块,就是五份几乎一样的基础设施代码。

路二:共享数据库,但版本号按模块隔离。 所有模块共用一个 .db 文件,但每个模块有自己的版本号------调度器的 v3 和 App 管理器的 v3 是两个东西,互不干扰。模块共享连接管理、PRAGMA 配置、事务机制,但各自追踪自己的 schema 版本。

路二就是"命名空间迁移"。它在 Rails 的全局迁移和微服务各自独立数据库之间找了一个中间点:共享基础设施,隔离版本演进。

这个方案的适用条件很明确:多个模块需要持久化,模块之间偶尔需要事务保证,但 schema 演进是独立的。 如果模块之间有大量 JOIN 查询(紧耦合),共享数据库但隔离版本反而碍事------不如用同一套迁移。如果模块之间完全无关(松耦合),独立数据库更干净。命名空间迁移适合中间地带。

实战:一张元表,四个命名空间

用一张 _migrations 表管理所有命名空间的版本:

sql 复制代码
CREATE TABLE IF NOT EXISTS _migrations (
  namespace  TEXT PRIMARY KEY,
  version    INTEGER NOT NULL DEFAULT 0,
  applied_at INTEGER NOT NULL
)

三列,每个命名空间一行记录。scheduler 版本 1,app_manager 版本 3,app_runtime 版本 3,store-cache 版本 1。不是每次迁移一行------那样要扫描历史。只关心"现在到了第几版"。

每个消费模块注册自己的迁移序列:

typescript 复制代码
const migrations = [
  { version: 1, up: (db) => { /* 建初始表 */ } },
  { version: 2, up: (db) => { /* 加索引 */ } },
  { version: 3, up: (db) => { /* 改字段 */ } },
]
dbManager.runMigrations('app_manager', migrations)

runMigrations 查当前版本,只跑未执行的迁移,全部在一个事务里完成。中间任何一步失败,整个事务回滚,版本停留在执行前。

为什么事务性不是可选的

事务在这里不是"最佳实践",是必需品。

SQLite 不支持 ALTER COLUMN------改一个字段类型,唯一的办法是建新表、复制数据、删旧表、改名。这四步如果中间断了(比如断电),数据库就停在一个不一致的中间状态:旧表删了,新表还没改名。事务把这四步变成原子操作------要么全成功,要么全回滚,不存在中间状态。

better-sqlite3db.transaction() 返回一个函数,不是启动一个事务。调用这个函数才真正执行。内部任何一步抛异常,整个事务回滚。这个 API 设计本身就值得学习------它让事务的边界在代码里一目了然。

损坏恢复:可用性优先于数据完整性

数据库可能损坏。断电、磁盘错误、或者开发阶段的 schema 实验,都可能导致 .db 文件无法打开。

多数应用的做法是报错退出。但对桌面应用来说,启动崩溃是不可接受的------用户打不开应用,什么都做不了。所以数据库初始化做了一件事:打开后立刻查一次 sqlite_master,如果查询失败(文件损坏),把损坏的文件改名备份,然后建一个全新的空数据库。

丢数据?是的。调度任务、运行日志、安装记录全没了。但 App 的定义存在 YAML 文件里(不在数据库),安装信息可以重建。相比之下,不能启动就不能恢复,能启动至少能重建。

这是一个可用性 vs 数据完整性的取舍。不同的产品会做不同的选择------银行系统选数据完整性,桌面应用选可用性。关键不是选哪个,是明确知道自己选了什么、放弃了什么

336 行,不多不少

database-manager.ts 全文 336 行。没有 ORM,没有查询构建器。每个消费模块拿到数据库连接,自己写 SQL,自己定义迁移序列。

为什么不用 ORM?数据模型不复杂------四个命名空间加起来不到十张表。ORM 的抽象层在查询调试时反而增加间接性。而且 better-sqlite3 是同步 API(这是它快的原因之一),ORM 大多假设异步,适配层又是一层复杂度。

这是一个工程判断:抽象层的价值只在问题规模足够大时才显现。 10 张表用 ORM,你花在理解 ORM 行为上的时间可能比写 SQL 还多。100 张表就不一样了------ORM 的类型安全和查询构建在大规模下能防止大量低级错误。判断标准不是"ORM 好不好",而是"你的问题规模是否值得引入这层抽象"。


本章回顾

这一章讲了五个设计思想,每一个都可以脱离 Halo 独立应用到你自己的项目里。

运行时 Adapter(2.1):把平台差异封装在一个薄层里,业务代码不知道自己跑在什么平台上。核心原则是"把变化的原因隔离开"。运行时检测 vs 编译时分支的选择取决于团队规模和平台差异程度。

声明式能力边界(2.2):用一份白名单定义所有能力,而不是在每个调用点做权限检查。白名单几乎总是比黑名单安全。多文件同步的工程代价,用规范约束、共享接口还是代码生成来解决,取决于变更频率和项目规模。

分层与依赖方向(2.3):单向依赖不是代码整洁的审美追求,它是风险隔离和性能优化的前提。循环依赖让懒加载和代码拆分做不了;在 AI 编程时代,分层更是损伤控制机制------坏代码被困在局部,不会跨层传染。

预算约束(2.4):性能不是事后优化,是事前约束。一个写在代码注释里的数字(500ms),比任何 profiling 工具都能更早地防止性能退化。同一个"推迟到需要时再做"的原则,从服务初始化延伸到数据加载和进程隔离。

命名空间迁移(2.5):多个独立演进的模块共享一个数据库时,用命名空间隔离版本号,避免全局协调的开销。事务性迁移不是可选的"最佳实践",在 SQLite 的 ALTER 限制下是必需品。

这五个思想会反复出现在后续章节。第六章的调度器建立在命名空间迁移上,第八章的 AI 浏览器通过能力边界暴露功能,第十一章的 Remote Access 依赖三端 Adapter,第十三章的 AI 编程方法论建立在分层提供的风险隔离之上。地基不出彩,但上层每一层都站在它上面。

下一章进入本书技术深度最深的区域:AI 引擎。一个经过 65 次迭代的自研 Agent 引擎,积累了大量关于"如何让 AI 可靠地完成工作"的工程经验。


本文节选自开源书籍《如何从零构建 7×24 小时 AI Agent》,更多 AI Agent 实战内容欢迎访问 book.imwangfu.com

上一章:全景 --- AI 数字员工是什么

作者博客:混沌随想,持续分享 AI Agent、LLM 工程化、Claude Code 等前沿技术实践。

相关推荐
说了很好1 小时前
马尔可夫扩散链+损失函数推导,手把手实现原生Diffusion
人工智能
聂二AI落地内参2 小时前
合同抽取别停在 JSON:标准规则和交易日历才是硬仗
人工智能
冬哥聊AI2 小时前
滴滴Agent岗二面:RAG 系统的 LLM 幻觉怎么治?从两类根源讲到四道防线
人工智能
AINative软件工程2 小时前
LLM 应用的 Bad Case 反馈闭环工程:别再把用户差评丢进客服表了
llm·openai·agent
唐老板2 小时前
AI 辅助开发的工程体系:从定规则到基础设施
ai编程
lyshlc2 小时前
# AI Agent的推迟判定协议:不确定性下的最优策略
人工智能
HjhIron2 小时前
🤖 一文搞懂 AI Agent 核心概念:从 LLM 到 Tools,手写一个“股票查询 Agent”
agent
用户329901675052 小时前
用zod在运行时兜住AI返回的JSON
人工智能
George3752 小时前
第一章:本体论是什么(以及它不是什么)
人工智能