ScriptableObject是独立于场景对象的Unity数据对象;保存成资源后,多个组件可以引用同一份项目配置。
引用同一资源的使用方也共享同一个已加载对象。把当前生命值、关卡进度或监听器写进资源,会把本应独立的运行时状态串在一起。
让资源保存编辑期定义,让组件或普通C#对象保存运行时状态;确实需要可变ScriptableObject时,显式创建并销毁运行时实例。
是什么,为什么存在
ScriptableObject 是继承自UnityEngine.Object的数据对象。它不像MonoBehaviour那样挂在GameObject上,而是通常保存成项目中的.asset资源,再由场景、 Prefab 或其他资源引用。最常见的用途是武器定义、角色模板、关卡规则和物品目录。
它解决的是“许多实例需要同一份编辑期数据”的问题。若十个敌人的组件都内嵌同一组配置,Prefab实例会各自保存一份值;若组件引用同一个ScriptableObject资源,配置有一个明确来源,设计人员也能在Inspector中单独编辑它。这里的收益来自共享数据,不等于任何使用ScriptableObject的架构都会更快。
ScriptableObject也给数据一个Unity能识别的对象身份。它可以引用材质、音频、Prefab和其他ScriptableObject,并通过Unity的资源工作流参与序列化。普通C#对象更适合只在内存中存在的短期状态;外部JSON更适合需要在Unity之外生产或交换的数据。
把“定义”和“状态”分开是这项工具的核心边界。剑的名称、图标和基础伤害属于定义;某位玩家手里这把剑的耐久度属于状态。前者适合共享资源,后者通常属于组件、存档模型或专门创建的运行时副本。
工作原理
一个ScriptableObject类只是类型声明。CreateAssetMenu把具体派生类型加入Assets/Create菜单;创建菜单项后,Unity才会生成可保存、可引用的资源实例。也可以用ScriptableObject.CreateInstance<T>()创建不绑定.asset文件的临时实例,或用Object.Instantiate克隆现有对象。
组件上的 序列化字段 保存的是对ScriptableObject资源的引用,不会把资源的所有字段内嵌复制到组件中。Unity加载引用后,两个字段若指向同一资源,读取到的是同一个对象。任何运行时写入也会立即被其他引用者看到。
下面的图把项目数据、加载后的对象和每个角色的运行时状态分开。虚线表示创建新状态,实线表示共享引用。
一次典型的数据流如下:
- 编辑器创建资源,并把支持的字段写入项目数据。
- 场景或Prefab在序列化字段中保存对该资源的引用。
- 运行时加载资源,所有引用者读取同一份定义。
- 每个使用方根据定义创建或初始化自己的可变状态。
资源、临时实例与普通对象
这三种载体的生命周期不同,不能只因为字段形状相似就互换。先确定谁创建它、谁保存它、谁销毁它,再决定类型。
| 载体 | 身份与保存位置 | 合适的数据 |
|---|---|---|
| ScriptableObject资源 | 项目资源,由Unity序列化 | 共享定义、编辑器生成的数据 |
| 运行时ScriptableObject | CreateInstance或Instantiate创建,不自动写回资源 | 必须以Unity对象身份传递的临时数据 |
| 普通C#对象或组件字段 | 由拥有者创建,按应用规则保存 | 每个玩家、敌人或会话的可变状态 |
CreateInstance<T>()创建的是新的ScriptableObject实例,不是磁盘资源。只有编辑器代码调用AssetDatabase.CreateAsset等API后,实例才会绑定到项目文件。AssetDatabase属于UnityEditor,不能混进Player运行时代码。
Instantiate(template)会返回模板的克隆。克隆根对象上的值可以独立修改,但其中指向其他UnityEngine.Object的字段仍表达对象引用,不能假定整个依赖图都变成私有副本。若只是要保存几个计数器,普通C#状态通常更简单。
序列化边界
Unity只保存它支持的字段。公开字段默认参与序列化,私有字段需要[SerializeField];属性本身不会因为有公开getter就自动保存。[NonSerialized]字段可以放临时缓存,但它不会把资源自动变成安全的会话状态容器。
Unity 6.6新增了原生Dictionary<TKey, TValue>序列化。受支持的键和值类型可以放在带[SerializeField]的字典字段中,不再一律需要平行List包装器。不过,多维数组、交错数组和直接嵌套的集合仍不受支持;复杂图还需要包装类型或[SerializeReference]。
ScriptableObject资源保存的是开发期间制作的数据。在已部署的Player中,修改已加载对象不会把更改写回项目中的.asset文件,也不能代替存档系统。需要跨启动保留的数据,应转成明确的存档DTO,并写入应用可写位置。
示例
下面四个示例依次展示共享定义、独立运行时状态、显式运行时克隆和稳定存档标识。它们需要Unity 6.6的编辑器、UnityEngine程序集和场景配置;本地环境没有这些工具,因此每个代码块都明确标记为未执行,输出栏不会伪造Console日志。
创建共享武器定义
WeaponDefinition只暴露读取接口。设计人员通过Assets/Create创建资源,再把同一个资源拖给多个Weapon组件。
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;
[CreateAssetMenu(fileName = "Weapon", menuName = "Game/Weapon Definition")]
public sealed class WeaponDefinition : ScriptableObject
{
[SerializeField] private string stableId = "";
[SerializeField] private string displayName = "Unnamed weapon";
[SerializeField, Min(0)] private int damage = 1;
public string StableId => stableId;
public string DisplayName => displayName;
public int Damage => damage;
private void OnValidate()
{
damage = Mathf.Max(0, damage);
}
}
public sealed class Weapon : MonoBehaviour
{
[SerializeField] private WeaponDefinition definition;
public void Fire()
{
if (definition == null)
{
Debug.LogError($"{name}: weapon definition is missing.", this);
return;
}
Debug.Log($"{definition.DisplayName}: {definition.Damage}", this);
}
}# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.私有序列化字段保留Inspector编辑能力,却不会无意间增加公开写入API。OnValidate只把资源修正到允许的伤害范围;它不是运行时输入验证器,也不应承担会话初始化。
两个Weapon若引用同一资源,就会读到相同名称与伤害。若关卡需要一把数值不同的武器,应创建另一个定义资源或建立明确的修饰规则,而不是在某个武器实例上偷偷改共享资源。
把当前生命值留给组件
ActorDefinition保存可共享的最大生命值,Actor保存每个GameObject自己的当前生命值。这个边界避免了两个敌人互相扣血。
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using UnityEngine;
[CreateAssetMenu(fileName = "Actor", menuName = "Game/Actor Definition")]
public sealed class ActorDefinition : ScriptableObject
{
[SerializeField, Min(1)] private int maxHealth = 100;
public int MaxHealth => maxHealth;
}
public sealed class Actor : MonoBehaviour
{
[SerializeField] private ActorDefinition definition;
public int CurrentHealth { get; private set; }
private void Awake()
{
if (definition == null)
{
Debug.LogError($"{name}: actor definition is missing.", this);
enabled = false;
return;
}
CurrentHealth = definition.MaxHealth;
}
public void TakeDamage(int amount)
{
if (amount <= 0 || !enabled)
return;
CurrentHealth = Mathf.Max(0, CurrentHealth - amount);
}
}# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.定义在Awake中只被读取。场景中的每个Actor都有独立的CurrentHealth,销毁GameObject时状态随拥有者结束。存档需要保留它时,应导出数值,而不是尝试修改ActorDefinition。
这个写法也让测试更直接:给两个实例同一份定义,伤害其中一个,然后断言另一个不变。只检查单个实例无法发现共享状态放错位置的问题。
必须传递Unity对象时克隆
有些现有API要求传入ScriptableObject。此时可以克隆模板,但要让一个明确的拥有者负责初始化和销毁副本。
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using System;
using UnityEngine;
[CreateAssetMenu(fileName = "SessionRules", menuName = "Game/Session Rules")]
public sealed class SessionRules : ScriptableObject
{
[SerializeField, Min(0)] private int retryLimit = 3;
[NonSerialized] private int remainingRetries;
public int RemainingRetries => remainingRetries;
public void BeginSession() => remainingRetries = retryLimit;
public bool TryUseRetry()
{
if (remainingRetries == 0)
return false;
remainingRetries--;
return true;
}
}
public sealed class SessionRulesOwner : MonoBehaviour
{
[SerializeField] private SessionRules template;
public SessionRules RuntimeRules { get; private set; }
private void Awake()
{
if (template == null)
throw new InvalidOperationException("Session rules template is missing.");
RuntimeRules = Instantiate(template);
RuntimeRules.BeginSession();
}
private void OnDestroy()
{
if (RuntimeRules != null)
Destroy(RuntimeRules);
}
}# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.BeginSession显式初始化非序列化状态,因此正确性不依赖资源何时收到OnEnable。拥有者保存克隆引用,并在自身结束时销毁它;模板始终只读。
若调用方并不要求UnityEngine.Object,把remainingRetries放进普通会话对象会更轻。克隆是生命周期选择,不是看到可变字段后的默认动作。
用稳定ID重建存档引用
存档只保存项目定义的itemId和数量。加载时由目录把ID解析回资源引用,存档格式不会依赖某次运行的对象实例。
// # not executed here: Unity Editor 6.6 and UnityEngine are unavailable.
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "Item", menuName = "Game/Item Definition")]
public sealed class ItemDefinition : ScriptableObject
{
[SerializeField] private string stableId = "";
[SerializeField] private string displayName = "Unnamed item";
public string StableId => stableId;
public string DisplayName => displayName;
}
[Serializable]
public sealed class InventoryRecord
{
public string itemId = "";
public int quantity;
}
[CreateAssetMenu(fileName = "ItemCatalog", menuName = "Game/Item Catalog")]
public sealed class ItemCatalog : ScriptableObject
{
[SerializeField]
private Dictionary<string, ItemDefinition> itemsById = new();
public bool TryResolve(InventoryRecord record, out ItemDefinition item)
{
item = null;
if (record == null || string.IsNullOrWhiteSpace(record.itemId))
return false;
return itemsById.TryGetValue(record.itemId, out item);
}
}# not executed here: Unity Editor 6.6 and UnityEngine are unavailable.这里的字典写法针对Unity 6.6。项目仍要验证ID非空且唯一,并为已删除或重命名的ID定义迁移策略。加载失败应产生可诊断结果,不能静默换成任意物品。
不要把GetInstanceID()写进存档。它用于区分当前运行中的对象,不是项目承诺的持久标识;稳定ID必须由内容管线拥有,并在发布前检查重复。
陷阱
修复方法: 先给每个字段标注“编辑期定义”或“运行时状态”。后者放进组件、普通C#状态对象或有明确拥有者的运行时克隆,并用两个共享同一定义的实例做隔离测试。
修复方法: 临时空实例使用ScriptableObject.CreateInstance<T>(),从模板复制使用Object.Instantiate,项目资源则由编辑器菜单或AssetDatabase创建。三条路径对应不同的保存和所有权语义。
修复方法: 把资源生成工具放进仅编辑器程序集或Editor目录。运行时存档使用应用可写目录和独立DTO,不把.asset当作玩家保存文件。
修复方法: 运行时把模板视为只读,并在测试前后对资源做快照或重新加载。临时状态使用显式BeginSession、ResetState和释放路径,不把OnEnable当作“每局恰好一次”。
修复方法: 把批量变化当作数据迁移。扫描现有资源,记录迁移版本,显示将要修改的对象,并在提交前检查YAML或Inspector差异。
修复方法: 对照目标Unity版本检查序列化规则。6.6支持带[SerializeField]的受支持字典字段,但多维数组、交错数组和直接嵌套集合仍需要改造,并应做保存后重新加载测试。
资源标识、加载与变更
引用保存对象身份
Unity对继承自UnityEngine.Object的字段按引用序列化。一个组件引用WeaponDefinition时,保存的是指向该资源对象的关系,而不是把武器字段复制进组件。多个字段指向同一对象时,重新加载后仍应表达共享关系。
普通[Serializable]类默认采用内联序列化,语义不同。同一个普通对象若被两个内联字段引用,保存和重新加载后可能成为两个数据相同的对象。确实需要普通托管对象的多态或共享引用时,才考虑[SerializeReference],并把迁移成本纳入设计。
共享身份使全局修改很容易,也使意外写入更危险。把ScriptableObject公开属性设计成只读只能约束常规调用方,Unity仍会通过序列化写入私有字段,编辑器工具也可能修改它。只读是项目协议,需要验证工具和测试共同守住。
引用不等于加载策略
ScriptableObject解决的是对象与序列化模型,不自动决定整个项目如何打包和流式加载。一个直接序列化引用会建立内容依赖;项目采用其他内容加载系统时,还要遵守该系统的句柄、释放和失败规则。不要把“它是ScriptableObject”当成永不卸载或总在内存中的保证。
运行时对象持有资源引用,就会让该引用在对象可达期间有意义。若缓存表、静态事件或长期服务保留了引用,场景卸载未必等于业务所有权结束。诊断资源寿命时,应从根引用追到最后一个使用方,而不是只看资源最初在哪个场景出现。
资源演进是数据迁移
资源一旦保存,序列化值就成为内容。修改字段初始器只影响之后创建或尚未保存该字段的对象,不能充当已有资源迁移。重命名或删除序列化字段也需要明确的兼容方案和数据检查。
稳妥的迁移工具先扫描并报告,再执行修改。每次迁移应能识别旧版本、拒绝无法判定的数据,并在写入后重新加载验证。版本控制中的资源差异是审查对象,不应被大量无关的自动格式变化淹没。
稳定内容ID同样需要迁移纪律。ID一旦进入存档、遥测或后端数据,就不能因资源改名而顺手变化。删除定义时,要决定旧存档是拒绝加载、映射到替代项,还是保留兼容资源。
Unity 6.6的序列化与生命周期边界
字典支持有明确范围
Unity 6.6可以原生序列化带[SerializeField]的Dictionary<TKey, TValue>。键和值仍必须满足序列化规则,序列化规则分析器会帮助发现部分不支持的签名。字典支持并没有取消所有集合限制。
多维数组、交错数组以及直接嵌套的集合仍需要包装。若数据结构已经复杂到需要多层映射和多态节点,先问它是否仍适合Inspector编辑;外部数据文件、导入步骤或专用编辑器可能更清楚。
从旧双列表迁移到字典前,也要检查序列化顺序、重复键和版本控制可读性。新API消除了通用限制,不代表现有稳定格式必须立刻重写。迁移价值应来自更少的同步错误或更好的编辑体验,而不是追逐新语法。
回调不是会话协议
OnEnable描述ScriptableObject被加载的引擎时机,不描述“新游戏开始”。编辑器重编译、资源重新加载和Play mode配置都可能改变你观察到的调用序列。把业务重置放进去,会把游戏规则绑到工具生命周期。
会话状态需要业务入口,例如BeginSession(saveData)。这个入口应明确初始数据、重复调用策略和失败行为,并由拥有会话的对象调用。OnDisable或OnDestroy可以协助清理临时对象,但不能替代正常完成和主动取消路径。
OnValidate适合在编辑器中限制数值范围、检查缺失引用并给出内容诊断。Player仍应防御来自存档、网络或动态内容的无效输入,因为这些数据不一定经过Inspector。不要让编辑器验证成为运行时正确性的唯一证据。
测试共享与重载边界
最小测试矩阵至少需要两个共享同一资源的消费者。分别修改组件状态、运行时克隆和模板资源,断言只有预期对象变化。随后销毁一个消费者,确认另一个仍能读取定义,而且临时克隆没有残留。
序列化测试要真正保存并重新加载资源。只在当前Inspector中观察字段,无法发现不支持的字段、旧序列化值或重建后身份变化。字典测试还应覆盖空字典、重复内容ID、缺失资源值和顺序无关性。
存档测试从字符串或字节重新构建DTO,再通过目录解析资源。不要把内存中的原对象直接传到断言另一侧,那只证明引用仍存在,没有证明跨进程格式可用。至少保留一个未知ID用例,验证错误信息包含ID和存档位置。
编辑器测试还应覆盖项目采用的Enter Play Mode配置。目标不是让所有配置表现出完全相同的回调日志,而是证明显式会话初始化、缓存清理和订阅所有权不依赖某一种重载方式。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题