内核的生命周期
内核做的事很少:登记插件、按依赖顺序激活、停用时把一切收回原样。这一页讲它的三条纪律,以及两个必须小心的地方。
三条纪律
代码评审按这三条卡(写在 Kernel.cs 头部):
- 内核里不许出现具体功能的名字。 出现下载、Nova、版本,就是设计错误。
- 激活顺序:引擎 → 功能 → 助手 → UI,依赖先于被依赖者,懒插件等第一次用到。
- 停用必须可逆:契约注销 + 贡献撤下 + 订阅断开,重启用不能重复注册。
第三条是最容易被写坏的一条。插件的 onLoad 里注册了面板、订阅了事件、提供了契约——停用时这些都得消失,不然插件页会看到已经卸载的插件还挂在导航栏里。
顺序与依赖
StartAsync 按 PluginKind 依次激活,同种之内按登记顺序:
foreach (var kind in new[] { PluginKind.Engine, PluginKind.Feature, PluginKind.Assistant, PluginKind.Ui })
foreach (var runtime in Snapshot(kind, PluginActivation.Eager))
await ActivateAsync(runtime.Descriptor.Id, ct);但登记顺序只是起点,真正的依赖关系由 ActivateAsync 递归处理:
foreach (var dependency in runtime.Descriptor.Depends.Keys)
{
if (Find(dependency) is not { } dep) { Fail(runtime, $"缺少依赖插件 {dependency}"); return false; }
if (dep.State is not PluginState.Active && !await ActivateAsync(dependency, ct))
{ Fail(runtime, $"依赖插件 {dependency} 激活失败"); return false; }
}也就是说「引擎 → 功能 → 助手 → UI」是兜底顺序,声明了 Depends 的插件永远服从自己的依赖声明。图标缓存插件声明依赖下载插件,就一定在它之后激活,哪怕注册顺序反了。
循环依赖保护
这里有个真实的坑,代码注释里留着单测抓到的现场:
// 循环依赖保护:状态必须在**递归激活依赖之前**置为 Activating,
// 否则 A→B→A 的依赖会让 ActivateAsync 无限递归到栈溢出(单测抓到过)
if (runtime.State is PluginState.Activating) return false;
runtime.State = PluginState.Activating;顺序反过来就完了:A 把状态置成 Activating,再去递归激活 B;B 又要 A,此时 A 还是 Registered(因为状态还没改),于是 B 再次递归激活 A,无限下去,最后栈溢出。先把状态置为 Activating 再递归,递归进来的那一层看到 Activating 就知道「我已经在激活链路上了」,直接返回 false。
注意这里的处理是「失败」而不是「报错」——循环依赖里的插件激活失败,状态变 Failed,错误信息写进 runtime.Error,插件页上能看到原因。
同样的循环,插件层有另一套检测
外部插件的 dependencies 由 PluginDependencyResolver 处理:DFS 栈内回边即环,环上全部阻断,给出「循环依赖:A → B → A」这样的可读原因。内核这套是运行时保护,插件那套是加载前预检——两边都留着是因为它们防的不是同一件事。
懒激活
不是所有插件都该在启动时起来。Get<T>() 在找不到实现时会找到「谁能提供这个契约」的插件,并同步激活它:
public T Get<T>() where T : class
{
if (_services.TryGet<T>(out var service)) return service;
if (ProviderOf(typeof(T)) is { State: PluginState.Registered } provider)
{
ActivateAsync(provider.Descriptor.Id).GetAwaiter().GetResult(); // 懒激活:第一次要用才起
if (_services.TryGet<T>(out service)) return service;
}
throw new KernelException($"没有插件提供契约 {typeof(T).Name}" + …);
}ProviderOf 靠 Descriptor.Provides 里声明的类型找提供者。所以 Provides 不只是文档——它是懒激活的索引。
停用的顺序
DeactivateAsync 的 finally 块里顺序是有讲究的:
// 顺序很重要:先撤贡献,再注销契约,最后断订阅
_contributions.RemoveByPlugin(pluginId);
_services.RemoveByProvider(pluginId);
_instances.Remove(pluginId);
_contexts.Remove(pluginId);
runtime.State = PluginState.Stopped;先撤贡献,外壳才不会在契约已经没了的时候还试图去取那个能力。订阅的断开靠 PluginContext.BeginShutdown()——上下文代管了所有 Subscribe 返回的句柄,插件忘了自己退订也不会泄漏:
internal void Track(IDisposable subscription)
{
if (_shuttingDown) subscription.Dispose();
else _subscriptions.Add(subscription);
}为什么订阅要由上下文代管
一个插件崩在半路,它订阅的事件处理器还挂在总线上。别人一广播就调进一个已经不存在的插件,轻则报错,重则拖垮整个广播。代管让「忘记退订」不可能发生。