云计算百科
云计算领域专业知识百科平台

Unity UI 框架设计:为什么一个 UIManager 最终会失控

《从能用到难以绕过》系列第 1/15 篇。本文从一个常见的 UIManager 出发,拆解 Unity UI 在页面身份、生命周期、导航策略与资源所有权上的七类失控问题。

很多 Unity 项目的 UI 框架,都从同一段代码开始:一个单例、一个 Dictionary<string, GameObject>,再加上 Open、Close、Get 三个方法。

它没有错。甚至在只有三五个页面的原型里,它可能就是性价比最高的方案。

以商城为例,第一版只要加载 Prefab 和响应购买按钮;随后它会增加分类切换、货币栏、商品列表、详情弹窗、异步购买、资源预览、红点和返回逻辑。原来的“页面开关工具”被迫回答越来越多不属于开关的问题:异步加载失败怎么办?连续点击打开两次怎么办?详情弹窗盖住商城时,商城算关闭还是暂停?关闭以后资源由谁释放?返回键回到哪里?Prefab 改名为什么要到运行时才报错?

这时需要设计的已经不是一个更大的 UIManager,而是一套能表达页面身份、状态、关系和所有权的运行时模型。

先给结论

一个值得长期维护的 Unity UI 框架,至少要管住七类容易失控的问题:

  • 页面身份:调用方到底在打开什么,而不是传了什么字符串。
  • 页面实例:同一路由的不同实例如何区分,谁有权关闭它。
  • 生命周期:加载、进入、遮挡、恢复、退出怎样形成合法状态迁移。
  • 数据流:谁把业务状态变成显示状态,用户输入又如何回到逻辑层。
  • 导航策略:层级、历史、缓存、多开和依赖关系由谁决定。
  • 资源所有权:Prefab、实例、动态资源和预加载句柄何时释放。
  • 验证:哪些错误在编译期、编辑期、PlayMode 和 Player 构建中暴露。
  • 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 开始。生成器会放大既有模型;模型错误时,它只会更快地批量生成错误。

    一个更稳妥的顺序是:

  • 手工实现 Route<TViewModel> 和 ViewHandle。
  • 用同步 Prefab 跑通 Open/Close 与状态机。
  • 加入异步加载,并为每一步定义回滚。
  • 引入最小 OneWay Binding,再加入 TwoWay 与 Command。
  • 抽象 Provider/Lease,接入第二种资源后端。
  • 固化 RoutePolicy,统一返回栈、遮挡和缓存。
  • 等手写装配代码稳定后,再用 Source Generator 消除重复。
  • 最后补齐 Analyzer、Prefab Validator、PlayMode 与 Player 验证。
  • 这个顺序的重点是:先证明运行时语义,再自动生成装配。

    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 篇路线

    本系列不固定使用同一个设置页面,而是为每个设计问题选择最能暴露矛盾的案例:

  • UI 框架不是套一层 Manager:先明确框架要替团队消灭哪些错误。
  • 为什么是 MVVM:让展示逻辑脱离场景并可以单元测试。
  • 别再用字符串打开页面:用 typed Route 与 ViewHandle 表达身份和代际。
  • 弹窗盖住页面不等于关闭:分清可见、焦点、绑定和资源生命周期。
  • 页面已经切走,回调为什么还在:用取消、版本和事务处理异步竞态。
  • 为什么选择 Source Generator:比较手写、反射、外部生成和编译期生成。
  • 一条 Bind 如何变成代码:沿 Roslyn 流水线看完整装配怎样产生。
  • OneWay、TwoWay、Command 怎么选:先确定数据方向,再谈绑定语法。
  • 页面关了,监听为什么还在:处理绑定事务、异步命令和列表复用。
  • 资源加载不是 Load 一下:用 Provider 与 Lease 收口所有权。
  • 导航不只是 Open 和 Close:把层级、历史、缓存和多开固化成策略。
  • 扩展控件为什么不该修改框架:让自定义 Element 服从稳定契约。
  • 如果明天不用 UGUI:检查核心与展示层是否真的解耦。
  • 让错误在进游戏前暴露:建立生成、Prefab、运行时与 Player 四层验证。
  • 不推倒重来:把旧 UIManager 按垂直切片渐进迁移。
  • 目标不是照抄 FUI,而是借助一套真实实现,学习如何把需求变成模型、如何比较方案、如何识别代价。

    资料与源码索引

    • FUI 仓库

    继续阅读

    下一篇:Unity MVVM 架构设计:为什么要让 UI 逻辑离开场景

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Unity UI 框架设计:为什么一个 UIManager 最终会失控
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!