在游戏开发中,我们经常遇到一个困境:ECS 架构追求极致的性能与解耦,却与 UI 系统的树状层级和严格时序产生了根本矛盾。本文将从架构、运行时、并发安全到像素渲染,完整拆解一个真实的高性能游戏框架如何在保持性能至上的同时,优雅地解决这个矛盾。
第一章:问题的根源——为什么需要框架
1.1 游戏开发的根本矛盾
想象你在开发一个包含大量 UI 交互的游戏。用户点击购买按钮,系统需要:
- 发起网络请求(高性能、非阻塞)
- 等待服务器响应(异步、不阻塞主线程)
- 获取数据后立即更新主界面的红点系统
- 同时更新背包页面、装备页面等多个 UI 的状态
- 确保所有操作高效且不相互干扰
传统做法的问题:
- 每个 UI 各自管理自己的网络请求 → 代码重复、逻辑分散
- UI 与 UI 直接通讯 → 耦合严重
- 数据存在 UI 里 → UI 关闭数据就消失,导致全局状态无法维护
- 频繁操作 GameObject → 性能灾难
1.2 框架的答案:职责分离
本文讨论的框架采用了ECS (Entity-Component-System) 架构的思想,但针对 UI 系统的特殊性,进行了深度创新:
- Entity:对象的身份证(ID + 层级关系)
- Component:纯数据(网络数据、UI 状态、物品信息等)
- System:纯逻辑(如何处理数据)
- EventSystem:神经中枢(所有模块通过事件通讯,完全解耦)
这样的设计让代码职责明确、易于测试、高度可复用。
第二章:静态世界的基石——启动与注册
2.1 三阶段启动机制
当游戏启动时,GameRunner 作为唯一的 MonoBehaviour 执行三个关键阶段:
graph TD
Start[游戏启动] --> Phase1["阶段1: AssemblyInitTask<br/>反射扫描"]
Phase1 --> P1_1["扫描所有代码程序集"]
Phase1 --> P1_2["识别 ObjectSystem 标记"]
Phase1 --> P1_3["识别 Event 标记"]
Phase1 --> P1_4["构建核心映射表"]
P1_4 --> Phase2["阶段2: BeforeHotfixInitTask<br/>核心组件挂载"]
Phase2 --> P2_1["创建 HttpComponent"]
Phase2 --> P2_2["创建 UIComponent"]
Phase2 --> P2_3["创建 UILifeCycleComponent"]
Phase2 --> P2_4["创建 UniTaskLockComponent"]
P2_4 --> Phase3["阶段3: UpdateResInitTask<br/>业务入口"]
Phase3 --> P3_1["初始化资源系统"]
Phase3 --> P3_2["打开第一个 UI"]
P3_2 --> Ready["✅ 框架就绪"]
style Start fill:#e1f5ff
style Phase1 fill:#fff3e0
style Phase2 fill:#f3e5f5
style Phase3 fill:#e8f5e9
style Ready fill:#c8e6c9
为什么采用三阶段而不是随意初始化?
- 确定性顺序:避免依赖地狱,每个阶段的前置条件明确
- 单点驱动:只有一个 MonoBehaviour,不会产生多个跨语言调用的开销
2.2 静态注册的魔法:映射表构建
阶段1 最关键的工作是通过反射构建三张核心映射表:
映射表1:Type-System Map(生命周期管理)
graph TD
TypeSysMap["Type-System Map<br/>Dictionary<Component类型, ISystem列表>"]
TypeSysMap --> UIShop["UIShop (Component类型)"]
UIShop --> UIShop_Awake["IAwakeSystem → UIShopAwakeSystem"]
UIShop --> UIShop_Update["IUpdateSystem → UIShopUpdateSystem"]
UIShop --> UIShop_Destroy["IDestroySystem → UIShopDestroySystem"]
TypeSysMap --> HttpComp["HttpComponent"]
HttpComp --> Http_Awake["IAwakeSystem → HttpComponentAwakeSystem"]
HttpComp --> Http_Update["IUpdateSystem → HttpComponentUpdateSystem"]
style TypeSysMap fill:#fff3e0
style UIShop fill:#e3f2fd
style HttpComp fill:#e3f2fd
作用:当开发者执行 AddComponent<UIShop>() 时,框架能在 O(1) 时间内找到需要执行的所有 System。
映射表2:Event Map(业务分发)
graph TD
EventMap["Event Map<br/>Dictionary<事件类型, Handler列表>"]
EventMap --> ItemPurchase["ItemPurchaseEvent"]
ItemPurchase --> Handler1["Handler: UpdateInventory"]
ItemPurchase --> Handler2["Handler: AddRedPoint"]
ItemPurchase --> Handler3["Handler: Log"]
EventMap --> BagOpen["BagOpenEvent"]
BagOpen --> Handler4["Handler: RefreshUI"]
BagOpen --> Handler5["Handler: PlayAnimation"]
style EventMap fill:#e8f5e9
style ItemPurchase fill:#fff4e6
style BagOpen fill:#fff4e6
作用:当某个事件被发布时,系统瞬间找到所有需要处理这个事件的 Handler。
映射表3:UILifeCycle Map(UI 生命周期处理)
graph TD
UILifeMap["UILifeCycle Map<br/>Dictionary<UI名称, Handler实例>"]
UILifeMap --> UIShopLC["'UIShop'<br/>↓<br/>UIShopLifeCycleHandler"]
UILifeMap --> UIItemLC["'UIShopItem'<br/>↓<br/>UIShopItemLifeCycleHandler"]
UILifeMap --> UITopBarLC["'UIShopTopBar'<br/>↓<br/>UIShopTopBarLifeCycleHandler"]
style UILifeMap fill:#f3e5f5
style UIShopLC fill:#fce4ec
style UIItemLC fill:#fce4ec
style UITopBarLC fill:#fce4ec
作用:当 UI 被打开时,框架根据 UI 名称立即找到对应的生命周期处理器。
2.3 为什么采用反射而不是手动注册?
// ❌ 手动注册(容易遗漏)
EventSystem.Register(typeof(UIShop), typeof(UIShopAwakeSystem));
EventSystem.Register(typeof(UIShop), typeof(UIShopUpdateSystem));
// ... 1000+个类,容易漏掉
// ✅ 自动反射注册(一劳永逸)
// 只需在类上标记 Attribute
[ObjectSystem]
public partial class UIShopAwakeSystem : AwakeSystem<UIShop> { }
优点:
- 自动发现:新增 System 时无需手动注册
- 约定优于配置:遵循命名规范即可
- 零 Bug 空间:不会因为遗漏注册而导致逻辑不执行
第三章:双轨并行运行时——ECS 与 UI 生命周期的融合
3.1 为什么需要双轨?
这是框架设计中最精妙的地方。框架面临两个相互冲突的需求:
需求 A:ECS 的理想主义
- 数据与逻辑完全分离
- 所有 Component 平等,扁平化管理
- 基于类型的批量处理(如所有网络组件一起更新)
需求 B:UI 的现实主义
- UI 之间有明确的树状层级(Page > Template > SubItem)
- 必须严格按 Parent → Child 的顺序执行初始化
- 父 UI 初始化完成,子 UI 才能初始化
- 异步操作必须按顺序执行(
await保证)
例如,打开一个"商店"页面时:
- 父 UI(商店页面)先发请求获取商品列表
- 等待响应完成
- 子 UI(每个商品 Item)才能获取自己的数据
- 最后才能显示整个页面
如果用纯 ECS 的扁平方式,所有 UI 会并发初始化,导致:
- 父 UI 的数据还没加载,子 UI 就尝试读取 → 数据为空 → Bug
- 子 UI 显示在父 UI 前面 → 页面闪烁
3.2 解决方案:双轨架构
graph TD
subgraph GameEngine["游戏主循环"]
Update["Update/LateUpdate/FixedUpdate"]
end
Update --> Split["数据分发"]
split --> ECSTrack["ECS轨道<br/>EventSystem管理"]
split --> UITrack["UI轨道<br/>UILifeCycleComponent管理"]
split --> OtherSys["其他系统"]
ECSTrack --> ECS1["✅ 扁平化管理"]
ECSTrack --> ECS2["✅ 高性能"]
ECSTrack --> ECS3["✅ 通用逻辑"]
UITrack --> UI1["✅ 树状结构"]
UITrack --> UI2["✅ 有序执行"]
UITrack --> UI3["✅ 异步串行"]
style ECSTrack fill:#e1f5ff
style UITrack fill:#fff3e0
style OtherSys fill:#e8f5e9
style Update fill:#f3e5f5
ECS 轨(标准生命周期):EventSystem 管理
职责:管理所有 Entity 的标准四大生命周期
Awake -> Initialize (可选) -> Update (每帧) -> Destroy
应用场景:
- 网络组件(HttpComponent)
- 音频组件(AudioComponent)
- 通用逻辑(如红点系统的数据维护)
执行方式:
// 当执行 AddComponent<HttpComponent>() 时
EventSystem.Awake(httpComponent);
// 框架内部:
// 1. 查询映射表:HttpComponent -> [IAwakeSystem列表]
// 2. 执行每个AwakeSystem
foreach (var system in systems)
system.Run(httpComponent);
UI 轨(特殊生命周期):UILifeCycleComponent 管理
职责:管理 UI 特有的生命周期状态转移
stateDiagram-v2
[*] --> Deactive: 创建UI
Deactive --> BeforeShow: Open()
note right of BeforeShow
等待网络数据
发起 HTTP 请求
这一步是 AWAIT 的
不会往下走,直到数据到达
end note
BeforeShow --> IntroAnim: 数据准备完成
note right of IntroAnim
播放入场动画
此时用户看到 UI 但不能交互
end note
IntroAnim --> AfterShow: 动画完成
note right of AfterShow
启动引导、上报数据
UI 现在可以交互了
end note
AfterShow --> Active: 初始化完成
note right of Active
UI 保持活跃
普通的 Update 逻辑
end note
Active --> BeforeHide: Close()
note right of BeforeHide
保存状态
end note
BeforeHide --> OutroAnim: 准备关闭
note right of OutroAnim
播放出场动画
end note
OutroAnim --> AfterHide: 动画完成
note right of AfterHide
清理资源
end note
AfterHide --> Deactive: 销毁或缓存
关键特性:
- 递归顺序保证:BeforeShow 时,Parent UI 先执行,然后 Child UI 再执行
- 异步串行:每一步都用
await保证顺序,不会跳过 - 状态防守:状态机设计防止同一阶段被重复执行
3.3 双轨协同的实际例子
场景:用户打开"背包页面"
sequenceDiagram
participant User as 用户
participant UIComp as UIComponent
participant ECS as EventSystem
participant UILife as UILifeCycle
participant HTTP as HttpComponent
participant UI as UIBackpack
User->>UIComp: 点击"背包"按钮
UIComp->>UIComp: 创建 UIBackpack Entity
par ECS轨
ECS->>ECS: EventSystem.Awake()
ECS->>ECS: 执行通用初始化
ECS->>ECS: (相机、音频等)
and UI轨
UILife->>UILife: UILifeCycle.HandleAwake()
UILife->>UI: Awake4Gen()
UILife->>UI: 绑定UI引用
end
UILife->>UI: BeforeShow()
UI->>HTTP: await Get("/inventory")
HTTP-->>UI: 返回数据
UILife->>UI: IntroAnim()
UI->>UI: await 播放动画
UILife->>UI: AfterShow()
UI->>UI: 启动引导
UI->>User: ✅ UI 完全可交互
核心洞察:
- ECS 轨处理通用逻辑(相机、音频等),执行速度快
- UI 轨处理特殊逻辑(网络请求、递归初始化),执行可以慢但必须有序
- 两条轨道互不阻塞,各司其职
第四章:生命周期精密控制——从异步陷阱到时序保证
4.1 BeforeShow 的异步陷阱
这是框架设计中最常见的坑:
// ❌ 错误做法
public override void BeforeShow(UIBackpack ui, object param)
{
// 直接发请求,不等待(Fire-and-Forget)
HttpComponent.Instance.Get("/inventory");
// 代码继续执行
foreach (var item in ui.items)
{
// ❌ 此时 items 还是空的!
// 服务器的响应还没回来!
item.SetData(xxx);
}
}
结果:
- UI 瞬间弹出,但没有任何数据(白屏)
- 用户等几秒后数据才加载上来(数据闪变)
- 业务逻辑判断失效(如"第一次购买显示特效"的判断发生在数据加载前,永远不会触发)
// ✅ 正确做法
public override async UniTask BeforeShow(UIBackpack ui, object param)
{
// 发请求 + 等待
var response = await HttpComponent.Instance.Get("/inventory");
if (!response.isSuccess)
{
ShowErrorUI("加载背包失败");
return;
}
// 现在数据已经到了,再处理
ui.items = response.items;
foreach (var item in ui.items)
{
// ✅ 此时 items 已经有数据了
item.SetData(xxx);
}
}
框架的强制约束:
BeforeShow的返回类型是async UniTask,必须使用await- 框架内部会
await BeforeShow()完成,然后才进入下一阶段 - 开发者不可能写出 Fire-and-Forget 的异步代码(设计上的强制约束)
4.2 递归执行的树状保证
当打开一个有子 UI 的页面时:
graph TD
UIShop["UIShop (父)"]
UIShop --> Item1["UIShopItem #1 (子)"]
UIShop --> Item2["UIShopItem #2 (子)"]
UIShop --> TopBar["UIShopTopBar (子)"]
执行顺序严格保证:
sequenceDiagram
participant Shop as UIShop
participant Item1 as UIShopItem#1
participant Item2 as UIShopItem#2
participant TopBar as UIShopTopBar
Shop->>Shop: 1. BeforeShow()
Shop->>Shop: await 商品列表
Shop->>Item1: 2. BeforeShow()
Item1->>Item1: await Item#1 数据
Shop->>Item2: 3. BeforeShow()
Item2->>Item2: await Item#2 数据
Shop->>TopBar: 4. BeforeShow()
TopBar->>TopBar: await 金币数据
Shop->>Shop: 5. IntroAnim()
Shop->>Shop: await 动画完成
Shop->>Shop: 6. AfterShow()
Shop->>Item1: 7. AfterShow()
Shop->>Item2: 8. AfterShow()
Shop->>TopBar: 9. AfterShow()
为什么使用 await 而不是普通的 for 循环?
await保证一个完成后再执行下一个- 如果第 1 个 Item 的网络请求失败,会立即
throw,后续 Item 不会执行 - 这样可以在第一个失败时快速退出,而不是继续加载剩余的 Item
4.3 Loading 和用户中断的处理
场景:用户打开页面时看到 Loading,2 秒内突然点击返回。
sequenceDiagram
participant User as 用户
participant UI as UIBackpack
participant HTTP as HttpComponent
participant Framework as 框架
User->>UI: t=0ms: 点"打开背包"
UI->>HTTP: BeforeShow 开始
HTTP->>HTTP: 发送网络请求
UI->>UI: 显示 Loading
User->>Framework: t=500ms: 点击"返回"
Framework->>Framework: 取消 CancellationToken
HTTP-->>UI: await 立即被中断
Framework->>UI: t=501ms: 进入 BeforeHide 流程
UI->>UI: Loading 消失
UI->>UI: 页面关闭动画
HTTP->>HTTP: 网络请求在后台继续
HTTP->>HTTP: 但响应会被忽视
代码层面:
public async UniTask BeforeShow(UIBackpack ui, object param)
{
try
{
// 这个 await 绑定了 CancellationToken
var response = await HttpComponent.Instance.Get(
"/inventory",
ui.token // ◄─ 如果 token 被 Cancel,await 立即结束
);
}
catch (OperationCanceledException)
{
// UI 被关闭时会捕获此异常
// 框架自动处理,不需要手动处理
}
}
关键点:
- 不需要手动管理 Loading:框架没有强制规定,业务层自己决定
- Cleanup 是自动的:UI 被销毁时,框架自动取消所有 Token,异步操作会自动停止
- 防止内存泄漏:被取消的异步任务不会继续占用资源
第五章:并发安全——Lock 机制的艺术
5.1 没有 Lock 的灾难
场景:用户疯狂点击"购买装备"按钮(连续点 10 次)。
sequenceDiagram
participant User as 用户
participant E1 as Event1
participant E2 as Event2
participant UI as UIBackpack
User->>E1: t=0ms: 点击购买
E1->>E1: 清空 UI 列表
E1->>E1: 发送网络请求
User->>E2: t=50ms: 再次点击
E2->>E2: 清空 UI 列表 (覆盖 Event1)
E2->>E2: 发送网络请求
E1-->>UI: t=100ms: Event1 返回结果
E1->>UI: ❌ 添加数据 (但列表已被 Event2 清空)
E2-->>UI: t=150ms: Event2 返回结果
E2->>UI: ❌ 覆盖 Event1 的数据
UI->>User: ❌ UI 显示错乱
结果:UI 里的数据混乱,可能显示错误的装备、价格错误等灾难性的 Bug
5.2 Queue Lock 的解决方案
框架采用了每个 UI 实例一个锁的设计:
graph TD
subgraph Incoming["多个 Event 到达"]
E1["Event1"]
E2["Event2"]
E3["Event3"]
E4["Event4"]
end
Incoming --> Check["检查 Lock 池<br/>UIBackpack#123"]
Check --> Queue["锁队列"]
Queue --> L1["Lock1<br/>执行中"]
Queue --> L2["Lock2<br/>等待中"]
Queue --> L3["Lock3<br/>等待中"]
Queue --> L4["Lock4<br/>等待中"]
L1 --> E1_Run["Event1 执行<br/>await HttpComponent.Get<br/>UI 更新<br/>释放 Lock1"]
E1_Run --> L2_Run["Event2 获得 Lock2<br/>开始执行"]
L2_Run --> L3_Run["Event3 获得 Lock3<br/>开始执行"]
L3_Run --> L4_Run["Event4 获得 Lock4<br/>开始执行"]
L4_Run --> Result["✅ 所有 Event 串行化处理<br/>不会互相干扰"]
style Incoming fill:#ffebee
style Queue fill:#e3f2fd
style Result fill:#e8f5e9
5.3 Lock 机制的源码原理
数据结构:
public class UniTaskLockComponent
{
// 双层索引:锁类型 -> 实例 ID -> 锁队列
private List<UniTaskLockQueueType> list;
// 超时管理
private SortedList<long, UniTaskLockTimer> timers;
}
// 一个 UI 实例的锁队列示例:
// UIBackpack#123 的锁队列:
// [Lock1 (执行中)] -> [Lock2 (等待中)] -> [Lock3 (等待中)] -> [Lock4 (等待中)]
// ^
// 正在执行
获取 Lock 的流程:
public static async UniTask<UniTaskLock> Wait(
int type, // 锁类型(如 UI、网络等)
long key, // 实例 ID(如 UIBackpack#123)
CancellationToken ct, // 取消令牌
int timeout = 3000 // 超时时间
)
{
// 情况1:这个 UI 实例没有任何等待中的 Lock
if (!lockQueue.Contains(key))
{
// 直接获取 Lock,不用等待
return CreateLock(type, key, level=1);
}
// 情况2:这个 UI 实例已有 Lock 被占用
else
{
// 创建一个等待对象,加入队列
var tcs = new UniTaskCompletionSource<UniTaskLock>();
lockQueue[key].Add(tcs); // 加入队列
// 等待队列中前面的 Lock 释放,然后获得新的 Lock
return await tcs.Task;
}
}
释放 Lock 并唤醒下一个:
public static void Notify(int type, long key, int level)
{
var queue = lockQueue[key];
// 从队列中取出下一个等待的 Task
if (queue.Count > 0)
{
var nextTask = queue.Dequeue();
// 给它一个新的 Lock,让它继续执行
nextTask.SetResult(CreateLock(type, key, level=level+1));
}
else
{
// 队列为空,删除这个 Key
lockQueue.Remove(key);
}
}
5.4 超时保护
// 每帧检查超时的 Lock
private void _CheckTimeout()
{
long now = TimeInfo.frameTime;
// 遍历所有超时的 Lock(SortedList 保证按时间排序)
foreach (var timer in timers)
{
if (timer.expire_time > now)
break; // 还没到超时时间
// 这个 Lock 已经超过 3000ms 还没释放
// 强制释放,通知下一个
ForceRelease(timer.lock);
}
}
防止死锁:
- 每个 Lock 默认 3000ms 超时
- 如果某个 Event Handler 中的 async 代码死掉了(如无限 await),3 秒后会被强制释放
- 后续的 Event 不会因为前一个的死亡而永久卡顿
第六章:业务数据流转——从点击到像素渲染
6.1 完整流程映射
以"购买装备"为例,追踪数据从用户点击到 UI 更新的完整过程:
graph TD
Step1["Step 1: 用户触发<br/>点击购买按钮"] --> Step2["Step 2: 发起网络请求<br/>HttpComponent.Post"]
Step2 --> Wait["等待 500ms"]
Wait --> Step3["Step 3: 接收响应<br/>code: 0, newEquipmentId: 456"]
Step3 --> Step4["Step 4: 发布事件<br/>EventSystem.Publish"]
Step4 --> HandlerA["Handler A<br/>更新背包"]
Step4 --> HandlerB["Handler B<br/>更新红点"]
Step4 --> HandlerC["Handler C<br/>记录日志"]
HandlerA --> LockA["获得 Lock<br/>UIBackpack#123"]
HandlerB --> LockB["获得 Lock<br/>UIHome"]
HandlerC --> LockC["获得 Lock<br/>Logger"]
LockA --> ExecA["清空列表<br/>请求最新数据<br/>显示特效<br/>释放锁"]
LockB --> ExecB["修改红点状态<br/>发布 UIRefresh<br/>释放锁"]
LockC --> ExecC["记录事件<br/>上报埋点<br/>释放锁"]
ExecA --> Step5["Step 5: UI 更新<br/>tplOpenParams 更新<br/>SetListItemCount 1000"]
ExecB --> Step5
ExecC --> Step5
Step5 --> Step6["Step 6: 虚拟化渲染<br/>下一帧<br/>ScrollView 计算可视区<br/>创建 Item#11-20<br/>绑定数据<br/>更新像素"]
style Step1 fill:#e1f5ff
style Step2 fill:#fff3e0
style Step3 fill:#e8f5e9
style Step4 fill:#f3e5f5
style Step5 fill:#fce4ec
style Step6 fill:#fff9c4
6.2 被动渲染 vs 主动渲染
这是框架的一个关键优化,理解它能解释为什么性能这么高。
❌ 主动渲染(传统做法)
// 购买后,立即创建所有 UI
var items = response.items; // 假设有 10000 个
foreach (var item in items)
{
// 每次循环创建一个 GameObject
var uiItem = Instantiate(prefab);
uiItem.SetData(item);
// 问题:
// - 创建 10000 个 GameObject
// - 10000 个 Component 的 Awake 逻辑执行
// - 10000 个 Transform 的初始化
// - 10000 个 Renderer 的初始化
// - 消耗 500MB+ 内存
// - 卡顿 3-5 秒
}
为什么这样做很慢?
- GameObject 创建涉及大量 C++ 引擎调用
- 每个 GameObject 有 Transform(9 个 float)、Renderer(多个参数)等基础组件
- 10000 个 GameObject = 至少 10000 个 C# ↔ C++ 跨语言调用
✅ 被动渲染(框架做法)
// 购买后,仅更新数据
var items = response.items;
// 1. 清空旧数据
self.itemList.Clear();
// 2. 添加新数据(纯 C# List 操作)
self.itemList.AddRange(items); // ◄─ 只是内存赋值,极快
// 3. 告诉 ScrollView:"我有 10000 条数据"
self.scrollView.SetListItemCount(10000); // ◄─ 只是一个 int
// 就这样,没有创建任何 GameObject!
ScrollView 的虚拟化逻辑:
用户只能在屏幕上看到 20 个 Item(假设)
虚拟化列表:
├─ Item#0 到 Item#10: 在屏幕上方,看不见,不创建
├─ Item#11 到 Item#30: 在屏幕中间,看得见,创建 UI
│ ├─ 使用对象池
│ ├─ 创建新的 GameObject(只需 20 个)
│ └─ 绑定数据
└─ Item#31 到 Item#9999: 在屏幕下方,看不见,不创建
性能对比:
┌─────────────────┬──────────────┬──────────────┐
│ │ 主动渲染 │ 被动渲染 │
├─────────────────┼──────────────┼──────────────┤
│ 初始化时间 │ 3-5 秒 │ <10ms │
│ 内存占用 │ 500MB+ │ 5MB │
│ GameObject 数 │ 10000 │ 20-30 │
│ 卡顿感 │ 明显 │ 无感 │
│ 滑动流畅度 │ 卡顿 │ 60fps │
└─────────────────┴──────────────┴──────────────┘
6.3 对象复用的陷阱
场景:在虚拟列表中,Item 是复用的。
Initial State:
┌─────────────────────────────────────┐
│ ScrollView 显示 Item 11-30 │
│ │
│ ┌──────────────┐ │
│ │ Item#11 │ (GameObject 实例) │
│ │ 装备ID: 123 │ (复用) │
│ │ 有红点 │ │
│ └──────────────┘ │
└─────────────────────────────────────┘
用户滑动,Item#11 滑出屏幕
Pooled State:
┌──────────────────────────────────────────┐
│ Item#11 GameObject 进入对象池 │
│ SetActive(false) │
│ 但是内部数据没有清理! │
│ ├─ equipmentId = 123 (旧数据) │
│ ├─ hasRedPoint = true (旧数据) │
│ └─ redPointUI.SetActive(false)(手动操作) │
└──────────────────────────────────────────┘
用户再滑动,Item#11 重新进入屏幕(对象池复用)
❌ Bug 时间:
Item#11 现在显示的装备是 456(新数据)
但红点却是打开的(旧数据还在!)
正确做法:
// ✅ 方案:永远通过数据驱动 UI
public class UIEquipmentItem
{
public Equipment data;
public void Refresh(Equipment newData)
{
// 1. 更新数据
this.data = newData;
// 2. 根据数据更新 UI(而不是记住状态)
equipmentNameText.text = newData.name;
equipmentIconImage.sprite = newData.icon;
// 3. 根据数据决定红点显示
// ❌ 错:redPointUI.SetActive(false);
// ✅ 对:
redPointUI.SetActive(newData.isNew);
// 关键:永远从 newData 读取状态,不依赖之前的状态
}
}
// 在 ScrollView 的回调中
void OnGetItemByIndex(int index)
{
var itemUI = GetOrCreateUIItem();
var data = itemList[index];
// 每次都完整刷新
itemUI.Refresh(data);
}
第七章:核心数据结构——性能优化的秘密
7.1 DicList:平衡查找与遍历
这是一个看似简单但非常精妙的设计。
问题:框架需要在两种操作间平衡性能:
- 查找(O(1)):AddComponent 时,快速判断"这个组件是否需要 Awake?"
- 遍历(零 GC):每帧 Update 时,遍历所有 Update 系统
// ❌ 只用 Dictionary(遍历产生 GC)
private Dictionary<Type, object> _systems;
foreach (var system in _systems.Values) // ◄─ 产生迭代器 GC
{
system.Run();
}
// ❌ 只用 List(查找很慢)
private List<object> _systems;
if (_systems.Contains(awakeSystem)) // ◄─ O(n) 线性查找
{
// ...
}
// ✅ 混合设计(DicList)
private Dictionary<Type, object> _dict; // ◄─ O(1) 查找
private List<object> _list; // ◄─ 零 GC 遍历
public bool Contains(Type key)
{
return _dict.ContainsKey(key); // O(1),快速
}
public void ForEach(Action<object> action)
{
for (int i = 0; i < _list.Count; i++)
{
action(_list[i]); // ◄─ 数组遍历,零 GC,CPU 缓存友好
}
}
7.2 双缓冲队列
// 每帧处理
public void Update()
{
// 当前队列:_updates(待处理)
// 备用队列:_updates2(空的)
while (_updates.Count > 0)
{
long instanceId = _updates.Dequeue();
var entity = _all_entities[instanceId];
// 执行 Update 系统
// 在此过程中可能会创建新实体或删除现存实体
UpdateSystems(entity);
// ✅ 关键:新创建的实体加入 _updates2
// 而不是 _updates
// 这样不会对当前遍历造成影响
_updates2.Enqueue(instanceId);
}
// 交换队列引用(零 GC,只是改了两个指针)
(_updates, _updates2) = (_updates2, _updates);
}
为什么需要双缓冲?
// ❌ 如果直接操作同一个队列
while (_updates.Count > 0)
{
var entity = _updates.Dequeue();
// 执行逻辑可能会创建新实体
if (someCondition)
{
var newEntity = CreateEntity();
_updates.Enqueue(newEntity); // ◄─ 正在遍历的队列被修改
}
// 结果:
// - 新创建的实体可能在这一帧被错误执行
// - 或者导致"集合已修改"的异常
}
7.3 TypeHandle 与接口分发
这涉及到 CLR 的内存布局,是框架性能高的根本原因。
// 存储时:用 object(统一接口)
OneTypeSystems systems;
systems.Add(typeof(IAwakeSystem), new UIBackpackAwakeSystem());
// ◄─ 在内存中,这是什么样的?
//
// object 的内存布局:
// +--------+--------+-------+-------+
// |TypeRef | Flags |Field1 |Field2 |
// +--------+--------+-------+-------+
// ^
// ├─ 指向 MethodTable(方法表)
// └─ MethodTable 包含该类型实现的所有接口的函数指针
// 执行时:强转为具体接口(零反射成本)
IAwakeSystem system = (IAwakeSystem)obj;
system.Run(entity);
// CLR 的强转过程:
// 1. 读取 obj 的 TypeHandle(一条 MOV 指令)
// 2. 在 MethodTable 中查找 IAwakeSystem 槽位(表查找)
// 3. 跳转到对应的 Run() 函数指针(一条 JMP 指令)
//
// 整个过程:3 条 CPU 指令,比反射快 1000 倍
第八章:对象生命周期管理——Entity、Component、System 的职责划分
8.1 Entity 的职责:只管容器,不管逻辑
public class Entity
{
public long InstanceId { get; set; } // 身份证
public Entity Parent { get; set; } // 上级容器
public List<Entity> Children { get; } // 下属容器
// ✅ 只管容器的职责
public void AddChild(Entity child)
{
Children.Add(child);
child.Parent = this;
}
public void RemoveChild(Entity child)
{
Children.Remove(child);
child.Parent = null;
}
// ❌ 不做这些(逻辑交给 Component 和 System)
// public void Update() { }
// public virtual void Init() { }
}
Entity 的生命周期:
Create -> AddComponent -> Event 驱动逻辑 -> Destroy
8.2 Component 的职责:只存数据,不写逻辑
public class EquipmentComponent : Entity
{
// ✅ 只存数据
public int EquipmentId { get; set; }
public int Quality { get; set; }
public string Name { get; set; }
public int Power { get; set; }
// ✅ 允许简单的数据访问方法
public int GetUpgradeCost()
{
return Power * 100;
}
// ❌ 不做这些
// public void Upgrade() { } // 这是逻辑
// public async UniTask Sell() { } // 这也是逻辑
}
为什么 Component 不能有逻辑?
- Component 可能有多个 System 对它操作
- 如果 Component 本身有逻辑,就会产生隐藏的依赖关系
- 难以维护和测试
8.3 System 的职责:只处理逻辑,不存状态
// ✅ 正确的 System(无状态)
[ObjectSystem]
public partial class EquipmentUpgradeSystem : UpdateSystem<EquipmentComponent>
{
public override void Run(EquipmentComponent equipment)
{
// 不产生任何实例变量
int newPower = equipment.Power * 1.1f;
equipment.Power = newPower;
// 发布事件让其他系统处理
EventSystem.Instance.Publish(new EquipmentUpgradedEvent
{
EquipmentId = equipment.EquipmentId,
NewPower = newPower
});
}
}
// ❌ 错误的 System(有状态)
public partial class EquipmentUpgradeSystem : UpdateSystem<EquipmentComponent>
{
private int _totalUpgrades = 0; // ❌ 有状态
private Dictionary<int, int> _upgradeMap = new(); // ❌ 有状态
public override void Run(EquipmentComponent equipment)
{
// 由于 System 是全局单例,所有 Equipment 共用这个 _totalUpgrades
_totalUpgrades++; // ❌ 数据被污染
// 不同的 Equipment 的升级记录会相互覆盖
_upgradeMap[equipment.EquipmentId]++; // ❌ 竞争
}
}
为什么 System 必须无状态?
- System 是全局单例(启动时只创建一次)
- 所有 Component 实例共用同一个 System
- 如果 System 有状态,所有 Component 的数据会互相污染
第九章:事件路由——两种分发模式
9.1 Publish:广播(一对多)
// ✅ 发布事件
await EventSystem.Publish(new EquipmentPurchasedEvent
{
EquipmentId = 456,
PlayerMoney = 5000
});
// 框架内部流程:
// 1. 快速查表:Event Map 中找 EquipmentPurchasedEvent
// 2. 获得所有订阅者列表:[Handler1, Handler2, Handler3]
// 3. 并发分发:
// - Handler1.Run() 返回 UniTask
// - Handler2.Run() 返回 UniTask
// - Handler3.Run() 返回 UniTask
// 4. 等待所有完成:await UniTask.WhenAll(task1, task2, task3)
特性:
- 如果没有订阅者,直接返回(零开销)
- 所有 Handler 获得 Lock 后才执行(并发安全)
- 任何 Handler 的异常不会影响其他 Handler(异常隔离)
9.2 Invoke:精确调用(一对一)
// ✅ 精确调用(需要返回值)
var damageResult = await EventSystem.Invoke<DamageEvent, DamageResult>(
new DamageEvent { AttackerId = 123, TargetId = 456 }
);
// 框架内部流程:
// 1. 在 Invoke Map 中精确查找这个 Event
// 2. 如果找不到,抛出异常(防止关键逻辑静默断裂)
// 3. 如果找到,执行唯一的 Handler,返回结果
Publish vs Invoke:
┌──────────────┬────────────────────┬──────────────────┐
│ 维度 │ Publish (广播) │ Invoke (精确) │
├──────────────┼────────────────────┼──────────────────┤
│ 订阅者数 │ 0 个、1 个或多个 │ 必须 1 个 │
│ 异常处理 │ 异常被隔离 │ 异常会抛出 │
│ 返回值 │ 无 │ 有 │
│ 使用场景 │ 通知、日志等 │ 数据计算、状态查询│
│ 找不到处理 │ 正常(可能没人关心)│ 抛异常(关键逻辑)│
└──────────────┴────────────────────┴──────────────────┘
第十章:热更与 Binder——代码与资源的物理隔离
10.1 热更的挑战
在支持热更新的项目中,运行时脚本代码会被替换。但 Prefab 资源中有对脚本的引用。
问题:
├─ View.dll 版本 1
│ ├─ UIShop.cs
│ └─ Inspector 中有对 shopIconImage 的引用
│
└─ 发起热更新
└─ View.dll 版本 2
├─ UIShop.cs(改动了)
└─ ❌ Prefab 中的引用丢失(Missing Script)
为什么会丢失?
- Prefab 中的引用是通过
[SerializeField]存储的 - 每次编译,Unity 生成新的 FileID
- 热更后,旧的 FileID 和新的 FileID 不匹配
- 引用丢失,UI 看不到图片
10.2 Binder 的解决方案
// ✅ 使用字符串作为 Key
public class UIShop : UIBase
{
// ✅ 获取引用时,通过字符串查询
private Image shopIconImage;
public override void Awake4Gen(UIContainer container)
{
// Binder 在 Prefab 中的配置:
// { "shopIcon": Image组件 }
// 即使代码热更,这个字符串和字典不会变化
shopIconImage = container.Binder.Get<Image>("shopIcon");
}
}
// Binder 的实现
public class ReferenceBinder : MonoBehaviour
{
[SerializeField]
private List<string> keys = new();
[SerializeField]
private List<Component> values = new();
private Dictionary<string, Component> _cache = new();
public T Get<T>(string key) where T : Component
{
if (_cache.TryGetValue(key, out var component))
return component as T;
// 第一次查询时,构建缓存
return null; // 如果没有
}
}
为什么用字符串?
- 字符串不会因为代码热更而改变
- 即使 UIShop.cs 完全改了,“shopIcon” 这个字符串永远在那里
- 通过字符串间接引用,实现了代码与资源的物理隔离
第十一章:性能优化总结
11.1 优化清单
| 优化点 | 技术 | 效果 | 适用场景 |
|---|---|---|---|
| 数据结构 | DicList 混合 | O(1)查找 + 零GC遍历 | 每帧轮询 |
| 内存安全 | 双缓冲队列 | 避免并发修改异常 | 动态创建/销毁 |
| 接口调用 | TypeHandle直接分发 | 零反射成本 | 每帧执行系统 |
| UI渲染 | 虚拟化+被动渲染 | 内存减少99%,零GC | 大列表 |
| 并发安全 | 队列锁+超时 | 避免数据竞争+防死锁 | 事件处理 |
| 生命周期 | 状态机 + async/await | 串行保证 + 无竞争 | UI初始化 |
| 热更隔离 | Binder字符串引用 | 代码热更不丢引用 | 动态更新 |
11.2 性能指标对比
场景:打开 1000 个商品的商店列表
┌─────────────────┬──────────┬──────────┐
│ 指标 │ 传统方式 │ 优化后 │
├─────────────────┼──────────┼──────────┤
│ 打开时间 │ 2-3s │ <100ms │
│ 初始内存 │ 500MB │ 5MB │
│ GameObject 数 │ 1000 │ 20-30 │
│ 首帧帧率 │ 15fps │ 60fps │
│ 滑动时GC │ 100MB/s │ 0 GC │
│ 帧率稳定性 │ 不稳定 │ 稳定60fps│
└─────────────────┴──────────┴──────────┘
第十二章:常见的 Bug 与陷阱
12.1 BeforeShow 的异步陷阱
// ❌ 错误
public override void BeforeShow(UIBackpack ui, object param)
{
HttpComponent.Instance.Get("/inventory"); // Fire-and-Forget
// ❌ 此时数据还没来,items 为空
foreach (var item in ui.items) { }
}
// ✅ 正确
public override async UniTask BeforeShow(UIBackpack ui, object param)
{
await HttpComponent.Instance.Get("/inventory");
// ✅ 此时数据已经来了
foreach (var item in ui.items) { }
}
12.2 虚拟列表的状态污染
// ❌ 错误:直接操作 GameObject
public void RefreshItem(UIEquipmentItem item, Equipment data)
{
item.redPointUI.SetActive(false); // ❌ 这个 Item 后来会被复用
}
// ✅ 正确:根据数据驱动
public void RefreshItem(UIEquipmentItem item, Equipment data)
{
item.redPointUI.SetActive(data.isNew); // ✅ 每次都从数据来
}
12.3 System 的有状态陷阱
// ❌ 错误
public partial class EquipmentSystem : UpdateSystem<EquipmentComponent>
{
private Dictionary<int, int> _cache = new(); // ❌ 全局单例的共享状态
public override void Run(EquipmentComponent equipment)
{
_cache[equipment.EquipmentId]++; // ❌ 所有实例都在修改同一个字典
}
}
// ✅ 正确
public partial class EquipmentSystem : UpdateSystem<EquipmentComponent>
{
public override void Run(EquipmentComponent equipment)
{
// 所有状态都存在 Component 里
equipment.UpdateCount++;
}
}
12.4 Event 异常不会传播
// ❌ 错误的期望
[Event]
public partial class EquipmentPurchaseHandler : AEvent<EquipmentPurchaseEvent>
{
protected override void Run(EquipmentPurchaseEvent args)
{
throw new Exception("Something wrong"); // ❌ 不会向外传播
}
}
// ✅ 如果需要处理异常
[Event]
public partial class EquipmentPurchaseHandler : AEvent<EquipmentPurchaseEvent>
{
protected override void Run(EquipmentPurchaseEvent args)
{
try
{
// 逻辑
}
catch (Exception ex)
{
Log.Error(ex); // 手动处理
// 或者发布另一个事件来通知
}
}
}
结语:架构的深层含义
这个框架之所以高性能,不是因为某一个技术有多牛,而是因为在每个层面都进行了极致的优化:
- 架构层:ECS 思想 + 双轨并行 = 性能 + 易维护
- 数据结构层:DicList + 双缓冲 = 查找快 + 遍历零GC
- 内存层:TypeHandle + 接口分发 = 类型安全 + 性能开销零
- 并发层:队列锁 + 超时 = 数据安全 + 无死锁
- 渲染层:虚拟化 + 被动渲染 = 内存减少99% + 帧率稳定
对于开发者的启示:
- 服从约定:不要试图突破框架的限制(如给 System 加状态)
- 数据驱动:永远从数据推导 UI,不要记住 UI 的状态
- 异步优先:所有异步操作都要用
await,不要 Fire-and-Forget - 事件解耦:模块间通过事件通讯,不要直接调用
理解了这个框架的设计哲学,就能写出高性能、易维护、高可扩展的游戏代码。