在Unity开发中,异步UI操作常常导致数据不一致。本文深入剖析异步重入问题的本质,并展示一种基于队列锁的优雅解决方案。
一、什么是重入问题?为什么它是灾难?
重入(Reentrancy):同一个异步函数被多次调用,执行流程相互交错,导致状态混乱。
1.1 典型场景:电商购物车
想象一个电商应用的购物车功能。用户点击"加入购物车"按钮后:
T1: 用户第一次点击 → 发送AddToCart请求 → await 挂起
T2: 网络慢,用户以为没反应,又点一次 → 又发送请求 → await 挂起
T3: 请求1返回 itemId=1 → 触发UI刷新 → await 获取购物车列表
T4: 请求2返回 itemId=2 → 又触发UI刷新 → 又await 获取列表
T5: 列表请求2先返回 [item1, item2] → UI显示两个商品
T6: 列表请求1后返回 [item1] → 覆盖UI,只显示一个商品!
结果:用户加了两个商品,但购物车只显示一个。这不是网络问题,是并发问题。
1.2 为什么会发生?
异步操作天生就是并发的:
async void OnAddToCartClick()
{
// 这里没有任何保护
var result = await HttpClient.Post("/cart/add", itemData);
// 发布事件通知UI刷新
EventSystem.Publish(new OnCartChanged { itemId = result.id });
}
// UI刷新处理
public class OnCartChanged_RefreshUI : AUIEvent<UICartPage, OnCartChanged>
{
protected override async UniTask Run(UICartPage ui, OnCartChanged args)
{
// 问题:这个函数可能被同时调用多次
var list = await HttpClient.Get("/cart/list");
ui.RefreshList(list); // 先返回的会被后返回的覆盖
}
}
核心问题:await挂起时,函数并没有结束,只是让出了线程。如果在此期间又有新的调用,两个执行流就会交错。
二、传统方案为什么治标不治本?
2.1 方案A:手动标志位(简单粗暴,但会丢数据)
private bool isRefreshing = false;
public async UniTask RefreshCartList()
{
if (isRefreshing) return; // 第二次调用直接丢弃
isRefreshing = true;
var list = await HttpClient.Get("/cart/list");
UpdateUI(list);
isRefreshing = false;
}
致命缺陷:
- 丢失更新:第二次刷新被直接忽略,用户可能看不到最新数据
- 重复代码:每个UI的每个异步方法都要写一遍这个逻辑
- 容易遗忘:新人接手项目,很可能忘记加这个判断
- 难以调试:UI没刷新,是因为isRefreshing=true吗?还是网络问题?
2.2 方案B:业务层维护队列(复杂易错)
private Queue<Func<UniTask>> pendingTasks = new Queue<Func<UniTask>>();
private bool isProcessing = false;
public async UniTask RefreshCart(long itemId)
{
// 把任务加入队列
pendingTasks.Enqueue(async () => {
var data = await HttpClient.Get($"/cart/{itemId}");
UpdateUI(data);
});
if (!isProcessing) await ProcessQueue();
}
private async UniTask ProcessQueue()
{
isProcessing = true;
while (pendingTasks.Count > 0)
{
var task = pendingTasks.Dequeue();
await task();
}
isProcessing = false;
}
问题:
- 重复代码:每个UI都要实现一遍队列逻辑
- 容易出bug:队列处理本身就复杂,很容易写错
- 没有超时:如果某个任务卡住,整个队列永久阻塞
- 没有取消:UI销毁了,队列还在后台执行
三、优雅的解决方案:框架级队列锁
3.1 核心思想:单UI实例粒度的锁
设计目标:
- 业务代码不需要关心并发控制
- 每个UI实例有独立的锁,互不干扰
- 自动排队,不丢失任何事件
- 自动超时,防止死锁
实现方式:
// 框架层封装(业务层看不到)
private async UniTask _Run(UIBase uibase, UI ui, EventTypes args)
{
// 关键:为每个UI实例获取独立的锁
using (await UniTaskLockComponent.Instance.Wait(
UniTaskLockComponent.UI, // 锁类型:UI
uibase.InstanceId, // 锁的key:UI实例的唯一ID
uibase.token, // 取消令牌
3000 // 超时3秒
))
{
if(ui.IsDisposed) return;
// 业务代码在这里执行,完全串行
await Run(ui, args);
}
}
三个关键设计:
- 锁的粒度是UI实例:通过
InstanceId作为key,UICartPage实例A和实例B的锁完全独立 - 自动排队:拿不到锁就进入等待队列,不会丢失
- 自动释放:using语句保证执行完自动释放,通知下一个
3.2 有锁情况下的完整时间轴
T1: 用户第一次点击"加入购物车" → 发送请求
T2: 用户又点一次 → 又发送请求
T3: 请求1返回 itemId=1
→ EventSystem.Publish(OnCartChanged)
→ AUIEvent 尝试获取 UICartPage(instanceId=12345) 的锁
→ 成功获取(因为是第一个)
→ 执行业务逻辑
→ await HttpClient.Get("/cart/list")
→ 挂起等待,但锁未释放! ← 关键
T4: 请求2返回 itemId=2
→ EventSystem.Publish(OnCartChanged)
→ AUIEvent 尝试获取 UICartPage(instanceId=12345) 的锁
→ 锁已被占用!加入等待队列 ← 不是丢弃,是排队
T5: 请求1的列表数据返回
→ ui.RefreshList([item1])
→ 业务逻辑执行完毕
→ using 块结束,自动释放锁
→ 通知等待队列:下一个可以执行了
T6: 请求2从队列中被唤醒
→ 获取锁
→ 执行业务逻辑
→ await HttpClient.Get("/cart/list")
→ 返回 [item1, item2]
→ ui.RefreshList([item1, item2]) ← 正确!
→ 释放锁
核心保证:请求1还在执行时(即使await挂起),锁一直被持有,请求2必须等请求1完全执行完。
四、底层实现:UniTaskLock的队列机制
4.1 数据结构:二维索引的队列
UniTaskLockComponent (全局单例)
└── list[type] // type=0 是UI类型
└── Dictionary<long key, UniTaskLockQueue>
├── key=12345 (UICartPage的InstanceId)
│ └── Queue<UniTaskCompletionSource>
│ ├── 事件1的等待任务
│ └── 事件2的等待任务
└── key=67890 (UIHomePage的InstanceId)
└── Queue<...> // 另一个UI的独立队列
设计亮点:
- 按(type, key)二维索引:不同类型、不同实例的锁完全隔离
- 队列存储等待任务:后来的事件不会丢失,而是排队
- UniTaskCompletionSource:手动控制异步任务的完成时机
4.2 Wait() 的核心逻辑
public static async UniTask<UniTaskLock> Wait(
this UniTaskLockComponent self,
int type, long key, CancellationToken ct, int time = 60000)
{
UniTaskLockQueueType queueType = self.list[type];
// 检查这个key是否已有队列
if(!queueType.TryGetValue(key, out UniTaskLockQueue queue))
{
// 第一次请求这个锁,直接创建并返回
queueType.Add(key, self.AddChildWithId<UniTaskLockQueue>(...));
return self.CreateUniTaskLock(type, key, time, 1);
}
// 已有队列,说明锁被占用,创建等待任务
UniTaskCompletionSource<UniTaskLock> tcs = new();
queue.Add(tcs, time); // 加入队列
// 等待被唤醒(这里会挂起,直到前一个任务释放锁)
return await tcs.Task;
}
4.3 完整的解锁-上锁流程
事件1执行完成
↓
using 块结束(自动调用Dispose)
↓
UniTaskLock.Dispose() 触发销毁系统
↓
UniTaskLockDestroySystem.Destroy()
↓
RunNextUniTask() 加入"下一帧执行队列"
↓
【下一帧】Update() 被调用
↓
UniTaskLockComponentUpdateSystem.Update()
↓
Notify() 从队列取出事件2的 tcs
↓
tcs.TrySetResult(newLock) 唤醒事件2
↓
事件2的 await tcs.Task 返回
↓
事件2拿到锁,开始执行
为什么延迟到下一帧?
如果立即唤醒下一个任务:
事件1完成 → 立即唤醒事件2 → 事件2完成 → 立即唤醒事件3 → ...
可能导致单帧执行大量任务 → 卡顿
延迟到下一帧的好处:
- 防止单帧时间过长:每帧最多处理一批任务
- 给其他系统机会:渲染、输入、物理等系统需要CPU时间
- 性能可控:如果连续处理100个事件,会打印warning
五、超时机制:防止死锁的最后一道防线
5.1 为什么需要超时?
假设某个网络请求永远不返回(服务器挂了、网络断了):
无超时机制:
事件1 → await 卡死 → 锁永远不释放
事件2、3、4... → 永远在队列等待
这个UI实例 → 彻底死锁 → 用户点击任何按钮都没反应
有超时机制(3秒):
事件1 → await 卡死
3秒后 → UniTaskLockTimer 超时触发
→ 强制释放锁
→ 打印错误日志:Log.Error($"[UniTaskLock] timed out!")
→ 事件2继续执行
→ UI恢复响应
5.2 超时的实现
// 每个Wait调用都设置了超时时间
using (await UniTaskLockComponent.Instance.Wait(
UniTaskLockComponent.UI,
uibase.InstanceId,
uibase.token,
3000 // ← 3秒超时
))
超时检查在每帧Update中进行:
private void _TimeoutCheck(UniTaskLockComponent self)
{
long time_now = TimeInfo.Instance.frameTime;
// 遍历所有超时的锁
foreach(var pair in self.timers)
{
if(pair.Key > time_now) break;
// 强制释放超时的锁
var locker = pair.Value.uni_task_lock;
self.RunNextUniTask(locker.lock_type, locker.key, locker.level + 1);
locker.lock_type = UniTaskLock.TYPE_NONE;
}
}
六、实战场景:新手引导的完美保护
6.1 问题场景
新手引导需要一步步控制UI:
async UniTask ShowStep1()
{
// 1. 打开商城页面
var shopPage = await UIComponent.Get<UIShopPage>();
// 2. 高亮"购买"按钮
await shopPage.HighlightButton("BuyButton");
// 3. 等待用户点击
await shopPage.WaitForButtonClick("BuyButton");
// 4. 继续下一步
await ShowStep2();
}
无锁情况下的灾难:
T1: ShowStep1() 调用 UIComponent.Get<UIShopPage>()
→ 开始异步加载UI资源
→ await 挂起
T2: 同时,服务器推送:"限时优惠开始"
→ Publish(OnPromotionStarted)
→ AUIEvent<UIShopPage> 被触发
→ 也调用 UIComponent.Get<UIShopPage>()
→ 又加载同一个UI
T3: 第一个Get完成
→ HighlightButton("BuyButton")
→ 开始播放高亮动画
T4: 第二个Get也完成
→ 事件处理调用 shopPage.RefreshPromotionBanner()
→ 刷新页面,可能触发UI重建
→ 高亮动画被打断! ← 引导流程被破坏
→ 用户困惑:为什么高亮没了?
有锁情况下的完美保护:
T1: ShowStep1() 调用 Get<UIShopPage>()
→ 获取锁(因为是第一个)
→ 开始加载UI
T2: OnPromotionStarted 触发
→ 尝试获取同一个UI的锁
→ 锁被占用,加入队列等待 ← 不会打断引导
T3: ShowStep1() 完成加载
→ HighlightButton("BuyButton")
→ WaitForButtonClick()
→ await 挂起,但锁还在持有 ← 关键
T4: 用户点击按钮
→ ShowStep1() 继续执行
→ ShowStep2()
→ 最终执行完毕,释放锁
T5: OnPromotionStarted 从队列出来
→ 获取锁
→ 执行刷新逻辑
→ 不会干扰引导流程 ← 完美!
七、业务代码的使用:极致简洁
7.1 示例:购物车UI刷新
// 1. 定义事件结构(纯数据)
namespace EventTypes
{
public struct OnCartItemAdded
{
public long itemId;
public string itemName;
public int quantity;
}
}
// 2. 实现事件处理(框架自动加锁)
public class OnCartItemAdded_RefreshUI : AUIEvent<UICartPage, OnCartItemAdded>
{
protected override async UniTask Run(UICartPage ui, OnCartItemAdded args)
{
// 只写业务逻辑即可,框架自动处理:
// ✓ 生命周期检查(UI是否已销毁)
// ✓ 自动加锁(防止重入)
// ✓ 异常捕获(不会崩溃)
// ✓ 超时保护(3秒)
await ui.AddItemToList(args.itemId, args.itemName, args.quantity);
}
}
// 3. 在业务代码中发布事件
async UniTask AddToCart(ItemData data)
{
var result = await HttpClient.Post("/cart/add", data);
// 发布事件,所有监听者都会收到
EventSystem.Instance.Publish(new OnCartItemAdded
{
itemId = result.id,
itemName = data.name,
quantity = data.quantity
});
}
你不需要关心:
- 场景中有几个UICartPage实例
- 它们是否处于激活状态
- 会不会发生重入
- 需不需要手动加锁
框架全帮你处理了。
7.2 数据层事件:不需要锁
// 数据层事件用 AEvent(不是 AUIEvent)
public class OnLogout_ClearCache : AEvent<OnLogout>
{
protected override async UniTask Run(OnLogout args)
{
// 清理缓存,不涉及UI
CacheManager.Instance?.ClearAll();
await UniTask.CompletedTask;
}
}
为什么不用 AUIEvent?
- 不需要遍历UI实例
- 不需要加锁(数据清理是单线程操作)
- 不需要生命周期检查
八、架构设计:分层与职责分离
graph TD
subgraph BusinessLayer ["业务层 (User Code)"]
A["实现 Run(UI ui, Event args)"]
B["执行业务逻辑"]
end
subgraph WrapperLayer ["AUIEvent 封装层"]
C["遍历 UI 实例"]
D["生命周期检查 (IsDisposed)"]
E["自动加锁 (Locking)"]
F["异常捕获 (Try-Catch)"]
end
subgraph LockLayer ["UniTaskLockComponent 锁管理层"]
G["任务队列管理"]
H["超时检查 (Timeout)"]
I["取消处理 (Cancellation)"]
end
subgraph DispatchLayer ["EventSystem 事件调度层"]
J["反射加载 (Reflective Loading)"]
K["事件注册 (Subscription)"]
L["事件分发 (Dispatching)"]
end
%% 流程连接
A --> C
C --> D
D --> E
E --> F
F --> G
G --> H
H --> I
I --> L
L -.-> K
K -.-> J
%% 样式美化
style BusinessLayer fill:#e1f5fe,stroke:#01579b
style WrapperLayer fill:#fff3e0,stroke:#e65100
style LockLayer fill:#f3e5f5,stroke:#4a148c
style DispatchLayer fill:#e8f5e9,stroke:#1b5e20
每层只做自己的事:
- 业务层:不知道锁的存在,专注业务逻辑
- 封装层:处理UI相关的通用逻辑(遍历、检查、加锁)
- 锁管理层:处理并发控制(队列、超时、取消)
- 调度层:处理事件路由(注册、分发)
这就是优秀框架设计的精髓:让复杂的事情在框架层解决,让业务代码保持简洁。
九、方案对比:为什么框架方案更优?
| 特性 | 手动标志位 | 业务层队列 | 框架队列锁 |
|---|---|---|---|
| 是否丢失事件 | ✗ 丢失 | ✓ 不丢失 | ✓ 不丢失 |
| 代码重复度 | ✗ 每个UI都要写 | ✗ 每个UI都要写 | ✓ 框架统一处理 |
| 超时保护 | ✗ 无 | ✗ 需自己实现 | ✓ 自动3秒超时 |
| 取消机制 | ✗ 无 | ✗ 需自己实现 | ✓ CancellationToken |
| 生命周期感知 | ✗ 需手动检查 | ✗ 需手动检查 | ✓ 自动检查 |
| 业务代码复杂度 | ✗ 高 | ✗ 高 | ✓ 低(只写核心逻辑) |
| 维护成本 | ✗ 高 | ✗ 高 | ✓ 低(框架统一维护) |
十、总结:这个设计解决了什么?
10.1 核心价值
- 异步重入问题:通过队列锁,保证UI事件串行执行
- 状态一致性:避免多个异步操作交错导致数据混乱
- 新手引导可靠性:引导流程不会被其他事件打断
- 开发体验:业务代码无需关心并发控制
- 可维护性:统一的锁机制,易于调试和优化
- 防御性:超时、取消、异常捕获,防止系统崩溃
10.2 设计哲学
- UI刷新数据不一致
- 新手引导被意外事件打断
- 异步操作死锁
- UI销毁后事件还在执行导致空指针
好的框架设计,就是让正确的事情变得容易,让错误的事情变得困难。
本文基于Unity项目的实际架构总结,采用ECS + 事件驱动的设计模式,适用于中大型Unity项目。