ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity原型模式实战:深拷贝、组件复制与对象池配合指南

Unity原型模式实战:深拷贝、组件复制与对象池配合指南 如果让我给 23 种创建型设计模式按“被低估程度”排个名原型模式一定进前三。大多数 Unity 开发者嘴上说着设计模式手上写创建对象时却只有两条路要么直接new要么Instantiate。这两种方法在小项目里确实够用但一旦对象种类变多、运行时状态需要被复制、初始化流程又臭又长你就会发现创建对象这个动作本身已经成为维护成本的一部分。这篇文章我打算围绕原型模式在 Unity 里怎么落地把接口定义、深拷贝、组件复制、对象池配合这些事一次性聊透顺便把我踩过的几个坑也摆在前面。1. 原型模式在解什么问题复制对象不是 new 完再赋值那么简单1.1 先说一个 Buff 系统把我逼到原型模式面前的场景有一次我做一个技能系统Buff 类型非常多每种 Buff 除了基类字段之外还有自己的一套初始化逻辑。最早我图省事把所有 Buff 的创建都塞到一个工厂类里用switch按类型分发。结果就是新增一个 Buff要动工厂、要加分支、要小心别影响旧的逻辑。后来一个同事提议不如让每种 Buff 自己实现一个Clone创建新 Buff 时直接从一个已经配置好的实例复制。我试了一下改动量比想象中小很多每个 Buff 只需要把字段复制逻辑写对调用方手持一个原型实例就能源源不断生成副本。新增类型时不再碰工厂只新增一个类并实现克隆方法就行。这个场景其实就是原型模式的教科书式收益把“创建流程”从调用方手里解耦调用方不关心具体类怎么构造只关心“给我一个和这个原型一样的对象”。1.2 原型模式的标准结构和它到底“指定”了什么GoF 对原型模式的定义是用原型实例指定创建对象的种类并且通过拷贝这些原型创建新的对象。注意“指定种类”这四个字它意味着决定对象类型的不是new关键字不是工厂里的分支而是一个已经存在的具体实例。标准结构里会有三样东西Prototype接口、ConcretePrototype具体实现、Client调用方。Client手里持有一个或多个原型需要新对象时调用Clone()而不是自己去写构造逻辑。在 C# 里最原始的做法是实现ICloneable但我不推荐直接用因为它返回object每次都要强转而且它没有约束返回类型和原类型一致很容易造出隐蔽的类型转换异常。Unity 项目里我更倾向于自定义一个泛型接口让Clone()的返回值直接就是具体类型。1.3 原型模式和直接 new、工厂模式的边界到底在哪儿很多人会把原型模式和工厂模式搞混因为两者都是为了把创建逻辑集中起来但它们的核心差异在于“创建依据”。直接new的依据是类型工厂模式的依据是一组构建参数原型模式的依据是一个现有实例。你可以把这个差异理解成做蛋糕直接new是照着菜谱从零开始烤工厂模式是提供一个甜品站你按订单选择不同配方统一出餐原型模式则是你已经有一个做好的蛋糕直接翻模复制一个再根据口味微调。这样看就清楚了。创建方式优点缺点适合场景new直接构造简单直接调用方绑定具体类型构造流程无法复用对象结构简单且稳定工厂模式创建逻辑集中便于统一处理参数类型增多时分支和类数量膨胀类型多但有统一创建流程原型模式按已有实例复制保留运行时状态深拷贝逻辑需要仔细设计创建成本高、需要复制状态、种类经常扩展在 Unity 里还要加一个特殊角色Instantiate。它本质上就是引擎层的“预制体原型复制”但它只复制 GameObject 层级和组件数据不会自动处理脚本里那些引用类型字段的深拷贝。所以更准确的说法是Unity 给了我们一个天然的原型复制底座而设计模式里的原型模式是在这个底座之上补上“业务状态如何复制”的规则。2. C# 克隆机制与 Unity 对象模型之间的那道坎2.1 MemberwiseClone 的浅拷贝陷阱List 共享是最常见的翻车点C# 里最省事的浅拷贝是MemberwiseClone()这是Object的受保护方法你可以在类内部调用。它的行为非常直接把对象里的所有字段按位复制一份。值类型字段没问题但引用类型字段复制的是“引用地址”也就是说复制品和原对象指向同一个底层对象。这在水面下埋着雷。public class Buff { public string name; public ListBuffModule modules new ListBuffModule(); public Buff ShallowCopy() { return (Buff)MemberwiseClone(); } }上面这个ShallowCopy看起来没什么问题但你拿到copy之后copy.modules和原始的buff.modules指向的是同一个List。一旦你在副本里Add一个模块原对象的modules也会跟着多一项。更隐蔽的是如果模块里的参数被修改两边同时变化这种问题往往要等跑到特定逻辑时才发现排查起来非常痛苦。所以只要对象里存在引用类型字段浅拷贝基本都不够用。2.2 深拷贝方案怎么选手写、JsonUtility、Newtonsoft.Json深拷贝没有银弹每种方案都有自己的使用边界。第一种是手写复制。这是我最推荐的方式因为只有你自己清楚哪些字段需要独立、哪些字段可以共享。比如配置数据这类只读内容完全没有必要深拷贝而运行时 Buff 列表、状态机、事件委托这些大概率需要独立。手写的方式虽然看起来笨但它准确、性能好并且当字段变化时编译器会逼着你同步维护复制逻辑。第二种方案是用JsonUtility.ToJson再FromJson好处是能处理 Unity 可序列化类型和预制体上的序列化字段坏处是 Dictionary、接口字段、属性这些它一律不认。如果你只是复制一个配置类它很合适但复制运行时业务对象时非序列化字段会悄悄丢数据。第三种是 Newtonsoft.Json功能强也能通过 Converter 处理 Unity 的 Vector3、Quaternion 等类型但对循环引用和性能要额外注意。其实无论选哪一种都要记住一个原则深拷贝不是把所有字段无脑拷贝一遍而是“可变状态深拷贝不可变数据共享”。如果什么都复制内存会翻倍性能也受影响但收益并没有增加。2.3 为什么 MonoBehaviour 不能直接走 C# 拷贝新手最容易踩的坑是觉得在 MonoBehaviour 里实现MemberwiseClone或者调用Activator.CreateInstance就能复制出一个活的组件。实际上MonoBehaviour 必须挂在 GameObject 上才能被 Unity 引擎识别你用普通 C# 方式创建的托管对象只是内存里的一堆字段场景里根本没有这个实体Update、协程、碰撞回调全都不会执行。正确做法是先Instantiate(gameObject)再GetComponentT()拿到复制后的组件然后手动复制业务字段。Instantiate已经完成了 GameObject 层级的组件复制你只需要在这个基础上处理脚本内部的动态数据。这个认知如果没建立后面所有的 Clone 方法都会写歪。3. 在 Unity 中把原型模式落地接口、组件克隆和运行状态复制3.1 设计一个适合 MonoBehaviour 的原型抽象类我建议为 Unity 项目单独定义一套原型接口而不是直接用ICloneable。泛型返回最大的好处是调用方拿到的就是具体类型不需要强转。public interface IPrototypeT where T : class { T Clone(); } public abstract class PrototypeBehaviourT : MonoBehaviour, IPrototypeT where T : PrototypeBehaviourT { public abstract T Clone(); }这个抽象类做了一件事让每个需要被复制的组件自带一个Clone入口。为什么要用泛型自引用where T : PrototypeBehaviourT因为子类实现Clone时返回类型可以直接写成自己不需要在子类里写一堆与业务无关的类型转换。比如Enemy继承PrototypeBehaviourEnemy后Clone()的返回值天然就是Enemy。3.2 带状态复制的 Enemy 克隆示例接下来看一个完整的例子。假设敌人身上有基础数值还带一个 Buff 列表这个列表是运行时动态变化的克隆敌人时必须把 Buff 也复制一份否则两个敌人会共享同一套 Buff 状态。public class Enemy : PrototypeBehaviourEnemy { public float maxHealth; public float moveSpeed; public ListBuff buffs new ListBuff(); public override Enemy Clone() { var go Instantiate(gameObject); var copy go.GetComponentEnemy(); copy.maxHealth maxHealth; copy.moveSpeed moveSpeed; copy.buffs new ListBuff(buffs.Count); foreach (var buff in buffs) { copy.buffs.Add(buff.Clone()); } return copy; } }这里有一个很容易被忽略的细节Instantiate(gameObject)会复制 Transform 下的所有子物体和组件也会复制已经序列化的字段值。比如maxHealth在预制体里是 100实例化后默认就是 100所以你其实不一定要再赋值一遍。真正需要手动处理的是那些运行时才产生、或者会被多个实例共享的引用类型字段。buffs就是一个典型例子Unity 不会知道你要让副本拥有独立的 List它只会把原来的 List 引用原样复制过去。3.3 使用场景实例还是预制体作为原型落地原型模式时你还要确定原型从哪来。第一种是拿场景中一个活的敌人作为原型比如 Boss 在血量低于 30% 时分裂出小怪这时候小怪应该继承 Boss 当前的血量、移动速度、Buff 状态直接Instantiate(gameObject)就能保留这些运行期状态这很符合“分裂”的直觉。第二种是拿预制体作为原型适合敌人刷怪点这类场景每次克隆都从预制体模板出发保证初始状态稳定。我在项目里会把这两种方式分开封装。如果 Clone 方法默认使用预制体那在原型实例上调用时可能不符合预期。更推荐的做法是让 Clone 方法内部自己决定用哪个模板例如在 Enemy 中维护一个预制体引用Clone 时Instantiate(_prefab)而不是Instantiate(gameObject)。这样原型实例只是“配置的持有者”它本身可以不出现在场景里。而如果你需要场景实例的运行时状态就直接写一个CloneFromCurrentState方法内部用Instantiate(gameObject)。两种方式不冲突反而能覆盖大部分需求。4. 我在实战中反复踩过的坑克隆之后才是麻烦的开始4.1 事件订阅和协程不会自动断干净第一次把原型模式用到项目里时我天真地以为Instantiate复制出来的对象会像预制体拷贝一样干净。结果很快就发现一个问题原对象在运行时通过代码注册的事件比如EventBus.OnGameStart HandleStart在副本上并不存在因为事件订阅关系不是可序列化字段Instantiate 不会把它带到新物体上。反过来如果你在 Clone 方法里用浅拷贝直接复制一个委托字段副本就会和原对象共享同一个委托列表导致原对象触发事件时副本也响应最终执行两次。我遇到过最实际的情况是 Boss 召唤分身分身引用了一个OnDie事件字段本尊死亡时分身也跟着触发死亡掉落逻辑。因为浅拷贝把委托对象直接赋给了分身两个对象的死亡事件实际指向同一批处理方法。修复方案是在 Clone 时有意跳过事件字段或者把事件列表初始化为空再根据副本的需求重新订阅。写 Clone 的时候一定要先问自己这个字段是数据还是行为引用行为引用通常不该被复制。4.2 组件缓存引用指向原物体Unity 的Instantiate会复制组件但组件之间在运行时建立的引用关系不会自动更新。比如脚本里有这样一个缓存字段private Animator _animator; void Awake() { _animator GetComponentAnimator(); }如果这个脚本是运行时通过代码动态赋值的实例化副本后_animator还会指向原来那个物体的 Animator而不是副本自己的。更麻烦的是如果你在克隆后直接操作副本的_animator你会发现动画状态变化全部作用在原物体上场景里出现“分身动一下本尊也跟着动”的灵异现象。最稳妥的方式是在 Clone 方法末尾重新绑定引用copy._animator copy.GetComponentAnimator(); copy._target null;所有和当前 GameObject 相关的组件缓存都必须重取和外部对象的引用也要按业务需求重新赋值。这个步骤如果在 Clone 里写全了后面能省掉很多疑难杂症。4.3 对象池和原型模式怎么配合才不翻车很多人把对象池和原型模式当成二选一其实它们是两个维度的东西原型模式解决“如何创建一个符合初始状态的副本”对象池解决“如何减少创建和销毁的损耗”。两者天然互补。我的做法是池子里持有几个原型实例需要时先从池里取池子为空才调用原型克隆。归还时不是销毁而是重置状态后压回栈里。public class EnemyPool { private readonly Enemy _prototype; private readonly StackEnemy _pool new StackEnemy(); public EnemyPool(Enemy prototype) { _prototype prototype; } public Enemy Get() { Enemy enemy _pool.Count 0 ? _pool.Pop() : _prototype.Clone(); enemy.gameObject.SetActive(true); enemy.ResetForSpawn(); return enemy; } public void Return(Enemy enemy) { enemy.gameObject.SetActive(false); _pool.Push(enemy); } }这里要特别强调ResetForSpawn。不要指望 Clone 做所有事Clone 负责“复制出一个新对象”ResetForSpawn 负责“让对象回到可复用的初始状态”。池子里的对象被多次使用如果没有合理的重置机制上一次残留的 Buff、事件、目标引用都会污染下一次使用。这两个方法拆开职责才清晰。4.4 什么时候不应该用原型模式原型模式不是万金油。如果项目里只有两三种敌人创建逻辑就是设置几个数值那直接Instantiate比引入接口和抽象类简单得多硬套模式只会增加阅读成本。还有一些对象本身就是场景里的唯一存在比如玩家主角、全局管理器这些对象不应该被复制也不需要原型模式。设计模式的价值是匹配问题的复杂度不是为了让代码看起来“高级”。我见过不少人为了申优到处抽象最后代码里接口套接口真改起来效率极低。原型模式最值得用的场景就三个对象种类多且经常扩展、创建成本高、需要保留运行时状态。不符合其中至少两条就要慎重。5. 抄作业一个很薄的原型注册表外加 ScriptableObject 模板5.1 注册表接口与实现当项目里原型多起来以后你会发现“拿到一个合适的原型”本身也变成了一个问题。我的解法是做一个很薄的注册表用字符串作为 Key把各种原型实例统一管理起来。业务方只需要知道 Key不依赖具体类型。public class PrototypeRegistry { private readonly Dictionarystring, object _prototypes new Dictionarystring, object(); public void RegisterT(string key, IPrototypeT prototype) where T : class { _prototypes[key] prototype; } public T CreateT(string key) where T : class { if (_prototypes.TryGetValue(key, out var proto)) { return ((IPrototypeT)proto).Clone(); } throw new KeyNotFoundException($Prototype: {key} not registered); } }这个实现的优点就是薄没有任何业务逻辑只负责注册和创建。你可以在游戏启动时从配置表注册所有原型也可以在运行时根据玩家进度动态注册新的原型。业务方再也不需要到处引用具体敌人类型只要传一个字符串或者枚举就能拿到一个符合要求的副本。5.2 ScriptableObject 作为配置模板时的注意事项我习惯把原型需要的配置参数放到 ScriptableObject 里这样策划可以在 Inspector 里直接调数值不用改代码。[CreateAssetMenu(fileName EnemyPrototype, menuName Game/Enemy Prototype)] public class EnemyPrototypeConfig : ScriptableObject { public Enemy prefab; public float baseHealth; public float moveSpeed; }使用时把配置里的预制体注册进注册表生成敌人时通过注册表创建。这里有一个坑ScriptableObject本身是 Asset如果你对它调用Instantiate得到的是一个运行时内存副本这个副本不会自动保存到磁盘但在内存里它是独立的。大多数情况下你不需要复制整个配置反而应该让多个敌人共享同一个只读的 ScriptableObject 引用这样能节省内存。需要复制时一定是想改某些参数那不如创建一个新的配置实例只覆盖要改的字段。所以对 ScriptableObject 的“深拷贝”要谨慎别把整个资源复制出一堆重复数据。5.3 运行时使用注册表的小结实际使用起来就像下面这样PrototypeRegistry registry new PrototypeRegistry(); EnemyPrototypeConfig normalEnemyConfig Resources.LoadEnemyPrototypeConfig(NormalEnemy); registry.Register(NormalEnemy, normalEnemyConfig.prefab); Enemy enemy registry.CreateEnemy(NormalEnemy);我个人的经验是注册表不要做得太重它只是把“满天飞的 Instantiate”收拢到一个入口。配合对象池以后新增怪物种类时只需要在初始化阶段注册一个新原型生成器代码完全不用动。这个收益在我维护一个几十种怪物的项目时体现得很明显后续扩展成本几乎全部集中在新怪物自己的 Clone 方法里。第一次用原型模式的时候我差点被浅拷贝和组件缓存这两个问题劝退但把这些问题理清楚之后它反而成了我在创建型模式里最喜欢的一个。每个组件通过 Clone 方法明确表达“我复制哪些状态、共享哪些数据”这个约束本身就是对代码边界的一种梳理。如果你也在 Unity 项目里被各种对象的复制逻辑折磨过不妨从今天这个接口定义开始挑一个需要复制的业务对象试一下把深拷贝和事件处理都写明白之后你会回来感谢自己的。
返回列表