一千个插件的冷启动
插件系统有一个几乎必然的命运:它会越来越慢。而且慢得没有元凶 —— 每个插件都只慢一点点,加起来就是用户盯着转圈的十二秒。这一章把这笔账算清楚,然后给出唯一有效的解法。有意思的是,那个解法你在第 7 章就已经造好了一半。
先算账
假设装了 1000 个插件,每个插件的 activate() 平均要 12ms —— 这不算慢,就是读个配置、建个索引、注册几样东西。
调一下「本次会话真正用到的插件数」,看两种策略的差距:
默认那一档(用户实际用到 5 个)的结果:
① 全量激活 1000 × 12ms = 12000ms = 12.0 秒 ② 懒激活 读 1000 份清单(≈ 20ms)+ 5 × 12ms = 80ms 快了 150 倍。而且那 80ms 还分散在用户的操作之间,感知上等于零。
注意「用到 100 个」那一档:仍然快 9.8 倍。懒激活不是只在极端情况下有用 —— 只要「装了的」比「用到的」多,它就一直在赚。
12 秒和 80ms 的差别,不只是一个性能数字。
全量激活的 12 秒,是用户什么都做不了、盯着启动画面的 12 秒。这是软件里最贵的时间 —— 用户此刻还没得到任何价值,只是在付出。
懒激活的 60ms,分散在用户主动触发某个功能之后。这时候用户有明确的期待,而且 12ms 根本感知不到。
同样的总计算量,放在不同的时刻,用户体验差了一个数量级。这就是「推迟」的全部价值 —— 它甚至不需要让总工作量变少。
懒激活的前提:宿主必须先知道「什么时候需要谁」
推迟听起来简单,但它有一个硬前提:宿主要在不执行插件代码的情况下,知道这个插件在什么条件下才需要被唤醒。
这正是第 7 章那份清单的第二个理由(第一个是安全):
{
"contributes": {
"commands": [{ "id": "wc.count", "title": "统计字数" }]
},
"activation": ["onCommand:wc.count"]
}
宿主读完这几行就能做两件事:
- 立刻把「统计字数」放进命令面板(用户看得见、搜得到、能配快捷键);
- 不加载这个插件的任何代码,直到用户真的点下去。
第 8 章那个「只有壳没有肉」的命令,做的就是这件事。宿主在 runCommand 里发现这条命令没有实现,才去激活对应的插件 —— 整个懒激活机制一共十几行。
VS Code 的激活事件:这个思路的成熟形态
VS Code 把这件事做得最彻底。它的激活事件覆盖了各种「什么时候才需要你」的场景:
| 激活事件 | 什么时候触发 | 典型用途 |
|---|---|---|
onLanguage:python | 打开了一个 Python 文件 | 语言支持插件 |
onCommand:foo.bar | 用户执行了这条命令 | 命令类插件 |
workspaceContains:**/.editorconfig | 工作区里有匹配的文件 | 只对特定项目有用的插件 |
onView:myTreeView | 用户展开了这个侧边栏视图 | 树视图插件 |
onDebug | 开始调试 | 调试适配器 |
onUri | 有外部链接唤起 | OAuth 回调之类 |
onStartupFinished | 启动完成之后的某个时刻 | 确实需要常驻但不急的插件 |
* | 启动时立刻 | 官方文档明确劝退 |
两个设计细节值得学:
① onStartupFinished —— 给「必须常驻」的插件一条体面的路
总有插件确实需要在后台一直跑(同步服务、状态监控)。如果只有 * 这一个选项,它们就会全部堵在启动路径上。
onStartupFinished 说的是:「我确实要启动,但不急,等 UI 出来之后再说。」它把这些插件从「用户等待的关键路径」挪到了「用户已经能开始工作的空闲时段」 —— 总工作量没变,但感知上的启动时间大幅下降。
这个「不急的常驻」档位,是很多人设计激活机制时会漏掉的一个,但它能救回一大批本来只能写 * 的插件。
② 自动生成激活事件
从 VS Code 1.74 起,命令、语言、视图、自定义编辑器这些贡献点会自动生成对应的激活事件,插件作者不用写。
逻辑很直白:宿主已经从 contributes 里知道「这个插件提供了 foo.bar 命令」,何必再让作者手抄一遍 "onCommand:foo.bar"?
这是一个小改动,但它消灭了一整类 bug(清单里写了命令但忘了写激活事件 → 命令面板里有、点了没反应),而且是从根上消灭 —— 不是靠校验器提醒,是靠让那个信息不需要被写第二遍。
同一个事实,永远不要让作者写两遍。
只要一个信息在两个地方各写一次,它们就一定会不一致 —— 不是「可能」,是「一定」,只是时间问题。
正确的做法有两个方向:要么让第二处从第一处推导出来(VS Code 的自动激活事件),要么让校验器强制它们一致(第 7 章那个「说了没做 / 做了没说」的检查)。
第一个方向永远更好,因为它让错误不可能发生,而不是「发生了能被发现」。
还有一类更隐蔽的慢:没有元凶的慢
第 15 章末尾提过,这里展开。
假设你的渲染流程会依次调用所有 transform 插件。装了 20 个,每个 8ms,那就是 160ms —— 每次渲染都掉帧。
但没有任何一个插件「有问题」。每个作者都会说「我这才 8ms 啊」,而且他说得对。
这类问题只能从扩展点的形状上解决:
做法一:让插件声明它关心什么
// ✗ 每次渲染都问所有插件
transforms.forEach(t => { text = t.fn(text); });
// ✓ 插件声明它只关心某类内容,宿主只调相关的
ctx.ui.addTransform('mermaid 渲染', fn, { appliesTo: 'code-block:mermaid' });
// 渲染一段普通文本时,这个插件根本不会被调用
这需要你在扩展点里设计一个「过滤条件」的概念。VS Code 的 when 子句、Chrome content script 的 matches,都是同一个思路:让宿主能在不调用插件的情况下,判断它这次要不要参与。
做法二:把插件移出热路径
更彻底的做法:渲染时不调插件,改成插件主动推送。
// ✗ 拉取模型:渲染时同步问插件要结果(插件慢 = 渲染慢)
render() { return transforms.reduce((t, f) => f(t), text); }
// ✓ 推送模型:插件异步算好了推给宿主,宿主渲染时只读缓存
ctx.onNoteChanged(async (note) => {
const result = await heavyCompute(note);
ctx.ui.setDecoration(note.id, result); // 算好了再推
});
推送模型的代价是「结果可能滞后一帧」,收益是插件再慢也不会卡住渲染。对大多数装饰性功能(语法高亮、行内提示、图标),这个交换非常划算 —— 这也是 VS Code 的装饰、CodeLens、诊断信息全都是异步推送的原因。
启动预算:把它变成一个能守住的数字
光有懒激活还不够,因为总有插件要在启动时跑。你需要一个预算,和一套能守住它的机制。
① 测量每个插件的激活耗时,并展示出来
VS Code 有一个「扩展运行状况」界面,列出每个扩展的激活耗时。这个功能的价值有三层:
- 用户能看到是谁拖慢了启动,自己决定要不要禁用;
- 插件作者能看到自己的排名,社会压力比任何文档都有效;
- 你能拿到全生态的数据,知道该优化哪个 API。
② 给激活设超时和警告线
const t0 = performance.now();
await plugin.activate(ctx);
const ms = performance.now() - t0;
if (ms > 100) {
log.warn(`[慢] 插件 ${id} 激活耗时 ${ms.toFixed(0)}ms(建议 < 100ms)`);
telemetry.slowActivation(id, ms);
}
在开发模式下把这条警告打得很显眼,大部分作者会自己去修 —— 性能问题绝大多数是无心的,作者根本不知道自己慢。
③ 给「启动时激活」设一道门槛
如果一个插件声明了 *(启动即激活),可以要求它在清单里写明理由,或者干脆在商店审核时人工过一遍。
让「拖慢所有人」这件事需要付出一点摩擦,比事后追着优化有效得多。
① 首次触发时的延迟。用户第一次点那条命令,要等插件加载 —— 如果插件加载要 500ms,用户会觉得「这个功能好卡」。
解法:给激活加一个 loading 反馈;或者在空闲时预热那些「很可能会用到」的插件(比如上次会话用过的)。
② 插件作者会误解生命周期。有作者会假设 activate() 在启动时就跑了,于是在里面做「注册一个全局快捷键」这种必须尽早发生的事 —— 结果那个快捷键在插件被激活前根本不存在。
解法:凡是「必须尽早生效」的东西,全部走清单声明(快捷键、菜单、文件关联),不允许在代码里注册。这又回到了第 5 章:能声明的别用回调。
装到自己身上
如果你的插件系统现在是全量激活,按这个顺序改,每一步都能单独上线:
第一步:先测量。加上每个插件的激活耗时统计,打进日志。你需要先知道现状有多糟 —— 而且这个数据是你说服自己做后面几步的依据。
第二步:把「贡献声明」挪进清单。命令、菜单、快捷键、文件关联,全部从代码注册改成清单声明。这一步最花时间,但它是所有后续优化的地基。(而且它顺便解决了第 7 章那些安全和分发问题。)
第三步:加激活事件。先支持最简单的 onCommand:,覆盖大部分场景。然后按需加 onLanguage:、onView:。
第四步:给剩下的常驻插件一个 onStartupFinished 档位,把它们挪出关键路径。
第五步:把耗时排行展示给用户和作者。这一步几乎不花钱,但它让优化变成一件持续发生的事,而不是你一个人的活。
// ✗ Application.onCreate 里全量加载插件 —— 直接拉长冷启动,甚至 ANR
class App : Application() {
override fun onCreate() {
super.onCreate()
extensionLoader.loadAll().forEach { it.activate() } // 💥
}
}
// ✓ 只读清单(便宜),激活推迟到真正需要
class App : Application() {
override fun onCreate() {
super.onCreate()
// 只做「发现 + 读元数据」,不碰扩展的代码
appScope.launch(Dispatchers.IO) {
extensionRegistry.discover() // PackageManager 扫描 + 读 meta-data
}
}
}
// 用户真的打开「浏览」页面时,才实例化那个源
suspend fun getSource(id: Long): Source = withContext(Dispatchers.IO) {
cache.getOrPut(id) { extensionLoader.instantiate(id) } // ← 这一刻才反射 newInstance
}
Android 上这件事的收益比桌面端还大,因为:冷启动时间直接影响用户留存和商店评分;Application.onCreate 里的任何阻塞都算在启动时间里;而且低端设备上,反射实例化几十个类的开销比你在旗舰机上测出来的高好几倍。
务必用低端设备实测 —— 在旗舰机上「感觉还行」的启动流程,在千元机上可能就是三秒白屏。
这一章的一句话
懒激活的前提是宿主能在不执行代码的情况下知道「什么时候需要谁」—— 所以清单不只是安全机制,它同时是性能机制;而同样的总计算量,放在用户等待时和放在用户操作后,体验差一个数量级。
卷 V 还剩最后一个问题,也是最难用技术解决的一个:用户装的那个插件,到底是不是作者写的那个?