聊框架设计之前,先亮明我的身份:一个在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是事件ID,value是回调函数的委托列表。发布消息时遍历字典,找到对应的回调列表,逐个调用。为了防止一个模块回调异常导致整个分发出问题,我在调用每个委托时都用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 Dictionary<int, List<Delegate>> _listeners = new(); public void AddListener(int eventId, Action<object[]> callback) { if (!_listeners.TryGetValue(eventId, out var list)) { list = new List<Delegate>(); _listeners[eventId] = list; } list.Add(callback); } public void RemoveListener(int eventId, Action<object[]> 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 { ((Action<object[]>)handler)?.Invoke(args); } catch (Exception e) { Debug.LogError($"事件分发异常: {eventId} {e}"); RemoveListener(eventId, (Action<object[]>)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 Dictionary<string, UIPanel> _cache = new(); private readonly Dictionary<UILayer, 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.GetComponent<UIPanel>(); 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说明写到这里,核心的设计理念、模块划分、落地实现和踩坑经验都讲透了。有具体模块想深入聊的,欢迎评论区交流。