《Cocos 游戏开发高级编程》10.2 大型项目模块化

大工程最先崩的不是性能而是依赖关系。本节把 assets/scripts 的四层职责深化成可强制的依赖方向,讲清 Bundle 与代码模块的对应、平台层如何用接口隔离三端差异,并给出一个纯 TypeScript 的循环依赖扫描器与 vitest 用例,把「core 不许 import cc」这类纪律从口头约定变成会变红的检查。

10.2 大型项目模块化

一个项目在 20 个脚本时不需要架构,在 200 个脚本时架构决定生死。崩掉它的往往不是某个函数写错了,而是依赖关系长成了毛线球:core 里不小心 import 了一个玩法模块,ui 直接读了存档,game 反过来又依赖 ui。到这一步,改任何一处都可能牵动全身,测试写不了,模块也拆不出去。

卷二 1.2 已经把 assets/scripts/ 分成 core / game / ui / platform 四层。这一节不再重复那四个目录怎么建,而是回答三个更深的问题:依赖方向为什么必须是单向的、怎么用工具把它钉死、以及当依赖已经乱了,怎么把它揪出来。

10.2.1 分层不是画图,是约束依赖方向

很多团队的分层只活在架构文档的一张图里,代码里没人遵守。原因很简单:「应该单向依赖」是一句愿望,而 import 是一行谁都能敲的代码。 只要没有工具拦截,跨层 import 一定会发生——不是因为开发者不专业,而是因为「这里正好要用到」是最省事的写法。

所以模块化的第一原则是:把依赖方向写成可检查的规则,而不是文档里的一句话。 规则要满足两个条件才有用:一是具体到能被机器判定(「core 不能 import cc」是具体的,「保持低耦合」不是);二是违反时会失败(CI 变红、提交被拒),而不是靠 code review 里某个人记得。

10.2.2 四层的依赖规则

四层的依赖方向是严格单向的,只有向下,不能向上,也不能横向绕:

ui ──┐
     ├──▶ game ──▶ core
platform ──┘        ▲
     └──────────────┘

规则展开成一张表,比看图更清楚:

层可以依赖绝不依赖能否 import cc能否单测
core无(只依赖标准库)game / ui / platform / cc❌✅ 直接测
gamecore / platformui✅需 mock
uicore / game / platform—✅需 mock
platformcoregame / ui✅需 mock

三条硬约束:

  1. core 是地基。 它只能依赖标准库,连 cc 都不能碰——这样它才能在 Node 里跑单测。这条纪律同时是 10.3 会用 ESLint 钉死的第一条规则。
  2. ui 是屋顶。 它可以看下面所有层,但没有任何层能反过来依赖它。
  3. game 与 platform 互不依赖。 玩法不该知道「存档是怎么落盘的」,平台层也不该知道「这一关有几枚金币」;两者要通信,走 core 里定义的接口。

10.2.3 平台层:用接口隔离三端差异

platform/ 存在的原因是 H5、微信小游戏、原生三端的存档、分享、广告 API 各不相同。如果 game 直接调 wx.setStorageSync,这套玩法代码就绑死在微信上,H5 和原生版本全得改。 正确做法是:在 core/ 里定义接口,在 platform/ 里给三端各写一份实现。

先看 core/ 里的接口(纯 TypeScript,不 import cc):

// assets/scripts/core/platform/storage.ts
export interface Storage {
  read(key: string): string | null;
  write(key: string, value: string): void;
}

// 业务侧只依赖接口,不知道背后是 localStorage 还是 wx.setStorageSync
export function saveScore(storage: Storage, score: number): void {
  storage.write('blockdash.save.v1', JSON.stringify({ score }));
}

再看 platform/ 里基于 cc 的 sys 的实现。sys.localStorage 在各端由引擎统一适配,是这里最省事的选择:

import { sys } from 'cc';
import { Storage } from '../core/platform/storage';

export class SysStorage implements Storage {
  read(key: string): string | null {
    return sys.localStorage?.getItem(key) ?? null;
  }

  write(key: string, value: string): void {
    sys.localStorage?.setItem(key, value);
  }
}

依赖方向在这里体现得很干净:platform 依赖 core(实现它的接口),game 只依赖 core(用它的接口)。三端差异被关在 platform/ 里,玩法代码一行都不用改。 这也是「接口定义在 core、实现在 platform」这条约定的价值——它让依赖方向不因平台适配而破例。

10.2.4 Bundle 与代码模块的对应关系

代码分成四层,资源也要分成包。两者不是一回事,但要对齐:代码是编译进主包的(脚本默认都打进主包,除非你手动处理),资源才由 Bundle 决定进主包还是分包。

《方块冲刺》的约定是:关卡资源放 assets/bundle-levels/,运行时用 assetManager.loadBundle('bundle-levels', ...) 按需加载。这个 Bundle 与代码层的对应关系是:

资源归属对应代码层
启动场景、主菜单主包ui
玩家、金币、通用图集主包game
关卡地图、关卡专属贴图bundle-levels 分包game(逻辑在主包,资源在分包)
存档、埋点配置主包platform

关键认知:分包分的是资源,不是代码。 关卡玩法脚本仍在主包的 game/ 里,进分包的是它用到的贴图与配置。想靠 Bundle 来「懒加载代码模块」是行不通的——那是打包器(bundle 工具)的事,不是引擎 Bundle 的职责。两者的名字撞车,是本领域最容易混淆的一点。

10.2.5 分包的 .meta 长什么样

让一个目录变成 Bundle,靠的是编辑器里的一次勾选,它会写进该目录的 .meta。以下是你会在编辑器里看到的配置状态:

以下配置需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

选中 assets/bundle-levels 文件夹 → 属性检查器
├── Is Bundle          ✅ 勾选
├── Bundle Name        bundle-levels
├── Priority           1
├── Compression        Merge Deps
└── Is Remote          ❌ 未勾选

在编辑器里选中这个文件夹,属性检查器顶部会多出「配置为 Bundle」的一组选项。Is Bundle 勾上后,Bundle Name 才会出现,默认取目录名 bundle-levels;Priority 控制加载优先级,多个分包时数字小的先加载。没勾这一步,运行时 loadBundle('bundle-levels') 会报「找不到 Bundle」——这是新手最常见的坑,分包靠目录 + 勾选实现,不是靠代码里写个名字。

10.2.6 循环依赖:为什么它会咬人

模块化里最难查的问题是循环依赖:A 依赖 B,B 又依赖 A。它不一定立刻报错,而是表现为一些诡异现象——某个模块的导出在别处是 undefined、初始化顺序敏感、打包器警告「circular dependency」。

在 TypeScript / ESM 里,循环依赖的后果是模块求值顺序被打破:当 A 求值时需要 B 的某个导出,而 B 还没求值完,拿到的是 undefined。Cocos 的脚本编译同样按模块图求值,所以这条坑在引擎里照样成立。最麻烦的是它经常「本地能跑、某个平台挂」——因为不同打包路径的求值顺序可能不同。

排查循环依赖,靠人肉看 import 是不现实的——200 个文件的图,肉眼追不出环。正确做法是写个工具自动扫。下面这段就是那个工具,它属于 core/(或更准确地说,属于工程脚手架),纯 TypeScript,不 import cc。

10.2.7 写一个纯 TS 的循环依赖扫描器

扫描器只做两件事:从源码里抽出相对 import 建成有向图,再用深度优先搜索找出所有环。

// scripts/analyze/find-cycles.ts —— 纯 TypeScript,不 import 'cc'
const IMPORT_RE = /import\s[^;]*?from\s+['"]([^'"]+)['"]/g;

export function extractRelativeDeps(source: string): string[] {
  const deps: string[] = [];
  for (const match of source.matchAll(IMPORT_RE)) {
    const spec = match[1];
    if (spec.startsWith('.')) deps.push(spec);
  }
  return deps;
}

export function findCycles(graph: Map<string, string[]>): string[][] {
  const cycles: string[][] = [];
  const state = new Map<string, number>(); // 0 未访问 / 1 在栈上 / 2 已完成
  const stack: string[] = [];

  const visit = (node: string): void => {
    state.set(node, 1);
    stack.push(node);
    for (const next of graph.get(node) ?? []) {
      const s = state.get(next) ?? 0;
      if (s === 1) {
        const start = stack.indexOf(next);
        cycles.push(stack.slice(start).concat(next));
      } else if (s === 0) {
        visit(next);
      }
    }
    stack.pop();
    state.set(node, 2);
  };

  for (const node of graph.keys()) {
    if ((state.get(node) ?? 0) === 0) visit(node);
  }
  return cycles;
}

findCycles 接收一张已经解析好的有向图(键是文件路径,值是它依赖的文件路径),返回每个环的路径序列。注意它只看相对 import——import { Component } from 'cc' 这类裸模块不是循环依赖的来源,跳过它们能少一堆噪音。

10.2.8 用 vitest 验证扫描器

扫描器本身也是纯逻辑,同样该被测试。构造一个三节点环和一个无环图:

import { describe, expect, it } from 'vitest';
import { extractRelativeDeps, findCycles } from '../scripts/analyze/find-cycles';

describe('findCycles', () => {
  it('识别出 core → game → ui → core 的环', () => {
    const graph = new Map<string, string[]>([
      ['core/a.ts', ['game/b.ts']],
      ['game/b.ts', ['ui/c.ts']],
      ['ui/c.ts', ['core/a.ts']],
    ]);
    expect(findCycles(graph)).toEqual([
      ['core/a.ts', 'game/b.ts', 'ui/c.ts', 'core/a.ts'],
    ]);
  });

  it('无环图返回空数组', () => {
    const graph = new Map<string, string[]>([
      ['ui/c.ts', ['game/b.ts']],
      ['game/b.ts', ['core/a.ts']],
      ['core/a.ts', []],
    ]);
    expect(findCycles(graph)).toEqual([]);
  });

  it('只抽取相对 import', () => {
    const src = "import { Component } from 'cc';\nimport { World } from '../core/ecs/world';";
    expect(extractRelativeDeps(src)).toEqual(['../core/ecs/world']);
  });
});

把这三条断言放进 CI,findCycles 一旦返回非空就 fail——循环依赖从「上线前才发现」变成「提交前就拦下」。 这段代码的价值不在于算法多高明,而在于它把一条团队纪律变成了自动检查。

10.2.9 用 loadBundle 按需加载分包

最后把 Bundle 用起来。下面这个组件负责异步加载 bundle-levels 并读一份关卡配置,它属于 game/ 层:

import { _decorator, Component, assetManager, AssetManager, JsonAsset, warn } from 'cc';

const { ccclass } = _decorator;

@ccclass('LevelBundleLoader')
export class LevelBundleLoader extends Component {
  private bundle: AssetManager.Bundle | null = null;

  async loadLevelConfig(): Promise<JsonAsset | null> {
    const bundle = await new Promise<AssetManager.Bundle | null>((resolve) => {
      assetManager.loadBundle('bundle-levels', (err, loaded) => {
        if (err) {
          warn('[block-dash] 分包加载失败:', err.message);
          resolve(null);
          return;
        }
        resolve(loaded);
      });
    });
    if (!bundle) return null;
    this.bundle = bundle;

    return new Promise<JsonAsset | null>((resolve) => {
      bundle.load('configs/levels', JsonAsset, (err, asset) => {
        if (err) {
          warn('[block-dash] 关卡配置加载失败:', err.message);
          resolve(null);
          return;
        }
        resolve(asset);
      });
    });
  }
}

assetManager.loadBundle 加载整个分包,回调拿到一个 AssetManager.Bundle;再用 bundle.load 从里面取具体资源。引擎运行时其实知道每个分包的依赖列表——AssetManager.Bundle 上有一个 deps 只读属性,返回该 Bundle 依赖的其他 Bundle 名(定义在 cocos/asset/asset-manager/bundle.ts,tag 3.8.8)。Bundle 之间的依赖,和代码模块之间的依赖,是同一套「有向图不能成环」的思想——只不过一个由引擎在加载时解析,一个得靠你自己扫。

10.2.10 工程决策

  • 依赖方向写成规则,不写成文档。 四层单向依赖,违反时 CI 必须变红。
  • core 绝不 import cc。 这是可测性的地基,也是 10.3 静态检查的第一道门。
  • 平台差异关在 platform/ 里。 接口定义在 core,实现在 platform,玩法只认接口。
  • Bundle 分的是资源,不是代码。 别指望用分包来懒加载模块。
  • 循环依赖要自动扫。 两百个文件靠人眼看环是自欺欺人,写个扫描器放进 CI。

模块化把「代码怎么分」讲完了,但光有结构还不够——没有检查的结构,三个月后就会退化回去。 下一节把 ESLint、TypeScript 严格模式和类型门禁组合起来,让上面这些纪律真正「钉死」在工具里。

阅读导航:上一节:10.1 ECS 与数据驱动架构 · 下一节:10.3 工程规范与静态检查 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画