知识基础:
- C#基础知识&反射相关
- 设计模式&单例模式相关
一、PlayerPrefs概述
1.1 PlayerPrefs
- Q:什么是PlayerPrefs?
- A:PlayerPrefs,它是一个Unity官方提供的,可以用于存储读取玩家数据,实现数据持久化的一个公共类。
- Q:什么是数据持久化?
- A:数据持久化是将内存中的数据模型转换为存储模型,以及将存储模型转换为内存中的数据模型的过程。
- 用人话说:关游戏前将一部分数据从内存存到硬盘,开游戏的时候从硬盘读取数据到内存来进游戏,这样的过程就是数据持久化。
1.2 PlayerPrefs基本方法
1.2.1 存
Unity中关于PlayerPrefs的数据存储,类似于字典,采用键值对的方式。
具体表现为用一个string类型的键,对应三种类型(int float string)的值。
PlayerPrefs.SetInt("myAge", 22);
PlayerPrefs.SetFloat("myHeight", 175f);
PlayerPrefs.SetString("myName", "小明");
但是注意此处直接调用Set相关方法,只会把数据暂时存到内存中。一般当游戏结束时,Unity会自动把数据存到硬盘。
但如果游戏不是正常结束,而是崩溃时,数据是不会存到硬盘中的,所以我们往往需要下面的API来直接存储:
PlayerPrefs.Save();
1.2.2 读
读取时我们提供存入的string类型的键即可获取。
这里有重载,可以让我们在没有对应的值时返回可自定义的参数。
int age = PlayerPrefs.GetInt("myAge");
float height = PlayerPrefs.GetFloat("myHeight");
string name1 = PlayerPrefs.GetString("myName");
//重载方法:当传入的键错误或没有对应值时返回后面的参数
string name2 = PlayerPrefs.GetString("Name", "张麻子");
Console.WriteLine(age) //22
Console.WriteLine(height) //175
Console.WriteLine(name1) //小明
Console.WriteLine(name2) //张麻子
1.2.3 查
查的时候和读取差不多,提供键,判断对应key的值是否存在。
//判断数据是否存在
if( PlayerPrefs.HasKey("myName") )
{
print("存在myName对应的键值对数据");
}
1.2.4 删
删一样,提供键即可。也可以直接删全部。
//删除指定键值对
PlayerPrefs.DeleteKey("myAge");
//删除所有存储的信息
PlayerPrefs.DeleteAll();
1.3 PlayerPrefs存储位置
Windows
- HKCU/Software/[公司名称]/[产品名称] 项下的注册表中
- (其中公司和产品名称是可以在"Project Settings"中进行自定义的名称。)
- (由于PlayerPrefs的特性,我们可以在注册表中找到对应存储的值,来直接更改内容,所以安全性需要额外考虑。)
安卓
- /data/data/包名/shared_prefs/pkg-name.xml
IOS
- /Library/Preferences/[应用ID].plist
1.4 PlayerPrefs的简单实践
这里我们使用最基础的PlayPrefs实现一个简单排行榜的功能,要求记录玩家名(可重复)、得分、通关时间。
//排行榜由一条一条的排行信息组成,所以先编写排行信息类
public class SingleRankInfo()
{
public string playerName;
public int playerScore;
public int playerTime;
//构造函数
public RankInfo(string name, int score, int time)
{
playerName = name;
playerScore = score;
playerTime = time;
}
}
//排行榜类,由一条条排行信息组成的list列表组成
public class RankList()
{
//存放一条条SingleRankInfo的list列表
public List<SingleRankInfo> rankList;
//构造函数
public RankList()
{
//每次初始化的时候读取一下
Load();
}
//往排行榜里加数据的功能
public void Add(string name, int score, int time)
{
rankList.Add(new SingleRankInfo(name, score, time));
}
//排行榜需要存数据的功能
public void Save()
{
//存储有多少条数据
PlayerPrefs.SetInt("rankListNum", rankList.Count);
for (int i = 0; i < rankList.Count; i++)
{
RankInfo info = rankList[i];//排行榜第几条
PlayerPrefs.SetString("rankInfo" + i, info.playerName);
PlayerPrefs.SetInt("rankScore" + i, info.playerScore);
PlayerPrefs.SetInt("rankTime" + i, info.playerTime);
}
PlayerPrefs.Save();
}
//排行榜需要读数据的功能
private void Load()
{
int num = PlayerPrefs.GetInt("rankListNum", 0);
rankList = new List<RankInfo>();
for (int i = 0; i < num; i++)
{
RankInfo info = new RankInfo(PlayerPrefs.GetString("rankInfo" + i),
PlayerPrefs.GetInt("rankScore" + i),
PlayerPrefs.GetInt("rankTime" + i));
rankList.Add(info);
}
}
}
二、完善PlayPrefs的存读档工具
从上面的知识中,我们可以利用Unity提供的PlayerPrefs简单构建出一个记录数据的对象。但是仔细思考不难发现,对于我们日常使用来说,这其中有一部分的需求仍是无法满足,主要体现在下面三处:
“Unity中关于PlayerPrefs的数据存储,类似于字典,采用键值对的方式。
具体表现为用一个string类型的键,对应三种类型(int float string)的值。”
(如果我有其他类型的数据要存储怎么办?)
“由于PlayerPrefs的特性,我们可以在注册表中找到对应存储的值,来直接更改内容,所以安全性需要额外考虑。”
(如果我对安全性有一点需求怎么办?)
同时,由于对于每一条信息都要手动set、save、load的方式过于繁琐,是否可以封装一下,统一进行一个对象的存储、读取呢?
我们将在下面部分逐步讨论以上问题。
2.1 前置知识:反射、单例模式相关
我们首先进行一点前置知识的回顾:
2.1.1 反射相关
要想利用PlayerPrefs存储其他类型,我们首先得让机器根据我们传进去的类,获取我们传进去的类信息和数据,这里不难联想到可以通过C#中反射相关的知识点解决问题。
- 反射可以获取一个类的所有信息;
- 反射可以帮助我们获取泛型类型;
(忘了的同学可以参考这篇笔记进行复习:简单理解CSharp中的反射(暂未施工完成))
2.1.2 单例模式相关
由于我们这里的想法是将PlayerPrefs存读档工具做的更完善一点,那么最好便是将其封装起来,我们下次使用时直接调用即可,那么我们同样不难联想到这里使用单例模式制作一个PlayerPrefsDataMgr的类,这样以后直接调用就行。
(忘了的同学可以参考这篇笔记进行复习:Unity中单例模式基类的几种简单写法(暂未施工完成))
2.2 完善存储与读取
2.2.1 数据管理单例类PlayerPrefsDataMgr
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
//因为是管理工具类,所以采用单例模式,且不继承MonoBehaviour
public class PlayerPrefsDataMgr
{
//单例模式惯例起手
private static PlayerPrefsDataMgr instance;
public static PlayerPrefsDataMgr Instance
{
get
{
if (instance == null)
{
instance = new PlayerPrefsDataMgr();
}
return instance;
}
}
private PlayerPrefsDataMgr() { }
//开始编写内容逻辑
//存档(传入要存储的 数据类 和 键名 -> 通过反射解析数据类,分门存取 -> 存档)
public void SaveData(object playerData, string keyName)
{
//获取playerData的类名,将字段信息存到infos列表里,拆开以待后面分析
Type playerDataType = playerData.GetType();
FieldInfo[] infos = playerDataType.GetFields();
//拼接存储用的键名,为了保证唯一性,命名规则采用: keyName存档名_playerData的类名_每一条info字段的类名_每一条info字段的名字
// 例: Player1_PlayerInfo_Int32_age
string saveKeyName = "";
FieldInfo info;
for (int i = 0; i < infos.Length; i++)
{
info = infos[i];
saveKeyName = keyName + '_' + playerDataType.Name + '_' + info.FieldType.Name + '_' + info.Name;
//有了playerData的每一个属性对应的saveKeyName,配合每一个属性的info.getValue(playerData)获取的数据进行存储。
//※重要!
//Q:为什么这里不直接PlayerPrefs存数据,而要单用一个函数 存具体的值 呢?
//A:因为我们只是通过反射获取了object playerData中每一个字段的信息,
// 但这里的每一个info字段不保证是string int float三种类型!
// 我们仍然需要逻辑去解析是何种类型,同时,如果object playerData中有列表、字典等可以嵌套叠罗汉的数据类型,
// 我们可以通过再次调用下面分解的SaveValue方法,来进行反向拆罗汉。
//SaveValue(24, "Player1_PlayerInfo_Int32_age");
//SaveValue(List<int>, "Player1_PlayerInfo_Int32_list^1");
SaveValue(info.GetValue(playerData), saveKeyName);
}
//手动存档
PlayerPrefs.Save();
//Debug.Log("邦邦咔邦!有一个类存档成功!");
}
private void SaveValue(object value, string saveKeyName)
{
//开始拆解
Type fieldType = value.GetType();
if (fieldType == typeof(int))
{
PlayerPrefs.SetInt(saveKeyName, (int)value);
}
else if (fieldType == typeof(float))
{
PlayerPrefs.SetFloat(saveKeyName, (float)value);
}
else if (fieldType == typeof(string))
{
PlayerPrefs.SetString(saveKeyName, value.ToString());
}
else if (fieldType == typeof(bool))
{
//bool类型我们用int去存
PlayerPrefs.SetInt(saveKeyName, (bool)value ? 1 : 0);
}
//如果是List<T>这种泛型类,无法直接将fieldType和typeof(list)去进行比较。
//这里我们针对传入的value的type:fieldType,先判断fieldType是否是泛型类,
// 接着调用.GetGenericTypeDefinition()的方法查看是否是List的泛型类
else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(List<>))
{
//如果是List<T>类型,将该泛型列表类用IList装载,
//开始拆罗汉,获取每一个List<T>内,存储的键名和数据内容进行存储。
IList valueList = (IList)value;
int index = 0;
foreach (var item in valueList)
{
//例:SaveValue( List<string>中的第一个string, "Player1_PlayerInfo_list^1" + '_' + 0 + '_' + string);
//Debug.Log("即将重复存泛型类数据,基础地址为:" + saveKeyName);
SaveValue(item, saveKeyName + '_' + index + '_' + item.GetType().Name);
index++;
}
//存长度以便后续读取生成
SaveValue(index, saveKeyName + "_count");
}
//字典也是一样存,但是字典有两个元素,需要分别存。
else if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
//拆罗汉,获取每一个Dictionary<,>内,存储的键名和数据内容进行存储。
IDictionary tempDic = (IDictionary)value;
int index = 0;
foreach (object key in tempDic.Keys)
{
//例:SaveValue(List<string>中的第一个string, "Player1_PlayerInfo_dic`2_dic" + '_' + 0 + "_key_" + 0);
//Debug.Log("即将重复存泛型类数据,基础地址为:" + saveKeyName);
SaveValue(key, saveKeyName + '_' + index + "_key_" + key.GetType().Name);
SaveValue(tempDic[key], saveKeyName + '_' + index + "_value_" + tempDic[key].GetType().Name);
index++;
}
//存泛型列表里有几个元素,方便回头读取的时候直接创建
SaveValue(index, saveKeyName + "_count");
}
else
{
//我们认为是自定义类,将这个类再次使用存档的根函数去解析
//(注意是SaveData不是SaveValue!!!)
//Debug.Log("这是一个自定义类,我们给他设置了起始存储name:" + saveKeyName);
SaveData(value, saveKeyName);
}
}
//读档(传入要读取的 数据类的类型 和 键名 ->
// 用存档时的命名格式读取 ->
// 通过反射解析后,直接内部实例化一个包含存档数据的类返回)
public object LoadData(Type dataType, string keyName)
{
//快速实例化传进来的需要读取的存档对象(需要存档类具有无参构造)
object data = Activator.CreateInstance(dataType);
FieldInfo[] fieldInfos = dataType.GetFields();
FieldInfo info;
string loadKeyName = "";
for (int i = 0; i < fieldInfos.Length; i++)
{
info = fieldInfos[i];
//和上面一样,怎么存的怎么读
loadKeyName = keyName + '_' + dataType.Name + '_' + info.FieldType.Name + '_' + info.Name;
//直接设值,值用函数读取
info.SetValue(data, LoadValue(info.FieldType, loadKeyName));
}
//Debug.Log("邦邦咔邦,有一个类读取完成!");
return data;
}
private object LoadValue(Type infoType, string loadKeyName)
{
if(infoType == typeof(Int32))
{
return PlayerPrefs.GetInt(loadKeyName, 0);
}
else if (infoType == typeof(float))
{
return PlayerPrefs.GetFloat(loadKeyName, 0);
}
else if(infoType == typeof(string))
{
return PlayerPrefs.GetString(loadKeyName, "");
}
else if(infoType == typeof(bool))
{
return PlayerPrefs.GetInt(loadKeyName, 0) == 1 ? true : false;
}
else if (infoType.IsGenericType && infoType.GetGenericTypeDefinition() == typeof(List<>))
{
//列表泛型类,先获取长度,再生成列表继承的基类,动态取数据放进去。
int count = PlayerPrefs.GetInt(loadKeyName + "_count", 0);
IList list = Activator.CreateInstance(infoType) as IList;
for(int i = 0; i < count; i++)
{
//Debug.Log("正在读取list泛型类,具体地址为: " + loadKeyName + '_' + i + '_' + infoType.GetGenericArguments()[0].Name);
list.Add(LoadValue(infoType.GetGenericArguments()[0], loadKeyName + '_' + i + '_' + infoType.GetGenericArguments()[0].Name));
}
return list;
}
else if (infoType.IsGenericType && infoType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
int count = PlayerPrefs.GetInt(loadKeyName + "_count", 0);
IDictionary tempDic = Activator.CreateInstance(infoType) as IDictionary;
for(int i = 0;i < count; i++)
{
//Debug.Log("正在读取dic泛型类,具体地址为: " + loadKeyName);
tempDic.Add(LoadValue(infoType.GetGenericArguments()[0], loadKeyName + '_' + i + "_key_" + infoType.GetGenericArguments()[0].Name),
LoadValue(infoType.GetGenericArguments()[1], loadKeyName + '_' + i + "_value_" + infoType.GetGenericArguments()[1].Name));
}
return tempDic;
}
else
{
//Debug.Log("这是一个自定义类,我们读取了起始存储name:" + loadKeyName);
return LoadData(infoType, loadKeyName);
}
}
}
2.2.2 测试用例
这里我们假设要存储的是PlayerInfo类,其中包含一些存档常见属性,以及一个自定义类型ItemInfo
和由该自定义类型组成的列表itemInfoList
,以及由其组成的字典itemInfoDic
。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
class PlayerInfo
{
public int age;
public string name;
public float height;
public bool sex;
public List<int> simpleList;
public Dictionary<int, string> dic;
public ItemInfo itemInfooo;
public List<ItemInfo> itemInfoList;
public Dictionary<int, ItemInfo> itemInfoDic;
public PlayerInfo() { }
}
public class ItemInfo
{
public int id;
public int num;
public ItemInfo()
{
}
public ItemInfo(int id, int num)
{
this.id = id;
this.num = num;
}
}
public class Test : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
Debug.Log("=======================================");
//先清除下所有的键值
PlayerPrefs.DeleteAll();
//正常使用先读取
PlayerInfo p = PlayerPrefsDataMgr.Instance.LoadData(typeof(PlayerInfo), "player1") as PlayerInfo;
p.age = 18;
p.name = "张牧之";
p.height = 70;
p.sex = true;
p.itemInfoList.Add(new ItemInfo(1, 99));
p.itemInfoList.Add(new ItemInfo(2, 199));
p.itemInfoDic.Add(3, new ItemInfo(3, 1));
p.itemInfoDic.Add(4, new ItemInfo(4, 2));
//再存档
PlayerPrefsDataMgr.Instance.SaveData(p, "player1");
}
}
2.3 完善安全性
首先明白一点:一般对于简单的单机游戏来说,加密只是提高别人修改你的游戏数据的门槛,只要源代码泄漏,知道加密规则后,一切加密都没意义。
对于普通数据的安全性,我们一般考虑下面三点来进行保护:
- 找不到
- 将游戏数据存放到不容易被找到的地方,比如无规律的文件夹内、多层文件夹包裹、采用名称辨识度低的文件夹命名方式等。
- 但是对于PlayerPrefs来说并不太适用,因为Unity已经将我们的位置固定了,暂时改不了。
- 看不懂
- 让我们数据的存放值让别人看不懂,即通过对存、读数据的操作,让其他人根据游戏具体数据反向查找的时候找不到。
- 例如存取玩家A的Hp为10,我们就可以在存值时对10进行加工,比如+6,存的值为16,读取时再-6读取还是10。这样其他人在本地查找为10的值时找不到我们的Hp存放数值。
- 解不出
- 利用一定算法等规则,来防止其他人通过找规律等形式识破加密规则,从而强行修改游戏数据。
三、总结
在文章的最后,总结一下本篇文章,我们在第一节学习了基础的PlayerPrefs知识,在第二节针对PlayerPrefs存储类型、安全性等方面的问题进行了小小的简单讨论,并写出一个较为通用的简单存读档工具。
在后续的开发中,我们只需要导入文件,便可以使用下面一行代码,实现简单自定义数据类型的存读取了。
//存放:
PlayerPrefsDataMgr.Instance.SaveData(p, “Player1”);
//读取:
PlayerInfo p = PlayerPrefsDataMgr.Instance.LoadData(typeof(PlayerInfo), “Player1”) as PlayerInfo;