[Unity设计模式与游戏开发]原型模式

前言

原型模式谈的最多的就是克隆,谈到克隆我们就会想到第一个克隆羊多利,是我们生物工程史上的一次重大突破。克隆又称作拷贝,记得在做iOS开发的时候,刚接触OC开发谈的比较多一个知识点就是深拷贝和浅拷贝,浅拷贝只是拷贝了变量的内存地址,深拷贝拷贝了变量的内容。提到克隆我们在Unity开发中最常见的API就是 GameObject.Instantiate(),看他们的注释,Clones the object original and returns the clone,参数就是我们给定的Object然后克隆返回这个对象。为什么要有这个克隆方法呢?假设没有这个克隆方法,也就是说不用原型模式,我们想要实例化10个甚至100个这样的对象,我们是不是都要重复这些操作,实例化模型的顶点,网格,材质等等,创建实例化一个模型是非常费资源的操作,没实例化一个我们就需要从0开始,如果提供克隆方法我们就直接将这段内存copy一份然后返回即可,就方便快捷了许多。在这里抛出一个问题,思考一下Unity的GameObject.Instantiate()是如何实现的?虽然我们看不到C++实现的源代码,我们可以先猜测一下,等到学完本章节,你能猜到答案嘛?

原型模式存再必要性

在谈基本介绍之前,先解释一下为什么要存在这样一种设计模式,也就是这个设计模式解决了什么问题。我们在软件开发或者游戏开发中,会存在这样一种情况,我们需要用到多个相同的实例,最简单的办法就是通过多次New来创建多个相同的实例,但是这样存在问题,首先代码上会有很多行相同或者类似的代码,这个是程序员最看不得的,其次是上面提到的创建实例比较耗费资源,创建起来步骤比较繁琐,如果创建大量相同的实例,都是在重复繁琐的创建过程。原型模式就因此诞生,原型模式就是通过现有的实例来实现克隆完成新对象的创建。

原型模式模式基本介绍

  • 原型模式是指:用原型实例指定创建对象的种类,并且通过拷贝这些原则,创建新的对象。
  • 原型模式是一种创建型设计模式,允许一个对象再创建另外一个可定制的对象,无需知道如何创建的细节。
  • 工作原理是:通过将一个原型对象传给那个要创建的对象,这个要创建的对象通过请求原型对象拷贝它们自己来实施创建对象,即对象Clone()。
  • 形象理解:孙大圣拔出猴毛,变出好多其他"大圣"。
    在这里插入图片描述

浅拷贝基本介绍

  • 对象语句类型是基本数据类型的成员变量,浅拷贝会直接进行值传递,也就是将该属性值直接复制一份给新对象。
  • 对于数据类型是引用类型的成员变量,那么浅拷贝会进行引用传递,也就是只会讲改成员变量的引用值(内存地址)复制一份给新的对象,实际上两个对象的该成员变量都指向同一个实例,在这种情况下修改一个对象的变量会影响到另一个对象的该成员变量的值。

深拷贝基本介绍

  • 赋值对虾干的所有基本数据类型的成员变量值
  • 为所有引用数据类型的成员白能量申请存储空间,并赋值每个引用数据类型成员变量所引用的对象,也就是说对象进行深拷贝要对整个对象进行拷贝。
  • 深拷贝的两种实现方式,1.重写clone方法实现深拷贝,2.通过对象序列化和反序列化实现深拷贝(推荐)

最常见的深拷贝写法

如果让我们写一个深拷贝,我们可能信手捏来,下面这种写法也是我们大多数人最常用的写法。

public class Character
{
    public int Id { get; set; }
    public string Name { get; set; }

    public bool IsAvatar { get; set; }

    public Character DeepClone()
    {
        Character character = new Character();
        character.Id = this.Id;
        character.Name = this.Name;
        character.IsAvatar = this.IsAvatar;
        return character;
    }
}

这种写法比较好理解,但是也有一个很明显的弊端,如果某个类字段特别多,夸张的成百上千,那我们难不成就写成百上千行,如果"勤快"的初级程序还真很有可能写那么多字段赋值,如果稍微高级一点的程序员或许会用反射的方式来字段赋值,针对这种字段比较多的类如何更好的实现深拷贝,请看下面的实现方式,也是推按的实现方式。

.NetFramework中用到的原型模式

在.net framework中,童工了ICloneable接口来对对象进行克隆。当然你也可以不去实现ICloneable接口自己直接定义一个Clone()方法,下面我们就来尝试使用一下这个接口来实现深浅拷贝的例子。

使用ICloneable接口实现克隆

UML设计图

在这里插入图片描述

代码
[Serializable]
public class Job
{
    public int Id { get; set; }
    public string JobName { get; set; }
    public override string ToString()
    {
        return this.JobName;
    }
}

[Serializable]
public class Person : ICloneable
{
    public int Age { get; set; }    //值类型字段
    public string Name { get; set; }    //字符串
    public Job Job { get; set; }        //引用类型字段
    //深拷贝
    public Person DeepClone()
    {
        using (Stream objectStream = new MemoryStream())
        {
            IFormatter formatter = new BinaryFormatter();
            formatter.Serialize(objectStream, this);
            objectStream.Seek(0, SeekOrigin.Begin);
            return formatter.Deserialize(objectStream) as Person;
        }
    }

    public object Clone()
    {
        return this.MemberwiseClone();//淺拷贝
    }

    //浅拷贝
    public Person ShallowClone()
    {
        return this.Clone() as Person;
    }
}

测试

Person p = new Person() { Name = "P", Age = 21, Job = new Job() { JobName = "Coder", Id = 1001 } };
Person p1 = p.ShallowClone();
Person p2 = p.DeepClone();
string Str = string.Format("修改前:p.Name={0},p.Age={1},p.Job.Id={2},p.Job.JobName={3}", p.Name, p.Age, p.Job.Id, p.Job.JobName);
Debug.Log(Str);
Str = string.Format("修改前:p1.Name={0},p1.Age={1},p.Job.Id={2},p1.Job.JobName={3}", p1.Name, p1.Age, p1.Job.Id, p1.Job.JobName);
Debug.Log(Str);
Str = string.Format("修改前:p2.Name={0},p2.Age={1},p.Job.Id={2},p2.Job.JobName={3}", p2.Name, p2.Age, p2.Job.Id, p2.Job.JobName);
Debug.Log(Str);

//修改P1的值
p1.Name = "PM";
p1.Age = 30;
p1.Job.JobName = "Manager";
p1.Job.Id = 1002;

Str = string.Format("修改后:p.Name={0},p.Age={1},p.Job.Id={2},p.Job.JobName={3}", p.Name, p.Age, p.Job.Id,p.Job.JobName);
Debug.Log(Str);
Str = string.Format("修改后:p1.Name={0},p1.Age={1},p1.Job.Id={2},p1.Job.JobName={3}", p1.Name, p1.Age, p1.Job.Id, p1.Job.JobName);
Debug.Log(Str);
Str = string.Format("修改后:p2.Name={0},p2.Age={1},p2.Job.Id={2},p2.Job.JobName={3}", p2.Name, p2.Age, p2.Job.Id, p2.Job.JobName);
Debug.Log(Str);

运行效果图:

在这里插入图片描述

代码优化

如果我们项目中有好多需要实现拷贝的Model,那我们是不是每个自定义Model里面都要继承一下ICloneable接口实现一下浅拷贝,再实现一下深拷贝,那多麻烦,程序员最重要的一项技能就是优化代码的能力,只要看到有重复性的代码,那我们就要想办法去抽象和优化,因此就抽象成一个通用的克隆基类,在想要有克隆功能的Model只要继承这个即可,代码如下:

//通用深拷贝基类
[Serializable]
public class BaseClone<T> : ICloneable where T : new()
{
    //浅拷贝
    public virtual T ShallowClone()
    {
        return (T)this.Clone();
    }

    //深拷贝
    public virtual T DeepClone()
    {
        try
        {
            using (Stream memoryStream = new MemoryStream())
            {
                BinaryFormatter formatter = new BinaryFormatter();
                formatter.Serialize(memoryStream, this);
                memoryStream.Position = 0;
                return (T)formatter.Deserialize(memoryStream);
            }
        }
        catch (Exception ex)
        {
            Debug.LogError("克隆异常:" + ex.ToString());
        }
        return default(T);
    }

    public object Clone()
    {
        return this.MemberwiseClone();//淺拷贝
    }
}

使用

[Serializable]
public class Person1 : BaseClone<Person1>
{
    public int Age { get; set; }
    public string Name { get; set; }
    public Job Job { get; set; }
}

代码测试还是跟上面一样,具体测试代码可以见给出的案例工程https://github.com/dingxiaowei/UnityDesignPatterns。

结论

从上面论证的结果来看,浅拷贝之后的对象跟原来的对象并不是一个对象,但浅表副本复制了原对象的值类型和string类型,但是非string类型的引用类型是复制了引用,也可以理解为复制了数据的地址指针。

这里有一个特别要注意的一个"坑",也是面试官比较喜欢用来考察面试者的一个点,就是在我们通常理解中,引用类型的浅拷贝在修改拷贝之后的对象的值的时候,是会引起原来初始对象的值的,因为引用类型的浅拷贝只是拷贝了一份引用类型的值的地址指针,但这里要注意字符串类型是一个特例。字符串类型是一个不可修改的引用类型,也就是说string虽然是引用类型,但浅表副本却复制了这个值,把它当值类型一样处理了。

关于MemberwiseCLone()方法,可以看MSDN上详细的文档说明,它上面注释是这样写的:MemberwiseClone 方法创建一个浅表副本,方法是创建一个新的对象,然后将当前对象的非静态字段复制到新的对象。 如果字段是值类型,则执行字段的逐位副本。 如果字段是引用类型,则会复制引用,但不会复制引用的对象;因此,原始对象及其复本引用相同的对象。

无论是浅拷贝还是深拷贝,C#都将源对象中所有字段复制到新的对象中。不过,对于值类型字段,引用类型字段以及字符串类型字段的处理,两种拷贝方式存在一定的区别,具体看下面的表:

字段拷贝类型拷贝操作详情副本或源对象中修改是否相互影响
值类型浅拷贝字段值被拷贝至副本中
深拷贝字段被重新创建并赋值
引用类型浅拷贝字段引用被拷贝至副本中
深拷贝字段被重新创建并赋值
字符串浅拷贝字段被重新创建并赋值
(看成值类型即可)
深拷贝字段被重新创建并赋值

解释GameObject.Instantiate原理

回到我们一开始抛出的问题,关于Unity中如何快速实例化Object的,我们应该知道原理了吧,就是利用深拷贝,
在这里插入图片描述
关键在于这个CloneObject的拷贝函数,我们找到这个CloneObject的实现
在这里插入图片描述
追本溯源,找到克隆GameObject的地方
在这里插入图片描述
想要深入学习源码,可以自己网上找相关资料,声明仅限于学习使用!
如果对C++指针不太熟悉的,可以看下图回顾一下,也可以看链接&和*的区别
在这里插入图片描述

游戏开发中哪儿用到原型模式

谈了这么多原理和原型模式的介绍,那么我们在游戏开发中哪儿要用到原型模式呢?面试官最喜欢问题的问题不单纯是让你介绍一下原型模式,还要结合具体项目来介绍,那时候就会感觉突然蒙了,在游戏开发中,最常用于属性、角色和对象克隆,例如塔防和rpg或者设计类游戏中的小怪都是一样的属性就很适合用原型模式来创建。下面我们就拿实际商业开发项目举例

需求

在回合制RPG中,创建的角色会有各种各样的属性,最简单的比如血量、物理攻击、魔法攻击、行动速度等等,但不同的模型角色都有不同的模板属性,这些属性需要我们配置,我们可以配置在Excel中,然后导成文本数据供程序实例化使用,也有的项目是通过Unity给我们提供的ScriptableObject文件来配置角色的初始属性值,如下图:
在这里插入图片描述
关于ScriptableObject不过多介绍,就是unity给我们提供的一种Unity能识别的asset格式的文件,如何自定义这样的文件,看下面的简单代码,只需要继承ScriptableObject类即可,然后就可以鼠标右击创建这样的asset配置文件,上面是实际项目中属性特别多比较复杂,我们用个简单的案例来演示一下,代码如下:

using System;
using UnityEngine;

[System.Serializable]
public class Attributes : ICloneable
{
    [Tooltip("角色血量")]
    public int hp;
    [Tooltip("物理攻击")]
    public int pAtk;
    [Tooltip("物理防御")]
    public int pDef;
    [Tooltip("魔法攻击")]
    public int mAtk;
    [Tooltip("魔法防御")]
    public int mDef;
    [Tooltip("行动速度")]
    public int spd;

    public object Clone()
    {
        return this.MemberwiseClone();
    }

    public Person ShallowClone()
    {
        return this.Clone() as Person;
    }

    public Attributes DeepClone()
    {
        Attributes result = new Attributes();
        result.hp = hp;
        result.pAtk = pAtk;
        result.pDef = pDef;
        result.mAtk = mAtk;
        result.mDef = mDef;
        result.spd = spd;
        return result;
    }
}

[CreateAssetMenu(fileName = "CharacterItem", menuName = "(ScriptableObject)CharacterItem")]
public class CharacterItem : ScriptableObject
{
    public string Name;
    public string Desc;
    [Tooltip("角色属性")]
    public Attributes Attributes;
}

鼠标右击创建asset配置文件
在这里插入图片描述
创建一个火影主人公漩涡鸣人的配置
在这里插入图片描述

备注:由于这里字段比较少,所以这里深拷贝就用了最常见的变量赋值的方式,那么我们有了模型属性数据,怎么根据这个配置数据来实例化两个"漩涡鸣人"游戏对象呢,我们不能游戏中一个对象我们就要对应一个asset配置文件吧,如果两个对象一模一样,只需要用一个asset文件即可,但我们需要copy两份这样的属性数据,所以在写了内置的Clone方法来实现,这也是原型模式在游戏开发中的典型应用。

根据数据模板创建不同的角色对象
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CharacterEntity
{
    public int id;
    public Attributes objectAttributes;
}

public class PropertyPatternExample2 : MonoBehaviour
{
    public CharacterItem CharDataModel;
    void Start()
    {
        CharacterEntity char1 = new CharacterEntity() { id = 0, objectAttributes = CharDataModel.Attributes.DeepClone() };
        CharacterEntity char2 = new CharacterEntity() { id = 1, objectAttributes = CharDataModel.Attributes.DeepClone() };
        Debug.Log("修改之前两个角色的属性:");
        Debug.Log("char1 property:" + char1.objectAttributes.ToString());
        Debug.Log("char2 property:" + char2.objectAttributes.ToString());

        Debug.Log("修改之后两个角色的属性");
        char1.objectAttributes.hp = 110;
        Debug.Log("char1 property:" + char1.objectAttributes.ToString());
        Debug.Log("char2 property:" + char2.objectAttributes.ToString());
    }
}

在这里插入图片描述
上面结果会发现我们根据一个数据模板实例化了两个不同的角色对象,每个对象的属性都是相互独立的,互不影响。

原型模式注意事项和小结

  • 创建新的对象比较复杂时,可以利用原型模式简化对象的创建过程,同时也能够提高效率。
  • 不用重复初始化对象,而是动态获得对象运行时的状态。
  • 缺点:需要每个类适配一个克隆方法,一般获取浅拷贝就是用MemberwishClone()方法来获取,这对全新的类来说不是很难,但对已有类进行改造时,需要修改其源码,违背了OCP原则,这点需要注意。

设计模式系列教程汇总

http://dingxiaowei.cn/tags/设计模式/

教程代码下载

https://github.com/dingxiaowei/UnityDesignPatterns

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页