首页 / 横向专题 / 重构与加功能指南
重构与加功能指南
从零加一个功能模块,到给已有模块加字段、加协议、加 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:
| 层 | 声明处 | 创建处 |
|---|---|---|
| Config | Manager/ConfigManager.ts加 import + static 字段 | Manager/ConfigManagerCreator.tsnew + Add,Clear 里置 undefined |
| Model | Manager/ModelManager.ts | Manager/ModelManagerCreator.ts |
| Controller | Manager/ControllerHolder.ts | Manager/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 的顺序 = 初始化顺序
如果 A 依赖 B 的数据,A 必须排在 B 后面。 不确定就放在列表末尾——依赖别人的比被依赖的晚初始化总是安全的。
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 | 放什么 |
|---|---|---|
| OnPreWorldActorTick | Actor Tick 之前 | 需要抢在所有 Actor 前跑的 |
| OnPrePhysicsTick | TG_PrePhysics | 改位置、施加速度(物理会在之后消费) |
| OnDuringPhysicsTick | TG_DuringPhysics | 很少用 |
| OnEndPhysicsTick | TG_EndPhysics | 物理刚算完 |
| OnPostPhysicsTick | TG_PostPhysics | 读物理结果(贴地检测、落地判定) |
| OnPostUpdateWorkTick | TG_PostUpdateWork | 相机/UI 相关的收尾 |
选错阶段 = 差一帧
在
写位置用 PrePhysics,读结果用 PostPhysics。
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 结构后必须回头核对这个数组。 不确定时用
所以:改 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 |
三条铁律
① 存句柄——
② 建之前先清——
③ OnClear / OnLeaveLevel 里清干净—— 残留的定时器会在下一局里突然触发,症状极难定位。
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-function | Sealed 类型不能被重写 | 框架基类的关键方法 |
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
· 发现范围外的问题 → 报告,不动手
· 改动涉及的配套(事件、日志、清理)算 in-scope
· 改动涉及的编译验证算 in-scope
❌ 不该做
· 顺手重构看不顺眼的代码
· 「既然改到这了,把相似的也改了」
· 悄悄缩小范围(只改一半就说完成)
· 只跑 tsc 就说「改完了」
· 「既然改到这了,把相似的也改了」
· 悄悄缩小范围(只改一半就说完成)
· 只跑 tsc 就说「改完了」
8. 提交前完整自查
| # | 检查项 |
|---|---|
| 1 | npm run lint-fix 过一遍(多文件改动) |
| 2 | npm run compile 真的 EXIT=0,不是「看起来没报错」 |
| 3 | 涉及编辑器代码时,gulp 那条流水线也过一遍 |
| 4 | 行尾还是 CRLF |
| 5 | Add/Remove、Register/UnRegister、Push/Pop 全部配对 |
| 6 | 新建的定时器在 OnClear / OnLeaveLevel 里清了 |
| 7 | 新加的日志带中文功能前缀 + 如实填写的 ELogAuthor |
| 8 | 提取的常量有中文 JSDoc(说明含义和用途,压在 2 行内) |
| 9 | grep 过功能名,确认没有第二套实现漏改 |
| 10 | 改动范围和需求一致,没有夹带 |
| 11 | P4 没有顺手 lock(会挡住同事编辑;发现已锁立即 unlock) |
9. 可复用的十条模式
| 模式 | 用在哪 | 要点 |
|---|---|---|
| 全量 + 增量 | 爬塔星级、地图数据、副本数据 | 全量建立基线,增量按主键「有则更新、无则插入」;增量可能不刷聚合字段,需客户端反算 |
| 脏标记 + 延迟一帧 | UI 刷新 | 同一帧多次触发合并成一次。用位掩码记「哪些部分脏了」 |
| 防重入锁 + 补刷 | 任何 async 创建流程 | 锁 + NeedRefresh 标记 + finally 解锁 |
| 超时兜底 | 等服务器、等资源、等流送 | 任何「等」都要有超时,超时后走降级路径而不是永久卡住 |
| 退避重试 | LoadDone 上报 | 5s/5s/10s/20s/40s,靠版本号幂等,任一取消条件命中即停 |
| 双源 OR 反算 | starMask | 服务端不刷聚合值时,客户端用「全量基线 OR 增量」自己算 |
| 逐帧重试直到有效 | Spawn 后取包围盒 | 零值当「还没准备好」而不是「就是零」 |
| 自维护改动集合 | 批量隐藏 / 批量改状态 | 记下「改过谁」,恢复时只恢复这些,别按全量列表来 |
| 引用计数 + 逃生口 | 黑屏、可见性锁 | 计数会漏 pop,必须配一个 ForceXxx 强制归零的口子 |
| 白名单优于黑名单 | 状态过滤 | === Running 而不是 !== Stopped——加新状态时不会漏 |
10. 重构时的判断
值得抽出来的
· 同一份逻辑出现第三次(第二次可以先忍)
· 局内局外/多入口共用同一套展示结构(如
· 魔法数字散在多处 → 提常量 + 中文注释
· 「将来肯定要加第三种」的枚举分支 → 抽工厂
· 局内局外/多入口共用同一套展示结构(如
ITowerStarDisplayItem)· 魔法数字散在多处 → 提常量 + 中文注释
· 「将来肯定要加第三种」的枚举分支 → 抽工厂
不值得动的
· 只出现两次、且很可能分化的
· 别人在维护的模块(先问)
· 需求范围外的
· 为了「看起来整齐」的重命名——diff 噪声远大于收益
· 别人在维护的模块(先问)
· 需求范围外的
· 为了「看起来整齐」的重命名——diff 噪声远大于收益
重构的安全网
· 先加日志覆盖旧路径,确认真实调用情况
· grep 全部调用点,别漏掉动态字符串拼出来的
· 一次只改一个维度(要么改结构、要么改行为,不要同时)
· 改完对着旧版本跑一遍相同操作
· grep 全部调用点,别漏掉动态字符串拼出来的
· 一次只改一个维度(要么改结构、要么改行为,不要同时)
· 改完对着旧版本跑一遍相同操作
留下痕迹
· 删协议要在注释里写「服务器需同步删 proto」
· 用了 workaround 要写清「为什么不能用正规做法」
· 空壳 Scheme、
· 用了 workaround 要写清「为什么不能用正规做法」
· 空壳 Scheme、
ts-expect-error、硬编码兜底 —— 都要有一句为什么