3.1 子系统注册、启动顺序与逆序关闭
一个 3D 引擎不是一整块代码,而是几十个各自管一摊的子系统:平台层、文件系统、渲染设备、资源加载器、场景、输入、音频、UI……它们之间还有先后关系——渲染设备必须在窗口/平台层之后才能创建,场景必须在资源加载器之后才能反序列化实体。
如果把这段启动逻辑写成一条写死的 main():initPlatform(); initFile(); initRenderer(); ...,很快就会失控:删一个子系统要手工调所有后续代码,加一个又要重新数一遍位置,谁依赖谁只存在于作者脑子里。本节把这件事变成一个可查询、可断言的数据结构。
本章与站内既有内容的分工
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-engine-architecture-ecs/ | 用一篇文章讲清 ECS 引擎的整体骨架 | 不重复骨架,只把「子系统编排」这一层拆出来从零实现 |
/defold-ecs-architecture/ | 从 Defold 视角介绍现成引擎的子系统划分 | 不介绍别人的引擎怎么分层,只写 helio 自己的 ISubsystem |
/posts/game/engine/ | 引擎选型:该用哪个引擎 | 不碰选型,假设已经决定自研 |
一句话:别人已经讲过的「是什么、怎么选」本节全部外链,本节只交付「怎么从零搭出来、跑出什么结果」。
为什么启动顺序不能拍脑袋
先把约束说清楚。设子系统 A 依赖 B,含义是 A 启动前 B 必须已经启动,A 关闭时 B 必须还在运行。于是:
- 启动顺序 = 依赖图的一个拓扑序,被依赖者排前面。
- 关闭顺序 = 启动顺序的严格逆序。这条不是「最好这样」,而是正确性要求:资源加载器关闭时场景已经不在,渲染设备关闭时场景和资源都已销毁。
- 依赖图里不能有环。若
A → B → A,则无任何合法启动顺序,必须在注册阶段就报错,而不是启动到一半才发现。
《微光岛》的内核启动链就是一条典型依赖:asset(资源表)← scene(场景)← render(渲染设备)。资源表无依赖,最先起;渲染设备最后起,也最先关。
ISubsystem 与 SubsystemRegistry
子系统只暴露四个钩子,注册表只认这张脸,不认识任何具体类型。下面这个文件同时给出接口与注册表本体,resolveOrder() 用 DFS 三色标记(白=未访问、灰=在栈上、黑=已完成)做拓扑排序,灰点被再次访问即说明成环。
下面 src/core/subsystem.ts 是完整可编译文件,层级 L1(纯 TypeScript 内核,Node 直跑)。
// file: src/core/subsystem.ts
// tier: L1
// verify: npx vitest run src/core/subsystem.test.ts
//
// helio 内核:子系统注册与生命周期编排。
// 每个子系统声明自己的名字与依赖;注册表负责拓扑排序、
// 按序 onInit/onStart、逆序 onStop/onShutdown,并在注册阶段就拒绝循环依赖。
/** 传给 onInit 的上下文:内核在启动时注入的公共设施。 */
export interface SubsystemContext {
readonly frameCount: number;
log(msg: string): void;
}
/** 一个内核子系统。四个生命周期钩子都会按确定顺序被调用。 */
export interface ISubsystem {
readonly name: string;
/** 本子系统启动前必须先启动的子系统名。 */
readonly dependencies: readonly string[];
onInit(ctx: SubsystemContext): void;
onStart(): void;
onStop(): void;
onShutdown(): void;
}
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
/**
* 子系统注册表。启动顺序 = 依赖拓扑序(被依赖者先启动),
* 关闭顺序 = 启动顺序的严格逆序。
*/
export class SubsystemRegistry {
readonly #subsystems = new Map<string, ISubsystem>();
#order: ISubsystem[] = [];
#phase: 'idle' | 'running' | 'stopped' = 'idle';
/** 注册。一旦启动过就不再允许注册(避免顺序在运行期变化)。 */
register(sub: ISubsystem): void {
if (this.#phase === 'running') {
throw new Error(`cannot register ${sub.name}: registry already running`);
}
if (this.#subsystems.has(sub.name)) {
throw new Error(`duplicate subsystem: ${sub.name}`);
}
this.#subsystems.set(sub.name, sub);
}
/** 只读地取出拓扑序,不触发任何生命周期钩子——方便测试与工具。 */
resolveOrder(): ISubsystem[] {
const color = new Map<string, number>();
const out: ISubsystem[] = [];
const path: string[] = [];
const visit = (name: string): void => {
const c = color.get(name) ?? WHITE;
if (c === BLACK) return;
if (c === GRAY) {
const start = path.indexOf(name);
const cycle = path.slice(start).concat(name);
throw new Error(`cyclic subsystem dependency: ${cycle.join(' -> ')}`);
}
const sub = this.#subsystems.get(name);
if (sub === undefined) {
throw new Error(`unknown subsystem dependency: ${name}`);
}
color.set(name, GRAY);
path.push(name);
for (const dep of sub.dependencies) visit(dep);
path.pop();
color.set(name, BLACK);
out.push(sub);
};
for (const name of this.#subsystems.keys()) visit(name);
return out;
}
/** 按拓扑序 onInit 全部,再按拓扑序 onStart 全部。 */
boot(ctx: SubsystemContext): void {
if (this.#phase === 'running') throw new Error('registry already booted');
this.#order = this.resolveOrder();
for (const sub of this.#order) sub.onInit(ctx);
for (const sub of this.#order) sub.onStart();
this.#phase = 'running';
}
/** 按启动顺序的逆序 onStop 全部,再逆序 onShutdown 全部。 */
shutdown(): void {
if (this.#phase !== 'running') throw new Error('registry is not running');
const reverse = [...this.#order].reverse();
for (const sub of reverse) sub.onStop();
for (const sub of reverse) sub.onShutdown();
this.#order = [];
this.#phase = 'stopped';
}
/** 当前生效的启动顺序(boot 之前为空)。 */
get startOrder(): readonly ISubsystem[] {
return this.#order;
}
}
几个刻意的设计决定:
#order在boot()里算一次并缓存,shutdown()直接取逆序,保证两次用的是同一个序,不会因为中途注册变动而对不上。resolveOrder()是纯函数式的读取,不碰任何钩子,所以测试和构建期工具可以单独调用它来检查依赖图。boot()之后禁止register(),把「启动期配置冻结」这条规则变成运行期强约束。- 环检测不只抛「有环」,还拼出
a -> b -> a的路径——线上排查时这一行能省很多时间。
可运行产物:把顺序真跑出来
下面这个测试文件用假子系统把所有生命周期调用写进一个数组,然后逐条断言顺序。层级 L1,命令是 npx vitest run src/core/subsystem.test.ts。
// file: src/core/subsystem.test.ts
// tier: L1
// verify: npx vitest run src/core/subsystem.test.ts
import { describe, expect, it } from 'vitest';
import { SubsystemRegistry } from './subsystem.ts';
import type { ISubsystem, SubsystemContext } from './subsystem.ts';
const ctx: SubsystemContext = { frameCount: 0, log: () => {} };
/** 造一个把所有生命周期调用写进 log 的假子系统。 */
function fake(name: string, deps: string[], log: string[]): ISubsystem {
return {
name,
dependencies: deps,
onInit: () => log.push(`init:${name}`),
onStart: () => log.push(`start:${name}`),
onStop: () => log.push(`stop:${name}`),
onShutdown: () => log.push(`shutdown:${name}`),
};
}
describe('SubsystemRegistry', () => {
it('按依赖拓扑序启动(被依赖者先启动)', () => {
const log: string[] = [];
const r = new SubsystemRegistry();
// 注册顺序刻意打乱:render 依赖 scene,scene 依赖 asset,asset 无依赖。
r.register(fake('render', ['scene'], log));
r.register(fake('scene', ['asset'], log));
r.register(fake('asset', [], log));
r.boot(ctx);
expect(log).toEqual([
'init:asset', 'init:scene', 'init:render',
'start:asset', 'start:scene', 'start:render',
]);
});
it('关闭顺序是启动顺序的严格逆序', () => {
const log: string[] = [];
const r = new SubsystemRegistry();
r.register(fake('render', ['scene'], log));
r.register(fake('scene', ['asset'], log));
r.register(fake('asset', [], log));
r.boot(ctx);
const startOrder = r.startOrder.map((s) => s.name);
log.length = 0;
r.shutdown();
const stopOrder = log.filter((e) => e.startsWith('stop:')).map((e) => e.slice(5));
expect(stopOrder).toEqual([...startOrder].reverse());
});
it('先全部 onStop 再全部 onShutdown', () => {
const log: string[] = [];
const r = new SubsystemRegistry();
r.register(fake('a', [], log));
r.register(fake('b', ['a'], log));
r.boot(ctx);
log.length = 0;
r.shutdown();
expect(log).toEqual(['stop:b', 'stop:a', 'shutdown:b', 'shutdown:a']);
});
it('检测循环依赖并给出环路径', () => {
const log: string[] = [];
const r = new SubsystemRegistry();
r.register(fake('a', ['b'], log));
r.register(fake('b', ['a'], log));
expect(() => r.resolveOrder()).toThrow(/cyclic subsystem dependency: a -> b -> a/);
});
it('依赖不存在的子系统时报错', () => {
const r = new SubsystemRegistry();
r.register(fake('a', ['ghost'], []));
expect(() => r.resolveOrder()).toThrow(/unknown subsystem dependency: ghost/);
});
it('重复注册同名子系统报错', () => {
const r = new SubsystemRegistry();
r.register(fake('a', [], []));
expect(() => r.register(fake('a', [], []))).toThrow(/duplicate subsystem: a/);
});
it('boot 之后禁止再注册', () => {
const r = new SubsystemRegistry();
r.register(fake('a', [], []));
r.boot(ctx);
expect(() => r.register(fake('b', [], []))).toThrow(/already running/);
});
});
本机实跑结果:Test Files 4 passed、Tests 26 passed(这一节贡献其中 7 条),tsc --noEmit 零错误。其中「关闭顺序是启动顺序的严格逆序」那条断言,正是本节的核心契约——它把「逆序关闭」从口头约定变成了 CI 里会红的东西。
小结
- 子系统用「名字 + 依赖列表 + 四个钩子」描述,注册表用 DFS 三色标记求拓扑序并在成环时给出环路径。
- 启动顺序 = 拓扑序,关闭顺序 = 严格逆序;两者由同一份缓存序保证一致。
- 启动后冻结注册、重复注册报错、未知依赖报错——所有错误都尽量在
boot()之前暴露。
下一节换到最底层:内核如何做到「同一份代码既跑 Node 又跑浏览器」——平台抽象层 PAL。
阅读导航:上一节:目录 · 下一节:3.2 PAL 平台抽象层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。