聊框架设计之前先亮明我的身份一个在Unity3d项目里摸爬滚打了多年的老开发。这篇文章就是围绕我自己维护的一套Frame架构做一次完整的复盘说明把为什么这么设计、每块的职责边界、具体的代码落地方式以及踩过的坑统统讲清楚。先给这套东西一个定位它不是某个商业框架的教程也不是纯理论层面的架构论文而是一个真正跑过上线项目、经历过团队协作、被业务反复捶打之后沉淀下来的Unity3d客户端框架。它解决的核心问题是当游戏玩法逻辑、UI界面、资源加载、网络同步这些模块全部堆在一个项目里时怎么让代码不腐烂、让多人协作不打架、让新功能迭代不被旧代码绑架。如果你正处在“项目越写越乱、改一个功能要牵连半个工程”的阶段或者想搭建一套属于自己的Unity3d Frame体系这篇说明应该能给你不少参考价值。1. 框架到底在解决什么问题1.1 没有架构的Unity3d项目是怎么烂掉的我在早期做过一个小游戏项目当时觉得游戏不大没必要搞框架所有逻辑直接挂在MonoBehaviour上UI的打开关闭到处new场景切换靠SceneManager模块之间互相直接调用静态方法。刚开始很爽写什么都快。等到功能加到一定量级噩梦就来了。举一个真实场景策划要求在主界面的某个按钮上增加一个红点提示而这个红点需要依赖背包、任务、商城三个模块的数据状态。没有框架的时候我只能在主界面脚本里分别引用三个模块的静态方法轮询或者等待事件通知。而这三个模块本身又各自依赖其他模块于是整个工程的脚本引用关系变成了一张蜘蛛网。每次改一个模块的接口其他模块全都要跟着改编译错误一片红。这就是典型的“没有架构”的项目问题。架构的本质不是炫技而是管理复杂度。Unity3d开发尤其需要一套Frame来规划项目因为引擎给了你很大的自由度但也因此特别容易写出难以维护的代码。引擎只负责渲染、物理、输入这些底层能力业务上的模块如何组织完全靠开发者自己约束。没有约束复杂度就会失控。1.2 我对Frame架构的核心设计目标做这套框架之前我明确写了四个设计目标之后所有模块的增删改都是围绕这几个目标来评判的。第一是模块解耦。任何两个业务模块之间不允许直接互相引用对方的类型。它们之间的通信全部走消息机制。这样做的目的是让每个模块可以独立替换、独立测试。比如聊天模块挂了不至于把背包模块也拖垮。第二是生命周期可控。Unity3d引擎给了我们Awake、Start、Update、OnDestroy这些回调但它们在场景切换、预制体加载时触发时机并不统一。我在Frame里设计了一套统一的模块生命周期管理任何模块注册进框架之后由框架来统一调度初始化、轮询更新、清理销毁等阶段而不是依赖MonoBehaviour的随机时序。第三是资源管理统一。Asset加载不能到处直接new或者加载Prefab后不管。框架通过资源管理模块统一管控记录引用计数确保场景切换时内存可控UI界面重复打开时不会产生泄漏。第四是扩展性优先。框架核心只提供机制不绑定具体业务。比如UI框架不关心你打开的是商店界面还是背包界面它只处理面板的层级、缓存和生命周期。业务层基于框架提供的接口各自实现内部逻辑。这四个目标看起来简单但真正落地的时候牵涉到很多细节接下来展开说。1.3 为什么选择自研而不是直接用现成框架聊到这个必然会被问Unity3d社区不是已经有StrangeIoC、UniRx、FairyGUI这些成熟方案了吗为什么要自己造轮子我的真实想法是现成框架确实好用但它们都是为通用场景设计的到了具体项目里总有很多“不够顺手”的地方。比如有些框架强依赖某种编程范式要求所有逻辑都走依赖注入或者响应式流学习成本高新同事上手慢。还有些框架把模块划分得很重一个小游戏项目引入它代码量翻倍纯属于大炮打蚊子。自研框架最大的好处是克制。我可以只保留项目真正需要的部分对不需要的部分果断砍掉。我不必follow某个框架对整个开发方式的强制要求而是让框架适配团队的开发习惯。当然自研也有代价需要自己踩坑、自己维护。我的建议是如果你有足够的时间成本并且对框架设计本身感兴趣完全可以自己搭一版——这个过程对理解游戏客户端架构的提升非常大。2. Frame架构的模块划分与核心细节2.1 总览这个Frame里都有什么整套架构分成五层从上到下分别是表现层、逻辑层、数据层、通信层、基础服务层。每层的职责很明确不允许跨层调用。表现层就是所有跟画面显示直接相关的东西UI面板、特效、场景物件。逻辑层是玩法规则、战斗计算、任务进度这些纯逻辑部分。数据层负责数据存储和持久化包括玩家存档、配置表、运行时数据。通信层统一管理网络消息的收发不管用的是Socket还是HTTP对上层只暴露收发接口。基础服务层是框架的底座包含资源管理、对象池、事件系统、定时器这些被各种模块依赖的基础设施。每一层的模块都会在框架里注册由核心的FrameManager统一管理。整个项目的入口场景只需要挂一个Bootstrap脚本剩下的模块全部自动装配。2.2 事件驱动模块之间不说废话模块解耦的核心是事件系统。我实现了一套轻量级的事件总线任何模块都可以发布消息任何模块都可以订阅自己感兴趣的消息。最关键的一个设计原则是发布者不需要知道谁在听订阅者也不需要知道消息的来源。事件系统本身实现并不复杂核心是一个字典key是事件IDvalue是回调函数的委托列表。发布消息时遍历字典找到对应的回调列表逐个调用。为了防止一个模块回调异常导致整个分发出问题我在调用每个委托时都用try-catch包裹出错的回调会被标记并从列表中移除。这个设计帮我排掉过不少隐匿的运行时错误。事件命名上我有一套强制规范所有事件ID必须以模块名为前缀比如UI_OnBagItemChanged、Battle_OnSkillCast。这个规范一开始觉得啰嗦但项目大了之后搜索和排查问题的效率提升非常明显。你在IDE里按前缀一搜就能快速定位这个事件所有相关的收发逻辑。2.3 生命周期管理打破MonoBehaviour的随机性很多新手写Unity3d时最容易遇到的一个问题是场景里几十个脚本它们的Awake、Start执行顺序完全不可控有时候一个脚本在Awake里访问另一个脚本的字段结果那个脚本还没初始化。我在Frame里用一套显式的Init流程来解决这个问题。所有需要生命周期的模块都继承自同一个基类基类里定义了框架层面的Initialize、Update、Release方法。FrameManager在启动时按注册顺序依次调用各模块的Initialize之后的每帧Update也是由FrameManager统一驱动的。每个模块内部可以决定自己是否监听Unity的Update回调但统一入口只有一个。这里特别说明一下不是完全不走MonoBehaviour。比如资源加载模块需要利用协程来处理异步加载这本身就是引擎特性。但业务逻辑绝不直接依赖引擎回调而是全部走框架的生命周期。这样模块的初始化顺序变成显式可控的问题排查也变成了“检查注册顺序”而不是“瞎猜脚本执行顺序”。2.4 UI框架界面管理的核心痛点Unity3d的UI系统在界面管理上有几个坑一个是界面打开关闭全靠手写Instantiate和Destroy频繁操作会带来卡顿第二个是界面的层级管理很痛苦弹窗要盖在界面上Toast要盖在弹窗上全屏界面打开后要屏蔽其他界面点击第三个是界面之间传参混乱经常靠静态字段或者直接互相引用。我在Frame里的UI管理器使用面板栈的机制。每个UI面板继承自UIPanel基类基类里定义了OnOpen、OnClose、OnRefresh这些生命周期方法。UIManager负责面板的加载、缓存和层级控制。打开面板时如果缓存里有就直接复用没有才走加载流程。关闭面板时不是直接销毁而是缓存起来限制一个最大缓存数量防止内存膨胀。层级管理我用了几个固定的层级槽位底层背景、主界面层、次级界面层、弹窗层、顶层提示层。每个面板创建时指定自己要挂在哪个层UIManager负责把同层的面板按打开顺序排列并且自动处理遮罩。这套UI框架还有另外一块重要功能是点击事件的屏蔽策略。全屏界面打开时底下的界面不能响应点击。我在UI层级的遮挡面板上实现了一个统一的遮挡逻辑由UIManager根据面板类型自动生成透明遮罩不需要业务层手动写。2.5 资源与工具链藏在框架背后的基础设施资源管理这块早期项目是直接Resources.Load后期我切换到Addressables。Frame结构里资源模块做了两层封装底层是具体加载器Resources或者Addressables上层是对业务暴露的加载接口。这样做的好处是底层加载方案可以随时切换业务代码不用动。工具链方面我写了一个编辑器扩展窗口用来扫描场景里非法挂载的组件、检查UI面板预制体的命名规范、生成事件ID枚举代码。这些工具本身不属于运行时框架但对维护框架健康度很有价值。尤其是事件ID枚举的自动生成避免了多人协作时手动敲字符串容易写错的问题。3. 实操把Frame架构搭起来3.1 项目目录与启动入口规划动手写代码之前先把工程目录规划好。我建议的目录结构如下Assets/ Scripts/ Core/ // Frame核心生命周期、事件、对象池、资源 Modules/ // 各业务模块 UI/ // UIPanel基类 UIManager 各面板脚本 Data/ // 数据模型、配置读取 Art/ // 美术资源 Plugins/ // 第三方插件启动入口不需要依赖特定场景我在入口场景里放一个空的GameObject挂Bootstrap脚本。这个脚本在Awake里完成两件事初始化FrameManager然后注册所有模块。模块注册通过一个静态的模块列表维护后续新加模块只需要在这个列表里追加一行。public class Bootstrap : MonoBehaviour { private void Awake() { FrameManager.Instance.Initialize(); FrameManager.Instance.RegisterModule(new EventManager()); FrameManager.Instance.RegisterModule(new UIManager()); FrameManager.Instance.RegisterModule(new AssetManager()); FrameManager.Instance.RegisterModule(new DataManager()); FrameManager.Instance.StartAllModules(); } }3.2 事件系统的核心实现事件系统的核心代码很简单但细节决定体验。我贴一版精简的实现重点看注册和分发这两块。public class EventManager : FrameModule { private readonly Dictionaryint, ListDelegate _listeners new(); public void AddListener(int eventId, Actionobject[] callback) { if (!_listeners.TryGetValue(eventId, out var list)) { list new ListDelegate(); _listeners[eventId] list; } list.Add(callback); } public void RemoveListener(int eventId, Actionobject[] callback) { if (_listeners.TryGetValue(eventId, out var list)) { list.Remove(callback); } } public void Dispatch(int eventId, params object[] args) { if (!_listeners.TryGetValue(eventId, out var list)) return; // 拷贝一份再遍历防止回调中修改列表导致异常 var snapshot list.ToArray(); foreach (var handler in snapshot) { try { ((Actionobject[])handler)?.Invoke(args); } catch (Exception e) { Debug.LogError($事件分发异常: {eventId} {e}); RemoveListener(eventId, (Actionobject[])handler); } } } }这段代码里有两个隐藏的细节值得说。第一是必须在分发时拷贝快照因为某个回调执行过程中可能注销了自己或者其他监听直接遍历原列表会抛错。第二是异常处理里我会移除出错的回调这是为了避免一个坏的回调反复报错拖垮整个流程。不过要注意这种方式只适合框架层保护业务代码还是应该自己做好容错。事件参数我用的是params object[]。有人可能觉得这样类型不安全但实际用下来灵活性远大于类型安全的收益。只要在事件命名规范里约束清楚参数含义配合编辑器生成的枚举注释团队协作完全没问题。3.3 UI面板基类与UIManager关键逻辑UI面板基类是所有界面脚本的父类我把公共的逻辑都放到这里。public abstract class UIPanel : MonoBehaviour { public UILayer Layer { get; private set; } public string PanelId { get; private set; } public bool IsCached { get; set; } protected object[] _openArgs; public virtual void OnCreate() { } // 面板预制体实例化后调用一次 public virtual void OnOpen(object[] args) { } // 每次打开时调用 public virtual void OnClose() { } // 关闭时调用 public virtual void OnRefresh() { } // 数据刷新时调用 public void Open(UILayer layer, string panelId, object[] args null) { Layer layer; PanelId panelId; _openArgs args; gameObject.SetActive(true); OnOpen(args); } public void Close() { OnClose(); gameObject.SetActive(false); } }UIManager在打开面板的时候会根据面板ID去缓存字典里查找如果缓存命中就直接激活并调用OnOpen否则走加载流程实例化Prefab然后调用OnCreate再调用OnOpen。public class UIManager : FrameModule { private readonly Dictionarystring, UIPanel _cache new(); private readonly DictionaryUILayer, Transform _layerRoots new(); public void OpenPanel(string panelId, UILayer layer, object[] args null) { if (_cache.TryGetValue(panelId, out var panel)) { panel.transform.SetParent(_layerRoots[layer], false); panel.Open(layer, panelId, args); RefreshLayerRaycast(layer); return; } var prefab AssetManager.Instance.LoadUIPrefab(panelId); var obj Object.Instantiate(prefab, _layerRoots[layer]); panel obj.GetComponentUIPanel(); panel.OnCreate(); panel.Open(layer, panelId, args); _cache[panelId] panel; } }打开面板时传入的args是UI之间传参的关键。比如背包面板需要知道当前是“选装备”还是“查看详情”就可以通过args里的枚举来区分而不是靠静态字段传参。整个面板复用一套实例传参就在一次Open里完成干净利落。3.4 对象池消除掉帧元凶Unity3d项目卡顿的一个常见原因就是频繁的Instantiate和Destroy尤其是特效、子弹这类高频创建销毁的对象。对象池的原理大家都懂但落地时有一个坑对象从池里取出时必须重置状态否则上一次的残留数据会污染这次使用。我在对象池里定义了一个IPoolable接口对象在放入池子和取出时都会回调对应方法业务方在钩子里重置自己的状态。public interface IPoolable { void OnSpawn(); // 从池中取出 void OnDespawn(); // 放回池中 }内存上我还做了预热机制在场景切换时预先实例化一批常用对象放到池里。比如战斗场景打开前把子弹、伤害数字、受击特效这些高频对象都准备到位。这样玩家在战斗过程中看到的永远是丝滑的不会因为动态创建对象而掉帧。4. 常见问题与排查技巧实录4.1 事件监听泄漏最隐蔽的内存问题用事件系统最常踩的坑就是订阅了事件但忘记取消订阅。比如界面打开时注册了某个网络消息回调关闭界面时没有反注册结果界面对象虽然被缓存或销毁了回调列表里还留着它的引用导致这个界面对象永远无法被GC回收而且事件发出来它还会响应。后来我在UIPanel基类里加入了一套自动反注册机制基类维护一份该面板订阅的所有事件ID列表在OnClose时自动调用RemoveListener。这样业务层只需要在面板内部集中订阅不用手动管反注册从根上避免了泄漏问题。检查泄漏的经验是打开再关闭一个界面在Memory Profiler里看堆内存有没有持续增长。如果一个界面的Prefab实例在关闭后仍然存在基本就是事件泄漏或者对象池缓存没有清干净。4.2 异步加载与生命周期错位协程或者异步加载时最常见的错误是一个界面发起了异步加载请求但在加载完成前界面已经被关闭了等异步回调回来时继续SetActive、继续刷新UI结果操作到一个已经被标记为关闭的面板上。我在Frame里统一给异步回调增加一个有效性校验核心做法是给每个面板定义一个版本号每次打开时版本号加一关闭时版本号不变。异步回调回来时先比对版本号版本不匹配就说明该面板已经进入新生命周期直接丢弃回调结果。private int _version; public void Open() { _version; LoadAssetAsync(panelId, OnLoaded); } private void OnLoaded(Object asset) { if (!IsVersionValid()) return; // 正常流程 }这个方案同样适用于网络请求回调。网络请求发出去之后请求返回前模块可能已经被销毁或重置了版本号校验能有效避免对旧状态的错误操作。4.3 多人协作时框架被“绕过去”框架建好之后最大的敌人不是技术而是团队成员不遵守约定。总有同事为了方便在业务代码里直接写FindObjectOfType去找某个模块或者直接new一个对象而不走对象池。我在项目里做了一个辅助检查工具用编辑器扩展扫描代码发现非法引用的情况就报警告。另外我强烈建议团队里的Code Review一定要把关这一点。框架的意义在于纪律没有纪律的框架只是摆设。4.4 问题速查表我把自己平时遇到的高频问题整理成一张表方便查阅现象可能原因排查手段解决方案UI点击无响应层级顺序被遮挡检查面板挂载层级和遮罩对象调整UILayer或者设置Panel的RaycastTarget内存持续上涨事件泄漏、缓存无上限Memory Profiler对比堆快照反注册监听限制缓存数量场景切换卡顿Instantiate高频创建对象Profiler查看Instantiate耗时使用对象池场景切换前预热异步回调操作空对象生命周期错位版本号校验缺失加入版本号校验机制事件回调报错导致连锁崩溃单个监听异常未隔离查看异常堆栈定位模块事件分发try-catch隔离异常回调移出列表5. 一些后续扩展与个人经验总结这套Frame架构目前已经支撑了我手头项目的多个版本迭代稳定性比我之前无框架状态提升了不止一个量级。后面我还在计划扩展的方向是代码热更新方案的接入框架本身预留了模块化接口热更只需要替换对应模块的加载逻辑不影响其他模块。最后说点实际的体会。框架设计最难的不是写代码而是做减法。每次有人提议往框架里加功能我都会先问一句这个功能能在框架外面实现吗如果可以就坚决不加。框架保持小而精维护成本才会低团队的接受度也才会高。我见过太多人一开始把框架设计得无比宏大结果项目还没上线框架本身的Bug比业务Bug还多最后不得不推倒重来。如果你也要搭自己的Unity3d Frame架构我的建议是从最小可用的版本开始跑起来在真实项目里发现问题再针对性地迭代。不要一开始就想做一个面面俱到的巨型框架那样大概率会死在半路上。这套Frame说明写到这里核心的设计理念、模块划分、落地实现和踩坑经验都讲透了。有具体模块想深入聊的欢迎评论区交流。
阅读完成 · 觉得有帮助?