【Unity3D】一个简单的基于反射和XML的存储/读取工具

前言

之前在学习Unity和做游戏的时候, 关于存储和读取一直是一件让我头疼的事。

无论用哪种存储方式,XML也好,JSON也好,或者手撸CSV,刚上手都免不了要手动地去写存储代码。(2019版本以前的Unity序列化不支持序列化引用类型)

我之前做一个偏策略类游戏,地图里面每个地区都要存储100以上个字段,而整个地图大概又会有几十个地区,合计数据量上万,而且要存储的数据种类繁多。

将字段Pop存为Xml节点

    public static XmlElement SaveRpop(XmlDocument doc, RegionPop _RPop, string _name = "RPop")
    {
        XmlElement RPop = doc.CreateElement(_name);

        XmlElement Pop = doc.CreateElement("Pop");

        XmlElement Need = doc.CreateElement("Need");
        XmlElement FoodNeed = doc.CreateElement("FoodNeed");
        XmlElement SerdNeed = doc.CreateElement("SerdNeed");
        XmlElement ProdNeed = doc.CreateElement("ProdNeed");

        XmlElement Percent = doc.CreateElement("Percent");
        XmlElement WorPercent = doc.CreateElement("WorPercent");
        XmlElement CulPercent = doc.CreateElement("CulPercent");
        XmlElement MauPercent = doc.CreateElement("MauPercent");
        XmlElement SerPercent = doc.CreateElement("SerPercent");

        XmlElement Cul = SaveRInd(doc, _RPop.Cul, "Cul");
        XmlElement Mau = SaveRInd(doc, _RPop.Mau, "Mau");
        XmlElement Ser = SaveRInd(doc, _RPop.Ser, "Ser");

        Pop.InnerText = _RPop.Pop.ToString();

        FoodNeed.InnerText = _RPop.FoodNeed.ToString();
        SerdNeed.InnerText = _RPop.SerdNeed.ToString();
        ProdNeed.InnerText = _RPop.ProdNeed.ToString();

        WorPercent.InnerText = _RPop.WorPercent.ToString();
        CulPercent.InnerText = _RPop.CulPercent.ToString();
        MauPercent.InnerText = _RPop.MauPercent.ToString();
        SerPercent.InnerText = _RPop.SerPercent.ToString();

        Need.AppendChild(FoodNeed);
        Need.AppendChild(SerdNeed);
        Need.AppendChild(ProdNeed);

        Percent.AppendChild(WorPercent);
        Percent.AppendChild(CulPercent);
        Percent.AppendChild(MauPercent);
        Percent.AppendChild(SerPercent);

        RPop.AppendChild(Pop);
        RPop.AppendChild(Need);
        RPop.AppendChild(Percent);
        RPop.AppendChild(Cul);
        RPop.AppendChild(Mau);
        RPop.AppendChild(Ser);

        return RPop;
    }

读取Xml节点,返回Pop实例

    public static RegionPop LoadRPop(XmlNode node)
    {
        XmlNodeList list = node.ChildNodes;

        long Pop = long.Parse(list[0].InnerText);
        XmlNodeList needList = list[1].ChildNodes;
        float FoodNeed = float.Parse(needList[0].InnerText);
        float SerdNeed = float.Parse(needList[1].InnerText);
        float ProdNeed = float.Parse(needList[2].InnerText);
        RegionPop_Set_Info info = new RegionPop_Set_Info(Pop,FoodNeed,ProdNeed,SerdNeed);

        XmlNodeList percentList = list[2].ChildNodes;
        float WorPercent = float.Parse(percentList[0].InnerText);
        float CulPercent = float.Parse(percentList[1].InnerText);
        float MauPercent = float.Parse(percentList[2].InnerText);
        float SerPercent = float.Parse(percentList[3].InnerText);
        RegionPop_Set_Percent percent = new RegionPop_Set_Percent(WorPercent, CulPercent, MauPercent, SerdNeed);

        RegionCul Cul = LoadInd<RegionCul>(list[3]);
        RegionMau Mau = LoadInd<RegionMau>(list[4]);
        RegionSer Ser = LoadInd<RegionSer>(list[5]);

        RegionPop result = new RegionPop(info, percent, Cul, Mau, Ser);
        return result;
    }

以上代码不过是游戏中整个存读系统中的冰山一角。肉眼可见,手动存储是个繁复的工作,同时意味着整个存储结构被定死。如果要新增/删除一个存储单位,或者是更大范围的改变格式,就得先去查找、改动存储的代码,然后去改对应的读取的代码。

正好,在这次游戏创作比赛中由我负责存档,于是我想到:能不能用反射写个存读工具,自动分析(序列化)并保存我写的存储类,然后还能再读取并复原(反序列化)?

需求

我需要一个工具,能做到:

  • 自动保存类里的值类型字段、属性
  • 对于引用类型,拆分为多个值类型并保存
  • 支持List等简单容器,支持数组/高维数组

原理

给定一个类,通过反射按顺序获取所有字段和属性,在XML文档中为每个字段和属性创建一个节点。创建节点时,若字段、属性是类或者类数组(不将string和数组本身视为类),则将其递归,再次反射并作为根节点创建文档。如此所有类最终都会被解析到其字段、属性全部为值类型或值类型数组,则可转为字符进行保存。

读取时,顺序遍历给定类的所有字段和属性,在XML文档中按同顺序读取。若字段、属性是值类型,则直接进行构造, 若是类,则将其对应节点递归,再次反射以构造该类。

性能

以一个具体场景下的保存用时为例。

给定一个基础类:

    public class ObjectData
    {
        //Animation播放的时间进度
        public float NormalizedTime { get; set; }
        //物体Active状态
        public bool Active { get; set; }
        //物体位置
        public Vector3 position { get; set; }
        public Quaternion rotation { get; set; }
        //刚体的IsKinematic选项
        public bool IsKinematic { get; set; }
        //刚体速度
        public Vector3 velocity { get; set; }
    }

需要保存的类型是Stack<ObjectData>[]

因为无法保证样本测试环境相同,实际保存的ObjectData数量存在误差,因此这里采用生成文件的行数作为横坐标。

图片[1]-[Unity3D][序列化]一个存储/读取工具
图1 存读文件用时随生成文件行数的变化(数据量较少时)

可以参考的是,图1中3762行的生成文件中,保存了10个Stack<ObjectData>,而每个Stack中保存了26个ObjectData,即共260个ObjectData。(ObjectData中的6个属性在保存时实际是6个字段+6个属性,若纯粹以字段进行储存,每行生成文件实际保存约0.82个数据单元,还是看得过去的)

图片[2]-[Unity3D][序列化]一个存储/读取工具
图2 存读文件用时随生成文件行数的变化(数据量较多时)

图2可见,当生成文件行数达到12万时,存读时间分别达到了388ms和574ms,已经是肉眼可见的卡顿了。而再往上更是惨不忍睹,应该没有谁能忍受超过一秒的等待吧(确信)。

图片[3]-[Unity3D][序列化]一个存储/读取工具
图3 存读文件速度随生成文件行数的变化(这里的存储是先后两次存储取平均值,与图1,2不同)

按照图3中曲线折算,单个文件数据单元在7000~530000个是比较能保持效率的。不过考虑到图2中运行时间过长的问题,单个文件的数据单元不建议超过100000个。

总结:因为依赖反射,再加上本人水平有限,此工具的性能并不强大。不过对于数据量不超过几万的小型游戏来说,已经完全足够了。

源码

因为源码过长,不方便直接展示,因此使用文件的形式提供下载。

说明

运行环境:C#版本为8.0及以上(对应Unity版本2020.2及以上)

如出现”Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo.Create”提示,在Unity的Project Settings-Player-Other Settings里将Api Compability Level从.Net Standard 2.1切换成4.0版本或者.Net Framework即可。这是由于.Net 2.1不兼容dynamic导致的。

注意:要保存的类必须具有无参构造函数,且类中不能出现循环引用!(引用自身也不行)

添加代码作为C#文件,引用命名空间SaveANDLoad。

核心方法:使用SaveSingle保存类的实例,使用LoadSingle读取使用SaveSingle保存的给定类型的文件;

辅助方法:使用SaveArray保存类的实例数组,使用LoadArray读取使用SaveArray保存的给定类型的文件。

    //想要保存的类
    public class MyGameData
    {
        public string playerName;
        public int level;
        public Vector3 position;
        public List<int> friendsID;
    }
    //需要保存的实例
    public static MyGameData GetIns()
    {
        MyGameData ins = new MyGameData();
        ins.playerName = "Acoloco";
        ins.level = 3;
        ins.position = new Vector3(11, 45, 14);
        ins.friendsID = new List<int> { 1919, 810 };
        return ins;
    }
    private void Awake()
    {
        //使用SaveSingle保存单个实例
        var data = GetIns();
        Saver.SaveSingle(data, "Data");
        //使用LoadSingle读取用SaveSingle保存的文件
        var load = Loader.LoadSingle<MyGameData>("Data");

        //使用SaveArray保存实例数组
        var datas = new MyGameData[] { GetIns(), GetIns() };
        Saver.SaveArray(datas, "Datas");
        //使用LoadArray读取用SaveArray保存的文件
        var loads = Loader.LoadArray<MyGameData>("Datas");
    }

允许含有以下字段/属性

  • 整形 int,byte,short,sbyte,long,uint,ulong,ushort
  • 浮点数 float,double,decimal
  • 布尔值 bool
  • 字符 char
  • 字符串 string
  • 可空类型 Nullable
  • Unity类型:Vector3, Vector2, Quaternion, Color
  • 数组/高维数组 [], [][], [][][]……
  • 任何可以被此工具保存的类
  • 符合以上条件的泛型 如List<>,Stack<>
  • 以上所有类型的复合类型,如List<int?[]>, List<Stack<float>>

会被跳过的类型

  • 不可设置量
  • 索引器

已知不允许含有的类型

  • 含有循环引用的类
  • 没有无参构造函数的类
  • 未被定义的值类型
  • 委托及其衍生类
  • 字典
  • LinkedList

由于该工具的原理是顺序遍历类的字段、属性来保存或读取,当类的储存结构或顺序改变时(方法改变无影响),已有的保存文件会失效。不过这也意味着只要储存结构和顺序相同,同样的文件也可以读取出多种类来。

避免保存文件失效的办法有两种:

1.暂时保留之前用来存储的类,用以读取旧的文件,然后再将其转换成现在所用的类。(适合原有存储类被废弃时)

2.新建一个类,使用与之前保存时相同的储存结构,用以读取旧的文件,然后再将其转换成现在所用的类。(适合原有存储类的储存结构被改变时)

若某部分储存结构变更频繁,可将整个存档拆分为多个类,储存在不同文件中,以尽量减少储存结构改变带来的变动。

补充

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

请登录后发表评论

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