1. 为什么要写这套Unity GUI MVVM系列先从痛点说起做Unity客户端开发这几年我花在UI逻辑上的时间远超预期。刚开始写界面无非是拖几个Button挂OnClick回调改个Text就完事。可项目一旦做大界面和逻辑就会纠缠在一起——排行榜界面既要刷数据又要管理滚动列表背包界面既要响应点击又要同步红点设置界面十几个开关要和存档联动。最折磨人的是每次策划改需求你都得在一堆名为XXXPanel_xxxEvent的方法里翻找改完一处还要担心其他地方有没有同步更新。后来我接触到了MVVM模式第一反应是这东西能在Unity里用WPF的绑定机制依赖底层框架强大的反射和类型系统Unity的UGUI自己可没有这套。当时我先去搜了市面上已有的方案发现要么是绑定语法繁琐、用起来像在写配置表要么是内部封装太重几万行的代码让我不敢引到项目里。自己写一个轻量级MVVM框架的想法就是在这种背景下冒出来的。所谓轻量级我的定义很明确不引入任何第三方依赖核心绑定器的源码控制在几百行以内接入成本低到像搭积木。这套框架解决的核心问题只有一个——让UI代码和业务数据彻底解耦。ViewModel只需要面向数据编程界面通过绑定器自动响应数据变化反过来用户在界面上的操作也会通过绑定机制转成对ViewModel的调用。界面不再需要知道我是被谁的手点了它只负责展示和传达。这套系列文章我打算从最基础的理论讲起一直写到完整可运行的框架源码和Demo。整个过程会包含我当时选型时的纠结、踩过的坑、以及最后沉淀下来的设计。适合的读者有两类一类是Unity开发经验在1到3年、被UI逻辑折磨过的人另一类是听说过MVVM但一直没找到Unity落地方案的人。如果你是精通底层的大佬可以直接跳到后面几篇源码实现部分来挑毛病欢迎交流。2. 框架整体架构与命名空间规划动工之前先画好图纸写框架最大的忌讳就是边写边想。早期我吃过这个亏写到一半发现绑定器设计得不够灵活只能推倒重来。所以这次系列文章我先把整体架构固定下来后续的每一篇都在这张图纸上施工。2.1 分层职责划分Model、ViewModel、View各管哪块MVVM的精髓在于三个角色的分离但在Unity环境下需要结合实际调整。我先说清楚这套框架里每一层的定义因为这是我踩过几次坑之后觉得最合理的分工。View层是最薄的。它只做两件事持有UI元素的引用负责把绑定器挂到合适的节点上。我在实测中发现很多开发者会在View里写逻辑比如if (hp 0) ShowGameOver()这在MVVM里是禁忌。View不该做任何判断它只是数据和界面的翻译官。所有来自用户的点击、输入View通过绑定机制转发给ViewModel自己在代码里连一个OnClick都不写。ViewModel层是核心。它面向数据编程暴露属性比如PlayerName、HpValue和命令比如OnClickAttack、OnDragEnd不关心这些属性最后显示在哪也不关心命令被谁调用。ViewModel需要继承一个基类获得属性变更通知的能力。一个ViewModel对应一个界面是这套框架的默认约定为了轻量我没有做多ViewModel复用一个View的复杂方案。Model层在这里要专门说明一下。Unity项目和传统前后端开发不同很多数据直接存在本地存档或者服务器发来的字节流里。我设计的框架不让专门的Model类存在而是把数据作为ViewModel的属性字段来管理。也就是说ViewModel既是数据容器又是逻辑处理者这偏离了严格MVVM但工程师就是用来解决实际问题的保持轻量比保持理论正确更重要。2.2 核心模块拆解绑定器、属性、命令三驾马车框架的核心可以拆成三个模块我用一个比喻来说明各自的职责属性是信号灯数据一变就发光绑定器是电线把信号从ViewModel传到UI命令是按钮的弹簧用户一按就能触发ViewModel里的方法。BindableProperty是可绑定属性的基类。我的方案是要让ViewModel里的每个需要绑定的属性都包一层类似BindablePropertyint Hp。属性值变化时内部触发事件通知所有订阅者。这里有一个关键设计事件采用里ActionT委托链而非C#的event关键字因为委托链可以被直接替换这在列表数据整体刷新时特别有用。BindingResolver是整个框架里最核心的类负责建立属性和UI元素之间的映射。它会解析你写的绑定表达式比如把txtName的text属性绑定到PlayerName然后注册属性变更监听。一旦属性变化就通过反射找到UI组件对应的属性并赋值。为了性能反射结果和绑定表达式都会被缓存这个细节后面我会专门写一篇来讲。Command则是把UI的事件比如按钮的点击映射到ViewModel的方法。我设计了一个RelayCommand内部保存一个Action委托绑定器检测到按钮点击后直接调用这个委托。为什么不用UnityEvent而是自己写Command因为UnityEvent无法在Inspector里设置绑定目标要写一大堆序列化字段绕了一圈又回到老路上了。2.3 目录结构与命名空间组织让代码自己说明自己目录结构是框架体验的一部分。我自己用过那种所有类都堆在一个文件夹里的框架表面上没什么问题用起来才知道什么叫折磨。这里给出我的规划也是系列后续代码的统一放置方式Assets/ Plugins/ SimpleMVVM/ Runtime/ Core/ # BindableProperty、RelayCommand、ViewModelBase Binder/ # UI绑定器、绑定表达式解析器、缓存管理 Attribute/ # 绑定特性标记 Demo/ Scripts/ # Demo用的ViewModel和View Prefabs/ # 示例UI预制体命名空间统一用SimpleMVVM.Core、SimpleMVVM.Binder。有必要强调一个小经验Runtime和Demo一定要分离。很多人喜欢把示例代码直接写进框架源码目录一旦框架要发布这些示例代码就会引发一堆引用混乱。把示例隔离出去既能当文档用又不会污染框架本体。3. 数据绑定底层原理从属性变化到UI刷新的完整链路任何MVVM框架最核心的机制就是数据绑定。我见过很多开发者能熟练使用WPF或者其他MVVM框架但问到属性变化之后界面是怎么自动刷新的就答不上来。这一节把底层链路彻底讲透后续的源码实现也会完全按照这个链路编写。3.1 INotifyPropertyChanged属性变更通知的根基在C#的MVVM体系里INotifyPropertyChanged是所有绑定的起点。这个接口只有一个事件PropertyChangedViewModel属性值变化时触发该事件传入属性名。绑定器监听这个事件发现是自己关心的属性名就执行刷新操作。但是原生的INotifyPropertyChanged用在Unity里有两个坑。第一PropertyChangedEventArgs每次触发都要new一个高频刷新时会增加GC负担。第二原生方案要求你每定义一个属性都得写PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(XXX))纯属体力活。所以我的框架在底层做了一个简化不用INotifyPropertyChanged接口而是自己定义一个轻量的IPropertyChanged事件参数直接复用string类型省掉一层的类分配。属性值变化时调用NotifyPropertyChanged(Hp)字符串可以直接用常量或者nameof表达式编译器还能帮你检查拼写。3.2 绑定表达式的解析与缓存策略让反射只痛一次直接通过反射写属性慢这是很多人的顾虑。我实测过Unity里通过FieldInfo.SetValue写UI属性执行十万次的耗时大约是直接赋值的几十倍。所以整套框架必须做缓存。绑定表达式我设计成这样一个格式[PropertyName]比如在View里写txtName.SetBind(text, PlayerName)。第一个参数目标是UGUI组件上的哪个属性第二个参数是ViewModel里的哪个属性。解析器的流程是这样的第一次遇到绑定表达式时通过反射找到UI组件对应属性的PropertyInfo找到ViewModel属性对应的PropertyInfo然后把这两个PropertyInfo和委托一起缓存到一个字典里。第二次再遇到同样的组合直接查字典拿缓存的委托执行。实际性能基本等同直接赋值。这里有个细节值得提前说PropertyInfo.SetValue本身还是反射做不到极致性能所以缓存的不是PropertyInfo而是编译后的委托。具体怎么编译委托我放到源码实现那一篇展开讲这里不提前把代码拉出来先建立一个缓存所有反射结果的直觉。3.3 双向绑定UI反馈是如何反向穿透的单向绑定是数据到界面双向绑定则要处理界面到数据的回路。实际项目里最典型的是InputField。玩家输入文字ViewModel里的属性要同步更新外部数据变化了输入框里的文字也要跟着变。这里最容易踩的坑是死循环。输入框内容变化会触发更新更新写入属性又触发通知通知又回头改输入框内容。如果不做护栏轻则表现异常重则栈溢出。我的解决方案是加一个isUpdating标志位。当数据流向是VM到UI时先把标志位置位写入UI组件后立刻复位。UI事件触发UI到VM的回写时检查标志位如果正在更新就跳过。这个小技巧看起来简单却能化解掉双向绑定里最大的危机。我最初没有加这个保护结果在做一个连续滑动条联动的界面时三个Slider互相触发更新直接卡死了编辑器。3.4 绑定生命周期管理不主动解绑就是在埋雷Unity的界面和普通桌面应用有个巨大的差异界面对象会被频繁销毁和重建。打开背包、关闭背包UI预制体Instantiate和Destroy来回切换。如果绑定器一直持有UI对象的引用轻则内存泄漏重则访问已销毁对象报错。我的框架规定View的OnDestroy里必须调用UnbindAll。绑定器会清空所有缓存的事件订阅和委托引用避免Unity对象在销毁后仍被引用。这里还有一个Unity特有的坑如果在OnDestroy里访问其他已经被销毁的组件会报已经销毁的异常。所以解绑逻辑要做到只解自己这一层不主动触碰UI组件本身。4. 系列章节规划与学习路线从哪里开始读最舒服因为是系列文章的第0篇我必须给出后续内容的地图。很多读者不想从第1篇挨着看我理解这种心情所以这里按目标分类整理一个阅读指南。4.1 章节安排一览从原理到实战的递进我规划的系列文章整体走原理-核心-进阶-实战的路线。第1篇先聊绑定器的核心实现把数据到UI的单向绑定走通这是框架的地基也是理解后续一切内容的前提。第2篇讲Command命令系统和事件绑定覆盖按钮点击、拖拽等交互场景。第3篇深入双向绑定和InputField、Slider等可输入控件的适配方案。第4篇讲列表动态绑定这是Unity UI里最头疼的部分我会针对UGUI的ScrollRect设计复用方案让列表数据的增删和UI同步做到丝滑。第5篇做性能优化与GC控制毕竟移动端的GC压力是躲不开的。最后一篇做一个完整的小项目Demo把前面所有内容串起来。4.2 前置知识自查清单开读之前先对照一下读这套系列不需要太多前置知识但有几项必须扎实不然会卡壳。C#的委托和事件机制要熟练这是整个绑定机制的基石不理解Action、Func和event的区别后面的源码会像天书。反射要掌握基本用法知道怎么获取PropertyInfo、怎么调用SetValue虽然最终代码会做缓存但你得先看懂缓存之前发生了什么。UGUI的组件体系也要有基础知道Text、Image、Slider、ScrollRect各自的属性和事件。如果你之前用过WPF或者Vue的双向绑定理解这套框架会非常顺畅因为它们思想同源。以上四项哪怕你是刚入行的新手花一周时间补齐也完全来得及。4.3 建议阅读姿势跟着矿走还是挑着看我的建议是从头到尾跟着敲一遍效果最好。每篇文章的源码我都提供了完整版本敲代码这个动作本身会强迫你思考每一行的意图。如果时间紧张至少第1篇和第2篇要精读因为它们是整个框架的骨架。第4篇的动态列表实现是我花了最多心思的部分如果项目里有列表需求这篇值得先读。另外我强烈建议在编写过程中自己动手加一个绑定类型试试比如给某个不支持的UI组件写拓展绑定这个过程中遇到的坑比读十篇文章都管用。5. 实操环节搭一个最小可运行的MVVM示例前面讲了不少理论接下来演示一个最小的完整运行示例。这个示例可以看成是框架的Hello World我会给出关键代码和解释完整版的代码在后续章节里逐步扩展。5.1 定义ViewModel数据驱动一切的开端先定义一个最简单的玩家信息ViewModelusing SimpleMVVM.Core; public class PlayerViewModel : ViewModelBase { private BindablePropertystring _playerName; private BindablePropertyint _playerLevel; public BindablePropertystring PlayerName _playerName ?? (_playerName new BindablePropertystring(this, PlayerName)); public BindablePropertyint PlayerLevel _playerLevel ?? (_playerLevel new BindablePropertyint(this, PlayerLevel)); }这是一个很典型的ViewModel写法。每个需要绑定的属性都是一个BindablePropertyT构造函数里传入持有者和属性名。这样设计的好处是访问属性走的都是同一个获取器按需创建没有性能浪费。ViewModelBase负责提供属性变更通知的入口。5.2 搭建View并挂上绑定代码和界面第一次握手假设场景里有一个Text用来显示玩家名字一个Button用来点击升级。View的脚本这样写using SimpleMVVM.Binder; public class PlayerView : MonoBehaviour { public Text nameText; public Button levelUpButton; private PlayerViewModel _viewModel; private BindingResolver _resolver; void Start() { _viewModel new PlayerViewModel(); _resolver new BindingResolver(_viewModel); // 属性绑定nameText.text 跟随 PlayerName _resolver.BindProperty(nameText, text, PlayerName); // 命令绑定点击按钮触发 ViewModel 的 LevelUp 方法 _resolver.BindCommand(levelUpButton, OnLevelUp); } void OnDestroy() { _resolver.UnbindAll(); } }这段代码里值得说明的是命令绑定的写法。它没有在View里写任何onClick.AddListener的代码按钮点击事件由BindingResolver内部通过反射找到ViewModel里名为OnLevelUp的方法并调用。这种字符串形式的命令绑定省掉了一堆接口和抽象类的定义代价是IDE的重构功能无法追踪调用关系。利弊我后面会专门分析。5.3 跑起来的效果与验证方法眼见为实运行起来之后改变ViewModel属性的值Text的文字会立刻刷新。我在编辑器里用一个协程模拟升级效果IEnumerator LevelUpSimulate() { while (true) { yield return new WaitForSeconds(2f); _viewModel.PlayerLevel.Value; } }每两秒等级数值往上跳UI上的文本同步变化。此时你可以在BindablePropertyT的Value设置器里下一个断点观察整个通知链路是怎么从属性走到UI的。我强烈建议新手做这个操作断点比任何文档都能教你理解框架的运行机制。6. 常见问题与产品迭代方向实战派速查手册任何框架的诞生都不是一蹴而就的我自己写这套框架的过程中也踩了不少坑。把这些坑整理成速查表后面你调试时会省下大把时间。6.1 入门阶段最容易踩的坑BindableProperty没有初始化就绑定导致空引用。我建议在ViewModel的构造函数里把所有属性都初始化好别用懒惰初始化省那一点性能毫无必要空引用报错才会让你怀疑人生。绑定后界面不刷新检查是不是忘了调用NotifyPropertyChanged。我在BindablePropertyT.Value的设置器里默认把这个调用写好了但如果你在ViewModel里直接操作内部字段而绕过了属性就会出现界面永远不动的情况。列表刷新时直接整体重绑导致滚动位置丢失。更合理的方案是只更新变化的那一项这个在列表专题里我会展开写。OnDestroy里没解绑界面销毁后数据一变Unity会报MissingReferenceException。这个问题在场景切换时尤其常见绑定器会在对象销毁后尝试访问UI组件直接抛异常。6.2 里程碑落地实践路线图框架不是终点回顾这个系列的路线第一阶段的里程碑是框架能用于核心界面的开发比如排行榜、背包、设置这类典型界面。第二阶段的里程碑是完成列表绑定和动态数据源支持这是使用体验的关键。第三阶段的里程碑是优化内存和GC同时完善编辑器工具链。我计划在系列后期实现一个Inspector面板的可视化绑定配置让策划也能在不改代码的情况下进行简单的UI绑定操作。6.3 框架的可扩展方向给想继续深挖的你留个话头除了UGUI这套框架的设计思路可以平移到很多其他场景。比如Unity新推出的UI Toolkit绑定原理完全一致只是API不同。另外如果你在项目里使用了热更新方案可以把绑定表达式配成远程数据这样服务端就能动态下发UI逻辑配置实现界面逻辑的灰度调整这也是一个有价值的扩展方向。7. 写在系列开始前的一些心里话在动手写这个系列之前我回看了自己项目里那些纠缠的UI代码最大的感慨是很多开发者在Unity里做UI缺的其实不是代码能力而是对界面和逻辑该怎样分离这个问题的思考。MVVM提供一个非常成熟的思考框架但Unity社区里专门做这套方案的并不多。这套轻量级框架从头到尾都是我一个人在产品项目的空闲时间里一点点磨出来的它或许在工业级健壮性上不如大厂自研的方案但胜在思路透明、代码可控、随意定制。你可以把它当成一个学习样本也可以直接在项目里用都不冲突。我个人在实际使用中的体会是写完这套框架之后我写UI逻辑的速度反而慢了因为要安排View和ViewModel的关系但后续改需求的效率提升了非常多。这个交换我相信做项目的人都能理解其价值。最后再分享一个小技巧如果你打算在自己的项目里引入这套框架先从一个小界面开始试水别一上来就重构大模块。把一个小界面的绑定逻辑理顺了你会自然而然地知道大模块怎么切开。这个系列才刚刚开始我们下一篇见。
阅读完成 · 觉得有帮助?