存档序列化
自定义组件最关键的一步是让数据在保存/读取时不丢失。ONI 有一套基于属性的序列化系统,大部分情况下你只需要加几个标记。
为什么需要关心
不加序列化标记的字段,读取存档后会回到默认值。建筑的状态机、计时器、进度条、储存内容——这些如果丢了,玩家体验非常差。
三步启用序列化
1. 类级别标记
[SerializationConfig(MemberSerialization.OptIn)]
public class MyComponent : KMonoBehaviour
{
}MemberSerialization.OptIn 表示"只序列化我明确标记的字段"。这是游戏里绝大多数组件的默认选择。
两个可用值:
| 值 | 含义 |
|---|---|
OptIn | 只序列化标了 [Serialize] 的成员(推荐) |
OptOut | 自动序列化所有 public 字段/属性,除非标了 [NonSerialized] |
如果整个类层级都没有 [SerializationConfig],默认是 OptOut。这意味着所有 public 字段都会被保存。为了避免意外保存大量无关数据,新组件推荐始终明确声明 OptIn。
2. 字段级别标记
[SerializationConfig(MemberSerialization.OptIn)]
public class MyComponent : KMonoBehaviour
{
[Serialize]
private int workCount;
[Serialize]
public float progress;
[Serialize]
public bool isEnabled;
}[Serialize] 是一个空标记属性,不限制访问级别——private、protected、public 都可以。
3. 可序列化的类型
[Serialize] 支持的字段类型:
| 类别 | 类型 |
|---|---|
| 数值 | int、float、double、long、short、byte、bool |
| 字符串 | string |
| Unity 结构 | Vector2、Vector3、Color、Vector2I |
| 集合 | List<T>、Dictionary<K,V>、HashSet<T>、Queue<T>、T[] |
| 自定义类/结构 | 任何未继承 MonoBehaviour 的类,且自身也有 [SerializationConfig] 和 [Serialize] 标记的字段 |
不支持直接序列化: GameObject、Component、delegate、Texture2D。引用其他对象需要用 Ref<T> 或 ResourceRef<T>。
完整示例
做一个带计数器和状态的组件:
using KSerialization;
using UnityEngine;
namespace MyMod
{
[SerializationConfig(MemberSerialization.OptIn)]
public class MyStorage : KMonoBehaviour
{
[Serialize]
private int itemsProcessed;
[Serialize]
private float nextProcessTime;
[Serialize]
public bool isActive;
[Serialize]
private Vector3 lastPosition;
protected override void OnSpawn()
{
base.OnSpawn();
// 存读档后这些字段的值不会丢失
}
}
}关键:using KSerialization; 不要漏。[Serialize] 和 [SerializationConfig] 都在 KSerialization 命名空间里。
不支持直接序列化的解决方案
引用其他 GameObject:Ref<T>
[Serialize]
public Ref<MinionAssignablesProxy> assignableProxy;Ref<T> 通过对象的 InstanceID 保存引用。读取存档时,即使 GameObject 被销毁重建,只要 InstanceID 匹配就能恢复引用。
引用数据库资源:ResourceRef<T>
[Serialize]
private ResourceRef<Accessory> targetAccessory;
// 写入前自动存 guid
[OnSerializing]
private void OnSerializing()
{
targetAccessory.Set(actualAccessory);
}
// 读取后从 guid 恢复引用
[OnDeserialized]
private void OnDeserialized()
{
actualAccessory = targetAccessory.Get();
}[OnSerializing] 和 [OnDeserialized] 是 C# 标准序列化回调,ONI 完全支持。
SkipSaveFileSerialization
有些字段你希望在内存中保留,但不需要存进存档(比如运行时缓存、临时计算结果):
[SkipSaveFileSerialization]
private float cachedValue;这个属性告诉序列化系统跳过该字段。只在 OptOut 模式下有意义——OptIn 模式下没标 [Serialize] 就不会被保存。
常见保存读取流程
- 游戏保存时,
SaveManager遍历所有场景对象。 SaveLoadRoot找到 GameObject 上所有ISaveLoadable组件(所有 KMonoBehaviour 都自动实现)。- 序列化系统用反射找到每个组件的
[SerializationConfig]和[Serialize]标记。 - 收集字段值,写入
BinaryWriter。 - 读取时,先还原类型模板,再逐字段填入保存的值。
存档和代码版本绑定的关键: 序列化是按字段名匹配的,不是按字段顺序。改名要小心——旧存档里的 oldFieldName 找不到对应的新字段,值会丢失。
状态机和序列化
状态机组件自带序列化支持。StateMachineComponent 基类已经声明了 [SerializationConfig(MemberSerialization.OptIn)]。
状态机实例中的 [Serialize] 字段会被保存。StateMachine 本身有 Serializable 标记控制状态保存粒度:
// 在 InitializeStates 中设置保存策略
base.serializable = StateMachine.SerializeType.ParamsOnly; // 只保存参数
// 或
base.serializable = StateMachine.SerializeType.Both_DEPRECATED; // 保存状态+参数(不推荐新项目使用)存档版本迁移
如果改动了数据结构,可能需要处理旧存档。简单方案是添加新字段而不删旧字段:
[Serialize]
private float oldValue; // 保留,旧存档能读
[Serialize]
private float newValue; // 新逻辑用这个
[OnDeserialized]
private void OnDeserialized()
{
if (newValue == 0f && oldValue != 0f)
{
newValue = oldValue * 2f; // 迁移逻辑
}
}更复杂的迁移需要自定义 ISaveLoadableDetails,但这已经超出入门范围。
调试
存档出问题时先做这几个检查:
// 1. 确认字段被标了 [Serialize]
// 2. 确认字段类型是受支持的类型
// 3. 确认类级别有 [SerializationConfig(MemberSerialization.OptIn)]
[OnDeserialized]
private void OnDeserialized()
{
Debug.Log($"[MyComponent] Loaded: items={itemsProcessed}, progress={nextProcessTime}");
}常见崩溃排查:
[Serialize]标记了GameObject或Component直接引用——改用Ref<T>。List<T>的T是不支持序列化的类型——确保 T 是可序列化类型。- 字段改名后旧存档读取报错——保留旧字段名,新增字段替代。
- 忘了加
using KSerialization;——编译通过但序列化静默失效。
常见问题
加了 [Serialize] 但读档后还是默认值 — 先确认类级别有没有 [SerializationConfig(MemberSerialization.OptIn)]。在 OptOut 模式下,[Serialize] 标记不影响行为;在 OptIn 模式下,没标记的字段不会被保存,标记了的才会。
存档文件突然变大很多 — 可能是 OptOut 模式下意外保存了大型 List 或 Dictionary。切换回 OptIn 并明确标记需要的字段。
字段改名后存档崩 — 序列化按名字匹配。改名字等同于删除旧字段 + 新增新字段,旧值丢失是正常的。想兼容旧存档,保留旧字段名。
不要序列化静态字段 — [Serialize] 只对实例成员有效。静态字段属于类型不属于实例,序列化系统不会处理。