ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从手动赋值到数据驱动:构建轻量级Unity UGUI MVVM框架

从手动赋值到数据驱动:构建轻量级Unity UGUI MVVM框架 你在Unity项目里写过多少个界面就有多少个不想再碰的UI脚本。UI逻辑从来不是什么高深技术但就是特别消磨耐心金币变了要刷新排行榜余额任务进度变了要更新进度条角色状态变了要切按钮交互每个需求看似简单堆在一起就是一场漫长的赋值接力。我之前在带一个中型ARPG项目的UI层时为了“玩家属性变化后所有相关界面自动同步”这个需求给一堆MonoBehaviour塞满了手动赋值代码。这还算能扛直到策划把排行榜界面层级调了两次那些用路径Find取控件的代码直接全线崩溃。我那时候才认真思考一个问题Unity3D GUI开发缺的不是单个控件封装而是一套把数据变化和界面刷新解耦的MVVM框架。这个系列就是围绕这套框架展开的我给项目起名叫UI-MVVM-Lite。它的定位是纯C#、零依赖、核心千行左右的轻量级MVVM主要适配UGUI。这篇是系列第0篇先把目录、选型和设计边界定下来后续每一篇都对应一次可运行的提交。适合正在用Unity做UI、被逻辑和视图纠缠得头疼的开发者也适合想了解UI框架内部原理的人。即使你最后选择接入官方UI Toolkit或其他成熟框架理解一套轻量框架的完整实现路径也会让你在用成熟框架时少踩很多坑。1. 先谈痛点Unity GUI开发里那堆“不难但烦”的活1.1 手动同步一场没有尽头的赋值流水传统UGUI的开发模式大概是这样的你有一个PlayerInfo脚本里面有金币数量界面上有一个Text显示金币。金币变了要么在PlayerInfo里主动调用uiText.text coin.ToString()要么让界面每隔几帧轮询一次数据源。前者是主动推送后者是被动轮询本质上都是手动同步。手动同步的问题在于当界面上需要显示的项超过十个时这种赋值流水就开始失控。金币变化可能要同时刷新主界面、排行榜、商店、任务面板每一处都要单独写赋值代码漏掉一个就是线上bug。策划改需求时比如“金币要加个渐变动画”你就得把所有赋值点都找出来改一遍。界面复杂到一定程度这已经不是写代码的问题而是维护成本的问题。我在实际项目里见过一种最夸张的写法某个界面在Update里每帧把所有文本重新赋一遍值理由是“这样最简单哪变了都能对上”。这种方案在小项目里确实能跑但数据量一上去每帧的字符串分配和UI重建就成了性能隐患。说到底这不是某个人的代码习惯问题而是缺少一个统一的数据驱动机制。1.2 按钮Click和他的兄弟们事件回调的隐形依赖UGUI里按钮点击就是AddListener一个委托或者把事件拖到Inspector里指向某个方法。小界面这么搞没问题界面一多你就会发现每个UI脚本里都挂着好几个Button_Click函数它们之间还要互相调用public方法。一个窗口打开另一个窗口要new一个对象传过去或者挂在静态类上全局访问。这种写法最大的坑是“隐形依赖”。你永远不知道这个界面的关闭按钮被谁监听了也不知道某个数值变了之后有多少界面在偷偷轮询。改一处UI逻辑得像排雷一样把整个流程扫一遍。团队协作时更明显别人打开你的UI脚本看到的是一堆事件注册和取消注册想理清数据流只能靠猜。MVVM能解决的第一个切入点就在这里把点击事件抽象成Command把事件监听统一交给绑定层管理。写业务的人不再关心按钮被谁点了只关心ViewModel里的某个命令被执行了。这个转变看似不大但对代码可读性的提升非常明显。1.3 为什么是MVVM而不是MVC或MVP很多人会问Unity社区里MVC和MVP的讨论也不少为什么偏偏是MVVM我的看法是MVVM最核心的“数据绑定”把最后那一层手动赋值也省了。MVC里View要通知Controller再更新ModelModel变了Controller再让View刷新层层调用MVP里Presenter直接在View和Model之间做胶水还是得写一堆接口方法。MVVM则是用一个绑定机制让View和ViewModel各自独立变化由框架负责同步。打个比方MVC像你请了个中间人A和B要通信都得先找中间人传话MVVM则像给A和B之间拉了一根水管A那边水一开B这边自动就有水。表面上都是传递但通信成本完全不是一个量级。尤其UGUI本身是组件化的把Text.text、GameObject.active这些属性直接绑给ViewModel的字段思路非常顺。还有一个现实原因MVVM的ViewModel是纯C#类不依赖UnityEngine.UI。这个特性在单元测试时价值巨大。你可以脱离编辑器直接构造ViewModel把输入输出测一遍。对小型团队来说这是性价比很高的可测试性提升。2. 轻量级的定位这个框架不打算解决什么问题2.1 轻量不只是“少代码”更是不越界做框架最大的风险是贪多。一上来就想搞定数据绑定、资源加载、对象池、UI导航、跨线程消息、IL热更新最后一定是个别人不敢碰的黑洞。我这套框架的定位从一开始就很明确只解决“View和ViewModel之间的绑定与通知”其他事情交给项目自己处理。所谓轻量我给自己定了几条硬性标准不依赖任何第三方库整套核心代码控制在千行量级实际目标大概在800到1200行不搞代码生成不搞IL注入不强制约束项目里ViewModel怎么组织只提供绑定工具。学习成本必须低到“下午看完博客晚上就能在新界面里用起来”的程度。这背后其实是一种“够用哲学”Unity项目里的UI绑定需求90%以上可以被一个属性通知系统、一个绑定中间层、一个命令系统覆盖。剩下那10%比如非常复杂的列表虚拟化、跨界面消息总线应该由使用方在框架外自己组合。边界越清晰框架越不容易成为项目质量的短板。2.2 三条设计底线无依赖、绑定可追溯、生命周期可控第一条底线是不引入第三方库。这不是说第三方库不好而是MVVM的核心绑定机制本身不复杂引入一个依赖库反而让排查问题的链条变长。我会在系列第一篇里给出一个最简版本你会发现核心的ViewModel基类大概不到两百行。有这不到两百行打底后面所有功能都能自己搭。第二条底线是绑定关系必须可追溯。我见过一些框架用反射在运行时自动匹配字段名搞得很magic出问题时Debug一拍脑袋不知道是谁绑了谁。我的选择是让每个UI组件上挂的Bind脚本显示明确的绑定目标Inspector面板里能直接看到“这个Text绑定了ViewModel里的Coin属性”。这样哪怕不写文档新人打开场景也能一眼看明白绑定关系。第三条底线是生命周期可控。View也就是MonoBehaviour销毁时必须自动解绑不能带着一堆事件引用跑到场景卸载之后。这个细节看起来简单实际在热重载、场景切换时是很多框架翻车的重灾区。后面我会专门用一篇讲这里的时序问题。2.3 哪些场景建议直接绕过这套框架虽然我是这个框架的作者但该泼冷水的时候得泼。如果你的项目里一共只有五六个界面每个界面就两三个文本在变直接手写赋值反而更快。MVVM的初始化成本——定义VM、写绑定、熟悉约定——在项目极小的情况下是负收益。另外如果团队里没人写过MVVM也没有意愿看文档那强行上框架会变成新一轮的“半懂不懂黑魔法”。框架不是用来解决人的问题的。我见过太多团队把架构问题归因于“缺一个框架”实际上缺的是纪律和约定。一个团队如果连“UI逻辑不许写在OnClick回调里”这种规范都执行不下去那换什么框架都白搭。3. 骨架拆解从ViewModel属性到UI控件的绑定管线3.1 四个核心成员属性、绑定、命令、根节点整个框架围绕四个东西转ViewModelBase基类、Bind组件、Command、UIBindRoot。ViewModelBase是纯C#的可通知对象ViewModel里所有需要和UI同步的属性都要在setter里触发PropertyChanged事件这是整个数据流的地基。Bind组件挂在UI对象上负责把一个UI属性比如Text.text、Image.sprite或GameObject.active和某个VM属性绑定起来。Command是一层委托包装把按钮点击等交互事件转成对VM方法的调用同时可以控制按钮可用性。UIBindRoot挂在界面根节点上负责创建ViewModel、遍历子物体上的Bind组件完成绑定、处理生命周期。最简的ViewModelBase实现大致长这样public class ViewModelBase { public event PropertyChangedEventHandler PropertyChanged; protected void RaisePropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } public class PlayerViewModel : ViewModelBase { private int _coin; public int Coin { get _coin; set { if (_coin value) return; _coin value; RaisePropertyChanged(nameof(Coin)); } } }这套东西的关系可以一句话概括UIBindRoot创建并持有ViewModelBind组件是VM和UI之间的“翻译官”Command处理UI到VM的“逆向反馈”生命周期钩子确保这一切在正确的时间被建立和拆除。3.2 一条绑定从注册到生效的完整链路当界面被实例化后UIBindRoot在Awake里执行绑定流程先创建ViewModel对象然后深度遍历子物体上所有Bind组件对于每个组件读取它在Inspector里配置的VM属性名把VM的属性变化事件和UI属性的赋值委托关联起来。绑定以后只要VM那边PropertyChanged一触发UI这边就自动更新。绑定的方向设计很关键。我的方案是UI到VM只走CommandVM到UI走Binding形成一种单向数据流的感觉。这样写起来清晰因为双向绑定一旦多了你很难判断一个值到底是UI改的还是VM改的排查起来非常痛苦。Bind组件内的核心逻辑简化后大概是这样的public class BindText : MonoBehaviour, IBind { public string ViewModelPropertyName; private Text _text; private ViewModelBase _vm; private Funcobject _getter; public void Bind(ViewModelBase vm) { _vm vm; _text GetComponentText(); // 运行时用反射拿到属性并缓存委托之后不再反射 var prop vm.GetType().GetProperty(ViewModelPropertyName); _getter CreateGetter(prop); _vm.PropertyChanged OnPropertyChanged; Refresh(); } private void OnPropertyChanged(string propertyName) { if (propertyName ViewModelPropertyName) Refresh(); } private void Refresh() { _text.text _getter()?.ToString() ?? string.Empty; } public void Unbind() { _vm.PropertyChanged - OnPropertyChanged; } }选择用字符串属性名做匹配而不是表达式树或代码生成是刻意为之。字符串属性名在Inspector里可直接配置、方便序列化缺点是重构时改属性名容易断。后续系列会专门讲如何用Attribute和编辑器检查脚本最大程度防呆但系统在运行期仍然只做一次反射取委托之后就是纯委托调用没有每帧反射的开销。3.3 生命周期对齐从Awake到OnDestroy的顺序陷阱MonoBehaviour的生命周期是Unity开发者最熟悉也最容易忽略的东西。绑定一定要在“UI可见之前”完成否则会出现短暂闪烁解绑一定要在“MonoBehaviour销毁时”完成否则会内存泄漏或报MissingReferenceException。在我目前的实现里顺序是这样的Awake阶段做绑定注册OnEnable阶段确保绑定的VM属性全量推一次值到UI避免显示旧值OnDestroy阶段把事件反注册、VM置空。这个顺序说来简单踩过的坑不少。尤其是场景切换时框架的销毁顺序如果处理不好轻则报一堆空引用重则整个场景卡死。还有一个经常被忽视的点UGUI界面经常用SetActive(true/false)来开关这种开关不会触发OnDestroy但会触发OnEnable和OnDisable。如果你的绑定只在Awake和OnDestroy里做那么界面被SetActive(false)再SetActive(true)时绑定关系可能已经失效或重复绑定。这套框架在OnDisable里也要做相应的状态清理才能保证反复开关界面的稳定性。3.4 集合绑定的第一版取舍先全量再扩展列表和循环列表是UI开发里被问得最多的问题。一套完整MVVM方案要处理List变化时的增量更新而不是整个列表重绘。轻量级框架怎么取舍我的方案是第一版只提供全量刷新也就是List绑定的数据源变化后整个列表重建。这样做代码量小、逻辑简单足够覆盖大多数非滚动列表场景。如果后面需要高性能的循环列表比如那种几百条消息的聊天栏可以再单独写一个RecycleListAdapter作为扩展而不是核心能力。这正好呼应了我前面说的边界原则核心保持简单复杂功能交给可插拔扩展。一句话总结整个框架的设计思路先想清楚必须做什么再想清楚不做什么。4. 系列路线图从最简ViewModel到实战改造的十二步4.1 十二篇规划与每篇核心产出作为第0篇目录我先把整个系列的文章规划放出来。写作顺序跟我实际开发这个框架的顺序一致保证读者能一条线跟下来。篇目主题核心产出第0篇目录与设计目标本文规划与选型第1篇环境准备与最简ViewModel一个不到两百行的可通知属性基类第2篇属性绑定的核心机制PropertyChanged事件与Bind组件雏形第3篇Bind组件的生命周期管理Awake/OnEnable/OnDestroy的正确时序第4篇命令系统从Click到VM方法ICommand实现与Inspector配置第5篇集合绑定List数据源与全量刷新ObservableList与UI列表同步第6篇列表扩展循环列表与增量更新可插拔的高性能列表适配器第7篇ViewModel组合与嵌套绑定属性路径与子VM管理第8篇异步与协程的MVVM适配在绑定中安全处理异步切换第9篇与窗口管理框架的整合UIRoot、UIStack与VM生命周期第10篇实际案例背包界面的全面改造把一个老界面用MVVM重写全过程第11篇性能测试与常见坑反射时机、GC分配、序列化陷阱这个列表不是拍脑袋定的。它的顺序基本遵循“先跑通最小链路再加能力再整合到实际项目”的思路。第1到第4篇加起来已经可以在一个界面里做最简单的绑定和按钮命令。第5和第6篇解决的是列表。第7到第9篇讲的是组合、异步和窗口管理这类扩展能力。第10篇是带着大家把一个真实的背包界面从手写赋值改成MVVM。第11篇负责泼冷水把性能问题和容易被坑的细节一次讲完。4.2 不同基础的读者怎么跟这个系列这里按读者的不同基础给一点跟读建议。如果你是Unity新手建议从第1篇开始每篇亲手把代码敲一遍再进下一篇不要跳着看。绑定机制这种东西只有自己写过一遍才能真正理解事件流。如果你已经有一定经验可以直接从第5篇切入然后回头补前4篇的源码讲解。如果只是想评估“这套框架适不适合我”重点看第0篇的选型和第2篇的边界设计再跳第11篇看性能和坑。这样能快速得到结论不用把整个系列都啃完。每篇我都会给出完整可运行的示例工程文章里的代码片段都可以直接从示例里摘出来不会出现“只贴片段但拼不起来”的情况。4.3 示例工程的组织与复现方式示例工程会遵循一条主线从最简单的“金币显示”界面开始每篇给它增加一个能力最后进化成一个包含主界面、背包、商店的小型Demo。界面UI用纯代码加Prefab构建不依赖任何资源商店素材方便在任何环境复现。工程目录按功能分Core放框架核心Runtime放运行时绑定组件Editor放编辑器辅助脚本Demo放示例场景。每篇博客对应一次commit方便对照版本差异来学习。示例工程用Unity 2021.3 LTS起步不依赖任何包打开就能跑。这个安排是刻意的框架本身不挑版本示例工程在LTS上能跑基本意味着绝大多数项目都能用。5. 为什么不上现成方案Unity MVVM生态的几句大实话5.1 官方UI Toolkit与第三方框架的现状Unity官方现在主推的UI Toolkit其实自带了一部分数据绑定的能力有SerializedProperty绑定也有UI Builder的可视化配置。如果项目是从零开始、不依赖旧UGUI代码用UI Toolkit确实是个好选择。但现实是大量存量项目还跑在UGUI上UI Toolkit的运行时表现、生态和团队熟悉度都决定了它不是“说迁就迁”的东西。第三方MVVM框架里有功能很全的带代码生成、依赖注入、事件总线的全家桶也有在UniRx或R3基础上再包一层的流式方案。它们的共性是很强但也都会带来明显的额外学习成本。如果你的团队只是想解决“UI赋值太乱”这个具体问题上这类重型方案多少有点杀鸡用牛刀。我的观点很明确框架的复杂度和团队规模、项目体量匹配才好。小型团队、中小型项目一个千行级、零依赖、能看懂原理的轻量框架往往比一个功能拉满的大框架更抗造。出了问题你能自己修遇到新需求你能自己加扩展这就是“轻量”最值钱的地方。5.2 哪些场景真的受益哪些场景别硬塞用上这套MVVM之后有一类场景是真的变好了界面上的“状态联动”。一个角色从“存活”变为“死亡”血量条、状态图标、技能按钮、特效开关全部自动响应。新增一个需要同步的UI元素时只需要在界面上加一个Bind组件、配一把属性名不用改任何逻辑代码。但也有没变好的场景复杂的交互动效排序。比如“点击按钮后先执行一段动画动画结束后再关闭窗口”这种时序性很强、和UI物理呈现强相关的逻辑放到MVVM里反而别扭。这类交互我一般建议在View层直接写明确的过程控制不要硬塞进ViewModel。框架是帮你省力的不是逼你把所有代码都塞进MVVM那一套里去。5.3 自研框架活过两三年之后我学到的三件事这类自研框架最大的敌人不是性能也不是功能缺失而是“半成品维护”。框架写到一半作者离职了剩下的人看不懂最后全项目提心吊胆。所以我做这个系列的时候刻意把代码风格、注释习惯、文档说明都按“接盘的人是个聪明但没看过代码的人”来写。所有公共方法和类都有注释所有绑定配置都能在Inspector里看到。第二件事是框架的敌人不是功能少而是边界模糊。功能少的框架不可怕可怕的是“这个框架什么都能干一点但什么都不彻底”。今天加个UI导航明天加个资源加载后天再加个事件总线半年后连你自己都不敢动了。守住边界比加功能更需要定力。第三件事是架构被团队接受的前提是让接盘的人觉得“这东西我能改”。不是所有人都有源码阅读习惯也不是所有人都愿意在用框架之前先看三篇原理文章。所以我把系统的使用方式设计成“配Inspector就能用”把原理留给想看的人。让使用者能用起来让研究者能看懂这两点兼顾了框架才算真的落地。如果让我重新选择一次我仍然会走自研轻量框架这条路但会从第一天就把边界画好。没有边界的框架最终都会被需求压成另一个层面的屎山。保持简单保持清楚这套UI-MVVM-Lite才能真正活过多个项目。下一篇我们直接从最简的ViewModel开始把第一行核心代码写出来。
返回列表