游戏中有很多数据需要在运行时读取或者写入,最典型的就是游戏的存档功能。Unity自己也提供了一套存档API,但是功能比较单一,只支持保存int、float、和string这三种类型。不过C#支持文件的读写,我们可以灵活拓展它。

PlayerPrefs

PlayerPrefs是Unity自带的存档方式,它的优点是使用起来非常方便。引擎已经封装好GetKey()以及SetKey()方法,并且还做保存数据的优化。由于保存数据可能是个耗时操作,频繁地保存可能会带来卡顿,所以Unity默认会在应用程序切入后台时统一保存文件,开发者也可以强制调用PlayerPrefs.Save()来保存

然而它的缺点就是,编辑模式下查看存档非常不方便,MacOS的存档在~/Library/Preferences/folder目录下,Windows存档在HKCU\Software[company name][product name]注册表中

新建PlayerPrefsDataTest脚本

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;
using System.Collections.Generic;
using UnityEngine;

public class PlayerPrefsDataTest : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
PlayerPrefs.SetInt("MyInt", 100);
PlayerPrefs.SetFloat("MyFloat", 200f);
PlayerPrefs.SetString("MyString", "ATAO2017");

Debug.Log(PlayerPrefs.GetInt("MyInt", 0));
Debug.Log(PlayerPrefs.GetFloat("MyFloat", 0f));
Debug.Log(PlayerPrefs.GetString("MyString", "默认值"));

//判断是否有某个键
if (PlayerPrefs.HasKey("MyInt")) { Debug.Log("含有键"); }

//强制保存数据
PlayerPrefs.Save();
}
private void OnGUI()
{
if (GUILayout.Button("删除键"))
{
//删除某个键
PlayerPrefs.DeleteKey("MyInt");
//删除所有键
PlayerPrefs.DeleteAll();
}
}
}

Debug输出

注册表内容

EditorPrefs

在编辑器模式下,Unity也提供了一组存档功能它不需要考虑运行时的效率,所以没有用PlayerPrefs的优化方式,而是立即保存,其使用方法和PlayerPrefs类似

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

public class EditorPlayerPrefsTest : MonoBehaviour
{
[MenuItem("Root/Save")]
static void Save()
{
EditorPrefs.SetInt("MyInt", 100);
EditorPrefs.SetFloat("MyFloat", 200f);
EditorPrefs.SetString("MyString", "ATAO2017");

Debug.Log(EditorPrefs.GetInt("MyInt", 0));
Debug.Log(EditorPrefs.GetFloat("MyFloat", 0f));
Debug.Log(EditorPrefs.GetString("MyString", "默认值"));

//判断是否有某个键
if (EditorPrefs.HasKey("MyInt")) { }
//删除某个键
EditorPrefs.DeleteKey("MyInt");
//删除所有键
EditorPrefs.DeleteAll();
}
}

PlayerPrefs保存复杂结构

PlayerPrefs可以保存字符串,结合JSON序列化和反序列化功能,它就可以保存各种复杂的数据结构了。另外,保存存档取决于硬件当时的条件,完全有保存不上的情况,所以可以通过try……catch来捕获保存时的错误异常

新建JsonToPlayerPrefs脚本

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;
using System.Collections.Generic;
using UnityEngine;

public class JsonToPlayerPrefs : MonoBehaviour
{
private void Start()
{
Record record = new Record();
record.stringVaule = "ATAO2022";
record.intValue = 5;
record.name = new List<string>() { "文字1", "文字2" };
string json = JsonUtility.ToJson(record);
try
{
PlayerPrefs.SetString("record", json);
}
catch (System.Exception err)
{

Debug.Log("Got:"+err);
}
record = JsonUtility.FromJson<Record>(PlayerPrefs.GetString("record"));
Debug.LogFormat("stringValue: {0},intValue: {1},", record.stringVaule, record.intValue);
}
}
[System.Serializable]
public class Record
{
public string stringVaule;
public int intValue;
public List<string> name;
}

凡是参与JSON序列化的对象都需要标记[System.Serializable]

TextAsset

TextAsset是Unity提供的一个文本对象,它可以通过Resources.Load()或者AssetBundle来读取数据,其中数据是string格式的。当然我们也可以按byte[]读取,它支持读取的文本格式包括txt、html、htm、xml、bytes、json、csv、yaml和fnt。

Resources.Load(string path)方法中的path不能带文件类型后缀

新建TextAssetTest脚本,用来读取Resources文件里面的MyText文本。

1
2
3
4
5
6
7
8
9
using UnityEngine;

public class TextAssetTest : MonoBehaviour
{
void Start()
{
Debug.Log(Resources.Load<TextAsset>("MyText").text);
}
}

读写文本

Unity可以利用C#的File类来读写文本,此时只需要提供一个目录即可。编辑器模式下读写文本是很方便的,但是一旦打包发布,Assets/目录都不存在了,运行时是无法读取它目录下的文本的。

使用File.WriteAllText()File.ReadAllText()来对文本进行读取和写入

新建FileClassTest脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine;
using System.IO;
using System.Text;
using UnityEditor;

public class FileClassTest
{
[MenuItem("Root/File")]
static void FIleClassReadWrite()
{
string path = Path.Combine(Application.dataPath, "FileClassTest");
if (File.Exists(path))
File.Delete(path);

StringBuilder sb = new StringBuilder();
sb.AppendFormat("第一行:{0}", 100).AppendLine();
sb.AppendFormat("第二行:{0}", 200).AppendLine();
File.WriteAllText(path, sb.ToString());

Debug.Log(File.ReadAllText(path));
}
}

结果

Runtime读写文本

在游戏运行期间,只有Resources和StreamingAssets目录具有读取权限,其中Resources用来读取游戏数据,而StreamingAssets可使用FIle类来读取文件(除了个别平台外比如Android、Web),但都是只读的。只有Application.persistentDataPath目录是可读、可写的。

我们先在编辑器Project窗口的Resources文件夹下读取上一节使用的“MyText.txt”文件,新建StreamingAssets文件夹,在其中新建Test.txt文本。

新建RunTimeIOTest脚本

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RunTimeIOTest : MonoBehaviour
{
string m_ResourceText = string.Empty;
string m_StreamAssetsText = string.Empty;
string m_PersistentDataText = string.Empty;

private void Start()
{
m_ResourceText = Resources.Load<TextAsset>("MyText").text;
m_StreamAssetsText = System.IO.File.ReadAllText(System.IO.Path.Combine(Application.streamingAssetsPath, "test.txt"));
}

private void OnGUI()
{
GUILayout.Label(string.Format("<size=50>Resources : {0}</size>", m_ResourceText));
GUILayout.Label(string.Format("<size=50>StreamingAssets : {0}</size>", m_StreamAssetsText));
GUILayout.Label(string.Format("<size=50>PersistentDataPath : {0}</size>", m_PersistentDataText));

if (GUILayout.Button("<size=50>写入并读取时间</size>"))
{
string path = System.IO.Path.Combine(Application.persistentDataPath, "test.txt");
System.IO.File.WriteAllText(path, System.DateTime.Now.ToString());
m_PersistentDataText = System.IO.File.ReadAllText(path);
}
}
}

我们需要将场景build出来才能测试这个脚本。

关于Application.persistentDataPath在不同平台指向的位置:

Unity - Scripting API: Application.persistentDataPath (unity3d.com)

关于不同环境下Application.Datapath指向的位置

Unity - Scripting API: Application.dataPath (unity3d.com)

PersistentDataPath目录

调用EditorUtility.RevealInFinder()方法,帮助我们在开发中快速打开当前平台的PersistentDataPath目录指向的文件资源管理器窗口

新建OpenPersistentDataFolder脚本

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
using UnityEditor;

public class OpenPersistentDataFolder
{
[MenuItem("Root/Open PersistentDataPath")]
static void Open()
{
EditorUtility.RevealInFinder(Application.persistentDataPath);
}
}

Root菜单

Windows下的PersistentDataPath位置

游戏存档

我们需要一个不影响开发的存档,并且查看要非常方便。

我们可以自己写一个存档类,在Editor模式下将存档保存在Assets同级目录下,这样查看存档内容就方便多了

新建RecordUtil脚本,在Editor模式下将数据保存与Application.dataPath目录,而在游戏真实平台下将数据保存在Application.persistentDataPath下。只有调用Save()方法时,数据才会被强制写入。

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
140
141
142
143
144
145
146
147
148
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System;
using System.Text;

public class RecordUtil
{
//游戏存档保存的根目录
static string RecordRootPath
{
get
{
#if(UNITY_EDITOR || UNITY_STANDALONE)
// “./”表示当前目录、“../”表示父目录、“/”表示根目录,这个存档文件夹要存在Application.dataPath的父目录里
return Application.dataPath + "/../Record/";
#else
return Application.persistentDataPath
#endif
}
}

//游戏存档
static Dictionary<string, string> recordDic = new Dictionary<string, string>();
//标记某个游戏存档是否需要重新写入
static List<string> recordDirty = new List<string>();
//标记某个游戏存档是否需要删除
static List<string> deleteDirty = new List<string>();
//表示某个游戏存档读取时需要重新从文件读取
static List<string> readDirty = new List<string>();

static private readonly UTF8Encoding UTF8 = new UTF8Encoding(false);

static RecordUtil()
{
readDirty.Clear();

if (Directory.Exists(RecordRootPath))
{
foreach (string file in Directory.GetFiles(RecordRootPath,"*.record",SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileNameWithoutExtension(file);
if (!readDirty.Contains(name))
{
readDirty.Add(name);
Get(name);
}
}
}
}

//强制写入文件
public static void Save()
{
foreach (string key in deleteDirty)
{
try
{
string path = Path.Combine(RecordRootPath, key + ".record");
if (recordDirty.Contains(key))
recordDirty.Remove(key);
if (File.Exists(path))
File.Delete(path);
}
catch (Exception ex)
{

Debug.LogError(ex.Message);
}
}
deleteDirty.Clear();

foreach (string key in recordDirty)
{
string value;
if(recordDic.TryGetValue(key,out value))
{
if (!readDirty.Contains(key))
readDirty.Add(key);
string path = Path.Combine(RecordRootPath, key + ".record");
recordDic[key] = value;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, value, UTF8);
}
catch (Exception ex)
{

Debug.LogError(ex.Message);
}
}
}
recordDirty.Clear();
}

public static void Set(string key,string value)
{
recordDic[key] = value;
if (!recordDirty.Contains(key))
recordDirty.Add(key);
#if UNITY_EDITOR || UNITY_STANDALONE
Save();
#endif
}

public static string Get(string key)
{
return Get(key, string.Empty);
}

public static string Get(string key,string defaultValue)
{
if (readDirty.Contains(key))
{
string path = Path.Combine(RecordRootPath, key + ".record");
try
{
string readStr = File.ReadAllText(path, UTF8);
recordDic[key] = readStr;
}
catch (Exception ex)
{

Debug.LogError(ex.Message);
}
readDirty.Remove(key);
}

string value;
if (recordDic.TryGetValue(key, out value))
return value;
else
return defaultValue;
}

public static void Delete(string key)
{
if (recordDic.ContainsKey(key))
recordDic.Remove(key);
if (!deleteDirty.Contains(key))
deleteDirty.Add(key);
#if UNITY_EDITOR || UNITY_STANDALONE
Save();
#endif
}
}

关于这堆代码的一些Tips:

  1. 我们执行Set()方法时,在Editor和UnityStandalone会执行Save()方法,执行了Save()方法后,deleteDirty(删除标记)和recordDirty(保存标记)都会被清空,并且在过程中回将新添加进来的key写在readDirty(读取标记)里,这个把key写进readDirty的过程只有一次,如果我们再执行Set()方法,并且使用了相同的key的话,readDirty不会更新。这样我们执行Get()方法,会根据readDirty的标记从本地文件里把数据读出来

  2. 注意#if UNITY_EDITOR || UNITY_STANDALONE预定义,在编辑器模式下不用关心性能所以有Save()操作

  3. RecordUtil实例化过程中,会利用readDirty(将存档文件全部读取入recordDic里面,通过调用Get(string)方法的重载Get(string,string),在过程中清空了readDirty

接下来新建一个CustomSaveClassTest脚本,来实际使用一下自己写的存档类

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CustomSaveClassTest : MonoBehaviour
{
void Start()
{
Setting setting = new Setting();
setting.stringValue = "测试字符串";
setting.intValue = 100;

RecordUtil.Set("setting", JsonUtility.ToJson(setting));
}

private Setting m_Setting = null;
private void OnGUI()
{
if (GUILayout.Button("<size=50>获取存档</size>"))
{
m_Setting = JsonUtility.FromJson<Setting>(RecordUtil.Get("setting"));
}
if(m_Setting != null)
{
GUILayout.Label(string.Format("<size=50>{0},{1}</size>", m_Setting.intValue, m_Setting.stringValue));
}
}
private void OnApplicationPause(bool pause)
{
//当游戏即将进入后台时,保存存档
if (pause)
RecordUtil.Save();
}

}

[System.Serializable]
class Setting
{
public string stringValue;
public int intValue;
}

获取存档

存档位置

存档文件内容

默认情况下,应用进入后台后会保存数据。如果应用进入后台发生了闪退现象,那么数据就无法保存了,所以某些重要的数据需要强制调用Save()方法