[转载]Unity数据读写与存档(2)Json:成为神笔马良

网上看到自己可能用得到的知识文章转载保存,防止时间长了网站没了或者被傻卵中国互联网产品经理以各种缺德的方式想办法让你去用他们的傻卵APP看文章。

Posted by 晴窗v on July 6, 2020

[转载]Unity数据读写与存档(2)Json:成为神笔马良

原文地址:https://blog.csdn.net/qq_35587645/article/details/107141246

在上一篇我们知道,通过引入适当的第三方库并编写代码,就可以在Unity内,从Excel表格中读取一些数据。

img

配置表的存在,的确可以帮助开发团队中没有编程基础的小伙伴管理游戏数据,或者将游戏的策划数据较为方便地批量转移到Unity内;不过,使用Excel来管理游戏数据,功能还是较为有限,而且使用起来不方便。Excel表格只适合存储简单的文本和数字类数据,对于数组、列表等复杂数据则完全无能为力;此外,读取配置表时必须手动编写非常繁琐的读表逻辑,将表格内各单元格的数据赋值到代码内部的各种变量上。更可怕的是,游戏内有多少个地方需要读表,就要编写多少种不同的读表算法;这对于大型项目来说绝对是可怕的负担。

现在我们希望找到一种更好的方法,来将代码内的数组、列表、乃至自定义类(class)这样的数据原封不动地存储成像“配置表”一样的可阅读文本;也能在适当的时机从上述文本中“读到”这些数据类型,然后将数据还原到代码中。有没有这样的解决方案呢?当然有,Unity为我们提供了UnityEngine.JsonUtility这个静态类。使用它,我们可以将游戏内数据转化为Json字符串,或者从Json字符串或文件中读取数据。

Json是什么东西?网上可以查到详细的解释,但那些解释对我们将要实现的功能而言没有什么用。目前你只需要知道:Json是一种以特殊格式写成的字符串或者文件,它可以用于表示我们代码中的某些数据。

1.使用Json记录和读入数据

我们直接来模拟一个情境,并且看一段代码。

假设我们的游戏中存在人物(Person)类,人物具有名字、生命值和移动速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Person
{
    public string name;//名字
    public int hitPoint;//生命值
    public int moveSpeed;//移动速度
 
    public Person()
    {
 
    }
 
    public Person(string Name,int HitPoint,int MoveSpeed)
    {
        name = Name;
        hitPoint = HitPoint;
        moveSpeed = MoveSpeed;
    }
}

容易知道,name-名字、hitPoint-生命值、moveSpeed-移动速度这三项数据是每个Person实例的属性——只要记录下来这三项数据,就可以唯一确定一个游戏内角色——Person。

现在,我们打开任意一个Unity场景,新建一个C#文件(文件名不限),编写代码来定义一个名为Tom的角色,然后用Json记录它的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
 
public class Person
{
    public string name;//名字
    public int hitPoint;//生命值
    public int moveSpeed;//移动速度
 
    public Person()
    {
 
    }
 
    public Person(string Name,int HitPoint,int MoveSpeed)
    {
        name = Name;
        hitPoint = HitPoint;
        moveSpeed = MoveSpeed;
    }
}
 
public static class JsonTest
{
    [MenuItem("Json/TestJson")]//在Unity菜单栏上添加选项卡和选项
    public static void TestJson()
    {
        Person Tom = new Person("Tom", 100, 300);//创建一个名为Tom的角色,生命值为100,移动速度为300
        string TomStr = JsonUtility.ToJson(Tom);
        Debug.Log(TomStr);
    }
}

编写完成后,在Unity菜单栏内选择Json-TestJson,观察控制台的输出。

img

我们看到,控制台输出了字符串TomStr,它是JsonUtility对Tom进行编码后生成的字符串。作为一行Json数据,TomStr记载了Person实例Tom的全部属性。不难猜到,通过这组字符串,我们可以准确无误地在代码中的其它地方还原出Tom,或者说还原出一个完整的Person实例。

下面我们为TestJson方法补充内容,来解读刚刚生成的TomStr字符串,并将其还原回Tom。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class JsonTest
{
    [MenuItem("Json/TestJson")]//在Unity菜单栏上添加选项卡和选项
    public static void TestJson()
    {
        Person Tom = new Person("Tom", 100, 300);//创建一个名为Tom的角色,生命值为100,移动速度为300
        string TomStr = JsonUtility.ToJson(Tom);
        Debug.Log(TomStr);
        Person unknownPerson = (Person)JsonUtility.FromJson(TomStr, typeof(Person));
        Debug.LogFormat("我的名字是{0},生命值是{1},移动速度是{2}", unknownPerson.name, unknownPerson.hitPoint, unknownPerson.moveSpeed);
    }
}

再次执行Json-TestJson并观察输出。

img 在上面的TestJson中,我们用JsonUtility.FromJson方法解读了TomStr中包含的Person属性信息,将解读出来的信息输出为Person类型,然后转化为一个新的Person实例——unknownPerson。在控制台中调取unknownPerson的各项属性,可以发现它成为了Tom的一个准确的副本——其所有属性都正是Tom的属性。

这时我们就看到,JsonUtility.FromJson方法完成了一件我们先前难以想象的事——它将一个平平无奇的字符串,转变成了一个在代码中活生生的Person类实例。而很明显,这个用于转化的字符串可以出自各种来源——通过适当的方法生成、手动打字输入、从现有的文件中读取,甚至是从网络连接中获取。

现在,我们继续修改TestJson方法,当一回“神笔马良”:手写一段字符串,并将其转化为一个游戏内角色Jerry。

1
2
3
4
5
6
7
8
9
10
public static class JsonTest
{
    [MenuItem("Json/TestJson")]//在Unity菜单栏上添加选项卡和选项
    public static void TestJson()
    {
        string JerryStr = @"{""name"":""Jerry"",""hitPoint"":75,""moveSpeed"":350}";
        Person Jerry = (Person)JsonUtility.FromJson(JerryStr, typeof(Person));
        Debug.LogFormat("我的名字是{0},生命值是{1},移动速度是{2}", Jerry.name, Jerry.hitPoint, Jerry.moveSpeed);      
    }
}

执行Json-TestJson并观察输出。

img

现在可以看到,通过上面的代码,我们直接将代码中手动写入的字符串JerryStr转化成了游戏内角色实例Jerry。是不是就像变魔术一样?

你可能会注意到,上面的字符串JerryStr中采用的格式,和先前输出的TomStr字符串有点不一样。这是因为C#字符串中对于双引号”需要转义,否则无法确定字符串的边界。在JerryStr中,开头的@表示在字符串中不使用转义符\,并用两个双引号”“指代一个双引号”。

(关于C#字符串中的转义,可以自行查阅相关资料)

此外,JsonUtility中还提供了FromJson的变种方法FromJsonOverwrite,该方法可以直接将读出的Json数据应用到另一个类实例上。例如,前面的语句

1
Person Jerry = (Person)JsonUtility.FromJson(JerryStr, typeof(Person));

还可以改写成这种形式:

1
2
Person Jerry = new Person();
JsonUtility.FromJsonOverwrite(JerryStr, Jerry);//将JerryStr中读出的数据应用到类实例Jerry上

这两种写法的语义和输出结果都没有区别。

2.更高灵活性:处理派生类型

上面的示例演示了Json字符串创建和读取的基本操作,但在实际应用中,我们不一定能保证需要读取的数据就是如假包换的Person类。现在来设想更复杂的情境。

游戏中的角色被定义为Person基类,但Person基类有若干个不同的派生类,作为游戏内角色的职业分支。

例如,战士(Warrior)子类拥有一个新属性Damage(攻击力),医疗兵(Medic)子类拥有新属性HealRate(治疗速率)、射手(Marksman)子类则拥有两个新属性Damage(攻击力)和Range(射程)。

现在,我们打算生成若干个游戏内角色的Json字符串,然后将这些字符串解码,使它们变回原来的游戏角色。

img

现在,我们大幅度修改前面的代码来表现这个工作流程。整个流程的代码如下:

Tips: 与前面的代码相比,这段代码为Person类及其子类都引入了重载的ToString方法。这是为了在调试时能够让每一个游戏角色进行“自我介绍”,报出自身的各项数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
 
public class Person
{
    public string name;
    public int hitPoint;
    public int moveSpeed;
 
    public Person()
    {
 
    }
 
    public Person(string Name,int HitPoint,int MoveSpeed)
    {
        name = Name;
        hitPoint = HitPoint;
        moveSpeed = MoveSpeed;
    }
 
    public override string ToString()
    {
        string ret = string.Format("name:{0} hitPoint:{1} moveSpeed:{2}", name, hitPoint, moveSpeed);
        return ret;
    }
}
 
public class Warrior : Person
{
    public int damage;
 
    public Warrior()
    {
 
    }
 
    public Warrior(string Name, int HitPoint, int MoveSpeed, int Damage) : base(Name, HitPoint, MoveSpeed)
    {
        damage = Damage;
    }
 
    public override string ToString()
    {
        string ret = string.Format("name:{0} hitPoint:{1} moveSpeed:{2} damage:{3}", name, hitPoint, moveSpeed, damage);
        return ret;
    }
}
 
public class Medic : Person
{
    public int healRate;
 
    public Medic()
    {
 
    }
 
    public Medic(string Name, int HitPoint, int MoveSpeed, int HealRate) : base(Name, HitPoint, MoveSpeed)
    {
        healRate = HealRate;
    }
 
    public override string ToString()
    {
        string ret = string.Format("name:{0} hitPoint:{1} moveSpeed:{2} healRate:{3}", name, hitPoint, moveSpeed, healRate);
        return ret;
    }
}
 
public class Marksman : Person
{
    public int damage;
    public int range;
 
    public Marksman()
    {
 
    }
 
    public Marksman(string Name, int HitPoint, int MoveSpeed, int Damage, int Range) : base(Name, HitPoint, MoveSpeed)
    {
        damage = Damage;
        range = Range;
    }
 
    public override string ToString()
    {
        string ret = string.Format("name:{0} hitPoint:{1} moveSpeed:{2} damage:{3} range:{4}", name, hitPoint, moveSpeed, damage, range);
        return ret;
    }
}
 
public static class JsonTest
{
    [MenuItem("Json/TestJson")]//在Unity菜单栏上添加选项卡和选项
    public static void TestJson()
    {
        //定义三名游戏角色,分别是战士Tom,医疗兵Jerry和射手Tuffy
        Warrior Tom = new Warrior("Tom", 100, 300, 50);
        Medic Jerry = new Medic("Jerry", 75, 350, 60);
        Marksman Tuffy = new Marksman("Tuffy", 60, 400, 30, 600);
 
        List<Person> Characters = new List<Person> { Tom, Jerry, Tuffy };//创建一个Person列表,用于存储全部游戏角色
 
        List<string> CharactersJson = new List<string>();//创建一个string列表,存储每名游戏角色的json数据
 
        //将每一名角色依次编码为Json数据,存入CharactersJson列表中
        foreach (Person character in Characters)
        {
            string json = JsonUtility.ToJson(character);
            Debug.LogFormat("已编码Json字符串:{0}", json);//在这里监控一下,看看json字符串的生成是否正确
            CharactersJson.Add(json);
        }
 
        List<Person> DecodedCharacters = new List<Person>();//创建一个新的Person列表,用于存储json数据解码后生成的游戏角色
 
        //对于每份json数据,将其解码为游戏角色Person
        foreach(string json in CharactersJson)
        {
            Person person_Decoded = DecodePerson(json);
            DecodedCharacters.Add(person_Decoded);
        }
 
        //要求解码出的游戏角色进行自我介绍
        foreach(Person character in DecodedCharacters)
        {
            Debug.Log("已解码获得游戏角色: " + character.ToString());
        }
    }
 
    public static Person DecodePerson(string personAsJson)
    {
        Person decodedPerson = (Person)JsonUtility.FromJson(personAsJson, typeof(Person));
        return decodedPerson;
    }
}

在这段代码的设计中,我们意图实现的效果是这样的:

第一步:将战士Tom医疗兵Jerry射手Tuffy的数据编码为json字符串并存入列表中;

第二步:从该列表(CharacterJson)中读取这些json字符串数据,将它们解码变回战士Tom、医疗兵Jerry、射手Tuffy;

第三步:让每名被解码出来的角色播发自己的ToString()方法,观察它们是否已经被正确解码。

那么,执行出来的效果是否正确呢?执行Json-TestJson并观察输出。

img

执行结果正确吗?不太对,好像出了一些问题。三名角色的攻击力、治疗速率、射程等职业专属数据,在解码完成后全都丢失了。

分析输出结果可以发现,我们在对三名游戏角色进行编码时,编码的内容符合它们各自的类型Warrior、Medic和Marksman,包含了它们各自类型所特有的数据;而在解码之后,三名游戏角色显然都被解码成了Person基类的实例——它们各自所属的派生类型,以及包含在派生类型属性中的攻击力、治疗速率、射程等数据都丢失了。

问题的来源出在这里:

1
List<string> CharactersJson = new List<string>();//创建一个string列表,存储每名游戏角色的json数据

我们在对角色列表内各个游戏角色的Json数据进行解码之前,面对存储着一大堆json字符串的列表CharactersJson,我们只能知道列表的每个成员都代表着一个游戏角色(Person类型);至于它们具体属于哪个派生类(战士/医疗兵/射手),在解码之前我们是不知道的。因此,我们的解码方法DecodePerson只能写成这个样子:

1
2
3
4
5
6
public static Person DecodePerson(string personAsJson)
{
    Person decodedPerson = (Person)JsonUtility.FromJson(personAsJson, typeof(Person));//将json数据解码为Person类
    return decodedPerson;
}

对于每一条传入解码方法内的json数据,因为我们无法断定它记载了哪种职业,所以只能根据Person类型进行笼统的破译,破译出来的结果只是一个Person基类实例,而非Person的某个派生类(角色职业)的实例;或者说,我们无法在FromJson的第二个参数中直接写入精确的类型(Warrior/Medic/Marksman),来获取最完整的数据。

这就好像我们拿着一本用密文写成的军队名册,目前只知道每一行代表军队中的一名成员;但是在破译密文之前,我们无法知道每一行密文记录的成员具体属于什么军种。在这种情况下,我们无法做到有针对性地解读每一行密文,来获取与该行密文所指代成员的军种相关的具体信息。

3.正确应对未知类型

怎样解决这个问题呢?其实,解决起来一点都不难。我们首先对游戏角色基类Person稍加修改,使每一个Person实例都能够自行指示出自身所属的派生类型,或者说职业。(这里要先添加using System来使用System.Type变量类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;
 
public class Person
{
    public string profession;//记录自身所属的具体类型名称
    public string name;
    public int hitPoint;
    public int moveSpeed;
 
    public Person()
    {
        profession = this.GetType().ToString();//在创建时记录自身所属的类型
    }
 
    public Person(string Name, int HitPoint, int MoveSpeed) : this()//同上
    {
        name = Name;
        hitPoint = HitPoint;
        moveSpeed = MoveSpeed;
    }
 
    public override string ToString()
    {
        string ret = string.Format("name:{0} hitPoint:{1} moveSpeed:{2}", name, hitPoint, moveSpeed);
        return ret;
    }
}

这里,我们为Person类添加了一个新属性string profession——它是每个Person实例所属的具体类型类名字符串形式。

这是什么意思呢?在C#中,GetType()是一个任意对象(object)都具有的方法成员;该方法会返回该对象的具体类型。这里的“具体类型”指的是该对象所属的最细分派生类。

例如,小明是中国人,也是中国-辽宁省人,也是中国-辽宁省-沈阳市人。现在对小明调用其GetType()方法,输出的应该是”中国辽宁省沈阳市人”,而不会是”中国人”或”中国辽宁省人”。

于是我们知道,string profession将会忠实地记录每个Person实例的具体派生类,即每名游戏角色的具体职业;这样一来,我们的解码方法DecodePerson就有了更大的操作空间。首先可以将json数据解码为Person基类,然后从解码到的Person属性中读取职业名profession;然后根据profession所代表的具体派生类再进行一次解码,获得准确无误的游戏角色数据。

修改解码方法*DecodePerson如下:

1
2
3
4
5
6
7
8
9
10
public static Person DecodePerson(string personAsJson)
{
    Person decodedPerson = (Person)JsonUtility.FromJson(personAsJson, typeof(Person));//先将json数据解码为Person类
    Type type = Type.GetType(decodedPerson.profession);//从解码出的Person类实例中读取角色的职业名
    //这里的Type.GetType(string)是System.Type静态类的特有重载,用于将字符串转化为Type类型,与前面的Object.GetType()不是一个意思
 
    decodedPerson = (Person)JsonUtility.FromJson(personAsJson, type);//根据职业名进行二次解码,获取准确的派生类型
    return decodedPerson;
}

现在,这个方法会对传入的json数据进行两次解码。第一次解码获取的是对应游戏角色作为Person基类的实例,用于取得Person属性中的profession字符串,即该角色所属的派生类名称(有点像“职业名片”);第二次解码会根据前面获取的职业签名再次解码json数据,获得完整的角色信息。

修改完成后,再次执行Json-TestJson并观察输出。

img

这一次,可以看到解码结果正确无误,所有的游戏角色数据在编码-解码后都得到了完美的还原。

Tips: 事实上,正确区分Json数据所指代的派生类的方法,并不止前面介绍的这一种。

例如,我们可以选择不修改Person类,而是让DecodeName方法不再接收一份Json数据,转而接收一份包含Json数据的“消息协议”。对于一个类型为Marksman的游戏角色,我们可以将其编码为类似”Marksman {……}”(花括号内为json数据)的形式再传入DecodeName方法,利用中间的分隔符来承载“类名”和“json字符串”两项数据,这样也能达到正确解码的效果。不同的处理方法各有优劣,你可以自行探究。其实,这种“消息协议”式的数据传输风格在Socket网络通信中更加常用,此处暂不详述。

到这里,希望你已经理解了JsonUtility的用法,并掌握了如何通过Json来记录、读取和应用各种数据。

4.一些注意事项

以下是一些使用JsonUtility时可能遇到的”坑“,写在这里以供参考。

一、哪些数据可以用JsonUtility编码?

JsonUtility.ToJson()可以编码以下对象:

(1)各类数值(int、float、double等);

(2)字符串(string);

(3)自定义的类(class)。

JsonUtility.ToJson()不能编码以下对象:

字典(Dictionary)、队列(Queue)、栈(Stack)。

以下对象的情况特殊:

数组、列表(List)

当它们作为单独的参数被传入JsonUtility.ToJson()中时,无法成功编码;但是当它们作为一个类的公有成员时,对包含它们的类进行编码,则类内部的数组/列表可以被正确编码。此外,如果数组和列表的成员类型是自定义类型,那么该自定义类型前面必须加上[Serializable]属性标记,否则不能成功编码。

二、用JsonUtility编码一个类时会记录什么?

会记录该类所有的公有(public)属性的值。如果一个公有属性是一个自定义类,则该类也会按照上述规则被记录。

如果一个公有属性的值需要使用get访问器获取,而非以明文方式记录,则不会被编码。

三、如果试图解码一个格式不对的json字符串,会发生什么?

会抛出异常。但特别地,如果在json字符串中只有某个属性的不合法,其它格式正确(例如一个int类型数据的值被错误地写成”Hello”),则该值会被重置为对应属性的默认值,例如0或null。

四、在非Unity环境下,能不能以与UnityEngine.JsonUtility相同的方式编码和解码Json数据?(例如,Unity网络游戏的服务器)

可以。在Windows的C盘中搜索System.Web.Extensions.dll这个文件,将其引用到C#项目中;然后加入using System.Web.Script.Serialization 指令。此时可以使用一个类JavaScriptSerializer,它可以看成是JsonUtility的非静态版本,调用时需要创建一个实例。其它用法与JsonUtility基本相同。

五、如何像配置表或数据库一样,使用Json编码并存储大量的类实例数据,而不是只有一个类实例?

定义一个包含目标类型数组或列表的新类,然后编码这个新类。例如,你可以这样存储许多个Person实例的信息:

1
2
3
4
public class People
{
    public List<Person> persons = new List<Person>();//编码之前将需要存储的Person实例加入到列表中
}

编码数组/列表时的注意事项参见第一条。

六、如何改善编码出来的Json字符串的排版,便于人工阅读和修改?

使用JsonUtility.ToJson的另一种重载:(object obj, bool prettyPrint),将prettyPrint填为true后,生成的Json就是带有换行和缩进的美观格式,例如这样:

img

更多未尽之处,留待以后更新~再会!