Unity 商业项目中,我为什么要做 UI 代码自动生成
Unity 商业项目中我为什么要做 UI 代码自动生成在 Unity 项目中UI 开发是一个非常容易被低估的部分。很多项目刚开始的时候UI 代码通常都很简单。比如一个界面上有几个按钮、几个文本、几张图片程序只需要把节点找到然后注册事件、刷新数据就可以完成一个界面。但随着项目规模变大UI 数量越来越多界面结构越来越复杂问题就会逐渐暴露出来。尤其是在商业项目中UI 往往是变化最频繁的部分主界面会改版活动界面会改版商城界面会改版任务界面会改版背包界面会改版新手引导会不断调整多语言适配会不断补充列表项结构会频繁变化如果所有 UI 节点绑定逻辑都靠手写后期会非常痛苦。所以在 MyFramework 中我做了一套 UI 代码自动生成工具。项目地址GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub这篇文章主要聊一下为什么 Unity 项目需要 UI 代码自动生成手写 UI 绑定代码会带来哪些问题MyFramework 中 UI 自动生成的基本思路代码生成应该生成什么不应该生成什么为什么要保留手写逻辑代码UI 自动生成在商业项目中的价值一、Unity UI 开发中最常见的问题在 Unity 中做 UI一般离不开下面几件事找到节点缓存组件注册事件设置文本设置图片控制显隐绑定列表项响应按钮点击根据数据刷新界面这些事情本身并不复杂。但它们的问题在于重复次数太多。一个简单界面可能只有十几个节点。一个复杂界面可能有几十个甚至上百个节点。如果项目里有上百个界面那么 UI 绑定代码的数量会非常可观。最常见的问题包括1. 节点绑定代码没有业务价值UI 绑定代码通常只是为了把界面上的节点保存到成员变量中。它本身没有太多业务含义。比如找到按钮 找到文本 找到图片 找到列表 找到子节点这些代码只是机械重复的结构性代码。真正有业务价值的是按钮点击后做什么 数据变化后界面如何刷新 界面打开时显示什么状态 界面关闭时清理什么数据如果绑定代码全部手写UI 脚本里会充满大量重复代码真正的业务逻辑反而被埋在里面。2. 节点改动后容易出错UI Prefab 在开发过程中经常变化。比如节点改名节点移动节点删除新增子节点列表项结构调整子界面拆分动画节点调整如果绑定代码全部手写每次 UI 改动都需要程序同步修改代码。这种同步完全依赖人。人一旦漏改就会出现运行时错误。而且这类错误很多时候不是编译期错误而是运行时才发现。3. 每个人写出来的 UI 代码风格不同如果没有统一的生成规则不同程序写 UI 绑定代码的方式可能完全不同。有人喜欢直接找组件。有人喜欢缓存 GameObject。有人喜欢缓存 Transform。有人喜欢写字符串路径。有人喜欢在 Inspector 里拖引用。有人喜欢在代码里动态查找。时间长了以后项目里的 UI 代码风格会越来越不统一。这会带来几个问题新人接手成本变高代码审查成本变高后期批量修改困难工具无法统一处理UI 框架难以持续演进所以在长期项目里UI 代码结构统一非常重要。4. 列表项和子窗口更容易混乱很多 UI 不是简单的几个按钮和文本而是包含ScrollViewItem子窗口嵌套面板数组节点多个重复结构可复用组件这些结构如果完全手写绑定代码会很容易混乱。比如一个登录界面里可能有一个 ScrollViewPanel。一个主界面里可能有多个子节点需要从某个背景节点下继续查找。这类结构非常适合由工具根据 Prefab 配置自动生成而不是每个界面都靠程序手写。二、为什么不用简单的拖引用Unity Inspector 拖引用也能解决一部分问题。比如[SerializeField] private Button mStartButton; [SerializeField] private Text mNameText; [SerializeField] private Image mIconImage;这种方式比纯字符串查找安全一些。但是在长期项目里它仍然有一些问题。1. 引用关系隐藏在 Inspector 中拖引用的关系不在代码里而是在 Prefab 或 Scene 里。代码审查时不容易看到这些引用关系。Prefab 发生冲突时也不如代码直观。对于大型项目来说我更希望关键结构能尽量明确地体现在代码里。2. 大量字段拖拽成本高复杂 UI 中字段很多。如果每个字段都手动拖一次工作量并不低。而且容易拖错。例如一个界面有几十个节点如果全部靠手动拖引用维护成本其实非常高。3. Prefab 变化后仍然依赖人工维护节点改了以后引用可能丢失。节点复制后引用可能指向旧对象。Prefab Variant 或嵌套 Prefab 中也可能出现引用不符合预期的情况。这些问题最终还是要靠人工检查。4. 不利于统一生成和批量维护如果 UI 绑定关系全部依赖 Inspector很难通过工具统一处理。而代码生成的好处是Prefab 配置 ↓ 生成代码 ↓ 统一结构 ↓ 统一维护当框架里的 UI 封装方式发生调整时可以通过生成器批量更新而不是逐个界面手动修改。三、MyFramework 的 UI 自动生成思路MyFramework 中的 UI 自动生成大致流程是UI Prefab ↓ 添加 UGUIGenerator ↓ 配置需要访问的节点 ↓ 生成 UI 成员变量 ↓ 生成构造逻辑 ↓ 生成 assignWindow 绑定逻辑 ↓ 保留手写业务逻辑也就是说Prefab 仍然是 UI 结构的来源。但代码不再完全手写而是由工具根据 Prefab 上的配置生成。生成结果一般包含几部分成员变量声明构造逻辑节点绑定逻辑子窗口绑定逻辑数组节点绑定逻辑自动生成区域标记MyFramework 中真实生成的 UI 脚本会有类似这样的结构// auto generate member start // generate from:Assets/GameResources/UI/UIPrefab/UILogin.prefab // 登录界面 [ObfuzIgnore(ObfuzScope.TypeName)] public class UILogin : LayoutScript { protected myUGUIObject mLogin; protected ScrollViewPanel mScrollViewPanel; // auto generate member end public UILogin() { // auto generate constructor start mScrollViewPanel new(this); // auto generate constructor end } public override void assignWindow() { // auto generate assignWindow start newObject(out mLogin, Login); mScrollViewPanel.assignWindow(mRoot, ScrollViewPanel); // auto generate assignWindow end } }也可能是这种结构// auto generate member start // generate from:Assets/GameResources/UI/UIPrefab/UIGame.prefab // 游戏界面 [ObfuzIgnore(ObfuzScope.TypeName)] public class UIGame : LayoutScript { protected myUGUIObject mAvatar; protected myUGUIText mSpeed; protected myUGUIDamageNumber mDamageNumber; protected myUGUITileImage mTile; // auto generate member end public override void assignWindow() { // auto generate assignWindow start newObject(out myUGUIObject background, Background, false); newObject(out mAvatar, background, Avatar); newObject(out mSpeed, background, Speed); newObject(out mDamageNumber, background, DamageNumber); newObject(out mTile, background, Tile); // auto generate assignWindow end } }这里有几个特点不是直接使用transform.Find不是直接暴露 Unity 原生组件UI 节点会生成框架自己的 UI 封装对象可以从父节点继续查找子节点子窗口可以有自己的assignWindow自动生成区域和手写区域是分开的这样生成出来的代码既能保留明确结构又不会把业务逻辑覆盖掉。四、代码生成最重要的是“只生成该生成的部分”很多代码生成工具最大的问题是一旦重新生成就覆盖手写代码。这在真实项目中非常危险。因为 UI 脚本里不可能完全没有手写逻辑。比如按钮点击事件数据刷新界面打开逻辑动画播放列表刷新网络消息响应红点刷新多语言刷新新手引导逻辑这些内容一定是程序手写的。所以 MyFramework 的 UI 代码生成不是覆盖整个文件而是只覆盖自动生成区域。通常会用类似这样的标记区分// auto generate member start // auto generate member end // auto generate constructor start // auto generate constructor end // auto generate assignWindow start // auto generate assignWindow end工具只修改这些区域。自动生成区域之外的代码全部保留。这样就可以做到UI Prefab 改了 ↓ 重新生成代码 ↓ 节点绑定逻辑更新 ↓ 手写业务逻辑保留这点非常重要。否则代码生成工具在真实项目中很难使用。五、为什么要生成 UI 成员变量有些人可能会问为什么不运行时直接按名字查比如在用的时候临时取需要按钮时再找按钮 需要文本时再找文本 需要图片时再找图片这种写法当然也可以。但是我更倾向于生成明确的成员变量。原因有几个。1. 代码更直观例如mSpeed.setText(speedText); mAvatar.setActive(showAvatar); mTile.setActive(tileVisible);看到成员变量就能知道当前脚本依赖哪些 UI 节点。这比在逻辑中到处动态查找节点更容易阅读。2. 初始化阶段完成绑定UI 节点查找集中在assignWindow阶段完成。后续业务逻辑只操作缓存好的 UI 对象。这样逻辑更清晰。3. 更符合强约束风格MyFramework 的整体设计倾向于明确字段明确生命周期明确访问方式明确初始化流程所以 UI 节点也更适合作为明确的成员变量存在。六、子窗口和复合控件为什么适合生成UI 中经常会有一些可复用的子结构。比如ScrollViewPanelItemTab子界面复合控件特殊列表项这些结构通常不是一个简单节点而是一个带有自己逻辑的小模块。在 MyFramework 中这类子窗口也可以通过生成逻辑自动创建和绑定。例如protected ScrollViewPanel mScrollViewPanel; public UILogin() { mScrollViewPanel new(this); } public override void assignWindow() { mScrollViewPanel.assignWindow(mRoot, ScrollViewPanel); }这样做的好处是子窗口有自己的代码结构主界面不需要关心子窗口内部节点子窗口可以复用主界面代码更干净生成器可以统一管理绑定逻辑对于复杂 UI 来说这一点非常重要。因为很多界面不是单层结构而是由多个子模块组合而成。七、数组节点也非常适合生成UI 中经常会出现一组同类节点。例如Star0 Star1 Star2 Star3 Star4或者Reward0 Reward1 Reward2 Reward3如果手写代码会非常重复。这种结构非常适合由生成器处理。因为它有明显规律而且没有太多业务含义。程序真正关心的是for (int i 0; i mStarList.Count; i) { mStarList[i].setActive(i starCount); }而不是每个节点怎么绑定。所以 UI 代码生成对数组节点、列表项、重复结构会特别有价值。八、UI 生成和 UI 封装是配套的MyFramework 中并不是直接访问 Unity 原生组件。比如 Text、Image、Button 这些组件一般都会封装成框架自己的 UI 对象。这样做的目的不是为了多写一层而是为了统一 UI 操作方式。例如设置文本设置图片设置显隐注册点击设置位置设置大小设置拖拽设置长按设置点击穿透处理多语言处理资源加载都可以通过封装对象完成。这样业务层尽量不直接访问 Unity 组件。好处是UI 操作风格统一后续可以集中优化方便做对象池方便做输入控制方便做多语言方便做资源管理封装方便控制生命周期所以 UI 自动生成不是孤立的工具而是和整个 UI 框架配套的。九、为什么不用 Unity 的自动布局和锚点作为核心方案MyFramework 的 UI 系统里很多地方不会强依赖 UGUI 的锚点和自动布局组件。这并不是说 Unity 的锚点和 LayoutGroup 不好。它们很适合很多项目也能提高 UI 搭建效率。但在我自己的项目中我更希望 UI 的位置、大小、适配、刷新逻辑都尽量由代码显式控制。原因是自动布局有时执行时机不直观复杂嵌套下布局刷新不好控制某些情况下位置和大小获取不稳定动态列表和特殊适配需求较多出问题后排查成本较高所以 MyFramework 更倾向于封装自己的 UI 操作方法并用代码明确控制布局行为。这也是整个框架“强控制”思想的一部分。十、UI 自动生成在商业项目中的价值UI 自动生成最大的价值不是“少写几行代码”。而是降低长期维护成本。在商业项目中UI 是变化非常频繁的部分。比如活动界面改版商城界面改版任务界面改版背包界面改版主界面改版新手引导调整多语言适配调整如果每次 UI 改动都需要程序手动同步大量节点绑定代码时间长了以后一定会出问题。自动生成可以带来几个实际收益。1. 减少低价值重复劳动程序不用反复写节点绑定代码。这些代码交给工具生成即可。2. 降低低级错误节点绑定逻辑由工具生成可以减少手写错误。尤其是节点较多、结构较复杂的界面效果更明显。3. 统一代码结构不同界面的绑定代码风格保持一致。新人打开脚本后可以很快分清哪些是自动生成的 哪些是手写业务逻辑这对长期维护很有帮助。4. 便于批量维护当 UI 封装类或绑定逻辑需要调整时可以通过生成器统一更新。如果所有界面都是手写代码批量修改会非常麻烦。5. 让程序更关注业务逻辑程序员真正应该关注的是数据如何显示按钮点击后做什么界面状态如何切换网络返回后如何刷新新手引导如何触发红点如何更新而不是一直写节点绑定代码。UI 自动生成可以把这部分机械工作从业务开发中剥离出来。十一、代码生成不是越多越好虽然我做了 UI 自动生成但我并不认为所有代码都应该生成。代码生成要有边界。适合生成的是重复性强的代码结构稳定的代码规则明确的代码与业务逻辑无关的代码可以通过配置准确描述的代码不适合生成的是复杂业务逻辑强依赖上下文的逻辑频繁变化且规则不稳定的逻辑需要程序员判断的逻辑UI 节点绑定就很适合生成。但按钮点击后做什么就不适合生成。所以 MyFramework 的做法是工具生成结构代码 程序编写业务逻辑而不是试图把整个 UI 逻辑都自动化。这样可以在效率和可控性之间取得平衡。十二、一个比较推荐的 UI 脚本结构在我的项目中一个 UI 脚本通常会大致分成几类逻辑构造 ↓ assignWindow ↓ init ↓ onGameState ↓ onHide ↓ 事件回调 ↓ protected 辅助函数大致职责是函数作用构造初始化数据分配必要容器assignWindow查找并绑定 UI 节点init注册事件和初始化 UIonGameState根据当前游戏状态刷新 UIonHide执行隐藏时逻辑事件回调响应按钮点击、拖拽等事件protected 函数当前界面内部辅助逻辑其中assignWindow非常适合自动生成。因为它本质上就是节点绑定过程。而onGameState、事件回调和业务刷新逻辑则应该由程序手写。十三、UI 自动生成不是为了炫技而是为了长期维护很多工具在 Demo 阶段看起来意义不大。因为 Demo 里 UI 数量少节点也少。但是商业项目不一样。当 UI 数量变多以后重复绑定代码、节点改动、多人协作、Prefab 维护都会变成实际成本。UI 自动生成的价值会随着项目规模变大而越来越明显。它解决的不是“能不能做 UI”的问题。而是解决UI 多了以后如何稳定维护 UI 改了以后如何减少错误 多人开发时如何保持统一结构 框架升级时如何批量修改绑定逻辑这也是我在 MyFramework 中保留 UI 自动生成工具的原因。十四、总结UI 代码自动生成并不是一个很复杂的概念。但它对长期 Unity 项目非常有价值。尤其是当项目 UI 数量很多、界面变化频繁、多人协作开发时自动生成可以明显减少重复劳动和低级错误。MyFramework 中的 UI 自动生成遵循几个原则只生成结构性代码不覆盖手写业务逻辑保留明确的成员变量与 UI 封装体系配套让业务逻辑和节点绑定逻辑分离让 UI 脚本结构保持统一这套设计并不一定适合所有项目。但对于我自己的商业项目以及偏中大型、长期维护的 Unity 项目来说它确实解决了很多实际问题。如果你也在做 Unity 项目并且项目中 UI 数量很多或者经常因为 UI 节点绑定出问题那么可以考虑引入类似的代码生成流程。项目地址GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub配套服务器框架GitHub - ZHOURUIH/MyServerFramework · GitHub欢迎一起交流 Unity 框架设计、UI 工具链、配置表工具链和游戏项目工程化相关内容。