前言
之前在学习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][序列化]一个存储/读取工具](https://www.shuibo.moe/wp-content/uploads/2022/10/用时随行数变化-上.png)
可以参考的是,图1中3762行的生成文件中,保存了10个Stack<ObjectData>,而每个Stack中保存了26个ObjectData,即共260个ObjectData。(ObjectData中的6个属性在保存时实际是6个字段+6个属性,若纯粹以字段进行储存,每行生成文件实际保存约0.82个数据单元,还是看得过去的)
![图片[2]-[Unity3D][序列化]一个存储/读取工具](https://www.shuibo.moe/wp-content/uploads/2022/10/用时随行数变化-下.png)
图2可见,当生成文件行数达到12万时,存读时间分别达到了388ms和574ms,已经是肉眼可见的卡顿了。而再往上更是惨不忍睹,应该没有谁能忍受超过一秒的等待吧(确信)。
![图片[3]-[Unity3D][序列化]一个存储/读取工具](https://www.shuibo.moe/wp-content/uploads/2022/10/速度随行数变化.png)
按照图3中曲线折算,单个文件数据单元在7000~530000个是比较能保持效率的。不过考虑到图2中运行时间过长的问题,单个文件的数据单元不建议超过100000个。
总结:因为依赖反射,再加上本人水平有限,此工具的性能并不强大。不过对于数据量不超过几万的小型游戏来说,已经完全足够了。
源码
因为源码过长,不方便直接展示,因此使用文件的形式提供下载。
说明
添加代码作为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.新建一个类,使用与之前保存时相同的储存结构,用以读取旧的文件,然后再将其转换成现在所用的类。(适合原有存储类的储存结构被改变时)
若某部分储存结构变更频繁,可将整个存档拆分为多个类,储存在不同文件中,以尽量减少储存结构改变带来的变动。
补充
文章版权归作者所有







- 最新
- 最热
只看作者