生命周期:卸载为什么那么难
「加载插件」是一个动作,一行代码。「卸载插件」不是一个动作 —— 它是一个需要宿主、API 设计、插件作者三方全程配合才能达成的状态,而只要有一处没配合,你就得到一个用户永远看不见、但会让应用越用越卡的泄漏。这一章用真的计数器把它演出来。
四个状态,不是两个
很多人心里的插件生命周期是「装上 / 卸掉」两个状态。真实的插件系统需要四个:
| 状态 | 意思 | 插件代码跑了吗 | 占用什么 |
|---|---|---|---|
installed已安装 | 清单读过了、校验过了、登记在册 | 没有 | 磁盘 + 一条记录 |
active已激活 | 代码加载了,activate() 跑过了 | 是 | 内存 + 它注册的一切 |
stopped已停用 | 撤销了它注册的东西,但记录还在 | 跑过,现在不该再跑 | 理论上只剩记录 |
uninstalled已卸载 | 记录也删了,文件也删了 | — | 什么都不该剩 |
第三行那个「理论上」,就是这一章的全部内容。
为什么需要 stopped 这个中间状态?因为用户经常想临时关掉一个插件(排查问题、嫌它慢、暂时不用),而不是删掉它 —— 删掉意味着丢失它的配置和数据。所有成熟系统都有这个「禁用但保留」的状态。
「停用」到底要做什么
看起来很简单:调一下插件的 deactivate(),让它自己清理。
这是个陷阱。把清理的责任交给插件作者,等于把你系统的健康交给了几百个陌生人的细心程度。而且他们清理不干净的时候,没有任何人会发现 —— 不报错、不崩溃,只是内存慢慢涨。
问题在于插件在 activate 期间留下的痕迹,散落在宿主的各个角落:
module.exports = {
activate(ctx) {
ctx.ui.addCommand('x', '…', fn); // → 躺在宿主的 commands 数组里
ctx.ui.addTransform('y', transform); // → 躺在 transforms 数组里
ctx.events.on('note:open', handler); // → 躺在宿主的监听器表里
setInterval(tick, 1000); // → 躺在事件循环里
document.addEventListener('click', h); // → 躺在 DOM 上
window.myCache = new Map(); // → 挂在全局上
}
};
这六处,每一处都持有一个指向插件闭包的引用,而每个闭包又引用着整个插件模块。只要有一处没清掉:
- 整个插件模块(连同它加载的所有数据)永远无法被垃圾回收;
- 它的定时器还在跑;
- 它的监听器还在响应事件 —— 一个「已停用」的插件还在偷偷干活。
亲眼看它漏
下面两个插件干的事一模一样,只差一个字:一个用 ctx.events.on(会登记),一个用 ctx.events.onLeaky(不登记)。
点「两个都启用」→「两个都停用」,来回几次,盯着「宿主上挂着的监听器」那个数字:
点「来回 5 轮」之后,你会看到:
命令面板里的命令 0 ← 干净了 宿主上挂着的监听器 5 ← 每停用一次,漏一个
那 5 个监听器属于早就「停用」了的插件。点「触发一次 note:open」试试 —— 它们还会响应,还在往日志里写东西。一个用户以为已经关掉的插件,还在运行。
这就是插件系统里最常见的内存泄漏,而且它有三个恶劣性质:
① 用户看不见。没有报错、没有崩溃,只有「用久了越来越卡」。
② 症状和原因隔得很远。用户抱怨卡顿时,早就不记得自己两小时前开关过某个插件。
③ 在开发期几乎不会暴露。开发时你启停一两次,漏一两个监听器毫无感觉。要跑上几百次才明显。
正确解法:让「对的写法」成为「最省事的写法」
解决办法不是写文档提醒插件作者记得清理。是把清理这件事,做进 API 的形状里。
核心是一个极其简单的模式,叫可弃置对象(disposable):
// ① 任何「注册」类操作,都返回一个撤销凭证
function disposable(fn) {
let done = false;
return {
dispose() {
if (done) return false; // 幂等:重复 dispose 不出错
done = true;
fn();
return true;
}
};
}
// ② 宿主给插件的 API,自动帮它登记
PluginRec.prototype.track = function (d) {
this.disposables.push(d);
return d;
};
api.events.on = (ev, fn) => rec.track(host.on(ev, fn));
// ↑↑↑↑↑↑↑↑↑ 插件根本不用管
// ③ 停用时,把凭证挨个兑现
PluginRec.prototype.disposeAll = function () {
let n = 0;
this.disposables.forEach(d => { if (d.dispose()) n++; });
this.disposables = [];
return n;
};
对比一下两种设计下,插件作者要记住什么:
坏设计:「记得在 deactivate 里 removeEventListener、clearInterval、注销所有命令」—— 要记 6 件事,漏一件就泄漏,而且漏了没人告诉你。
好设计:「用 ctx.events.on 就行」—— 要记 0 件事。
把正确的写法做成最省事的写法,是 API 设计里最强的一招。因为你不能指望几百个陌生人都很细心,但你可以指望他们都很懒 —— 那就让懒惰的路径通向正确。
两个真实系统都是这么做的:
// Obsidian:Plugin 基类提供的 register* 系列
export default class MyPlugin extends Plugin {
onload() {
this.registerEvent(this.app.workspace.on('file-open', handler));
this.registerDomEvent(document, 'click', handler);
this.registerInterval(window.setInterval(tick, 1000));
this.addCommand({ id: 'x', name: '…', callback: fn }); // 也是自动回收的
}
// onunload 里什么都不用写 —— 上面全部会被自动撤销
}
// VS Code:context.subscriptions
function activate(context) {
context.subscriptions.push(
vscode.commands.registerCommand('x', fn),
vscode.workspace.onDidChangeTextDocument(handler),
vscode.window.onDidChangeActiveTextEditor(handler),
);
// 插件停用时,VS Code 把整个数组 dispose 一遍
}
两家的 API 长得不一样,机制完全相同。如果你要设计插件 API,这个模式请直接抄,没有别的更好的做法。
一份「停用时要收什么」的清单
就算有了 disposable 模式,也得先知道有哪些东西需要被登记。这份清单可以直接拿去对照:
| 类别 | 具体的东西 | 不收会怎样 |
|---|---|---|
| 扩展点贡献 | 命令、菜单项、视图、文本处理器、主题 | 界面上残留幽灵条目,点了报错 |
| 事件监听 | 宿主事件总线、DOM 事件、系统广播 | 已停用的插件继续响应事件 |
| 定时器 | setInterval、setTimeout、调度任务 | 后台持续耗电耗 CPU |
| 长连接 | WebSocket、SSE、文件监视器 | 连接泄漏,服务端也跟着受累 |
| 子进程 / 线程 | 语言服务器、Worker | 孤儿进程,用户要去任务管理器杀 |
| 全局修改 | 改了原型链、改了全局配置、注册了全局快捷键 | 最难修复 —— 往往需要重启 |
| 缓存 / 大对象 | 它自己建的索引、图片缓存 | 内存占用不释放 |
如果你允许插件修改全局状态(原型链、全局配置、单例),那么「干净卸载」在原理上就不可能。
因为宿主不知道插件改了什么,也不知道改之前是什么。你可以做快照 + 对比,但那个成本高得离谱,而且对「插件改了另一个插件的东西」这种情况依然无解。
唯一的真解法是让全局状态根本不可达 —— 也就是运行时隔离(第 16 章)。这也是 Chrome 卸载扩展如此干净的原因:整个上下文销毁掉就完了,它根本不需要「收拾」。
换个角度说:运行时隔离的第二个红利,是免费的完美卸载。第一个红利是安全,第三个是崩溃隔离 —— 三样东西一起买,这就是为什么它虽然贵但值得。
还有几个容易忽略的坑
坑一:deactivate 自己抛异常
如果插件的 deactivate() 抛了异常,而你没接住,后面的清理就全没做:
// ✗ 一个插件抛异常,它自己的 disposables 就不收了
plugin.deactivate();
rec.disposeAll();
// ✓ 无论如何都要收
try {
plugin.deactivate();
} catch (e) {
log(`${id} 的 deactivate 抛了:${e.message}`); // 记下来,但不阻止清理
}
rec.disposeAll(); // ← 一定会执行
原则:插件的清理代码可以失败,宿主的清理代码不能。
坑二:dispose 时的顺序
如果插件之间有依赖(第 20 章),停用顺序必须和启用顺序相反。先停被依赖的那个,依赖它的插件在清理时可能就找不到东西了。
坑三:异步的 deactivate
如果 deactivate() 是 async 的(要保存数据、要关连接),宿主必须等它,而且要有超时:
await Promise.race([ plugin.deactivate(), new Promise(r => setTimeout(r, 3000)), // 3 秒不完事就不等了 ]);
不等的话,用户点「禁用」之后插件还在后台写文件;一直等的话,一个卡住的插件能让整个「禁用」操作永远转圈。超时是唯一的答案 ——但要注意:超时之后你并不能真的让它停下来(同进程没这个能力,第 15 章会讲清楚),你只是不再等它了。
装到自己身上
JVM 侧的等价物,以及一个 Kotlin 特有的巨大便利:
fun interface Disposable { fun dispose() }
class PluginRecord(val manifest: PluginManifest) {
private val disposables = mutableListOf<Disposable>()
// ★ 插件的所有协程都跑在这个 scope 里
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun <T : Disposable> track(d: T): T = d.also { disposables += it }
fun disposeAll() {
// 后进先出:和注册顺序相反
disposables.asReversed().forEach {
runCatching { it.dispose() }.onFailure { e -> log.w("清理失败", e) }
}
disposables.clear()
scope.cancel() // ← 这一行取消该插件启动的所有协程,包括嵌套的
}
}
那个 scope.cancel() 是 Kotlin 给插件系统的一份大礼。只要你强制插件用 ctx.scope.launch { } 而不是自己 GlobalScope.launch,那么「取消这个插件的所有后台工作」就是一行代码 —— 而且是结构化的:它会连带取消所有子协程,不管嵌套多深。
这解决了上面那个「异步 deactivate」的坑的一大半:不需要等插件自己收拾,直接取消它的整个 scope。
所以在 Kotlin 里设计插件 API 时,务必把 CoroutineScope 放进 ctx 里,并在文档里明确要求插件用它。这是那种「一个决定省掉一类 bug」的设计。
泄漏最麻烦的是「开发期发现不了」。写一个测试逼它现形:
@Test
fun `反复启停不应该泄漏`() {
val host = Host()
host.install(manifest, source)
repeat(100) {
host.activate(manifest.id)
host.deactivate(manifest.id)
}
// 停用之后,这些计数必须回到基线
assertEquals(0, host.listenerCount())
assertEquals(0, host.registry.totalItems())
assertEquals(0, host.activeTimers())
assertTrue(host.pluginRecord(manifest.id).scope.coroutineContext.job.children.none())
}
100 次是关键 —— 跑一次两次什么都测不出来。这个测试很便宜,但它是唯一能在 CI 里抓住泄漏的手段。
如果你想更狠一点,可以在停用后强制 GC 并用弱引用断言插件实例已被回收(JVM 上可行,但会有点 flaky,适合放在单独的慢速测试集里)。
这一章的一句话
卸载不是一个动作而是一个需要全程配合的状态;唯一可靠的做法不是提醒插件作者细心,而是把「登记—撤销」做进 API 的形状里,让最省事的写法恰好就是正确的写法。
卷 III 结束。下一卷是全书最硬也最好玩的一卷 —— 我们要真的逃一次沙箱、真的把这个页面卡死、然后把三种隔离手段并排跑一遍,看同一段坏代码分别落到什么下场。