Skip to content

序列化

Unity 支持序列化哪些类型?

一句话答案: Unity 主要序列化的是字段 Field,不是普通属性 Property;字段必须是 public[SerializeField],并且不能是 staticconstreadonly,类型也必须被 Unity 支持。Unity 官方手册也是按这套规则描述的:Unity Script Serialization

unity-serialization-supported-types

Unity 常见支持序列化的类型:

类型例子
基础类型intfloatdoubleboolstring
枚举enum State { Idle, Run }
Unity 内置类型Vector2Vector3QuaternionColorRectBoundsAnimationCurve
Unity 对象引用GameObjectTransformMonoBehaviourScriptableObjectMaterialTexture
自定义结构体[Serializable] struct ItemData
自定义类[Serializable] class PlayerData
数组int[]ItemData[]
ListList<int>List<ItemData>

常见不能直接序列化的类型:

Dictionary<TKey, TValue> 默认不支持。 多维数组,比如 int[,] 默认不支持。 嵌套容器,比如 List<List<int>> 默认不支持。 普通属性,比如 public int Hp { get; set; } 通常不按正常序列化规则保存。 staticconstreadonly 字段不会被 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],并且不能是 staticconstreadonly;其次字段类型必须是 Unity 支持的类型,比如基础类型、枚举、Unity 内置结构体、UnityEngine.Object 引用、带 [Serializable] 的自定义类或结构体,以及这些类型的一维数组和 List<T>。常见坑是 Dictionary、多维数组、嵌套 List、委托和事件默认不能直接序列化。对于多态数据,可以考虑 [SerializeReference],但核心资源引用还是更常用 ScriptableObjectUnityEngine.Object 引用来管理。

Unity 为什么不直接序列化属性?

一句话答案: Unity 不直接序列化属性,是因为属性本质上是 get / set 方法,里面可能有逻辑、副作用、计算、异常或依赖初始化顺序;而 Unity 序列化系统更希望保存的是稳定、直接、可预测的字段数据

unity-why-not-serialize-properties

属性不是字段,它背后是方法:

c
public int Hp { get; set; } // 看起来像变量,但本质是 get_Hp 和 set_Hp 方法

如果 Unity 序列化属性,就可能要在保存、加载、Inspector 刷新、Prefab 保存、Domain Reload 时调用 getset。这很危险,因为属性里可能写了逻辑。

比如:

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 不直接序列化属性,核心原因是属性不是纯数据,而是方法。getset 里可能有计算、校验、事件通知、资源访问,甚至依赖运行时初始化。Unity 的序列化会发生在场景保存、Prefab 保存、资源导入、反序列化、Domain Reload 等很多编辑器和运行时阶段,如果直接调用属性逻辑,会让序列化过程变得不可预测。字段更像稳定的数据布局,所以 Unity 默认序列化字段。实际开发中我会用 [SerializeField] private 字段保存数据,再用 public 属性封装访问逻辑。

私有字段如何显示在 Inspector?

一句话答案: 私有字段想显示在 Unity Inspector 里,用 [SerializeField]。这样字段仍然是 private,外部代码不能随便访问,但 Unity 可以序列化它并在 Inspector 中显示。

private-field-show-in-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 支持的字段类型显示。 staticconstreadonly 字段一般不适合 Unity 序列化。 Dictionary 默认不能直接显示。 普通属性默认不会直接显示在 Inspector。 字段是 private,但值会被 Unity 序列化保存到场景或 Prefab 里。

WARNING

面试高分回答: Unity 中私有字段默认不会显示在 Inspector,但可以通过 [SerializeField] 暴露给 Unity 的序列化系统。这样既能让策划或开发在 Inspector 中配置参数,又不会把字段变成 public 破坏封装。实际项目里我通常写 [SerializeField] private 字段用于编辑器配置,再通过 public 只读属性或方法对外提供访问和修改入口。这样 Inspector 可调、代码安全性和数据控制都能兼顾。

字典为什么默认不能被 Unity 序列化?

一句话答案:Dictionary<TKey, TValue> 默认不能被 Unity 序列化,是因为 Unity 默认序列化系统更擅长保存“字段”和“一维列表数据”,而 Dictionary 底层是哈希表,有桶、哈希值、比较器、冲突处理等运行时结构,不是一个简单稳定的线性数据。

unity-dictionary-not-serialized

为什么 List<T> 可以,Dictionary<K,V> 不行?

List<T> 很简单,本质上可以理解成一排数据:

c
[0] sword
[1] shield
[2] potion

Unity 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 默认能保存的是这种“表格”:

idname
1001Sword
1002Shield
1003Potion

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 上,常用来保存配置数据、共享数据和编辑器可调参数。

scriptableobject-overview

它和 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数据资源对象,负责物品、技能、怪物、关卡等共享配置。

scriptableobject-vs-monobehaviour

核心区别:

对比点MonoBehaviourScriptableObject
本质组件资源型数据对象
是否挂 GameObject必须挂在 GameObject不挂在 GameObject
是否有 Transform可以通过 transform 访问没有 transform
生命周期AwakeStartUpdateOnDestroy没有 StartUpdate 这种帧生命周期
存在哪里场景对象或 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,那它们看到的是同一份数据。 所以 cooldowndamagerange 这种配置可以放进去。 但“当前剩余冷却时间”这种运行时状态最好不要直接放进去,否则多个角色可能互相影响。

IMPORTANT

面试高分回答:MonoBehaviourScriptableObject 都是 Unity 常用对象类型,但职责不同。MonoBehaviour 是组件,必须依附在 GameObject 上,适合写行为逻辑,并且拥有 AwakeStartUpdateOnDestroy 等生命周期函数。ScriptableObject 不挂在对象上,它通常保存为 .asset 资源文件,适合保存可复用的配置数据,比如物品、技能、怪物和关卡数据。项目里我一般用 ScriptableObject 做数据配置,用 MonoBehaviour 做运行时行为,让数据和逻辑分离。这样配置可以复用,Prefab 不需要重复保存大量相同数据,系统也更容易扩展。

ScriptableObject 适合做配置吗?

一句话答案:MonoBehaviour 是挂在 GameObject 上的行为组件,负责移动、攻击、AI、UI 控制等逻辑;ScriptableObject 是保存成 .asset数据资源对象,负责物品、技能、怪物、关卡等共享配置。

scriptableobject-config-use

核心区别:

对比点MonoBehaviourScriptableObject
本质组件资源型数据对象
是否挂 GameObject必须挂在 GameObject不挂在 GameObject
是否有 Transform可以通过 transform 访问没有 transform
生命周期AwakeStartUpdateOnDestroy没有 StartUpdate 这种帧生命周期
存在哪里场景对象或 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,那它们看到的是同一份数据。 所以 cooldowndamagerange 这种配置可以放进去。 但“当前剩余冷却时间”这种运行时状态最好不要直接放进去,否则多个角色可能互相影响。

NOTE

面试高分回答:MonoBehaviourScriptableObject 都是 Unity 常用对象类型,但职责不同。MonoBehaviour 是组件,必须依附在 GameObject 上,适合写行为逻辑,并且拥有 AwakeStartUpdateOnDestroy 等生命周期函数。ScriptableObject 不挂在对象上,它通常保存为 .asset 资源文件,适合保存可复用的配置数据,比如物品、技能、怪物和关卡数据。项目里我一般用 ScriptableObject 做数据配置,用 MonoBehaviour 做运行时行为,让数据和逻辑分离。这样配置可以复用,Prefab 不需要重复保存大量相同数据,系统也更容易扩展。

Prefab 序列化大概保存了什么?

一句话答案: Prefab 序列化保存的是一份“对象模板数据”,大概包括:GameObject 层级、组件列表、组件上的可序列化字段、资源引用、Prefab 嵌套关系、Variant 差异,以及场景实例上的 Overrides。

prefab-serialization-contents

Prefab 大概保存这些东西:

GameObject 信息:对象名、激活状态、Tag、Layer、Static 标记等。

层级结构:父子关系、子物体顺序,比如角色下面有武器节点、特效节点、挂点节点。

组件列表:每个 GameObject 上挂了哪些组件,比如 TransformMeshRendererAnimatorCollider、自定义 MonoBehaviour

组件字段值:组件中能被 Unity 序列化的字段,比如 Transform 的位置旋转缩放,脚本里的 hpspeeddamage[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-missing-reference-causes

底层怎么理解: Unity 的对象引用不是把整个对象复制一份存进去,而是保存一组“能找到它的信息”。

资源引用常见可以理解成:

c
GUID + fileID

GUID:资源文件的唯一 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 找资源,而不是单纯靠文件路径。

unity-guid-meta-file-purpose

GUID 是什么?GUID 可以理解成 Unity 给每个资源生成的唯一 ID。

比如:

Sword.png 有自己的 GUID。 Player.prefab 有自己的 GUID。 PlayerController.cs 也有自己的 GUID。

Unity 的场景、Prefab、ScriptableObject 里保存资源引用时,通常不是只保存路径,而是保存类似:

c
GUID + fileID

GUID 用来找到哪个资源文件。 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.meta

TIP

面试高分回答: Unity 的 .meta 文件主要保存资源的 GUID 和导入设置。GUID 是 Unity 识别资源的稳定 ID,场景、Prefab、ScriptableObject 等序列化引用通常通过 GUID 和 fileID 去定位资源和资源内部对象,而不是只靠路径。所以只要 .meta 不变,资源移动或重命名后引用通常不会断。反过来,如果 .meta 丢失或被重新生成,GUID 改了,原来保存的引用就找不到目标资源,就会出现 Missing Reference。项目协作时必须把 .meta 文件纳入版本管理,移动资源也尽量在 Unity Editor 里操作。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0