存盘功能--备忘录模式

思考并回答以下问题:

  • 备忘录模式的官方定义是什么?
  • 备忘录属于哪种设计模式?为什么?
  • 动手画备忘录模式的类图。
  • 直接将数据存盘功能实现于游戏功能类中违反了单一职责原则怎么理解?
  • 对外公开成员数据是有风险的。类应该尽量减少对外显示内部的数据结构,以减少客户端有机会破坏内部成员的记录,而对外公布过多的操作方法,也容易增加与其他系统的耦合度(即依赖度)。怎么理解?
  • 定义中的“不破坏原有类封装性”怎么理解?
  • 获取一个对象的内部状态并保留在外部是什么意思?
  • Originator、Memento和Caretaker都是什么意思?
  • 没有Caretaker也可以实现备忘录模式怎么理解?那为什么要使用Caretaker呢?
  • 备忘录模式如何搭配命令模式?

本章涵盖:

  • 存储成就记录
  • 备忘录模式
    • 备忘录模式的定义
    • 备忘录模式的说明
    • 备忘录模式的实现范例
  • 使用备志录模式实现成就记录的保存
    • 成就记录保存的功能设计
    • 实现说明
    • 使用备忘录模式的优点
    • 实现备忘录模式的注意事项
  • 备忘录模式面对变化时
  • 结论

存储成就记录

成就系统-观察者模式中,《P级阵地》增加了成就系统来强化玩家对游戏成绩的追求,但以当前项目成就的实现方式,除非不关掉游戏,否则这些成就分数就会在玩家关掉游戏的同时也一并消失。因此,现在需要的是一个“存储成就记录”的功能,让这些记录能够被保存下来,不会因为关闭游戏而消失,如图1所示。

图1 将玩家游戏数据存盘的示意图

数据保存的方式有很多种,以单机游戏而言,大多是以文件的方式存储在玩家的计算机中。若是网络游戏,则玩家的数据大多是存储在游戏服务器(Game Server)的数据库系统(Database)中,不过有时也会将少数的设置信息利用文件系统存储在玩家的计算机上。

在Unity3D开发工具中,提供了多种的信息存储方式:

  • PlayerPrefs类:Unity3D引擎提供的类,使用Key-Value的形式将信息存放在文件系统中,不需自行指定文件路径及名称,适合存储简单的数据。
  • 自行存储文件:使用C#中的System.IO.File类,自行打开文件以及存储数据到文件中,需自行定义存储数据的格式以及文件路径和名称,适合存储复杂的数据。
  • 使用XML格式存盘:使用C#中的System.Xml下的类,以XML格式存盘,原理同上一种方式,只是存档的内容会以XML格式来表现,适合存储复杂的数据。

《P级阵地》中的成就项目并不复杂,全都可以使用Key-Value方式存储,所以《P级阵地》将使用PlayerPrefs类实现保存记录的功能。

Unity3D的PlayerPrefs类提供了众多的存储和读取方法:

  • SetFloat:存储为浮点数。
  • SetInt:存储为整数。
  • SetString:存储为字符串。
  • GetFloat:读取浮点数。
  • GetInt:读取整数。
  • GetString:读取字符串。

上列方法都为静态方法,而调用时只需要提供“Key”和Value值即可,存储方式如下:

1
PlayerPrefs.SetInt("Key1", IntValue);

读出方式如下:

1
int iValue = PlayerPrefs.GetInt("Key1", 0);

GetInt的第2个参数为默认值,当无法读取“以Key值存储的数据”时,会以默认值返回给调用者。

因为当前的成就项目全部都记录在成就系统(AchievementSystem)中,所以采取简单实现的方式,就是直接在成就系统内实现成就记录的存储和读取:

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
// 成就系统
public class AchievementSystem : IGameSystem
{
// 记录的成就项目
private int m_EnemyKilledCount = 0;
private int m_SoldierKilledCount = 0;
private int m_StageLv = 0;

...
// 存储记录
public void SaveData()
{
PlayerPrefs.SetInt ("EnemyKilledCount", m_EnemyKilledCount);
PlayerPrefs.SetInt ("SoldierKilledCount", m_SoldierKilledCount);
PlayerPrefs.SetInt ("StageLv", m_StageLv);
}

// 读取记录
public void LoadData()
{
m_EnemyKilledCount = PlayerPrefs.GetInt("EnemyKilledCount", 0);
m_SoldierKilledCount = PlayerPrefs.GetInt("SoldierKilledCount", 0);
m_StageLv = PlayerPrefs.GetInt ("StageLv", 0);
}
...
}

直接将数据存盘功能实现于游戏功能类中,一般来说是不太理想的方式,因为违反了单一职责原则(SRP)。也就是说,各个游戏功能类应该专心处理与游戏相关的功能,至于“记录保存”的功能,应该由其他的专职类来实现才是。因为每个平台上数据保存的方式迥异,也会因项目的需求而采取不同的方式,所以这一部分不该由游戏功能类自己去实现。

如果每个游戏功能类都有记录保存的需求,但又没有专职的记录保存类,那么,可想而知的是,每个游戏功能类都实现自己的记录保存功能,这样就会造成功能重复实现、记录单元格式不统一或是存盘名称重复的问题。

因此应该要有一个专职的类来“负责记录保存”,这个类会去获取各个系统想要存储的记录后,再一并执行数据保存的操作。虽然《P级阵地》中只有成就系统(AchievementSystem)有记录保存的需求,但我们还是要实现另一个类来专门负责记录的保存

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
// 成就系统
public class AchievementSystem
{
// 记录的成就项目
private int m_EnemyKilledCount = 0;
private int m_SoldierKilledCount = 0;
private int m_StageLv = 0;

// 记录的成就项目
public int GetEnemyKilledCount ()
{
return m_EnemyKilledCount;
}

public int GetSoldierKilledCount ()
{
return m_SoldierKilledCount;
}

public int GetStageLv()
{
return m_StageLv;
}

public void SetEnemyKilledCount (int iValue)
{
m_EnemyKilledCount = iValue;
}

public void SetSoldierKilledCount (int iValue)
{
m_SoldierKilledCount = iValue;
}

public void SetStageLv(int iValue)
{
m_StageLv = iValue;
}

// 存储记录
public void SaveData()
{
AchievementSaveData.SaveData(this);
}

// 读取记录
public void LoadData()
{
AchievementSaveData.LoadData(this);
}
}

AchievementSaveData.cs

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
// 成就记录存盘
public static class AchievementSaveData
{
// 存盘
public static void SaveData( AchievementSystem theSystem )
{
PlayerPrefs.SetInt("EnemyKilledCount", theSystem.GetEnemyKilledCount());
PlayerPrefs.SetInt("SoldierKilledCount", theSystem.GetSoldierKilledCount());
PlayerPrefs.SetInt("StageLv", theSystem.GetStageLv());
}

// 读取
public static void LoadData( AchievementSystem theSystem )
{
int tempValue = 0;
tempValue = PlayerPrefs.GetInt("EnemyKilledCount",0);
theSystem.SetEnemyKilledCount(tempValue);

tempValue = PlayerPrefs.GetInt("SoldierKilledCount",0);
theSystem.SetSoldierKilledCount(tempValue);

tempValue = PlayerPrefs.GetInt("StageLv",0);
theSystem.SetStageLv(tempValue);
}
}

新的做法实现了一个AchievementSaveData类,它是专门负责成就的记录保存。由于AchievementSaveData类存盘时还是必须获取和设置成就系统(AchievementSystem)的相关信息,所以成就系统就必须声明对应的信息获取方法。当然如果使用C#实现的话,可以利用getter和setter的语句,这样可以少写一些程序代码,但无论是采用哪种方式,这种实现方式最主要的缺点是:成就系统必须将“内部成员数据”全部对外公开

对外公开成员数据是有风险的,如果从接口隔离原则(ISP)的角度来看,除非必要,否则类应该尽量减少对外显示内部的数据结构,以减少客户端有机会破坏内部成员的记录,而对外公布过多的操作方法,也容易增加与其他系统的耦合度(即依赖度)

所以,对于这个版本的存盘功能而言,修改目标除了“将记录保存交由项目类去负责”之外,也必须同时“减少不必要的成员访问方法”。在GoF的设计模式中的备忘录模式为我们提供了修改时的引用模板。

备忘录模式

本章将使用备忘录模式来保存游戏数据,因为要保存游戏的数据,所以需要重新设计游戏系统中的某些功能。备忘录模式是用来记录对象状态的设计模式,如果将系统内某一时间内的对象状态全都保留下来,那么就等于实现了“系统快照”(Snapshot,即系统保存)的功能,为用户提供了可以返回某一个“快照”时点的系统状态。

备忘录模式的定义

GOF对备忘录模式(Memento)的定义是:

1
在不违反封装的原则下,获取一个对象的内部状态并保留在外部,让该对象可以在日后恢复到原先保留时的状态。

如果以“游戏存盘”的功能来解释备忘录模式的定义,就能更明白一些,也就是:在不增加各个游戏系统类成员的“存取”方法的前提之下,存盘功能要能够获取各个游戏系统内部需要保存的信息,然后在游戏重新开始时,再将记录读取,并重新设置给各个游戏系统。游戏存盘示意图如图2所示。

图2 游戏存盘示意图

那么,怎么让现有的游戏系统“不违反封装的原则”,还能提供内部的详细信息呢?其实如果从另一个方向来思考,那么就是由游戏系统本身“主动提供内部信息”给存盘功能,而且也“主动”向存盘功能提供与自己(系统)有关的信息。

这与原本由游戏本身提供一大堆“存取内部成员”的方法,有什么不同?最大的不同在于:游戏系统提供存取内部成员方法,是让游戏系统处于“被动”状态。游戏系统本身不能判断提供这些访问方法后,会不会有什么后遗症,而备忘录模式则是将游戏系统由“被动”改为“主动提供”,意思是,由游戏系统自己决定要提供什么信息和记录给存盘功能,也由游戏系统决定要从存盘功能中,读取什么样的数据及记录还原给内部成员。而这些信息记录的设置和获取的实现地点都在“游戏系统类内”,不会发生在游戏系统类以外的地方,如此就可确保类的“封装的原则”不被破坏。

备忘录模式的说明

备忘录模式的概念,是让有记录保存需求的类,自行产生要保存的数据,外界完全不用了解这些记录产生的过程及来源。另外,也让类自己从之前的保存数据中找回信息,自行重设类的状态。

基本的备忘录模式结构如图3所示。

图3 游戏存盘示意图

GoF参与者的说明如下:

  • Originator(记录拥有者)
    • 拥有记录的类,内部有成员或记录需要被存储。
    • 会自动将要保存的记录产生出来,不必提供存取内部状态的方法
    • 会自动将数据从之前的保存记录中取回,并恢复到之前的状态。
  • Memento(记录保存者)
    • 负责保存Originator的内部信息。
    • 无法获取Originator的信息,必须由Originator主动设置和读取
  • Caretaker(管理记录保存者)
    • 管理Originator产生出来的Memento。
    • 可以增加对象管理容器来保存多个Memento。

备忘录模式的实现范例

Originator指的是系统中拥有需要保存信息的类:

Listing1 需要存储内容信息(Memento.cs)

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
public class Originator
{
string m_State; // 状态,需要被保存

public void SetInfo(string State)
{
m_State = State;
}

public void ShowInfo()
{
Debug.Log("Originator State:" + m_State);
}

// 产生要存储的记录
public Memento CreateMemento()
{
Memento newMemento = new Memento();
newMemento.SetState( m_State );
return newMemento;
}

// 设置要恢复的记录
public void SetMemento( Memento m)
{
m_State = m.GetState();
}
}

Originator类内拥有一个需要被保存的成员:m_State,而这个成员将由Originator在CreateMemento方法中自行产生Memento对象,并将存储数据(m_State)设置给Memento对象,最后传出到客户端。客户端也可以将之前保留的Memento对象,通过Originator的类方法:SetMemento传入类中,让Originator可以恢复到之前记录的状态。

Memento类的定义并不复杂,原则上是定义需要被存储保留的成员,并针对这些成员设置访问方法

Listing2 存放Originator对象的内部状态(Memento.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Memento
{
string m_state;

public string GetState()
{
return m_state;
}


public void Setstate(string state)
{
m_state= state;
}
}

如果只是单纯记录需要保存的数据,也可以直接使用C#语句的getter和setter语句来实现,让程序代码更简洁。

在测试程序代码中,先将Originator的状态设置为“Step1”之后,利用CreateMemento方法将内部状态保留下来。随后设置为“Step2”,但假设此时设置发生错误,没有关系,只要将之前保留的状态,利用SetMemento方法再设置回去就可以了:

Listing3 测试备忘录模式(MementoTest.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void UnitTest () 
{
Originator theOriginator = new Originator();

// 设置信息
theOriginator.SetInfo( "Step1" );
theOriginator.ShowInfo();

// 存储状态
Memento theMemento = theOriginator.CreateMemento();

// 设置新的信息
theOriginator.SetInfo( "Step2" );
theOriginator.ShowInfo();

// 恢复
theOriginator.SetMemento( theMemento );
theOriginator.ShowInfo();
}

执行结果

1
2
3
Originator State:Info:Step1
Originator State:Info:Step2
Originator State:Info:Step1

除了测试程序保留下来的Memento对象,如果再搭配Caretaker(管理记录保存者)类,就可以具备同时保留多个记录对象的功能,让系统可以决定Originator要恢复到哪个版本:

Listing4 保管所有的Memento(MementoTest.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Caretaker
{
Dictionary<string, Memento> m_Mementos = new Dictionary<string, Memento>();

// 增加
public void AddMemento(string Version, Memento theMemento)
{
if(m_Mementos.ContainsKey(Version) == false)
m_Mementos.Add(Version, theMemento);
else
m_Mementos[Version] = theMemento;
}

// 获取
public Memento GetMemento(string Version)
{
if(m_Mementos.ContainsKey(Version) == false)
return null;
return m_Mementos[Version];
}
}

Caretaker类使用Dictionary泛型容器来保存多个Memento对象,让测试程序可以决定要恢复到哪一个版本

Listing5 测试管理记录保存者(MementoTest.cs)

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
void UnitTest2 () 
{
Originator theOriginator = new Originator();
Caretaker theCaretaker = new Caretaker();

// 设置信息
theOriginator.SetInfo("Version1");
theOriginator.ShowInfo();
// 保存
theCaretaker.AddMemento("1", theOriginator.CreateMemento());

// 设置信息
theOriginator.SetInfo( "Version2" );
theOriginator.ShowInfo();
// 保存
theCaretaker.AddMemento("2", theOriginator.CreateMemento());

// 设置信息
theOriginator.SetInfo( "Version3" );
theOriginator.ShowInfo();
// 保存
theCaretaker.AddMemento("3", theOriginator.CreateMemento());

// 退回到第2版
theOriginator.SetMemento(theCaretaker.GetMemento("2"));
theOriginator.ShowInfo();

// 退回到第1版
theOriginator.SetMemento( theCaretaker.GetMemento("1"));
theOriginator.ShowInfo();
}

执行结果

1
2
3
4
5
Originator State:Version1
Originator State:Version2
Originator State:Version3
Originator State:Version2
Originator State:Version1

使用备志录模式实现成就记录的保存

如果游戏是实现在“存储成本”比较高或“存储空间”比较小的环境下,那么就会限制玩家可以存储信息的数量。例如,在网络在线游戏中,玩家的数据存储在服务器端的数据库系统中,因为“存储成本”比较高,所以网络在线游戏通常会限制每一个玩家可以设置多少个角色(所以为什么会有需要花钱才能多用一只角色的游戏设计)。如果是移动游戏机,一般内部使用的“存储空间”比较小,通常每款游戏只能存储三份记录。最没有限制的就是个人计算机主机上的单机游戏了,因其相关的限制比较不明显,所以通常不会限制可以存盘的数量。

成就记录保存的功能设计

对于《P级阵地》的成就系统(AchievementSystem)而言,只需要保留每一项成就项目的最佳纪录,并不需要保留多个版本。因此,在运用备忘录模式时,省去了Caretaker(管理记录)保存者的实现,类结构如图4所示。

图4 运用备忘录模式保留游戏成就项目的结构图

参与者的说明如下:

  • AchievementSystem:成就系统拥有多项成就记录需要被存储,所以提供CreateSaveData方法让外界获取“存盘记录”,并利用SetSaveData方法将“之前存储的成就记录”回存。
  • AchievementSaveData:记录成就系统中需要被存盘的成就项目,并实现Unity3D中的数据保存功能。
  • PBaseDefenseGame:配合游戏启动和关闭,适时地调用成就系统的CreateSaveData、SetSaveData方法,来实现成就记录的保存和读取。

实现说明

在运用备忘录模式之后,将成就系统(AchievementSystem)要存储的项目,定义在成就存盘功能(AchievementSaveData)中,并增加对应的访问方法,让成就系统可以在产生存盘信息时进行设置:

Listing6 成就记录存盘(AchievementSaveData.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class AchievementSaveData 
{
// 要存盘的成就信息
public int EnemyKilledCount {get;set;}
public int SoldierKilledCount {get;set;}
public int StageLv {get;set;}

public AchievementSaveData()
{}

public void SaveData()
{
PlayerPrefs.SetInt("EnemyKilledCount", EnemyKilledCount);
PlayerPrefs.SetInt("SoldierKilledCount", SoldierKilledCount);
PlayerPrefs.SetInt("StageLv", StageLv);
}

public void LoadData()
{
EnemyKilledCount = PlayerPrefs.GetInt("EnemyKilledCount",0);
SoldierKilledCount = PlayerPrefs.GetInt("SoldierKilledCount",0);
StageLv = PlayerPrefs.GetInt("StageLv",0);
}
}

类中也使用Unity3D的PlayerPrefs类来实现记录的存储和读取功能。

原本在成就系统中增加的访问方法都被删除了,并增加产生成就记录存盘和恢复的方法:

Listing7 成就系统(AchievementSystem.cs)

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
public class AchievementSystem : IGameSystem 
{
private AchievementSaveData m_LastSaveData = null; // 最后一次的存盘信息

// 记录的成就项目
private int m_EnemyKilledCount = 0;
private int m_SoldierKilledCount = 0;
private int m_StageLv = 0;

...
// 产生存盘数据
public AchievementSaveData CreateSaveData()
{
AchievementSaveData SaveData = new AchievementSaveData();

// 设置新的高分者
SaveData.EnemyKilledCount = Mathf.Max(m_EnemyKilledCount, m_LastSaveData.EnemyKilledCount);
SaveData.SoldierKilledCount = Mathf.Max(m_SoldierKilledCount, m_LastSaveData.SoldierKilledCount);
SaveData.StageLv = Mathf.Max(m_StageLv, m_LastSaveData.StageLv);

return SaveData;
}

// 设置旧的存盘数据
public void SetSaveData(AchievementSaveData SaveData)
{
m_LastSaveData = SaveData;
}
}

因为不必实现保存多个存盘的版本,并且为了配合游戏系统的运行,所以将成就系统与成就记录存盘的一并实现并放在PBaseDefenseGame中:

Listing8 成就记录串接(PBaseDefenseGame.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class PBaseDefenseGame
{
...
// 存盘
private void SaveData()
{
AchievementSaveData SaveData = m_AchievementSystem.CreateSaveData();
SaveData.SaveData();
}

// 读取存盘记录
private AchievementSaveData LoadData()
{
AchievementSaveData OldData = new AchievementSaveData();
OldData.LoadData();
m_AchievementSystem.SetSaveData( OldData );
return OldData;
}
}

除了要保留成就记录外,也需要显示界面才能显示这些记录,而《P级阵地》选择了在暂停界面(GamePauseUI)上显示这些信息,如图5所示。

图5 暂停界面

Listing9 游戏暂停界面(GamePauseUI.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class GamePauseUI : IUserInterface 
{
private Text m_EnemyKilledCountText = null;
private Text m_SoldierKilledCountText = null;
private Text m_StageLvCountText = null;

...
// 显示暂停
public void ShowGamePause( AchievementSaveData SaveData )
{
m_EnemyKilledCountText.text = string.Format("当前杀敌数总和:{0}", SaveData.EnemyKilledCount);
m_SoldierKilledCountText.text = string.Format("当前我方单位阵亡总和:{0}", SaveData.SoldierKilledCount);
m_StageLvCountText.text = string.Format("最高关卡数:{0}", SaveData.StageLv);
Show();
}
...
}

使用备忘录模式的优点

在运用备忘录模式之后,记录成就的功能就从成就系统中独立出来,让专职的AchievementSaveData类负责存盘的工作,至于AchievementSaveData类该怎么实现存盘功能,成就系统也不必知道。并且成就系统本身也保留封装性,不必对外开放过多的存取函数来获取类内部的状态,信息的设置和恢复也都在成就系统内部完成

实现备忘录模式的注意事项

当每个游戏系统都有存盘的需求时,负责保存记录AchievementSaveData类就会过于庞大,此时可以让各个系统的存盘信息以结构化方式编排,或是内部再以子类的方式加以规划。另外也可以配合串行化工具,先将要存盘的信息转换成XML或JSON格式,然后再使用存盘工具来保存那些已转换好的格式数据,这样也能减少针对每一项存盘信息读写的实现。

备忘录模式面对变化时

一般复杂的单机游戏都提供了存盘功能,而且可以存盘的数量可能不只一份,如果《P级阵地》改变游戏方式,或者是想提供多人共同游玩时,那么就必须加入多份存盘的功能,此时系统可以增加Caretaker(管理记录保存者)类,来维护多个版本的记录存盘(可以引用备忘录模式的实现范例一节的实现说明)。多人共玩一个游戏的存盘示意图如图6所示。

图6 多人共玩一个游戏的存盘示意图

另外,如果后续《P级阵地》中不止一个系统需要存盘功能时,那么可以增加一个“游戏存盘系统(GameDataSaver)”,将原有的AchievementSaveData类声明为该类的成员,再加入其他系统的存盘类,统一由游戏存盘系统负责实现。而此时的游戏存盘系统也可以扮演Caretaker的角色,维护多个版本的存盘功能。

结论

运用备忘录模式提供了一个不破坏原有类封装性的“对象状态保存”方案,并让对象状态保存可以存在多个版本,并且还可选择要恢复到哪个版本。

与其他模式的合作

如果备忘录模式搭配命令模式(Command)来作为命令执行前的系统状态保存,就能让命令在执行恢复操作时,能够恢复到命令执行前的状态。

其他应用方式

游戏服务器常需要针对执行性能进行分析及追踪,所以要定期地让各个系统产生日志(Log),汇报各个游戏系统当前的执行情况,如内存的使用、执行时占用的时间、数据存取的频率等。要让系统日志功能更有弹性的话,可以使用备忘录模式让各个游戏系统产生要定期汇报的信息,并将信息内容的产生交给各个游戏系统,而日志系统只负责存储记录。

0%