首页 / 横向专题 / 重构与加功能指南

重构与加功能指南

从零加一个功能模块,到给已有模块加字段、加协议、加 UI, 这一页是可以照着做的操作手册。每一步都对应工程里真实存在的文件和命令。

116 个业务模块 Model 136 / Controller 192 / Config 47 自定义编译流水线

1. 从零加一个功能模块

1.1 目录与文件

模块统一放在 Src/Game/Module/<ModuleName>/,标准四件套:

Src/Game/Module/TowerClimb/
├── TowerClimbController.ts   // 逻辑 + 协议收发(必须)
├── TowerClimbModel.ts        // 服务器数据缓存(有服务器数据才要)
├── TowerClimbConfig.ts       // 配置表加工与索引(有复杂查表才要)
├── TowerClimbDefine.ts       // 枚举、常量、接口(推荐一开始就建)
└── View/                     // UI,一个 Panel/Item 一个文件
    ├── TowerClimbInfoView.ts
    └── TowerClimbStarItem.ts
不是每个模块都要四个文件 BombTimer 只有 Controller / Model / Define 三个(没有独立配置表)。 按需建,不要为了对称建空文件。

1.2 注册进三大管理器

光建文件不会被加载,必须在 Creator 里 new 出来并 Add:

层声明处创建处
ConfigManager/ConfigManager.ts
加 import + static 字段
Manager/ConfigManagerCreator.ts
new + Add,Clear 里置 undefined
ModelManager/ModelManager.tsManager/ModelManagerCreator.ts
ControllerManager/ControllerHolder.tsManager/ControllerRegisterManager.ts
// ModelManagerCreator.ts
ModelManager.TowerClimbModel = new TowerClimbModel();
ModelManager.Add(ModelManager.TowerClimbModel);

// ControllerRegisterManager.ts
ControllerHolder.TowerClimbController = new TowerClimbController();
this.Add(ControllerHolder.TowerClimbController);
注意声明的类型不一样
// ModelManager / ConfigManager —— 可能为 undefined,用处要判空
public static TowerClimbModel: TowerClimbModel | undefined = undefined;

// ControllerHolder —— 断言非空,因为 Controller 生命周期覆盖全程
public static TowerClimbController: TowerClimbController = undefined!;
所以代码里 ModelManager.XxxModel?. 要带 ?, ControllerHolder.XxxController. 不带。 这不是随意的——Model 会在切场景时被销毁重建,Controller 不会。

1.3 顺序很重要

Add 的顺序 = 初始化顺序 ModelManagerBase 是失败即中断:某个 Model 的 OnInit 返回 false, 后面所有 Model 都不会初始化。
ControllerManagerBase 有 try/catch,单个挂了不影响别人,但会打 Error。

如果 A 依赖 B 的数据,A 必须排在 B 后面。 不确定就放在列表末尾——依赖别人的比被依赖的晚初始化总是安全的。

1.4 Controller 骨架

export class TowerClimbController extends UiControllerBase {
    // ① 协议注册:进游戏时一次
    protected override OnRegisterNetEvent(): void {
        Net.Register(ENotifyMessageId.WorldDungeonTowerStarStartSCNotify, this.OnStarStart);
    }
    protected override OnUnRegisterNetEvent(): void {
        Net.UnRegister(ENotifyMessageId.WorldDungeonTowerStarStartSCNotify);
    }

    // ② 事件监听:和上面严格配对
    protected override OnAddEvents(): void {
        EventSystem.Add(EEventName.DungeonStateChange, this.OnDungeonStateChange);
    }
    protected override OnRemoveEvents(): void {
        EventSystem.Remove(EEventName.DungeonStateChange, this.OnDungeonStateChange);
    }

    // ③ 回调用箭头函数属性,否则 this 丢失且无法 Remove
    private readonly OnStarStart = (notify: TowerStarStartSCNotify): void => { ... };

    // ④ 关卡级清理(换关卡)vs 彻底清理(登出)
    protected override OnLeaveLevel(): boolean { /* 清关卡数据、停定时器 */ return true; }
    protected override OnClear(): boolean { /* 清所有 */ return true; }
}
回调必须是箭头函数属性
// ❌ this 会丢,而且 Remove 时传的是另一个函数对象,摘不掉
private OnStarStart(notify) { ... }
EventSystem.Add(EEventName.X, this.OnStarStart.bind(this));   // bind 每次产生新对象

// ✅
private readonly OnStarStart = (notify): void => { ... };
EventSystem.Add(EEventName.X, this.OnStarStart);
匿名箭头函数同理不行——事件系统持弱引用,匿名函数没人持有会被 GC, 表现为「事件处理方法已被回收」。

1.5 需要 Tick 时选对阶段

方法对应 UE TickGroup放什么
OnPreWorldActorTickActor Tick 之前需要抢在所有 Actor 前跑的
OnPrePhysicsTickTG_PrePhysics改位置、施加速度(物理会在之后消费)
OnDuringPhysicsTickTG_DuringPhysics很少用
OnEndPhysicsTickTG_EndPhysics物理刚算完
OnPostPhysicsTickTG_PostPhysics读物理结果(贴地检测、落地判定)
OnPostUpdateWorkTickTG_PostUpdateWork相机/UI 相关的收尾
选错阶段 = 差一帧 在 PostPhysics 里改位置,这一帧的物理已经算完了,改动要等下一帧才生效—— 表现为「抖动」或「慢半拍」。
写位置用 PrePhysics,读结果用 PostPhysics。

2. 给已有模块加一个字段

这是最常见的需求。「加一个字段」实际要动 5~7 个地方:

1
和服务器约定 proto 字段
服务器改 .proto 并跑生成。 Protocol.d.ts 客户端不要手改,改了下次生成会覆盖
2
编辑器 Scheme 加字段(如果策划要配)
必须 Optional: true,否则全项目老配置变红。 见 Scheme 加可选参数
3
Model 存字段 + 提供读接口
int64 记得 MathUtils.LongToNumber。 增量协议按主键「有则更新、无则插入」,不要假设顺序
4
Controller 在收包处理里写入 Model
写完必须 Emit 事件,否则 UI 不刷新
5
EventDefine.ts 加事件名 + 签名
两处:enum EEventName 加名字, 下面的签名表加 [EEventName.Xxx]: (a: number) => void;
6
View 监听事件刷新
AddEvents / RemoveEvents 配对
7
找齐所有执行分支
grep 这个功能名,确认没有第二套实现。 退出副本 ShowInfo 就是漏了 entity 版

2.1 加事件的两处

// Src/Game/Common/Event/EventDefine.ts —— 第一处:枚举
export const enum EEventName {
    ...
    /** 副本爬塔星级数据变化(开始/单档更新都派发) */
    DungeonTowerStarChange,
}

// 同文件 —— 第二处:签名表(漏了这里编译不过)
export interface IEventMap {
    [EEventName.DungeonTowerStarChange]: () => void;
}

事件名带中文注释,写清「什么时候派发」。 全工程 768+ 个事件,没注释的两周后自己都不知道该不该监听。

3. 加 UI

3.1 控件索引是位置,不是名字

protected override OnRegisterComponent(): void {
    this.ComponentRegisterInfos = [
        [EMainUiTaskType.QuestLayout,      UE.UIItem],   // index 0
        [EMainUiTaskType.QuestItem,        UE.UIItem],   // index 1
        [EMainUiTaskType.PnlTowerStar,     UE.UIItem],   // index 2
        ...
    ];
}
这个数组的顺序必须和 prefab 里的挂载顺序完全一致 框架是按下标取控件的,不是按名字查找。 美术在 prefab 里调了一下层级顺序,代码这边不会报错,只会静默拿到错误的控件—— 表现为「点 A 按钮触发了 B 的逻辑」「文本显示到了别的框里」。

所以:改 prefab 结构后必须回头核对这个数组。 不确定时用 FindDescendantByName 按名字找,慢一点但不会错位。

3.2 批量创建子项的标准写法

// prefab 里放一个隐藏的模板 Item,运行时 CopyItem 复制 N 份
private async CreateStarItems(count: number): Promise<void> {
    if (this.IsCreatingStarItems) {      // ① 防重入锁
        this.NeedRefreshAfterCreate = true;  //    记下「创建完再刷一次」
        return;
    }
    this.IsCreatingStarItems = true;
    try {
        for (let i = this.ItemList.length; i < count; i++) {
            const item = await CopyItem(this.Template);   // ② 这里会让出,可能被重入
            this.ItemList.push(item);
        }
    } finally {
        this.IsCreatingStarItems = false;      // ③ finally 里解锁,异常也不会卡死
    }
    if (this.NeedRefreshAfterCreate) {         // ④ 补刷
        this.NeedRefreshAfterCreate = false;
        this.Refresh();
    }
}
任何带 await 的函数都要假设「它会被重入」 await 之后其它代码可以跑,包括再调一次这个函数。 不加锁的结果是档位翻倍(Bug#16)。
锁 + 脏标记补刷 + finally 解锁,三样缺一不可。

4. 用定时器

TimerSystem.Next(cb);                    // 下一帧
TimerSystem.Delay(cb, ms);                // 延迟一次
TimerSystem.Loop(cb, ms, maxLoop);       // 循环 N 次
TimerSystem.Forever(cb, ms);              // 无限循环
约束值越界后果
最小间隔20 ms静默返回 undefined,定时器根本没建
最大间隔90000 ms同上(除非传 needCheckMaxInterval = false)
最大循环MaxLoop 10超过要用 Forever
三条铁律 ① 存句柄——private XxxTimer: TimerHandle | undefined, 不存就永远停不掉。
② 建之前先清——this.ClearXxxTimer() 再 =TimerSystem.Delay(...), 否则重复触发会叠加。
③ OnClear / OnLeaveLevel 里清干净—— 残留的定时器会在下一局里突然触发,症状极难定位。

5. 加日志

Log.Info(ELogModule.Debug, ELogAuthor.ZZB,
    '传送-HandleLocalPlayerTeleportApplied REVIVE 不接管,交回复活链路',
    ['reason', snap.Reason],
    ['serverMoveVersion', snap.ServerMoveVersion]);
习惯为什么
中文功能前缀(传送-)复测时用户只 grep「传送」两个字,没前缀就漏了
如实填 ELogAuthor按它 grep 能把同一条链路的日志整段捞出来,一个文件挂多条链路时尤其有用
关键状态用键值对['reason', x] 比拼字符串好 grep、好对齐
打三样:入参 / 状态标志 / 走了哪个分支缺任何一样都还得再加一轮日志重跑
聚合结论后面跟明细「加载完成」后面跟上哪几个 cell、耗时多少

6. 编译与提交

6.1 编译流水线到底做了什么

1
node ./Script/parallel-lint.js
并行跑两个检查:lint-serious-combat(战斗目录严格 lint) 和 lint-top-decls(顶层声明检查)。任一失败直接终止,不进编译
2
ts.createProgram + getPreEmitDiagnostics
全项目诊断。有任何一个错误就 process.exit(DiagnosticError), 连 emit 都不会跑
3
加载编译器插件
LogPlugin(if-log)、BpConstructorPlugin、 ExceptionErrorHandlePlugin 等 AST 变换
4
program.emit() → 产物落盘
输出到 ../Content/Aki/JavaScript/
「0 错误」不代表产物更新了 第 2 步是全项目诊断——别人提交的一个错误,会导致你的改动压根没 emit。 磁盘上的 .js 还是老的,你在游戏里测的是旧代码。

卡住时的应急手段:
npm run compile-skip-diagnostic   # 跳过诊断,强制 emit
然后直接 grep 产物 .js 确认你的改动进去了,别信控制台。

6.2 命令速查

命令什么时候用
npm run lint-fix改了多个文件先跑这个,自动修 prettier 格式
npm run compile标准编译,唯一算「编译过了」的命令
npm run compile-skip-diagnostic被别人的错误卡住时强制 emit
npm run tsc-native只做 tsc 类型检查,快但不等于编译
npm run genmsg重新生成协议(服务器改了 proto 之后)
gulp watch编辑器代码(UniverseEditor)专用
tsc ≠ compile 自定义编译器除了 tsc,还会跑顶层声明检查、战斗目录严格 lint、AST 插件变换。 tsc 过了 compile 挂掉是常态:两者检查的规则集不同。

6.3 项目自定义的 lint 规则

eslint-plugin-rule/ 下的规则是这个项目独有的, 第一次撞上会一头雾水:

规则报错信息为什么
no-uninitialized-vars成员变量 "X" 必须显式初始化puerts 下未初始化字段会有隐藏类型切换
no-number-or-undefined请不要使用 number|undefined同上,V8 隐藏类抖动影响性能。用 -1 / 0 当哨兵值
only-const-enum非 const enum 有性能开销普通 enum 会生成运行时对象
no-this-in-constructor请不要在构造函数中调用成员方法 / 传递 this构造未完成时 this 不完整
no-top-level-expression-stmt顶层副作用语句需人工评审模块加载副作用时机不可控,应移到显式初始化函数
no-export热更目录中的脚本不应使用 export热更脚本不该被外部引用
no-override-sealed-functionSealed 类型不能被重写框架基类的关键方法

6.4 CRLF 与格式

整文件重写时必须保留 CRLF 这个仓库用 CRLF 行尾。用工具整文件写入时如果把 CRLF 变成 LF, git diff / p4 diff 会显示整个文件都变了,review 完全没法看。
多文件改动统一跑 npm run lint-fix。 prettier 最常报的是多行函数签名的换行位置。

7. 需求边界纪律

开工前先列 in-scope / out-of-scope 路径 用户说「只改 X,Y 不要动」的时候,开工前把要改的文件路径列出来, 每次 edit 前对一遍这个清单。

最容易犯的错是「顺手把相似的入口也改了」—— 看到同一个功能有两个入口,觉得两个都改才对称。 但如果用户只说了一个,另一个可能有别的团队在维护、或者有意保持旧行为。

发现范围外的问题:说出来,不要顺手改。
✅ 应该做
· 完成用户点名的全部内容
· 发现范围外的问题 → 报告,不动手
· 改动涉及的配套(事件、日志、清理)算 in-scope
· 改动涉及的编译验证算 in-scope
❌ 不该做
· 顺手重构看不顺眼的代码
· 「既然改到这了,把相似的也改了」
· 悄悄缩小范围(只改一半就说完成)
· 只跑 tsc 就说「改完了」

8. 提交前完整自查

#检查项
1npm run lint-fix 过一遍(多文件改动)
2npm run compile 真的 EXIT=0,不是「看起来没报错」
3涉及编辑器代码时,gulp 那条流水线也过一遍
4行尾还是 CRLF
5Add/Remove、Register/UnRegister、Push/Pop 全部配对
6新建的定时器在 OnClear / OnLeaveLevel 里清了
7新加的日志带中文功能前缀 + 如实填写的 ELogAuthor
8提取的常量有中文 JSDoc(说明含义和用途,压在 2 行内)
9grep 过功能名,确认没有第二套实现漏改
10改动范围和需求一致,没有夹带
11P4 没有顺手 lock(会挡住同事编辑;发现已锁立即 unlock)

9. 可复用的十条模式

模式用在哪要点
全量 + 增量爬塔星级、地图数据、副本数据全量建立基线,增量按主键「有则更新、无则插入」;增量可能不刷聚合字段,需客户端反算
脏标记 + 延迟一帧UI 刷新同一帧多次触发合并成一次。用位掩码记「哪些部分脏了」
防重入锁 + 补刷任何 async 创建流程锁 + NeedRefresh 标记 + finally 解锁
超时兜底等服务器、等资源、等流送任何「等」都要有超时,超时后走降级路径而不是永久卡住
退避重试LoadDone 上报5s/5s/10s/20s/40s,靠版本号幂等,任一取消条件命中即停
双源 OR 反算starMask服务端不刷聚合值时,客户端用「全量基线 OR 增量」自己算
逐帧重试直到有效Spawn 后取包围盒零值当「还没准备好」而不是「就是零」
自维护改动集合批量隐藏 / 批量改状态记下「改过谁」,恢复时只恢复这些,别按全量列表来
引用计数 + 逃生口黑屏、可见性锁计数会漏 pop,必须配一个 ForceXxx 强制归零的口子
白名单优于黑名单状态过滤=== Running 而不是 !== Stopped——加新状态时不会漏

10. 重构时的判断

值得抽出来的
· 同一份逻辑出现第三次(第二次可以先忍)
· 局内局外/多入口共用同一套展示结构(如 ITowerStarDisplayItem)
· 魔法数字散在多处 → 提常量 + 中文注释
· 「将来肯定要加第三种」的枚举分支 → 抽工厂
不值得动的
· 只出现两次、且很可能分化的
· 别人在维护的模块(先问)
· 需求范围外的
· 为了「看起来整齐」的重命名——diff 噪声远大于收益
重构的安全网
· 先加日志覆盖旧路径,确认真实调用情况
· grep 全部调用点,别漏掉动态字符串拼出来的
· 一次只改一个维度(要么改结构、要么改行为,不要同时)
· 改完对着旧版本跑一遍相同操作
留下痕迹
· 删协议要在注释里写「服务器需同步删 proto」
· 用了 workaround 要写清「为什么不能用正规做法」
· 空壳 Scheme、ts-expect-error、硬编码兜底 —— 都要有一句为什么