前言:为什么你的存档代码越来越难维护?
在 Unity 项目中,数据持久化(存档/读档)是几乎所有游戏都绕不开的功能。很多初学者会在各个 UI 脚本里直接写 PlayerPrefs.SetInt("Phone", 1),结果项目发展到中期,散落在几十个文件里的 PlayerPrefs 键名拼写错误频发,改一个字段要全局搜索,改完还得祈祷没漏掉哪个地方。
本文介绍一套**"数据层与 UI 层彻底解耦"** 的架构方案。核心思想是:所有持久化数据集中在一个单例中管理,通过 C# 属性访问器(get/set)自动完成读写,UI 层只负责展示和交互,完全不感知存储细节。
这套方案在商业项目中经过大量验证,尤其适合中小型 Unity 项目快速搭建健壮的设置系统。
首先我需要给大家科普存储的三大法则后面我们会说明这个东西:1、存储即时化2、读取仅开始3、运行时自用
一、架构全景:三层的职责划分
在开始写代码之前,先明确三层各自的职责:
| 层级 | 核心脚本 | 职责 |
|---|---|---|
| 数据层 | GameData |
集中管理所有持久化数据,通过属性访问器自动读写 PlayerPrefs。外部完全不知道存储介质是什么。 |
| 管理层 | UIManager |
负责 UI 的打开、关闭、缓存、生命周期调度。与数据层隔离,不直接读写任何存档数据。 |
| 视图层 | SettingPop / SettingPopInGame |
负责 UI 展示和用户交互。打开时从 GameData 读取数据,用户操作时写入 GameData。 |
核心原则 :数据流向永远是单向的------GameData 是唯一的数据源,UI 层只是它的"镜子"。
二、数据层:用属性访问器封装 PlayerPrefs
2.1 传统写法的痛点
csharp
// ❌ 传统写法:散落在各个脚本中
PlayerPrefs.SetInt("PhoneEnabled", 1);
PlayerPrefs.SetInt("SoundEnabled", 0);
// 键名是字符串,拼写错误编译器发现不了
2.2 封装后的写法
csharp
public class GameData : MonoBehaviour
{
public static GameData Instance { get; private set; }
// 电话开关:默认开启 (true)
public bool PhoneEnabled
{
get => PlayerPrefs.GetInt("PhoneEnabled", 1) == 1;
set
{
PlayerPrefs.SetInt("PhoneEnabled", value ? 1 : 0);
PlayerPrefs.Save(); // 立即持久化
}
}
// 声音开关:默认开启 (true)
public bool SoundEnabled
{
get => PlayerPrefs.GetInt("SoundEnabled", 1) == 1;
set
{
PlayerPrefs.SetInt("SoundEnabled", value ? 1 : 0);
PlayerPrefs.Save();
}
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
}
2.3 这段代码做了什么?
| 组成部分 | 作用 |
|---|---|
get 访问器 |
外部读取时,自动从 PlayerPrefs 取值,并返回 bool。默认值 1 代表开启。即读取存档别人调用为 bool phone = GameData.Instance.PhoneEnabled; 而get => PlayerPrefs.GetInt("PhoneEnabled", 1) == 1;这个读取到了我们PhoneEnabled;也就是给短暂生命的UI赋值 |
set 访问器 |
外部赋值时,自动将值写入 PlayerPrefs,并立即调用 Save() 落盘。即存档 GameData.Instance.PhoneEnabled = newState;就会调用set的方法 set { PlayerPrefs.SetInt("PhoneEnabled", value ? 1 : 0); PlayerPrefs.Save(); }赋值如果true及为1,newstate给他一个值赋值及时存档最后以1/0形式存档到单例中 |
Awake 单例初始化 |
确保全局只有一个实例,且场景切换时不销毁。 |
外部调用者完全不知道底层是 PlayerPrefs、JSON 还是数据库------这就是封装的价值。
三、视图层:UI 与数据层的联动
3.1 打开时读取(OnOpen)
当设置弹窗打开时,需要从 GameData 读取存储的状态,并刷新 UI:
csharp
protected override void OnOpen(object args)
{
base.OnOpen(args);
// ★ 从数据层读取:触发 get 访问器
bool phone = GameData.Instance.PhoneEnabled;
bool sound = GameData.Instance.SoundEnabled;
// 将数据绑定到 UI 控件
BindToggleButton(PhoneToggleButton, phone);
BindToggleButton(SoundToggleButton, sound);
}
3.2 变化时存储(OnToggleButtonClicked)
当用户点击开关切换状态时,将新状态写入 GameData:
csharp
private void OnToggleButtonClicked(Button btn)
{
// ... 切换逻辑,得到 newState ...
// ★ 写入数据层:触发 set 访问器,自动保存
if (btn == PhoneToggleButton)
GameData.Instance.PhoneEnabled = newState;
else if (btn == SoundToggleButton)
GameData.Instance.SoundEnabled = newState;
}
3.3 完整的时序图
四、三大持久化法则
这套架构之所以健壮,核心在于遵循了三条黄金法则:
法则一:一变就存(即时存储)
原则:只要数据发生变化,立刻持久化到硬盘。
csharp
// 在 set 访问器中立即保存
set
{
PlayerPrefs.SetInt("Key", value ? 1 : 0);
PlayerPrefs.Save(); // ★ 立刻落盘
}
为什么不能等到场景切换或游戏退出才存?
-
Unity 应用可能被系统强杀、断电、崩溃,依赖
OnApplicationQuit存档极不可靠。 -
PlayerPrefs.Save()的调用开销很小(毫秒级),对用户体验无影响。
法则二:开时就读(显示前加载)
原则:UI 在打开显示之前,从数据层读取最新的值。
csharp
protected override void OnOpen(object args)
{
// ★ 每次打开都重新读取,保证显示的是最新数据
bool phone = GameData.Instance.PhoneEnabled;
BindToggleButton(PhoneToggleButton, phone);
}
为什么不在 Start 或 Awake 里读一次就够了?
-
如果用户在别处修改了数据(比如另一个设置面板、或者重置功能),当前面板打开时应该看到最新值。
-
OnOpen每次打开都会执行,保证了数据的新鲜度。
法则三:运行时自用(内存即真相)
原则:在UI短暂面板打开的时候所有的变化自己调节自己控制不需要实时调用读取存档,除非被销毁后启用才使用get读取
csharp
//自己的bool initialState自己在refresh里用来控制变化
public Button PhoneToggleButton;
public Button SoundToggleButton;
private void BindToggleButton(Button btn, bool initialState)
{
RefreshToggleUI(btn, initialState);
」
private void RefreshToggleUI(Button btn, bool state)
{
Image btnImage = btn.GetComponent<Image>();
if (btnImage != null)
btnImage.color = state ? onColor : offColor;
if (labelDict.TryGetValue(btn, out Text label))
label.text = state ? "ON" : "OFF";
if (checkmarkDict.TryGetValue(btn, out RectTransform checkmarkRT))
{
Vector2 targetPos = state ? onPosition : onPosition + new Vector2(moveDistance, 0);
checkmarkRT.anchoredPosition = targetPos;
}
}
根据自己生命内的state进行控制UI不到不销毁重新启动绝对不用单例子的get读取数据给生命内的变量
// 不要每帧去读写 PlayerPrefs
// ❌ void Update() { var v = PlayerPrefs.GetInt("Phone"); }
为什么?
-
PlayerPrefs的读写虽然快,但本质是 I/O 操作,高频调用仍会有性能损耗。 -
通过
GameData的属性访问器,未来可以轻松加入缓存层(比如在get中返回内存变量,只在set时写硬盘),而对 UI 层完全透明。
五、完整调用链路示例
以"用户点击电话开关"为例,走一遍完整的数据流:
| 步骤 | 执行位置 | 代码 | 触发的动作 |
|---|---|---|---|
| 1 | SettingPopInGame.OnToggleButtonClicked |
bool newState = !toggle.isOn; |
计算新状态 |
| 2 | 同上 | GameData.Instance.PhoneEnabled = newState; |
触发 setter |
| 3 | GameData 的 setter |
PlayerPrefs.SetInt("PhoneEnabled", ...) |
写入 PlayerPrefs |
| 4 | 同上 | PlayerPrefs.Save(); |
立即落盘 |
| 5 | SettingPopInGame |
RefreshToggleUI(btn, newState); |
刷新 UI 显示 |
六、对比总结
| 维度 | 传统做法(散落式) | 本架构(集中式 + 属性访问器) |
|---|---|---|
| 存储键名 | 字符串散落在各个脚本中 | 集中在 GameData 的属性中 |
| 读取方式 | 各处直接调用 PlayerPrefs.GetInt |
通过 GameData.Instance.xxx 属性访问 |
| 写入方式 | 各处直接调用 PlayerPrefs.SetInt |
通过 GameData.Instance.xxx = value 赋值 |
| 修改键名 | 全局搜索替换,容易遗漏 | 只需修改 GameData 中对应的属性 |
| 扩展新字段 | 需要在所有使用处添加代码 | 只需在 GameData 中添加一个属性 |
| 单元测试 | 难以 mock PlayerPrefs | 可以替换 GameData 的实现,易于测试 |
七、结语
这套"数据层集中管理 + 属性访问器自动读写 + UI 层单向数据流"的架构,本质上是将变化隔离在数据层内部 。UI 层只关心"显示什么"和"用户做了什么",不关心"数据怎么存"。未来即使需要从 PlayerPrefs 迁移到 SQLite 或云端存档,也只需要修改 GameData 这一个文件,UI 层完全不受影响。
三大法则回顾:
-
一变就存:数据变化立即持久化,不依赖生命周期事件。
-
开时就读:UI 打开时从数据层读取最新值,保证数据新鲜。
-
运行时自用:运行期间依赖内存数据,不频繁读写硬盘。
遵循这三个法则,你的存档系统将变得健壮、可维护、易于扩展。后面我们会单独给出链接给大家观看案例根据这个get set和法则你的代码将会更容易观看