Unity手写轻量UI框架设计与实践
1. 为什么我宁愿手写一个“简陋”的UI框架,也不用NGUI/UGUI现成方案?
在Unity项目做到中后期,你大概率会遇到这么个场景:美术同学发来一版新UI切图,策划提了个“就改个按钮颜色+加个弹窗提示”的需求,你打开工程一看——好家伙,整个UI系统像被塞进洗衣机又甩干过:Canvas层级嵌套七层深、PanelManager里混着状态机逻辑、Button点击事件绑在OnEnable里、DestroyImmediate满天飞……改完按钮颜色,登录界面突然不响应触摸了。这不是段子,是我去年在一款上线半年的MMO手游里真实踩过的坑。
“【Unity小技巧】手戳一个简单易用的游戏UI框架(附源码)”这个标题里的“手戳”二字,不是谦虚,是刻意选择。它背后藏着一个被很多团队忽略的真相:UI框架的复杂度,永远不该由“功能多不多”决定,而该由“修改一次UI组件,需要牵动多少无关代码”来衡量。我见过太多项目,为了支持“动态加载”“热更皮肤”“多语言自动适配”这些听起来高大上的特性,把UI管理器写成二十个单例互相调用的迷宫。结果呢?一个新手程序员改个血条位置,得先读懂三页UML时序图,再祈祷别触发某个隐藏的资源卸载钩子。
这个框架的核心关键词是:简单、易用、可预测。它不追求覆盖所有UI场景,但保证你改一个按钮,绝不会让背包界面闪退;它不内置动画系统,但留出干净的Hook让你接任何Tween库;它不强制MVC,但天然隔离View和Logic——View只管显示和接收输入,Logic只管数据和流程。它适合中小团队、独立开发者、以及那些被“企业级UI框架”反向驯化到不敢动一行UI代码的资深工程师。如果你正卡在“想快速验证一个UI交互原型”“需要给外包美术提供零学习成本的UI组装规范”“或者单纯受够了每次改UI都要全局搜索‘OnButtonClick’然后祈祷没漏掉哪个匿名委托”,那这个手戳框架,就是为你写的。它不是银弹,但是一把趁手的螺丝刀——拧得紧,不打滑,换电池只要两秒。
2. 框架设计的底层逻辑:从“Canvas怎么挂”开始的哲学思辨
很多人一上来就想设计“UIManager”“UIModule”“IUIView”这些高大上的抽象,结果写着写着发现,连“一个弹窗该挂在哪级Canvas下”都争论不休。所以这个框架的第一步,不是写代码,而是定规矩。我把这套规矩叫“三层Canvas宇宙观”,它直接决定了整个框架的呼吸节奏。
2.1 为什么必须严格区分World、UI、Popup三级Canvas?
Unity的Canvas渲染顺序,本质是Z轴深度+Render Order+Sorting Layer的混合体。但绝大多数项目只依赖“Hierarchy顺序”这一个维度,结果就是:当一个3D角色头顶飘字(World Canvas)和背包面板(UI Canvas)同时存在时,飘字可能被面板遮住,或者面板按钮点不到——因为它们在同一个Canvas下,排序逻辑混乱。我的解决方案是物理隔离:
- World Canvas:仅用于3D世界中的UI元素,如角色头顶血条、任务标记。它的Render Mode必须是World Space,Camera指定为MainCamera,Plane Distance设为0.1(避免穿模)。关键:它绝不挂任何UI逻辑脚本,只负责将Transform位置映射到3D世界。
- UI Canvas:游戏主界面的载体,如主菜单、背包、技能栏。Render Mode为Screen Space - Overlay,Scale Factor设为1,确保像素精准。它是唯一允许挂
UIRootController(框架核心管理器)的Canvas。 - Popup Canvas:所有临时弹窗、提示框、加载遮罩的专属容器。Render Mode同为Screen Space - Overlay,但Sorting Order必须高于UI Canvas至少100(比如UI Canvas设为10,Popup Canvas设为110)。这是硬性铁律——它保证无论主界面如何变化,弹窗永远在最上层,且不参与主界面的Canvas重建(避免弹窗闪烁)。
提示:Popup Canvas必须独立于UI Canvas创建,不能作为其子物体。我试过把Popup挂进UI Canvas下并靠Sorting Order控制层级,结果在Android低端机上频繁出现弹窗消失或触摸失效——原因是Unity在Canvas重建时,对子Canvas的排序处理有未公开的优化策略,独立Canvas则完全可控。
2.2 “手戳”的第一行代码:为什么UIRootController必须是MonoBehaviour?
框架入口点UIRootController看似普通,但它承载着两个反直觉的设计决策:
它必须继承MonoBehaviour,且必须挂载在UI Canvas GameObject上。
很多人倾向用纯C#单例(public static class UIManager),理由是“解耦”。但问题来了:当UI Canvas被SetActive(false)隐藏时,纯C#单例依然活着,它持有的所有UI引用(如List<UIPanel>)可能指向已销毁的GameObject。而挂载在Canvas上的MonoBehaviour,其生命周期与Canvas完全同步——Canvas销毁,它自动OnDisable;Canvas重建,它自动Awake。这省去了90%的空引用检查。它不管理资源加载,只管理实例生命周期。
UIRootController里没有LoadPanel<T>()方法,只有OpenPanel<T>(params object[] args)和ClosePanel<T>()。资源加载交给独立的AssetBundleLoader或Resources.LoadAsync,加载完成后再调用UIRootController.Instance.InstantiatePanel<T>(loadedPrefab, args)。这样做的好处是:当美术替换了一个Panel prefab,你只需改加载路径,框架逻辑零改动;当需要接入Addressables,也只需替换加载器,UIRootController一行代码不用动。
2.3 Panel基类的精妙平衡:既要“傻瓜式”,又要“可扩展”
BasePanel是所有UI界面的父类,它只有三个公开方法:OnInit()、OnOpen(params object[] args)、OnClose()。没有Start()、没有Update()、没有OnEnable()——这些Unity生命周期方法全部被封装在内部,对外不可见。为什么?
OnInit():在Panel实例化后、首次显示前调用。这里做初始化:获取组件引用(_btn = transform.Find("Btn").GetComponent<Button>())、注册事件(_btn.onClick.AddListener(OnClick))、设置默认状态(_text.text = "Loading...")。关键:它不执行任何耗时操作,不访问网络,不加载资源。OnOpen(args):在Panel真正显示时调用。这里处理业务逻辑:根据args参数设置界面数据(SetPlayerInfo((PlayerData)args[0]))、播放入场动画、请求服务器数据。注意:args是object数组,不是泛型T,避免泛型擦除带来的反射开销。OnClose():在Panel隐藏前调用。这里清理:注销事件(_btn.onClick.RemoveListener(OnClick))、停止协程、释放临时数据。绝不调用Destroy()——销毁由UIRootController统一管理。
这种设计让新手开发者无法“误操作”:他看不到Start(),就不会在里面写GetComponent导致性能浪费;他看不到OnEnable(),就不会把数据刷新逻辑写错地方。而资深开发者要扩展,只需重写这三个方法,框架的底层机制(如自动事件注销、资源回收)依然生效。
3. 核心实现:从InstantiatePanel到事件总线的178行代码拆解
现在进入实操环节。下面这段代码,就是整个框架的骨架,共178行(不含注释),我把它拆成四个关键模块,逐行解释为什么这么写,以及踩过的坑。
3.1 InstantiatePanel:如何让Prefab实例化既快又稳?
// UIRootController.cs 部分代码 public T InstantiatePanel<T>(GameObject prefab, params object[] args) where T : BasePanel { // Step 1: 确保Prefab有BasePanel组件 if (!prefab.GetComponent<BasePanel>()) { Debug.LogError($"Prefab {prefab.name} must have BasePanel component!"); return null; } // Step 2: 实例化并挂载到对应Canvas GameObject instance = Instantiate(prefab); // 关键:强制挂载到UI Canvas(非Popup) instance.transform.SetParent(_uiCanvas.transform, false); // 重置本地坐标,避免因Prefab原生缩放导致UI错位 instance.transform.localScale = Vector3.one; instance.transform.localPosition = Vector3.zero; // Step 3: 获取BasePanel实例并初始化 T panel = instance.GetComponent<T>(); panel._rootController = this; // 反向引用,用于后续关闭 panel.OnInit(); // 执行初始化逻辑 // Step 4: 缓存Panel实例,供后续Close使用 _activePanels.Add(panel); return panel; }这段代码表面简单,但藏着三个关键细节:
SetParent(_uiCanvas.transform, false)的false参数:
这个布尔值决定是否保持世界坐标。设为false,实例化后的Panel会以UI Canvas为原点,坐标归零。如果设为true,Panel会保留Prefab在编辑器里的世界坐标,导致它出现在屏幕外——这是新手最常见的“UI不见了”问题。我曾帮一个团队排查了两天,最后发现是某位同事把false写成了true。localScale = Vector3.one的强制重置:
Unity的Prefab如果在编辑器里被缩放过(比如美术为了预览效果把按钮放大2倍),实例化后会继承这个缩放。在UI Canvas下,缩放会导致RectTransform计算异常,按钮点击区域错位。强制归一,是从根源上杜绝这类视觉BUG。_activePanels.Add(panel)的时机:
必须在panel.OnInit()之后添加。因为OnInit()里可能调用GetComponent,如果此时Panel还没加入列表,某些依赖列表的调试工具(如实时Panel监控面板)会报错。这个顺序,是我在三次崩溃后才确定的。
3.2 OpenPanel:如何让“打开一个Panel”变成原子操作?
public void OpenPanel<T>(params object[] args) where T : BasePanel { // Step 1: 检查是否已存在同类型Panel(防重复打开) T existing = _activePanels.FirstOrDefault(p => p.GetType() == typeof(T)) as T; if (existing != null) { // 策略:已存在则聚焦(激活)它,而非新建 existing.gameObject.SetActive(true); existing.OnOpen(args); // 重新传参,刷新数据 return; } // Step 2: 加载Prefab(此处简化,实际应异步) GameObject prefab = Resources.Load<GameObject>($"Prefabs/UI/{typeof(T).Name}"); if (prefab == null) { Debug.LogError($"Prefab for {typeof(T).Name} not found in Resources/Prefabs/UI/"); return; } // Step 3: 实例化并打开 T panel = InstantiatePanel<T>(prefab, args); if (panel != null) { panel.OnOpen(args); // 传入业务参数 panel.gameObject.SetActive(true); // 真正显示 } }这里的关键是“已存在则聚焦”策略。很多框架默认“重复打开就新建”,结果玩家狂点设置按钮,瞬间弹出十个设置面板。我们的策略是:同一类型Panel只允许存在一个实例。当再次OpenPanel<SettingPanel>()时,直接激活已有实例并调用OnOpen()刷新数据。这要求OnOpen()必须是幂等的——即多次调用效果相同。为此,我在BasePanel里强制规定:OnOpen()的第一行必须是ClearAllData(),清空所有文本、图片、列表项,再根据args重新填充。这个约定,比写一百行校验代码更可靠。
3.3 事件总线:为什么不用SendMessage,而用轻量级MessageBus?
框架需要一种方式,让Panel之间不直接引用也能通信。比如:背包Panel点击出售按钮,要通知角色Panel更新金币数。传统做法是FindObjectOfType<CharacterPanel>().UpdateGold(),但这违反了“低耦合”原则。我的方案是自研一个极简MessageBus:
// MessageBus.cs public static class MessageBus { private static readonly Dictionary<string, List<Action<object[]>>> _subscribers = new Dictionary<string, List<Action<object[]>>>(); public static void Subscribe(string topic, Action<object[]> handler) { if (!_subscribers.ContainsKey(topic)) _subscribers[topic] = new List<Action<object[]>>(); _subscribers[topic].Add(handler); } public static void Unsubscribe(string topic, Action<object[]> handler) { if (_subscribers.ContainsKey(topic)) _subscribers[topic].Remove(handler); } public static void Publish(string topic, params object[] args) { if (_subscribers.ContainsKey(topic)) { // 复制列表,防止订阅者在回调中修改列表导致遍历异常 var handlers = new List<Action<object[]>>(_subscribers[topic]); foreach (var handler in handlers) { try { handler(args); } catch (Exception e) { Debug.LogException(e); } } } } }为什么不用Unity的SendMessage?因为SendMessage是通过字符串反射查找方法,性能差,且IDE无法跳转、无编译检查。而MessageBus的Subscribe("GoldChanged", OnGoldChanged),IDE能直接跳转到OnGoldChanged方法,编译期就能发现方法签名错误。更重要的是,它支持Unsubscribe——当Panel关闭时,BasePanel.OnClose()里自动调用MessageBus.Unsubscribe("GoldChanged", OnGoldChanged),彻底杜绝内存泄漏。这个设计,让我在接手一个老项目时,把原本每帧GC 2MB的UI系统,优化到GC 0KB。
3.4 Popup系统的特殊处理:遮罩层与返回键的优雅妥协
Popup(弹窗)是UI中最容易出问题的模块。我们的Popup系统包含三个核心对象:PopupCanvas、PopupMask(半透明遮罩)、PopupContent(内容区域)。关键逻辑在PopupManager.OpenPopup<T>()中:
public void OpenPopup<T>(params object[] args) where T : BasePopup { // Step 1: 创建Popup实例(挂载到PopupCanvas) GameObject prefab = Resources.Load<GameObject>($"Prefabs/Popup/{typeof(T).Name}"); GameObject instance = Instantiate(prefab, _popupCanvas.transform); // Step 2: 自动添加遮罩(如果Popup prefab没自带) if (instance.transform.Find("Mask") == null) { GameObject mask = new GameObject("Mask"); mask.transform.SetParent(instance.transform, false); Image maskImage = mask.AddComponent<Image>(); maskImage.color = new Color(0, 0, 0, 0.5f); // 添加全屏遮罩点击关闭逻辑 Button maskBtn = mask.AddComponent<Button>(); maskBtn.onClick.AddListener(() => ClosePopup<T>()); } // Step 3: 初始化Popup T popup = instance.GetComponent<T>(); popup._rootController = this; popup.OnInit(); popup.OnOpen(args); popup.gameObject.SetActive(true); }这里有个精妙的“妥协”:遮罩的点击关闭,不是监听整个屏幕,而是监听遮罩自身。很多框架用EventSystem.current.RaycastAll检测点击位置,但低端机上性能堪忧。而遮罩本身就是全屏Image,点击它自然就关闭Popup。更关键的是,我们重写了Android返回键逻辑:
// 在UIRootController.Update()中 void Update() { if (Input.GetKeyDown(KeyCode.Escape) && _activePopups.Count > 0) { // 优先关闭最上层Popup ClosePopup(_activePopups.Last()); } }这个逻辑简单粗暴,但极其有效。它不尝试判断“当前焦点在哪个InputField”,因为移动端根本不需要——用户按返回键,就是想关掉当前弹窗。这种对平台特性的尊重,比写一堆兼容逻辑更“简单易用”。
4. 实战避坑指南:从“按钮点不动”到“内存泄漏”的完整排错链路
再好的框架,落地时也会遇到各种“意料之外”。我把过去三年在五个项目中踩过的UI相关坑,按排查难度从低到高排列,还原完整的诊断过程。这不是理论,是血泪经验。
4.1 现象:“按钮点击无反应”,但Inspector里onClick事件明明挂着
这是新手最高频的问题。排查链路如下:
第一步:确认Canvas Group是否启用
选中按钮所在Canvas,在Inspector中检查Canvas Group组件。如果Interactable勾选了但Blocks Raycasts未勾选,按钮将无法接收点击。Blocks Raycasts是Raycast的开关,必须开启。我见过最离谱的案例:美术为了“预览UI效果”,在Canvas Group里把Alpha调成0,顺手把Blocks Raycasts也关了,结果测试时说“按钮点了没反应”,其实是整个Canvas都屏蔽了射线。第二步:检查Raycast Target层级
Unity的射线检测是自上而下穿透的。如果按钮上方有一个Image(比如背景图),且它的Raycast Target为true,但Image Type是Filled且Fill Amount为0,它依然会拦截射线!因为Raycast Target只看开关,不看实际像素。解决方案:给所有纯装饰性Image(无交互需求)手动关闭Raycast Target,并在团队规范里写死:“所有背景图、分割线、装饰元素,Raycast Target必须为false”。第三步:验证EventSystem是否存在且配置正确
场景中必须有EventSystemGameObject,且其Standalone Input Module组件的Input Actions Per Second不能为0(默认是10)。如果被改成0,所有点击事件都会被丢弃。这个值调太小会丢事件,调太大(如1000)会导致连续点击被误判为长按。实测下来,12是黄金值——既能响应快速连点,又不会误触发。
注意:如果项目用了新的Input System,必须禁用
Standalone Input Module,启用Input System UI Input Module,否则两者冲突,按钮彻底失灵。这个坑,我在Unity 2021.3升级时踩了整整一天。
4.2 现象:“UI文字模糊”,截图放大后全是锯齿
这通常不是Shader问题,而是RectTransform的锚点(Anchors)和轴心(Pivot)错位导致的像素偏移。排查步骤:
选中Text组件,在Scene视图中开启Gizmos(右上角眼睛图标),观察蓝色矩形(RectTransform)是否与文字实际渲染区域重合。如果不重合,说明锚点设置错误。
检查Anchor Presets:对于居中显示的文字,必须用
Stretch-Stretch锚点,并将Pos X/Y/Z设为0。如果用了Top-Left锚点,即使Pos X/Y是0,文字也会因父容器缩放而偏移亚像素。终极方案:强制像素对齐
在BasePanel.OnInit()里,为所有Text组件添加以下代码:Text text = GetComponent<Text>(); if (text != null) { // 强制RectTransform的localPosition四舍五入到整数像素 RectTransform rt = text.rectTransform; rt.anchoredPosition = new Vector2( Mathf.Round(rt.anchoredPosition.x), Mathf.Round(rt.anchoredPosition.y) ); }这行代码能解决90%的模糊问题。原理是:Unity在渲染时,如果RectTransform的位置带小数(如x=10.321),GPU会进行双线性插值,导致边缘模糊。强制取整,让每个像素都精准落在渲染网格上。
4.3 现象:“切换场景后,UIPanel的OnClose没被调用,内存泄漏”
这是框架设计缺陷的典型表现。当场景切换时,Unity会销毁所有GameObject,但BasePanel的OnClose()如果没被调用,它持有的事件监听器、协程、资源引用就不会释放。排查与修复:
确认UIRootController的生命周期:
UIRootController必须挂载在DontDestroyOnLoad的GameObject上,否则场景切换时它被销毁,无法调用OnClose()。但直接DontDestroyOnLoad(gameObject)有风险——如果UI Canvas里有其他脚本也依赖它,可能引发引用混乱。我的方案是:创建一个独立的UIManager空GameObject,DontDestroyOnLoad它,并让UIRootController作为其子物体。在SceneManager.sceneUnloaded事件中兜底:
即使UIRootController还在,场景卸载时部分Panel可能已被销毁。因此在UIRootController.OnEnable()中注册:SceneManager.sceneUnloaded += OnSceneUnloaded; void OnSceneUnloaded(Scene scene) { // 遍历所有活跃Panel,强制调用OnClose foreach (var panel in _activePanels.ToList()) { if (panel != null && panel.gameObject != null) { panel.OnClose(); Destroy(panel.gameObject); } } _activePanels.Clear(); }这个兜底逻辑,让我在接手一个用旧版框架的项目时,把内存泄漏从每次场景切换增长5MB,降到稳定在0KB。
协程泄漏的隐形杀手:
很多人在OnOpen()里写StartCoroutine(ShowAnimation()),却忘了在OnClose()里调用StopAllCoroutines()。协程一旦启动,即使GameObject被Destroy,它依然在运行,持续引用变量。我的强制规范是:BasePanel基类里内置一个protected Coroutine _currentCoroutine,OnOpen()中赋值,OnClose()中if (_currentCoroutine != null) StopCoroutine(_currentCoroutine)。这个小约定,救了无数个深夜加班的程序员。
4.4 现象:“Popup遮罩点击无效”,但按钮点击正常
这几乎100%是Canvas层级问题。完整排查链路:
检查Popup Canvas的Sorting Order:
在Hierarchy中,Popup Canvas必须在UI Canvas的下方(即渲染顺序更高)。如果Popup Canvas的Sorting Order是10,UI Canvas是100,那么Popup永远被UI遮住。正确顺序:UI Canvas=10,Popup Canvas=110。验证遮罩Image的Raycast Target:
遮罩是一个Image组件,必须确保其Raycast Target为true。但更隐蔽的坑是:如果遮罩Image的Source Image为空(即没赋Sprite),它默认不接收射线!必须给它赋一个1x1的纯色Sprite(如白色方块),哪怕只是临时占位。终极验证:用Scene视图的Raycast Gizmo:
在Scene视图右上角,点击“Gizmos”下拉菜单,勾选Raycast Visualization。然后点击遮罩区域,如果看到一条绿色射线从MainCamera射向遮罩,说明射线可达;如果射线在中途变红,说明被上层物体拦截。这个工具,比读一百行文档都管用。
5. 源码集成与定制化:如何在三天内让团队全员上手
框架的价值,不在于代码多漂亮,而在于能否让团队快速用起来。我把源码集成流程拆解为“三日上手法”,每天一个目标,确保零基础成员也能独立开发UI。
5.1 第一天:跑通Demo,理解“打开-关闭”闭环
目标:让新人在不改一行框架代码的前提下,创建一个新Panel并成功打开/关闭。
步骤清单:
- 在
Assets/Prefabs/UI/下新建文件夹TestPanel; - 创建空GameObject,命名为
TestPanel,挂载BasePanel脚本; - 在
TestPanel下创建一个Text(显示“Hello World”)和一个Button(显示“Close”); - 为
Button的onClick事件,添加TestPanel的CloseSelf方法(BasePanel已内置); - 在任意测试脚本(如
GameStart.cs)中,Awake()里写:UIRootController.Instance.OpenPanel<TestPanel>(); - 运行游戏,确认Panel弹出,点击Close按钮后消失。
关键教学点:
- 强调
OpenPanel<T>()的泛型T必须是Panel的脚本名(TestPanel),不是Prefab名; - 解释
CloseSelf()是BasePanel提供的快捷方法,它内部调用UIRootController.Instance.ClosePanel<T>(),确保资源统一回收; - 让新人自己删掉
CloseSelf(),手动写UIRootController.Instance.ClosePanel<TestPanel>(),体会框架的“显式控制”哲学。
5.2 第二天:接入业务逻辑,实践“参数传递”与“事件通信”
目标:让新人实现“点击按钮,弹出Popup显示玩家等级”,并用MessageBus通知主界面更新。
步骤清单:
- 创建
LevelPopup : BasePopup,在OnOpen()中接收int level参数,显示“您的等级:X”; - 在
TestPanel的按钮点击事件中,调用:UIRootController.Instance.OpenPopup<LevelPopup>(playerData.Level); - 在
MainUIPanel(主界面)的OnInit()中,订阅消息:MessageBus.Subscribe("LevelUp", OnLevelUp); void OnLevelUp(object[] args) { /* 更新等级显示 */ } - 在
LevelPopup的确认按钮中,发布消息:MessageBus.Publish("LevelUp", newLevel);
避坑强调:
MessageBus.Subscribe必须在OnInit()中,不能在OnOpen()——因为OnOpen()可能被多次调用,导致重复订阅;MessageBus.Publish的参数必须是object[],不能是单个int,否则订阅者收到的是int而非object[],类型转换失败;- 所有
Subscribe必须配对Unsubscribe,BasePanel.OnClose()里已内置MessageBus.UnsubscribeAll(this),新人只需确保自己的Handler方法是private即可。
5.3 第三天:定制化扩展,添加“加载中”遮罩与国际化支持
目标:让新人为框架添加两个实用功能,理解扩展点设计。
功能1:全局Loading遮罩
在UIRootController中添加:
public void ShowLoading(string tip = "Loading...") { // 复用Popup系统,创建一个专用LoadingPopup _loadingPopup = Instantiate(Resources.Load<GameObject>("Prefabs/Popup/LoadingPopup")); _loadingPopup.transform.SetParent(_popupCanvas.transform, false); _loadingPopup.GetComponent<Text>().text = tip; _loadingPopup.SetActive(true); } public void HideLoading() { if (_loadingPopup != null) Destroy(_loadingPopup); }然后在任意网络请求前调用ShowLoading(),回调中调用HideLoading()。这个功能,让所有程序员无需关心遮罩实现,专注业务。
功能2:Text组件的国际化
创建LocalizedText : MonoBehaviour,挂载在Text上:
public string key; // 如 "UI_LOGIN_TITLE" void Start() { // 从JSON字典中读取key对应的文本 text.text = Localization.Get(key); }在BasePanel.OnInit()中自动查找并初始化所有LocalizedText:
foreach (var localized in GetComponentsInChildren<LocalizedText>()) { localized.Start(); }这样,美术只需在Inspector里填key,程序员维护Localization.json,彻底分离。
最后分享一个小技巧:我在每个项目的
UIRootController里,都加了一个[Header("Debug Tools")],下面放public bool showDebugPanel = false;。运行时勾选它,会动态生成一个调试面板,显示当前所有活跃Panel、订阅的MessageBus主题、Canvas层级信息。这个面板不打包进正式版本,但救了我无数次线上BUG定位。它提醒我:最好的框架,不是代码最少的那个,而是让开发者“少想事、多做事”的那个。
