卷 III · 入CH 13深度 13/24

生命周期:卸载为什么那么难

「加载插件」是一个动作,一行代码。「卸载插件」不是一个动作 —— 它是一个需要宿主、API 设计、插件作者三方全程配合才能达成的状态,而只要有一处没配合,你就得到一个用户永远看不见、但会让应用越用越卡的泄漏。这一章用真的计数器把它演出来。

四个状态disposable监听器泄漏register* 模式

四个状态,不是两个

很多人心里的插件生命周期是「装上 / 卸掉」两个状态。真实的插件系统需要四个:

状态意思插件代码跑了吗占用什么
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;
};
◆ 这个模式的关键,在于责任被搬走了

对比一下两种设计下,插件作者要记住什么

坏设计:「记得在 deactivateremoveEventListenerclearInterval、注销所有命令」—— 要记 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 事件、系统广播已停用的插件继续响应事件
定时器setIntervalsetTimeout、调度任务后台持续耗电耗 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 章会讲清楚),你只是不再等它了。

装到自己身上

◆ Kotlin:Disposable 与结构化并发

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 结束。下一卷是全书最硬也最好玩的一卷 —— 我们要真的逃一次沙箱、真的把这个页面卡死、然后把三种隔离手段并排跑一遍,看同一段坏代码分别落到什么下场。