《从能用到难以绕过》系列第 1/15 篇。本文从一个常见的 UIManager 出发,拆解 Unity UI 在页面身份、生命周期、导航策略与资源所有权上的七类失控问题。
很多 Unity 项目的 UI 框架,都从同一段代码开始:一个单例、一个 Dictionary<string, GameObject>,再加上 Open、Close、Get 三个方法。
它没有错。甚至在只有三五个页面的原型里,它可能就是性价比最高的方案。
以商城为例,第一版只要加载 Prefab 和响应购买按钮;随后它会增加分类切换、货币栏、商品列表、详情弹窗、异步购买、资源预览、红点和返回逻辑。原来的“页面开关工具”被迫回答越来越多不属于开关的问题:异步加载失败怎么办?连续点击打开两次怎么办?详情弹窗盖住商城时,商城算关闭还是暂停?关闭以后资源由谁释放?返回键回到哪里?Prefab 改名为什么要到运行时才报错?
这时需要设计的已经不是一个更大的 UIManager,而是一套能表达页面身份、状态、关系和所有权的运行时模型。
先给结论
一个值得长期维护的 Unity UI 框架,至少要管住七类容易失控的问题:
FUI 的设计主线可以概括为:把原本散落在调用方、Prefab 和运行时反射里的关系,逐步变成显式契约、编译产物和可验证状态。
1. 为什么常见的 UIManager 会不断膨胀
先看一个典型实现:
public sealed class UIManager : MonoBehaviour
{
readonly Dictionary<string, GameObject> opened = new();
public async void Open(string pageName)
{
var prefab = await Load(pageName);
var instance = Instantiate(prefab, transform);
opened[pageName] = instance;
}
public void Close(string pageName)
{
Destroy(opened[pageName]);
opened.Remove(pageName);
}
}
它把三个不同层次的问题揉成了一个过程:
- pageName 同时承担业务身份和资源地址;
- Open 同时负责加载、实例化、入栈、显示和初始化;
- Close 默认“关闭等于销毁”,没有缓存和回滚语义。
当需求增加时,最容易出现的演化是继续加参数:
Open(
"ShopView",
layer: 3,
addToHistory: false,
cache: true,
closeCovered: false,
allowMultiple: false);
调用看似灵活,代价却是每个调用点都可以重新解释“商城页是什么”。同一个页面在 A 处进入历史,在 B 处不进入;C 处允许多开,D 处又假定它是单例。框架没有形成规则,只是把规则的选择权推给了所有业务代码。
2. 先判断你是否真的需要框架
框架并不是页面数量达到某个数字后自动解锁的成就。判断依据应该是变化的维度。
| 一次性原型,3~5 个页面,无异步资源 | 简单 UIManager |
| 页面不多,但有返回栈和弹窗遮挡 | UIManager + 显式状态机 |
| 多人长期开发,页面策略重复,使用 Addressables | 路由、生命周期、资源所有权分层 |
| 多渲染后端、自动绑定、编译期检查 | 完整框架或成熟方案 |
| 页面由远端配置或热更新脚本动态发现 | 静态框架 + 有边界的动态注册 |
第一次写框架的人常见的错误,是先追求“支持所有项目”。更实际的做法是先列出当前项目已经重复出现的矛盾,再为矛盾建立模型。
3. 七类失控问题,分别应该由什么模型解决
3.1 字符串失控:Route
"ShopView" 只告诉编译器这是一个字符串,无法说明它对应哪个 ViewModel、能否多开、属于哪个 Layer。
更稳定的入口是类型化路由:
public sealed class Route<TViewModel>
{
public string AssetKey { get; }
public Func<TViewModel> CreateViewModel { get; }
public RoutePolicy Policy { get; }
}
资源地址仍可以是字符串,但它被封装在路由内部,不再扩散到业务调用方。
3.2 实例失控:ViewHandle
如果页面允许多开,Close<RewardViewModel>() 到底关闭哪一个?如果页面从缓存中复用,旧调用方保存的引用还能否关闭新实例?
框架应该为每次打开分配句柄:
ViewHandle handle = navigator.Open(Routes.Shop);
navigator.Close(handle);
句柄代表一次打开代际,而不是某个 GameObject 的永久身份证。缓存复用时应生成新句柄,让旧句柄保持终态,避免迟到的异步回调误关新页面。
3.3 时序失控:状态机
OnOpen、OnShow、OnHide、OnClose 只是方法名,不会自动保证调用顺序。
最小状态可以是:
Created → Loading → Entering → Visible
↘ Covered
Visible/Covered → Exiting → Cached 或 Released
状态机不仅用于文档,它还要拒绝重复 Close、处理 Open 与 Close 竞态,并在失败时回滚已创建的资源。
3.4 同步失控:BindingContext
当页面脚本既读业务服务、又格式化文本、又订阅按钮,还要在销毁时解绑,View 会迅速变成不可测试的胶水层。
MVVM 的价值不是名字更现代,而是把“展示状态”和“视图装配”拆开:
Model/Service → ViewModel → BindingContext → View
↑ ↓
Command ← User Event
ViewModel 不应知道 Button、Text、Prefab;BindingContext 只负责两端同步,不承载业务流程。
3.5 策略失控:RoutePolicy
Layer、History、Coverage、Cache、AllowMultiple 是互相正交的维度。把它们编译为不可变策略,比把布尔参数散到调用点更容易审查。
3.6 所有权失控:Lease
“加载到了资源”不等于“拥有资源”。框架需要一个明确的释放凭证:
public interface IViewLease : IDisposable
{
IView View { get; }
IAssetScope Assets { get; }
}
谁取得 Lease,谁负责把它移交给缓存或释放。取消、失败和重复 Dispose 都必须有确定结果。
3.7 错误发现太晚:分层验证
没有任何单一工具能看见所有事实:
- Roslyn 看得见 C# 类型与 Attribute,看不见 Prefab 层级;
- Editor Validator 看得见 Prefab 结构,看不见真实异步竞态;
- PlayMode 看得见生命周期,却不代表 IL2CPP 裁剪安全;
- Player 构建能验证 AOT 与裁剪,但反馈最慢。
所以验证必须分层,而不是迷信“编译通过”。
4. 为什么不直接选 MVC、事件总线或响应式框架
它们解决的不是同一个问题。
| MVC | 业务职责分离 | 没有统一规定 View 如何持续同步状态 |
| MVP | Presenter 显式驱动 View | 接口和逐项赋值会随页面复杂度膨胀 |
| 事件总线 | 跨模块通知 | 来源和生命周期不透明,容易忘记退订 |
| 响应式流 | 组合异步事件和状态变化 | 学习、调试和分配成本更高,仍需导航与资源模型 |
| MVVM + 显式导航 | 管展示状态、数据方向和页面装配 | 需要绑定基础设施,简单页面可能显得重 |
FUI 的思路不是宣称 MVVM 取代一切。页面内部的数据流适合 MVVM;跨页面跳转交给 Navigator;资源生命周期交给 Provider/Lease;需要复杂流程编排时仍可使用 Presenter。
5. 正确的实现顺序
不要从 Source Generator 开始。生成器会放大既有模型;模型错误时,它只会更快地批量生成错误。
一个更稳妥的顺序是:
这个顺序的重点是:先证明运行时语义,再自动生成装配。
6. 最容易踩的五个坑
坑一:把框架 API 做成业务 API
OpenShop()、OpenGuild() 不属于框架。框架提供 Route 和 Navigator,业务层可以再封装自己的流程。
坑二:为了“解耦”让所有东西都走字符串或事件
调用者不知道接收者,不等于系统没有依赖。隐藏依赖只会让重构和错误定位更难。
坑三:同步版本正确,就以为异步版本只是多一个 await
在等待资源时,页面可能被关闭、依赖可能失败、场景可能切换。异步导航必须有版本号、取消令牌和事务式回滚。
坑四:缓存只缓存 GameObject
页面实例还关联 ViewModel、BindingContext、Presenter、资源 Lease 和句柄代际。只保留 GameObject 会制造半死不活的对象图。
坑五:用功能数量衡量框架成熟度
成熟度更应该看:规则是否有唯一来源、失败是否可回滚、所有权是否闭合、错误能否提前、扩展点是否有边界。
7. 一个可执行的最小验收清单
在继续扩展前,先写以下测试:
- 连续两次打开单实例页,不会产生两个活动实例。
- 同一路由允许多开时,可以用不同 Handle 精确关闭。
- 加载过程中 Close,不会在资源返回后把页面重新显示出来。
- 弹窗覆盖页面时,页面进入 Covered,而不是错误触发 Closed。
- Binding 失败一半时,已订阅的事件会全部回滚。
- Provider 加载失败或取消后,没有遗留 Lease。
- 返回栈只包含声明为可返回的页面。
- 缓存复用产生新 Handle,旧 Handle 仍然不可操作。
- Prefab 缺少 Element 时,构建前失败而不是上线后空引用。
- IL2CPP + 目标裁剪级别能打开所有核心页面。
8. 本系列的 15 篇路线
本系列不固定使用同一个设置页面,而是为每个设计问题选择最能暴露矛盾的案例:
目标不是照抄 FUI,而是借助一套真实实现,学习如何把需求变成模型、如何比较方案、如何识别代价。
资料与源码索引
- FUI 仓库
继续阅读
下一篇:Unity MVVM 架构设计:为什么要让 UI 逻辑离开场景
网硕互联帮助中心


评论前必须登录!
注册