【Unity】ScriptableObject实践入门-1

前言

(本段与正文无关,可跳过)这两天写一个编辑器,大量利用了ScriptableObject,越用越觉得一言难尽。即使已经有前人踩坑,实际使用时仍有不少意外,非常耽误开发时间。趁此机会,写一文谈谈我的理解和感受。

入门

关于ScriptableObject(以下简称SO)的正规介绍到处都有,这里就不再赘述了。我的理解是,SO是Unity帮忙处理一些繁杂逻辑(如序列化),在此之上允许开发者自己定义资源数据结构的一套规则。简言之,能让开发者快速创建自己需要的资源格式,在功能上看把数据写在脚本/文本/excel/SO里是平级的。只不过SO可以方便存储更复杂的数据,例如引用、动态逻辑(方法)等不方便存表的信息。

下文将以一种极简单的“卡牌”开发过程为例,说明SO的实际使用过程。需求:一款卡牌对战游戏,其中一张卡牌实质上就是一个角色,有血量和攻击力。游戏中有数十种数据不同的卡牌。

第一步:写一个类继承ScriptableObject,定义它的数据结构。这里的卡牌结构非常简单,包含名称、血量和攻击力。

public class Card:ScriptableObject
{
    //卡牌名称
    public string cardName;
    //卡牌血量
    public int hp;
    //卡牌攻击力
    private int atk;
}

如此便创建好了“Card”这种资源格式。如果说Prefab是用来存储游戏物体层次的资源,Audio Clip是用来存储音频的资源,那“Card”就是我们自定义的,用来存储卡牌信息的资源。

接下来,为了在Unity编辑器里创建“Card”这种资源文件,需要使用CreateAssetMenu标签为Card类标注。

[CreateAssetMenu(fileName ="NewCard",menuName ="Card",order = 1)]
//这里必须用 参数名=值 的形式,因为它实际上不是在调用构造函数,而是在为标签里的属性赋值
public class Card:ScriptableObject
{
//...
}

fileName指的是创建这种资源后,资源实例的默认名称。好比新建C#脚本的默认名是“NewMonobehaviour”,我们设置新建Card的默认名是“NewCard”;menuName指的是创建这种资源的按钮,在创建菜单里叫什么。

图片[1]-ScriptableObject实践入门-1
菜单里的“Card”就是通过CreateAssetMenu标签创建的按钮

点击按钮,创建出一个卡牌的实例。

图片[2]-ScriptableObject实践入门-1
这里的“NewCard”就是刚才创建出来的卡牌资源

在其Inspector窗口,可以看到我们定义的“Card Name”和“Hp”两个字段。

图片[3]-ScriptableObject实践入门-1
窗口里的字段会默认把首字母大写,遇到大写字母会自动分隔。比如”cArdNAme”会显示为”C Ard N Ame”

创建好后,这个“NewCard”就可以和Texture、Prefab之类的资源一样,使用拖拽、同步异步加载等方式被脚本引用了。

序列化

上图中可以发现,Card类里定义的int atk(攻击力)并不在面板上。这是由于Unity安照序列化规则,没有将atk字段进行序列化。

序列化可以简单理解为,把复杂的数据转换成结构清晰有序、易于存储的格式(通常是文本或字节)。比如说有一个用于存储玩家进度的结构体,其中有关卡数、分数、金钱三个int字段。要把它存到文件中,为了区分数据中谁是关卡数,谁是分数谁是金钱,可以考虑这样的格式:“stage:2 score:100 gold:500”。这种繁复工作已经有了现成的方案,比如xml,Json等序列化方式,就可以一键将已有的数据序列化成文本,然后只要把文本存到磁盘里就好了。

反序列化就是解析依照某种规则存储的数据。比如说,我可以用Json库里的ToJson方法把存档序列化成文本,也可以用Json库里的FromJson方法再把文本反序列化为存档。更广泛一点地,我可以在excel里面建一个表格,表头就是不同的卡牌以及每张卡牌的各项数据。那么在程序运行过程中,我new一个卡牌,卡牌的各项数据从excel文件(导出后的格式)中读取,这也近似于一种把表格数据反序列化成卡牌的过程。

在深入SO序列化之前,得先明确一个关于序列化的事实:在Inspector里进行编辑SO,不是在直接编辑资源文件里实际的数据。

Inspector窗口背后大概是这样一个流程:Unity首先把资源数据反序列化到Inspector窗口上,在开发者通过Inspector窗口编辑后,再将编辑过的数据序列化到资源文件里。

要印证这点也很简单,我们将刚才创建的卡牌资源的Inspector窗口里的”hp“设为5,然后打开其实际的资源文件。

图片[4]-ScriptableObject实践入门-1
在面板里将hp设为5
图片[5]-ScriptableObject实践入门-1
NewCard实际的资源文件(Unity将SO序列化为YAML格式文件进行存储)

在文件最后一行可发现hp的值仍然为0,这说明我们的修改只停留在Inspector面板上,还没有实际作用到文件中。而在保存场景或着保存项目后再打开文件,文件中的hp就变为了5。这里就埋下了一个坑,后面再提。

回到开始的问题,Card类里定义的int atk(攻击力)为什么不在面板上?在资源文件(上图)里可以发现,atk这个字段在文件里根本就没有任何信息,这说明在Unity序列化过程中,atk这个字段被忽略了。

那哪些数据会被Unity序列化,哪些数据不会被Unity序列化呢?这里用一张图简单概括之。

图片[6]-ScriptableObject实践入门-1

其中Unity支持类型,包括string,ScriptableObject等,也支持数组、List<>泛型,不支持字典。具体参考脚本序列化 – Unity 手册(注意,Unity的文档不一定完整)。另外,Unity默认只序列化公开字段,对于private和protected等数据需要标记[SerializeField](意为强制序列化私有字段)。

继续卡牌设计,为Card类加入“能否拖拽或聚焦”字段。新建一个类代表一对bool值,这个类中的每条数据能否序列化同样遵循上图的流程。

[CreateAssetMenu(fileName ="NewCard",menuName ="Card",order = 1)]
public class Card:ScriptableObject
{
    //卡牌名称
    public string cardName;
    //卡牌血量
    public int hp;
    //卡牌攻击力
    [SerializeField]
    private int atk;
    //能否拖拽或聚焦
    [SerializeField]
    private BoolPair canDragOrFocus;

}
[Serializable]
public class BoolPair
{
    public bool bool1;
    public bool bool2;
}

关于更进一步的序列化内容,如ISerializationCallbackReceiver(自定义序列化接口)和SerializeReference(按引用序列化),受限于篇幅,这里暂且不展开。

特性

回归到ScriptableObject这个类本身,可以发现有些特殊的地方。比如说它有几个与Monobehaviour相似的回调,Awake、OnDestroy等。另外,和Monobehaviour一样,应避免使用构造函数(下文会测试这一点)。

在实际开发过程中,有的SO结构比较复杂。这些SO,并非所有数据都是在Inspector里面配置的,有些复杂数据需要在代码中完成初始化。乍看起来或许可以在构造函数或着Awake里面直接实现,实际上并没有这么简单。

在构造函数和Awake里加一点代码,观察两者都会在什么时候被调用。

    public Card()
    {
        Debug.Log($"{GetInstanceID()} 构造函数");
        hp = 555;
    }
    private void Awake()
    {
        Debug.Log($"{GetInstanceID()} Awake");
        hp = 888;
    }

然后,分多种情况看看分别会发生什么。

图片[7]-ScriptableObject实践入门-1
创建一个InsanceID-17984的Card。这里有一个-18024的报告,但文件中并不存在ID为-18024的资源,推测是临时文件
图片[8]-ScriptableObject实践入门-1
(接上图)复制-17984资源,粘贴生成的31546资源构造函数和Awake都被执行了两次
图片[9]-ScriptableObject实践入门-1
运行场景,尽管场景中没有任何关于这两个资源的引用,还是各自执行了构造函数

删除31546文件,没有触发任何消息。

图片[10]-ScriptableObject实践入门-1
修改Card的数据结构,调用了构造函数和Awake。其中InstanceID为0的报告推测为中间文件

测试得到:

  • 在编辑器中手动创建的资源hp为555,说明尽管报告显示最后执行的是Awake,实际数据还是构造函数中的;
  • 在编辑器中手动复制粘贴得到的资源,执行构造函数和Awake,hp更改为888;
  • 运行场景时虽然会调用构造函数,但不会更改hp数据;
  • (补充)重新编译时虽然会调用构造函数,但不会更改hp数据;
  • 修改数据结构,执行构造函数和Awake,调用hp变为888;
  • (补充)用CreateInstance代码创建的资源,执行构造函数和Awake,hp为888
  • (补充)用Instantiate代码复制的资源,执行构造函数和Awake,hp为888
  • (补充)在打开项目时,若场景中含有引用,执行Awake,hp为888

SO和Monobehaviour不能使用构造函数的原因一样,就是Unity在某些情况下会反复调用其构造函数(这个过程不受开发者控制)。在排除构造函数之后,可以总结出以下几种调用Awake的情况。

  • 使用CreateInstance或手动创建出的资源会调用
  • 修改了数据结构会调用
  • 使用Instantiate或手动复制粘贴了资源,新生成的资源会调用(在拷贝完数据之后)
  • 重新打开了项目,场景中有SO的引用,会调用

在我的实际案例里,SO中有一个List<节点> nodes。节点的初始化较为复杂,不方便在Inspector里完成。我在Awake的时候赋值nodes=new(){ node1,node2 }……然后node1、node2里面的详细数据在Inspector里配置。

于是,当我Instantiate的时候触发了Awake,新节点的配置数据就丢失了。更难受的是,为了方便运行测试,我的场景里有SO的引用。所以,每次打开项目都会触发Awake丢失配置数据。

到此都还好,理论上很快就能发现问题。真正混淆我的是,当我用Plasticscm将SO数据同步到其他电脑上后,其他电脑上的SO也丢失了数据。我推测同步时新创建的SO也会调用Awake,但由于时间关系没来得及验证。因此以我的视角来看,共有三种情况会触发问题,难以确认丢失数据的真正原因。

知道原因后,我的解决办法也很简单:在SO里加了个Bool变量表示是否为首次创建,然后在Awake里判断,只有首次创建才会初始化。

© 版权声明
THE END
喜欢就点个赞吧
点赞0 分享
评论 共1条

请登录后发表评论

    • Acoloco的头像-水波萌Acoloco等级-LV4-水波萌作者0