Appearance
序列化
Unity 支持序列化哪些类型?
一句话答案: Unity 主要序列化的是字段 Field,不是普通属性 Property;字段必须是 public 或 [SerializeField],并且不能是 static、const、readonly,类型也必须被 Unity 支持。Unity 官方手册也是按这套规则描述的:Unity Script Serialization。
Unity 常见支持序列化的类型:
| 类型 | 例子 |
|---|---|
| 基础类型 | int、float、double、bool、string |
| 枚举 | enum State { Idle, Run } |
| Unity 内置类型 | Vector2、Vector3、Quaternion、Color、Rect、Bounds、AnimationCurve |
| Unity 对象引用 | GameObject、Transform、MonoBehaviour、ScriptableObject、Material、Texture |
| 自定义结构体 | [Serializable] struct ItemData |
| 自定义类 | [Serializable] class PlayerData |
| 数组 | int[]、ItemData[] |
| List | List<int>、List<ItemData> |
常见不能直接序列化的类型:
Dictionary<TKey, TValue> 默认不支持。 多维数组,比如 int[,] 默认不支持。 嵌套容器,比如 List<List<int>> 默认不支持。 普通属性,比如 public int Hp { get; set; } 通常不按正常序列化规则保存。 static、const、readonly 字段不会被 Unity 正常序列化。 委托、事件、方法不能序列化。
代码例子:
c
using System; // 引入 Serializable 特性
using System.Collections.Generic; // 引入 List 集合
using UnityEngine; // 引入 Unity 常用类型
public class SerializeExample : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
public int hp; // public 字段可以被 Unity 序列化
[SerializeField] private float speed; // private 字段加 SerializeField 后也可以序列化
public string playerName; // string 可以被 Unity 序列化
public Vector3 spawnPosition; // Unity 内置类型 Vector3 可以被序列化
public GameObject target; // UnityEngine.Object 派生对象会被序列化为引用
public List<ItemData> items; // List 的元素可序列化时,List 也可以序列化
public ItemData[] itemArray; // 数组元素可序列化时,数组也可以序列化
public static int globalCount; // static 字段不会被 Unity 正常序列化
public readonly int readonlyValue; // readonly 字段不会被 Unity 正常序列化
public Dictionary<int, string> nameMap; // Dictionary 默认不能被 Unity 直接序列化
} // 类结束
[Serializable] // 标记这个自定义类可以被 Unity 序列化
public class ItemData // 定义物品数据类
{ // 类开始
public int id; // public 字段可以被序列化
public string name; // string 字段可以被序列化
public int count; // int 字段可以被序列化
} // 类结束SerializeReference 是什么? 普通 [Serializable] class 默认更像“按值内联保存”。如果你需要保存多态引用,比如一个字段声明为基类或接口,实际装的是不同子类,可以用 [SerializeReference]。Unity 官方说明它会让字段“按引用”序列化,并且有一些限制,比如不能用于 UnityEngine.Object 派生对象,也不能用于字典等不支持的类型:SerializeReference API。
TIP
面试高分回答: Unity 的序列化系统主要服务于场景、Prefab、Inspector 和资源保存,它序列化的是字段而不是普通属性。一个字段要被序列化,首先要是 public 或加 [SerializeField],并且不能是 static、const、readonly;其次字段类型必须是 Unity 支持的类型,比如基础类型、枚举、Unity 内置结构体、UnityEngine.Object 引用、带 [Serializable] 的自定义类或结构体,以及这些类型的一维数组和 List<T>。常见坑是 Dictionary、多维数组、嵌套 List、委托和事件默认不能直接序列化。对于多态数据,可以考虑 [SerializeReference],但核心资源引用还是更常用 ScriptableObject 或 UnityEngine.Object 引用来管理。
Unity 为什么不直接序列化属性?
一句话答案: Unity 不直接序列化属性,是因为属性本质上是 get / set 方法,里面可能有逻辑、副作用、计算、异常或依赖初始化顺序;而 Unity 序列化系统更希望保存的是稳定、直接、可预测的字段数据。
属性不是字段,它背后是方法:
c
public int Hp { get; set; } // 看起来像变量,但本质是 get_Hp 和 set_Hp 方法如果 Unity 序列化属性,就可能要在保存、加载、Inspector 刷新、Prefab 保存、Domain Reload 时调用 get 或 set。这很危险,因为属性里可能写了逻辑。
比如:
c
using UnityEngine; // 引入 Unity 常用类型
public class Player : MonoBehaviour // 定义玩家脚本类
{ // 类开始
private int hp; // 定义真正保存血量的字段
public int Hp // 定义血量属性
{ // 属性开始
get // 定义读取逻辑
{ // get 开始
Debug.Log("读取血量"); // 读取属性时会执行逻辑
return hp; // 返回字段里的血量
} // get 结束
set // 定义写入逻辑
{ // set 开始
hp = Mathf.Clamp(value, 0, 100); // 写入属性时会执行限制逻辑
Debug.Log("修改血量"); // 写入属性时还可能触发其他逻辑
} // set 结束
} // 属性结束
} // 类结束如果 Unity 在序列化或反序列化时直接调用这个属性,就可能在不该执行游戏逻辑的时候执行了 Debug.Log、资源访问、事件通知、状态修改等代码。
推荐写法:字段负责序列化,属性负责访问控制:
c
using UnityEngine; // 引入 Unity 常用类型
public class Player : MonoBehaviour // 定义玩家脚本类
{ // 类开始
[SerializeField] private int hp = 100; // 用私有字段让 Unity 序列化血量
public int Hp // 对外暴露属性
{ // 属性开始
get // 定义读取逻辑
{ // get 开始
return hp; // 返回被 Unity 序列化的字段值
} // get 结束
set // 定义写入逻辑
{ // set 开始
hp = Mathf.Clamp(value, 0, 100); // 对外写入时做合法范围限制
} // set 结束
} // 属性结束
} // 类结束这样做的好处是: Unity 保存的是 hp 字段,稳定直接;外部代码访问的是 Hp 属性,可以加校验和保护。
补充一个细节:
c
using UnityEngine; // 引入 Unity 常用类型
public class Player : MonoBehaviour // 定义玩家脚本类
{ // 类开始
[field: SerializeField] public int Hp { get; private set; } // 让 Unity 序列化自动属性背后的编译器字段
} // 类结束这种写法可以用,但要注意:Unity 序列化的仍然是自动属性背后的字段,不是属性的 get / set 逻辑。
NOTE
面试高分回答: Unity 不直接序列化属性,核心原因是属性不是纯数据,而是方法。get 和 set 里可能有计算、校验、事件通知、资源访问,甚至依赖运行时初始化。Unity 的序列化会发生在场景保存、Prefab 保存、资源导入、反序列化、Domain Reload 等很多编辑器和运行时阶段,如果直接调用属性逻辑,会让序列化过程变得不可预测。字段更像稳定的数据布局,所以 Unity 默认序列化字段。实际开发中我会用 [SerializeField] private 字段保存数据,再用 public 属性封装访问逻辑。
私有字段如何显示在 Inspector?
一句话答案: 私有字段想显示在 Unity Inspector 里,用 [SerializeField]。这样字段仍然是 private,外部代码不能随便访问,但 Unity 可以序列化它并在 Inspector 中显示。
最常用写法:
c
using UnityEngine; // 引入 Unity 常用类型
public class Player : MonoBehaviour // 定义玩家脚本类
{ // 类开始
[SerializeField] private int hp = 100; // 让 private 字段显示在 Inspector 中,同时保持字段私有
public int Hp // 对外暴露只读属性
{ // 属性开始
get // 定义读取逻辑
{ // get 开始
return hp; // 返回私有字段 hp 的值
} // get 结束
} // 属性结束
} // 类结束为什么不直接写 public int hp? 因为 public 字段会暴露给所有外部代码,其他类可以随便改:
c
player.hp = -999; // 外部可以随便修改 public 字段,容易破坏数据而 [SerializeField] private 的好处是: Inspector 里可以调参数。 外部代码不能直接乱改。 类内部可以统一控制数据合法性。 更符合封装思想。
常配合这些特性使用:
c
using UnityEngine; // 引入 Unity 常用类型
public class PlayerConfig : MonoBehaviour // 定义玩家配置脚本类
{ // 类开始
[Header("角色属性")] // 在 Inspector 中显示分组标题
[SerializeField] private int hp = 100; // 让私有血量字段显示在 Inspector 中
[Tooltip("角色移动速度")] // 在 Inspector 中显示提示说明
[SerializeField] private float moveSpeed = 5f; // 让私有移动速度字段显示在 Inspector 中
[Range(0f, 1f)] // 在 Inspector 中显示滑动条并限制范围
[SerializeField] private float criticalRate = 0.2f; // 让私有暴击率字段显示在 Inspector 中
} // 类结束注意点:[SerializeField] 只能让 Unity 支持的字段类型显示。 static、const、readonly 字段一般不适合 Unity 序列化。 Dictionary 默认不能直接显示。 普通属性默认不会直接显示在 Inspector。 字段是 private,但值会被 Unity 序列化保存到场景或 Prefab 里。
WARNING
面试高分回答: Unity 中私有字段默认不会显示在 Inspector,但可以通过 [SerializeField] 暴露给 Unity 的序列化系统。这样既能让策划或开发在 Inspector 中配置参数,又不会把字段变成 public 破坏封装。实际项目里我通常写 [SerializeField] private 字段用于编辑器配置,再通过 public 只读属性或方法对外提供访问和修改入口。这样 Inspector 可调、代码安全性和数据控制都能兼顾。
字典为什么默认不能被 Unity 序列化?
一句话答案:Dictionary<TKey, TValue> 默认不能被 Unity 序列化,是因为 Unity 默认序列化系统更擅长保存“字段”和“一维列表数据”,而 Dictionary 底层是哈希表,有桶、哈希值、比较器、冲突处理等运行时结构,不是一个简单稳定的线性数据。
为什么 List<T> 可以,Dictionary<K,V> 不行?
List<T> 很简单,本质上可以理解成一排数据:
c
[0] sword
[1] shield
[2] potionUnity Inspector 很容易显示它:第 0 个、第 1 个、第 2 个。
但 Dictionary<K,V> 不是单纯一排数据,它更像:
c
key -> hash -> bucket -> entry -> value它内部关心的是快速查找,不是 Inspector 展示。它还涉及这些问题:
key 可以是什么类型? key 重复了怎么办? key 的比较规则是什么? 字典顺序怎么显示? 哈希桶和冲突链要不要保存? 运行时扩容后的内部结构要不要保存?
Unity 默认序列化系统没有给 Dictionary 做一套通用、稳定、可编辑的保存规则,所以默认不支持。
常用解决方案:用 List 保存,运行时转 Dictionary
c
using System; // 引入 Serializable 特性
using System.Collections.Generic; // 引入 List 和 Dictionary
using UnityEngine; // 引入 Unity 常用类型
public class DictionarySerializeExample : MonoBehaviour, ISerializationCallbackReceiver // 定义示例类,并实现序列化回调接口
{ // 类开始
[SerializeField] private List<ItemEntry> itemEntries = new List<ItemEntry>(); // 用 List 保存 Inspector 可见的数据
private Dictionary<int, string> itemMap = new Dictionary<int, string>(); // 用 Dictionary 保存运行时快速查询的数据
public string GetItemName(int id) // 根据物品 id 获取物品名字
{ // 方法开始
itemMap.TryGetValue(id, out string name); // 尝试从字典中根据 id 查找名字
return name; // 返回查到的名字,如果没查到就是 null
} // 方法结束
public void OnBeforeSerialize() // Unity 序列化前调用
{ // 方法开始
itemEntries.Clear(); // 清空旧的 List 数据
foreach (KeyValuePair<int, string> pair in itemMap) // 遍历字典中的所有键值对
{ // foreach 开始
itemEntries.Add(new ItemEntry(pair.Key, pair.Value)); // 把字典数据转换成 List 里的可序列化条目
} // foreach 结束
} // 方法结束
public void OnAfterDeserialize() // Unity 反序列化后调用
{ // 方法开始
itemMap.Clear(); // 清空旧的字典数据
foreach (ItemEntry entry in itemEntries) // 遍历 Inspector 中保存的 List 数据
{ // foreach 开始
itemMap[entry.id] = entry.name; // 用 List 数据重建运行时 Dictionary
} // foreach 结束
} // 方法结束
} // 类结束
[Serializable] // 标记这个类可以被 Unity 序列化
public class ItemEntry // 定义一个可序列化的键值对类
{ // 类开始
public int id; // 保存字典的 key
public string name; // 保存字典的 value
public ItemEntry(int id, string name) // 定义构造函数
{ // 构造函数开始
this.id = id; // 保存传入的 id
this.name = name; // 保存传入的 name
} // 构造函数结束
} // 类结束更简单的理解: Unity 默认能保存的是这种“表格”:
| id | name |
|---|---|
| 1001 | Sword |
| 1002 | Shield |
| 1003 | Potion |
而 Dictionary 更像是运行时为了快速查找建立的“索引系统”。所以开发里经常是:
Inspector / 配置保存:List<Entry>运行时查询:Dictionary<TKey, TValue>
IMPORTANT
面试高分回答:Dictionary 默认不能被 Unity 序列化,主要是因为 Unity 的序列化系统不是完整的 .NET 序列化器,它更关注能稳定保存到场景、Prefab 和 Inspector 的字段数据。Dictionary 底层是哈希表,包含桶数组、Entry、哈希值、比较器和冲突处理,而且 key 的类型和比较规则非常自由,Inspector 也不好提供通用编辑方式。所以 Unity 默认支持数组和一层 List<T>,但不直接支持 Dictionary<TKey,TValue>。实际项目中我会用一个 [Serializable] 的 Entry 列表保存数据,加载后再重建成字典,用 List 负责序列化,用 Dictionary 负责运行时快速查询。
ScriptableObject 是什么?
一句话答案:ScriptableObject 是 Unity 提供的一种可保存成资源文件的对象,它不挂在 GameObject 上,常用来保存配置数据、共享数据和编辑器可调参数。
它和 MonoBehaviour 最大区别:
MonoBehaviour 是组件,挂在 GameObject 上,负责行为逻辑,比如移动、攻击、AI、UI 控制。
ScriptableObject 是资源对象,通常保存成 .asset 文件,放在 Project 面板里,负责保存数据,比如物品配置、技能配置、怪物配置。
代码例子:物品配置
c
using UnityEngine; // 引入 Unity 常用类型
[CreateAssetMenu(fileName = "NewItemData", menuName = "Game/Item Data")] // 让 Unity 可以在右键菜单中创建这个资源
public class ItemData : ScriptableObject // 定义物品数据类,并继承 ScriptableObject
{ // 类开始
public int id; // 物品 id
public string itemName; // 物品名字
public Sprite icon; // 物品图标
public int price; // 物品价格
public string description; // 物品描述
} // 类结束创建后,你可以在 Unity 里右键:
c
Create -> Game -> Item Data然后生成一个 ItemData.asset,比如:
Sword.assetPotion.assetShield.asset
在角色或背包里引用它:
c
using UnityEngine; // 引入 Unity 常用类型
public class ItemSlot : MonoBehaviour // 定义物品格子脚本
{ // 类开始
[SerializeField] private ItemData itemData; // 在 Inspector 中引用一个 ScriptableObject 物品配置
public void PrintItemInfo() // 定义打印物品信息的方法
{ // 方法开始
Debug.Log(itemData.itemName); // 输出物品名字
Debug.Log(itemData.price); // 输出物品价格
} // 方法结束
} // 类结束为什么它有用?
如果不用 ScriptableObject,你可能会把物品数据写在每个 Prefab 或脚本里。这样会有很多重复数据。
比如 100 个怪物都引用同一个“哥布林配置”,如果配置直接写在每个怪物身上,就会重复保存 100 份。 如果用 ScriptableObject,就只需要一份 GoblinData.asset,所有怪物都引用它。
常见使用场景:
物品配置:id、名字、图标、价格、描述。 技能配置:伤害、冷却、范围、消耗、特效。 怪物配置:血量、攻击力、移动速度、掉落表。 关卡配置:出生点、波次、怪物组合。 音频配置:BGM、音效、音量参数。 游戏全局配置:经验曲线、数值表、品质颜色。
重要坑点:
ScriptableObject 是共享资源。 如果多个对象引用同一个 ScriptableObject,它们看到的是同一份数据。
所以配置数据适合放在里面,比如:
技能基础伤害物品名字怪物基础血量
但运行时状态不要随便直接存在里面,比如:
当前血量当前冷却时间当前背包数量
因为这些状态通常是每个角色、每个实例自己独有的。如果放进共享的 ScriptableObject,可能一个对象改了,其他对象也受到影响。
CAUTION
面试高分回答:ScriptableObject 是 Unity 里一种继承自 UnityEngine.Object 的资源型数据容器。它不像 MonoBehaviour 那样挂在 GameObject 上,而是通常保存成 .asset 文件,用来存配置、共享数据和编辑器可调参数。它的好处是能让数据和逻辑分离,多个 Prefab 或对象可以引用同一份数据,减少重复配置和内存浪费。实际项目里我会用它做物品、技能、怪物、关卡、音频等配置,但不会把每个实例独有的运行时状态直接存在里面,因为它是共享资源,直接修改可能影响所有引用者。
ScriptableObject 和 MonoBehaviour 区别是什么?
一句话答案:MonoBehaviour 是挂在 GameObject 上的行为组件,负责移动、攻击、AI、UI 控制等逻辑;ScriptableObject 是保存成 .asset 的数据资源对象,负责物品、技能、怪物、关卡等共享配置。
核心区别:
| 对比点 | MonoBehaviour | ScriptableObject |
|---|---|---|
| 本质 | 组件 | 资源型数据对象 |
| 是否挂 GameObject | 必须挂在 GameObject 上 | 不挂在 GameObject 上 |
| 是否有 Transform | 可以通过 transform 访问 | 没有 transform |
| 生命周期 | Awake、Start、Update、OnDestroy | 没有 Start、Update 这种帧生命周期 |
| 存在哪里 | 场景对象或 Prefab 上 | Project 中的 .asset 文件 |
| 适合做什么 | 行为逻辑 | 配置数据、共享数据 |
| 是否共享 | 通常每个对象一份组件实例 | 多个对象可引用同一份资源 |
MonoBehaviour 示例:角色移动逻辑
c
using UnityEngine; // 引入 Unity 常用类型
public class PlayerMove : MonoBehaviour // 定义玩家移动组件,必须挂在 GameObject 上
{ // 类开始
[SerializeField] private float moveSpeed = 5f; // 在 Inspector 中配置移动速度
private void Update() // 每帧调用一次
{ // 方法开始
float horizontal = Input.GetAxis("Horizontal"); // 获取横向输入
float vertical = Input.GetAxis("Vertical"); // 获取纵向输入
Vector3 direction = new Vector3(horizontal, 0f, vertical); // 根据输入创建移动方向
transform.position += direction * moveSpeed * Time.deltaTime; // 修改当前 GameObject 的位置
} // 方法结束
} // 类结束这个适合放“行为”,因为它要每帧执行,要操作 transform,要跟场景里的对象绑定。
ScriptableObject 示例:技能配置数据
c
using UnityEngine; // 引入 Unity 常用类型
[CreateAssetMenu(fileName = "NewSkillData", menuName = "Game/Skill Data")] // 允许在 Unity 右键菜单中创建技能配置资源
public class SkillData : ScriptableObject // 定义技能配置类,继承 ScriptableObject
{ // 类开始
public int id; // 技能 id
public string skillName; // 技能名字
public float cooldown; // 技能冷却时间
public int damage; // 技能伤害
public float range; // 技能释放范围
} // 类结束这个适合放“数据”,比如火球术、冰锥术、治疗术都可以各自创建一个 .asset 配置文件。
两者怎么配合:
c
using UnityEngine; // 引入 Unity 常用类型
public class SkillCaster : MonoBehaviour // 定义技能释放组件,挂在角色身上
{ // 类开始
[SerializeField] private SkillData skillData; // 引用一个 ScriptableObject 技能配置
public void CastSkill() // 定义释放技能方法
{ // 方法开始
Debug.Log(skillData.skillName); // 使用技能配置中的技能名字
Debug.Log(skillData.damage); // 使用技能配置中的技能伤害
} // 方法结束
} // 类结束这里 SkillCaster 是行为,负责“释放技能”;SkillData 是数据,负责“技能数值配置”。
常见坑:ScriptableObject 是共享资源。如果 10 个角色都引用同一个 SkillData.asset,那它们看到的是同一份数据。 所以 cooldown、damage、range 这种配置可以放进去。 但“当前剩余冷却时间”这种运行时状态最好不要直接放进去,否则多个角色可能互相影响。
IMPORTANT
面试高分回答:MonoBehaviour 和 ScriptableObject 都是 Unity 常用对象类型,但职责不同。MonoBehaviour 是组件,必须依附在 GameObject 上,适合写行为逻辑,并且拥有 Awake、Start、Update、OnDestroy 等生命周期函数。ScriptableObject 不挂在对象上,它通常保存为 .asset 资源文件,适合保存可复用的配置数据,比如物品、技能、怪物和关卡数据。项目里我一般用 ScriptableObject 做数据配置,用 MonoBehaviour 做运行时行为,让数据和逻辑分离。这样配置可以复用,Prefab 不需要重复保存大量相同数据,系统也更容易扩展。
ScriptableObject 适合做配置吗?
一句话答案:MonoBehaviour 是挂在 GameObject 上的行为组件,负责移动、攻击、AI、UI 控制等逻辑;ScriptableObject 是保存成 .asset 的数据资源对象,负责物品、技能、怪物、关卡等共享配置。
核心区别:
| 对比点 | MonoBehaviour | ScriptableObject |
|---|---|---|
| 本质 | 组件 | 资源型数据对象 |
| 是否挂 GameObject | 必须挂在 GameObject 上 | 不挂在 GameObject 上 |
| 是否有 Transform | 可以通过 transform 访问 | 没有 transform |
| 生命周期 | Awake、Start、Update、OnDestroy | 没有 Start、Update 这种帧生命周期 |
| 存在哪里 | 场景对象或 Prefab 上 | Project 中的 .asset 文件 |
| 适合做什么 | 行为逻辑 | 配置数据、共享数据 |
| 是否共享 | 通常每个对象一份组件实例 | 多个对象可引用同一份资源 |
MonoBehaviour 示例:角色移动逻辑
c
using UnityEngine; // 引入 Unity 常用类型
public class PlayerMove : MonoBehaviour // 定义玩家移动组件,必须挂在 GameObject 上
{ // 类开始
[SerializeField] private float moveSpeed = 5f; // 在 Inspector 中配置移动速度
private void Update() // 每帧调用一次
{ // 方法开始
float horizontal = Input.GetAxis("Horizontal"); // 获取横向输入
float vertical = Input.GetAxis("Vertical"); // 获取纵向输入
Vector3 direction = new Vector3(horizontal, 0f, vertical); // 根据输入创建移动方向
transform.position += direction * moveSpeed * Time.deltaTime; // 修改当前 GameObject 的位置
} // 方法结束
} // 类结束这个适合放“行为”,因为它要每帧执行,要操作 transform,要跟场景里的对象绑定。
ScriptableObject 示例:技能配置数据
c
c#c#using UnityEngine; // 引入 Unity 常用类型
[CreateAssetMenu(fileName = "NewSkillData", menuName = "Game/Skill Data")] // 允许在 Unity 右键菜单中创建技能配置资源
public class SkillData : ScriptableObject // 定义技能配置类,继承 ScriptableObject
{ // 类开始
public int id; // 技能 id
public string skillName; // 技能名字
public float cooldown; // 技能冷却时间
public int damage; // 技能伤害
public float range; // 技能释放范围
} // 类结束这个适合放“数据”,比如火球术、冰锥术、治疗术都可以各自创建一个 .asset 配置文件。
两者怎么配合:
c
using UnityEngine; // 引入 Unity 常用类型
public class SkillCaster : MonoBehaviour // 定义技能释放组件,挂在角色身上
{ // 类开始
[SerializeField] private SkillData skillData; // 引用一个 ScriptableObject 技能配置
public void CastSkill() // 定义释放技能方法
{ // 方法开始
Debug.Log(skillData.skillName); // 使用技能配置中的技能名字
Debug.Log(skillData.damage); // 使用技能配置中的技能伤害
} // 方法结束
} // 类结束这里 SkillCaster 是行为,负责“释放技能”;SkillData 是数据,负责“技能数值配置”。
常见坑:ScriptableObject 是共享资源。如果 10 个角色都引用同一个 SkillData.asset,那它们看到的是同一份数据。 所以 cooldown、damage、range 这种配置可以放进去。 但“当前剩余冷却时间”这种运行时状态最好不要直接放进去,否则多个角色可能互相影响。
NOTE
面试高分回答:MonoBehaviour 和 ScriptableObject 都是 Unity 常用对象类型,但职责不同。MonoBehaviour 是组件,必须依附在 GameObject 上,适合写行为逻辑,并且拥有 Awake、Start、Update、OnDestroy 等生命周期函数。ScriptableObject 不挂在对象上,它通常保存为 .asset 资源文件,适合保存可复用的配置数据,比如物品、技能、怪物和关卡数据。项目里我一般用 ScriptableObject 做数据配置,用 MonoBehaviour 做运行时行为,让数据和逻辑分离。这样配置可以复用,Prefab 不需要重复保存大量相同数据,系统也更容易扩展。
Prefab 序列化大概保存了什么?
一句话答案: Prefab 序列化保存的是一份“对象模板数据”,大概包括:GameObject 层级、组件列表、组件上的可序列化字段、资源引用、Prefab 嵌套关系、Variant 差异,以及场景实例上的 Overrides。
Prefab 大概保存这些东西:
GameObject 信息:对象名、激活状态、Tag、Layer、Static 标记等。
层级结构:父子关系、子物体顺序,比如角色下面有武器节点、特效节点、挂点节点。
组件列表:每个 GameObject 上挂了哪些组件,比如 Transform、MeshRenderer、Animator、Collider、自定义 MonoBehaviour。
组件字段值:组件中能被 Unity 序列化的字段,比如 Transform 的位置旋转缩放,脚本里的 hp、speed、damage、[SerializeField] private 字段等。
资源引用:材质、贴图、Mesh、Sprite、AudioClip、AnimatorController、ScriptableObject 等通常不是完整复制进 Prefab,而是保存引用关系,底层可以理解成 GUID + fileID。
Prefab 关系:如果是 Nested Prefab,会保存嵌套 Prefab 的连接关系;如果是 Prefab Variant,会保存它基于哪个 Prefab,以及覆盖了哪些内容。
实例覆盖:场景里的 Prefab 实例如果改了局部值,比如位置变了、某个字段改了、材质替换了,Unity 会记录这些 Overrides,而不是把整个 Prefab 完全复制一份。
它不保存什么?
不保存普通不可序列化字段。 不保存方法逻辑本身。 不保存事件订阅列表。 不保存线程状态、协程执行到哪一步这类运行时状态。 不保存 static 字段。 不保存没有被 Unity 序列化系统支持的数据,比如默认的 Dictionary。
简单理解: Prefab 就像一张“对象蓝图”。 Unity 加载 Prefab 时,会根据这张蓝图重新创建对象层级、添加组件、恢复字段值、连接资源引用。
IMPORTANT
面试高分回答: Prefab 本质上是 Unity 对一个或多个 GameObject 的序列化模板。它保存了对象的层级结构、每个对象上的组件、组件中的可序列化字段,以及材质、贴图、脚本、ScriptableObject 等资源引用。资源本体通常不会被完整复制进 Prefab,而是通过类似 GUID 和 fileID 的引用关系连接。对于场景中的 Prefab 实例,Unity 还会记录它相对于原始 Prefab 的 Overrides,比如位置、字段值、组件增删或引用替换。Prefab 保存的是能重建对象的数据,不是运行时逻辑和临时状态。
引用丢失 Missing Reference 是怎么发生的?
一句话答案:Missing Reference 是因为 Unity 序列化里保存的“引用信息”还在,但它指向的真实对象已经找不到了。简单说就是:链接还在,目标没了。
底层怎么理解: Unity 的对象引用不是把整个对象复制一份存进去,而是保存一组“能找到它的信息”。
资源引用常见可以理解成:
c
GUID + fileIDGUID:资源文件的唯一 ID,通常来自 .meta 文件。 fileID:资源内部某个具体对象或子对象的 ID,比如某个组件、子资源、脚本对象。
当 Unity 加载场景、Prefab、ScriptableObject 时,会根据这些信息去找真实对象。 如果找不到,就显示 Missing Reference。
常见发生原因:
资源被删除: 比如脚本里引用了一个 Explosion.prefab,后来这个 Prefab 被删了,字段里还保存旧引用,就会 Missing。
.meta 文件丢失或被重新生成: Unity 靠 .meta 里的 GUID 识别资源。如果你在文件夹外面复制、删除、恢复资源,导致 .meta 丢了,Unity 重新生成新的 GUID,旧引用就找不到原资源了。
脚本类名或命名空间改了: 如果脚本文件、类名、命名空间变化不一致,或者脚本编译失败,Unity 找不到原来的脚本类型,就可能出现 Missing Script。
组件被删除: Prefab 或场景对象原本引用某个组件,后来组件被删掉,但别的字段或 Override 还指向它,就会 Missing。
Prefab Variant 或 Override 断了: Variant 或场景实例保存的是“相对于原 Prefab 的差异”。如果原 Prefab 结构变化太大,比如组件删了、子节点删了,旧 Override 可能指向不存在的目标。
AssetBundle / Addressables 资源没加载或版本不匹配: 如果运行时加载的资源包版本和代码或引用关系不一致,也可能导致引用找不到。
代码里怎么防御:
c
using UnityEngine; // 引入 Unity 常用类型
public class EffectPlayer : MonoBehaviour // 定义特效播放组件
{ // 类开始
[SerializeField] private GameObject effectPrefab; // 在 Inspector 中引用特效 Prefab
public void PlayEffect() // 定义播放特效方法
{ // 方法开始
if (effectPrefab == null) // 判断特效引用是否为空或已经丢失
{ // if 开始
Debug.LogError("特效 Prefab 引用丢失,请检查 Inspector"); // 输出错误,提示引用丢失
return; // 提前返回,避免继续执行 Instantiate 报错
} // if 结束
Instantiate(effectPrefab, transform.position, transform.rotation); // 引用正常时创建特效实例
} // 方法结束
} // 类结束怎么避免:
移动、重命名资源尽量在 Unity Editor 里操作。 不要随便删除 .meta 文件。 版本管理里一定要提交 .meta 文件。 重命名脚本时,保持文件名和类名一致。 删除 Prefab 子节点或组件前,先确认有没有其他地方引用。 做 Addressables 或 AssetBundle 时,要保证资源版本和代码版本匹配。 重要 Prefab 可以写工具扫描 Missing Reference。
IMPORTANT
面试高分回答:Missing Reference 本质是 Unity 序列化引用解析失败。Unity 保存对象引用时通常不是保存对象本体,而是保存类似 GUID、fileID、类型信息这样的引用数据。加载场景、Prefab 或资源时,Unity 会根据这些数据去恢复引用。如果资源被删除、.meta 丢失导致 GUID 改变、脚本类型找不到、组件被删除,或者 Prefab Override 指向了不存在的对象,就会出现 Missing Reference。项目里我会通过规范资源移动、提交 meta 文件、避免随意改脚本类型、以及写 Editor 扫描工具来提前发现这类问题。
GUID 和 meta 文件有什么用?
一句话答案:.meta 文件是 Unity 给资源配的“身份证文件”,里面最重要的是 GUID。Unity 引用资源时主要靠 GUID 找资源,而不是单纯靠文件路径。
GUID 是什么?GUID 可以理解成 Unity 给每个资源生成的唯一 ID。
比如:
Sword.png 有自己的 GUID。 Player.prefab 有自己的 GUID。 PlayerController.cs 也有自己的 GUID。
Unity 的场景、Prefab、ScriptableObject 里保存资源引用时,通常不是只保存路径,而是保存类似:
c
GUID + fileIDGUID 用来找到哪个资源文件。 fileID 用来找到这个资源文件里面的具体对象,比如某个组件、某个子资源、某个脚本类型。
meta 文件有什么用?
.meta 文件通常和资源文件放在一起:
c
Sword.png
Sword.png.meta.meta 里面主要保存:
资源的 GUID。 资源导入设置。 纹理压缩设置。 Sprite 切片信息。 模型导入设置。 音频导入设置。 脚本导入信息。 AssetBundle 名称、资源标签等信息。
为什么移动资源不容易断引用? 因为 Unity 引用资源不是单纯靠路径。
如果你在 Unity Editor 里把:
c
c#Assets/Textures/Sword.png移动到:
c
Assets/UI/Icons/Sword.png只要 .meta 文件没变,GUID 还一样,Unity 就还能找到它,所以引用通常不会断。
为什么 meta 丢了会 Missing? 如果你把 Sword.png.meta 删除了,Unity 会重新生成一个新的 .meta 文件,也就会生成新的 GUID。
旧 Prefab 或场景里保存的还是旧 GUID。 新资源变成了新 GUID。 旧引用找不到旧 GUID 对应的资源。 于是 Inspector 就显示 Missing Reference。
版本管理里为什么必须提交 meta? 多人协作时,如果 A 提交了资源但没提交 .meta,B 拉下来后 Unity 会在 B 本地生成一个新的 .meta 和新的 GUID。这样 A 机器上的引用和 B 机器上的引用就可能对不上。
所以 Unity 项目里必须提交:
c
Assets/xxx.png
Assets/xxx.png.meta
Assets/xxx.prefab
Assets/xxx.prefab.metaTIP
面试高分回答: Unity 的 .meta 文件主要保存资源的 GUID 和导入设置。GUID 是 Unity 识别资源的稳定 ID,场景、Prefab、ScriptableObject 等序列化引用通常通过 GUID 和 fileID 去定位资源和资源内部对象,而不是只靠路径。所以只要 .meta 不变,资源移动或重命名后引用通常不会断。反过来,如果 .meta 丢失或被重新生成,GUID 改了,原来保存的引用就找不到目标资源,就会出现 Missing Reference。项目协作时必须把 .meta 文件纳入版本管理,移动资源也尽量在 Unity Editor 里操作。