Appearance
场景与资源生命周期
场景加载时资源引用如何变化?
一句话答案: 场景加载时,Unity 会把场景文件里的序列化引用,比如 GUID + fileID,解析成内存里的真实对象引用;场景卸载时,场景对象和组件会被销毁,但资源是否释放,要看它是否还被别的对象、静态字段、DontDestroyOnLoad、Addressables handle 等继续引用。
加载前:场景里存的是引用信息
场景文件里不会把材质、贴图、音频、Prefab、ScriptableObject 全部复制一份进去。 它更多保存的是:
c
这个字段引用哪个资源
这个组件引用哪个对象
这个 Prefab 实例来自哪里底层可以粗略理解成:
c
GUID + fileIDGUID 找到资源文件。 fileID 找到资源内部的具体对象,比如某个组件、子资源、脚本对象。
加载时:引用被解析成真实对象
比如场景里有一个怪物:
c
Monster
- Transform
- Animator
- AudioSource
- EnemyAI它引用了:
c
MonsterMaterial
MonsterTexture
AttackAudio
EnemyConfig.asset
AnimatorController加载场景时,Unity 会反序列化 GameObject 和 Component,然后把这些序列化引用解析成内存里的 UnityEngine.Object 引用。
也就是说,脚本里的字段会从“保存的引用 ID”变成真正可用的对象引用。
卸载场景时:引用会减少,但资源不一定立刻释放
如果使用:
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.SceneManagement; // 引入场景管理 API
public class SceneLoadExample : MonoBehaviour // 定义场景加载示例类
{ // 类开始
public void LoadBattleScene() // 定义加载战斗场景的方法
{ // 方法开始
SceneManager.LoadScene("Battle"); // 使用 Single 模式加载 Battle 场景,旧场景通常会被卸载
} // 方法结束
} // 类结束旧场景里的对象会被销毁,它们对资源的引用会断开。 但是资源不会一定马上从内存里消失。
因为它可能还被这些东西引用:
新场景也用了同一张贴图。 DontDestroyOnLoad 对象还引用它。 静态字段或单例缓存了它。 对象池里还有对象引用它。 Addressables 的 handle 没有 Release。 AssetBundle 还没正确卸载。
什么时候资源会真正释放?
旧场景卸载后,如果某个资源已经没有任何地方引用,它会变成“未使用资源”。 但通常还需要:
c
using UnityEngine; // 引入 Unity 常用类型
public class UnloadExample : MonoBehaviour // 定义资源清理示例类
{ // 类开始
public void CleanUnusedAssets() // 定义清理未使用资源的方法
{ // 方法开始
Resources.UnloadUnusedAssets(); // 请求 Unity 卸载当前没有被引用的资源
} // 方法结束
} // 类结束注意:Resources.UnloadUnusedAssets() 可能比较慢,可能造成卡顿,所以一般放在切场景、加载界面、战斗结算后这种能接受停顿的时机。
Single 和 Additive 的区别:
LoadSceneMode.Single: 新场景加载时,旧场景会被卸载。旧场景对象销毁,它们持有的资源引用会断开。
LoadSceneMode.Additive: 新场景叠加进来,旧场景不卸载。两个场景的资源引用会同时存在。只有卸载某个 Additive 场景时,那个场景持有的引用才会减少。
TIP
面试高分回答: 场景加载时,Unity 会反序列化场景中的 GameObject、Component 和可序列化字段,并把场景文件里保存的资源引用信息解析成真实的内存对象引用。场景依赖的材质、贴图、Mesh、音频、AnimatorController、ScriptableObject 等会被加载或关联起来。场景卸载时,场景对象和组件会被销毁,它们持有的资源引用会断开,但资源本身不一定立即释放,因为它可能还被新场景、静态字段、DontDestroyOnLoad 对象、对象池、Addressables handle 或 AssetBundle 引用。只有资源没有任何有效引用后,调用 Resources.UnloadUnusedAssets() 或正确释放 Addressables/AssetBundle,才可能真正释放内存。
Additive Scene 有什么用?
一句话答案:Additive Scene 的作用是:在不卸载当前场景的情况下,再加载一个或多个场景。它适合做大世界分块加载、常驻管理器场景、UI 场景、加载过渡场景、战斗副本叠加等。
Single 和 Additive 的区别:
Single:加载新场景时,旧场景会被卸载。
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.SceneManagement; // 引入场景管理 API
public class SingleSceneLoader : MonoBehaviour // 定义普通场景加载器
{ // 类开始
public void LoadBattle() // 定义加载战斗场景方法
{ // 方法开始
SceneManager.LoadScene("Battle", LoadSceneMode.Single); // 加载 Battle 场景,并卸载当前旧场景
} // 方法结束
} // 类结束Additive:加载新场景时,旧场景仍然保留。
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.SceneManagement; // 引入场景管理 API
public class AdditiveSceneLoader : MonoBehaviour // 定义叠加场景加载器
{ // 类开始
public void LoadUIAdditive() // 定义叠加加载 UI 场景方法
{ // 方法开始
SceneManager.LoadScene("UI", LoadSceneMode.Additive); // 在当前场景基础上叠加加载 UI 场景
} // 方法结束
public void UnloadUI() // 定义卸载 UI 场景方法
{ // 方法开始
SceneManager.UnloadSceneAsync("UI"); // 只卸载 UI 场景,不影响其他已加载场景
} // 方法结束
} // 类结束它常用在哪里?
大世界分块加载: 玩家靠近某个区域,就 Additive 加载这个区域场景;玩家离开后,就卸载这个区域。这样不用一次加载整个大地图。
常驻管理器场景: 比如 Bootstrap 场景里放游戏管理器、网络管理器、音频管理器。后续切换主城、战斗、关卡时,管理器场景不卸载。
UI 场景: 可以把全局 UI 单独做成一个场景,叠加到主场景上,方便 UI 独立维护。
加载过渡场景: 先叠加一个 Loading 场景,显示进度条;后台异步加载目标场景;加载完成后卸载 Loading 场景。
战斗副本或临时空间: 主城场景保留,叠加战斗场景;战斗结束后卸载战斗场景。
资源引用怎么变化? Additive 加载一个场景后,这个场景里的对象、组件、材质、贴图、音频、Prefab 引用都会进入内存引用链。 卸载这个 Additive 场景时,只会销毁这个场景里的对象,减少这一层场景持有的资源引用。 但资源是否真正释放,还要看它有没有被其他场景、静态字段、对象池、DontDestroyOnLoad 对象继续引用。
注意坑点:
不要重复创建多个 EventSystem。 不要重复创建多个 AudioListener。 不要重复创建多个全局 Manager。 跨场景引用要谨慎,卸载场景后引用可能失效。 需要时设置 Active Scene,否则新创建对象可能进错场景。 光照、天空盒、NavMesh、场景后处理要规划好。 卸载场景后,如果要清理未使用资源,可以选择合适时机调用 Resources.UnloadUnusedAssets()。
设置 Active Scene 示例:
c
using UnityEngine; // 引入 Unity 常用类型
using UnityEngine.SceneManagement; // 引入场景管理 API
public class ActiveSceneExample : MonoBehaviour // 定义 Active Scene 示例类
{ // 类开始
public void SetBattleAsActive() // 定义设置战斗场景为激活场景的方法
{ // 方法开始
Scene battleScene = SceneManager.GetSceneByName("Battle"); // 根据名字获取 Battle 场景
SceneManager.SetActiveScene(battleScene); // 把 Battle 设置为当前激活场景
} // 方法结束
} // 类结束CAUTION
面试高分回答:Additive Scene 是 Unity 的叠加场景加载方式,它不会像 Single 模式那样加载新场景时卸载旧场景,而是让多个场景同时存在。它常用于大世界分块加载、常驻管理器场景、UI 场景、加载过渡场景和战斗副本。这样可以把不同职责的内容拆成多个场景,按需加载和卸载,降低首屏加载压力,也方便团队并行编辑。需要注意的是,Additive 场景会让对象和资源引用叠加,卸载时只减少该场景持有的引用;资源是否释放还要看是否有其他引用。同时要避免重复的 EventSystem、AudioListener、全局 Manager,并处理好 Active Scene、光照和跨场景引用。
多场景编辑有什么好处?
一句话答案: 多场景编辑的好处是:可以把一个大场景拆成多个小场景同时编辑,减少团队冲突,提升内容管理效率,也方便模拟运行时的 Additive 加载结构。
为什么要多场景编辑?
如果所有东西都放在一个大场景里:
地形在里面。 灯光在里面。 怪物在里面。 UI 在里面。 管理器也在里面。
这个场景会越来越大,打开慢、保存慢、多人协作容易冲突。比如美术改地形,策划改怪物刷点,程序改管理器,三个人都在改同一个 .unity 场景文件,就很容易产生版本冲突。
多场景编辑怎么拆?
常见会拆成:
Bootstrap 场景:放全局管理器、初始化逻辑。 UI 场景:放常驻 UI、EventSystem。 Level 场景:放当前关卡主体。 Lighting 场景:放灯光、反射探针、后处理。 EnemySpawn 场景:放怪物刷点、关卡触发器。 WorldBlock_01、WorldBlock_02:开放世界或大地图分块。
编辑时可以把它们一起打开,在 Hierarchy 里同时看到多个场景的内容。
主要好处:
减少版本冲突: 不同人负责不同场景文件,减少多人同时改同一个 .unity 文件。
职责更清晰: 地形、灯光、UI、刷怪、管理器可以拆开维护,哪个模块出问题更容易定位。
编辑更轻量: 不用每次打开整个大场景,只打开当前要改的那部分。
更适合大世界: 开放世界可以按区域拆分场景,运行时靠 Additive 按需加载和卸载。
更接近运行时结构: 如果游戏运行时就是 Bootstrap + UI + Level + Additive Block,编辑器里也用这种结构,就更容易提前发现加载、引用、重复对象问题。
方便多人并行: 美术可以开地形和灯光场景,策划可以开刷怪场景,程序可以开管理器场景,彼此不容易互相踩文件。
注意坑点:
要小心重复 EventSystem。 要小心重复 AudioListener。 要小心重复全局管理器。 要管理好 Active Scene,新建对象会默认进当前 Active Scene。 跨场景引用要谨慎,某个场景卸载后引用可能失效。 多个场景一起编辑时,要记得分别保存每个被修改的场景。 团队要约定每个场景的职责边界,不然拆了也会乱。
WARNING
面试高分回答: 多场景编辑主要是为了解决大场景维护和多人协作问题。它允许我们在 Unity Editor 中同时打开多个 Scene,把原本一个庞大的场景拆成地形、灯光、UI、管理器、怪物刷点、世界分块等多个小场景。这样美术、策划、程序可以分别修改自己的场景文件,减少版本冲突;同时内容职责更清晰,加载和调试也更方便。对于开放世界或大型关卡,多场景编辑还能和运行时 Additive 加载思路保持一致,编辑时就能预览多个场景组合后的效果。需要注意的是,要管理好 Active Scene、重复管理器、跨场景引用和保存顺序。
AssetBundle 加载后对象和 Bundle 的生命周期关系是什么?
一句话答案:AssetBundle 是资源包容器,LoadAsset 出来的对象是内存里的资源对象。bundle.Unload(false) 只卸载 Bundle 容器,已加载对象还能用;bundle.Unload(true) 会连从这个 Bundle 加载出来的资源对象一起卸掉,场景里还在用就可能出问题。
生命周期怎么走?
第一步,加载 Bundle:
c
AssetBundle.LoadFromFile这一步得到的是一个 AssetBundle 对象,可以理解成“打开了资源包”。
第二步,从 Bundle 里加载资源:
c
bundle.LoadAsset<GameObject>("Enemy")这一步才把资源对象加载到内存,比如 Prefab、Material、Texture、AudioClip。
第三步,使用资源:
c
Instantiate(enemyPrefab)如果加载的是 Prefab,你通常会实例化出场景对象。
第四步,卸载 Bundle:
这里关键看你传 false 还是 true。
Unload(false):只卸载包,不卸载已加载对象
c
using UnityEngine; // 引入 Unity 常用类型
public class BundleUnloadFalseExample : MonoBehaviour // 定义 Bundle 卸载 false 示例类
{ // 类开始
private AssetBundle bundle; // 保存 AssetBundle 引用
private GameObject enemyPrefab; // 保存从 Bundle 加载出来的 Prefab
public void LoadAndUnloadBundleOnly(string bundlePath) // 定义加载资源并只卸载 Bundle 容器的方法
{ // 方法开始
bundle = AssetBundle.LoadFromFile(bundlePath); // 从文件加载 AssetBundle
enemyPrefab = bundle.LoadAsset<GameObject>("Enemy"); // 从 Bundle 中加载 Enemy Prefab
Instantiate(enemyPrefab); // 实例化 Enemy Prefab 到场景中
bundle.Unload(false); // 只卸载 AssetBundle 容器,不卸载已经加载出的资源对象
} // 方法结束
} // 类结束Unload(false) 后:
已经 LoadAsset 出来的 enemyPrefab 还在。 已经 Instantiate 出来的对象还在。 但是这个 bundle 不能再继续拿来加载新资源。 它释放的是 Bundle 容器相关内存,不是已加载资源对象。
Unload(true):包和已加载资源一起卸
c
using UnityEngine; // 引入 Unity 常用类型
public class BundleUnloadTrueExample : MonoBehaviour // 定义 Bundle 卸载 true 示例类
{ // 类开始
private AssetBundle bundle; // 保存 AssetBundle 引用
private GameObject enemyPrefab; // 保存从 Bundle 加载出来的 Prefab
public void LoadAndUnloadAll(string bundlePath) // 定义加载资源并彻底卸载的方法
{ // 方法开始
bundle = AssetBundle.LoadFromFile(bundlePath); // 从文件加载 AssetBundle
enemyPrefab = bundle.LoadAsset<GameObject>("Enemy"); // 从 Bundle 中加载 Enemy Prefab
bundle.Unload(true); // 卸载 Bundle,并卸载从它加载出来的资源对象
} // 方法结束
} // 类结束Unload(true) 后:
Bundle 容器没了。 从它加载出来的资源对象也会被卸载。 如果场景里还有对象引用这些材质、贴图、Prefab、音频,就可能出现引用丢失、材质异常、贴图丢失等问题。
依赖 Bundle 要特别小心:
比如:
c
enemy.bundle 存 Enemy Prefab
material.bundle 存 EnemyMaterial
texture.bundle 存 EnemyTexture你加载 Enemy Prefab 之前,可能要先加载它依赖的 material.bundle 和 texture.bundle。
卸载时也不能只看主 Bundle。 如果还有对象正在用 EnemyMaterial 或 EnemyTexture,依赖 Bundle 和依赖资源不能乱卸。
所以项目里一般会做:
Bundle 引用计数。 Asset 引用计数。 依赖 Bundle 管理。 统一资源管理器。 不用的资源延迟到切场景或 Loading 时释放。
TIP
面试高分回答:AssetBundle 和它加载出来的资源对象生命周期不是完全绑定死的。AssetBundle 本身更像资源包容器,加载 Bundle 后只是获得一个可以读取资源的句柄;调用 LoadAsset 后,资源对象才真正进入内存。调用 Unload(false) 时,只卸载 Bundle 容器和相关数据,已经加载出来的资源对象以及实例化出来的场景对象还可以继续使用,但不能再从这个 Bundle 继续加载新资源。调用 Unload(true) 时,会同时卸载从该 Bundle 加载出的资源对象,如果场景对象还引用这些资源,就可能出现丢引用或显示异常。实际项目里我会用资源管理器维护 Bundle 和 Asset 的引用计数,先处理依赖 Bundle,再根据对象是否仍被使用决定释放时机,避免资源泄漏或过早卸载。
卸载 AssetBundle 会不会卸载已经实例化的对象?
一句话答案:AssetBundle.Unload(false) 不会卸载已经实例化的对象;AssetBundle.Unload(true) 会卸载从 Bundle 加载出来的资源,已经实例化的对象可能还在场景里,但它依赖的材质、贴图、Mesh、音频等资源可能丢失或显示异常。
先分清三个东西:
AssetBundle:资源包容器,像箱子。 LoadAsset 出来的资源:比如 Prefab、Material、Texture、Mesh。 Instantiate 出来的对象:场景里的运行时实例。
比如流程是:
c
加载 enemy.bundle
从 bundle 里 LoadAsset 得到 EnemyPrefab
Instantiate EnemyPrefab 得到 Enemy(Clone)Unload(false):通常安全一些
c
using UnityEngine; // 引入 Unity 常用类型
public class BundleUnloadFalseExample : MonoBehaviour // 定义 AssetBundle 卸载 false 示例类
{ // 类开始
private AssetBundle bundle; // 保存 AssetBundle 引用
private GameObject enemyPrefab; // 保存从 Bundle 加载出来的 Prefab 资源
private GameObject enemyInstance; // 保存实例化出来的场景对象
public void LoadEnemy(string bundlePath) // 定义加载怪物方法
{ // 方法开始
bundle = AssetBundle.LoadFromFile(bundlePath); // 从文件加载 AssetBundle
enemyPrefab = bundle.LoadAsset<GameObject>("Enemy"); // 从 Bundle 中加载 Enemy Prefab
enemyInstance = Instantiate(enemyPrefab); // 实例化 Enemy Prefab 到场景中
bundle.Unload(false); // 只卸载 Bundle 容器,不卸载已经加载出来的资源和实例
} // 方法结束
} // 类结束Unload(false) 后:
场景里的 enemyInstance 还在。 enemyPrefab 资源通常还在内存里。 材质、贴图、Mesh 等依赖也通常还能正常用。 但这个 bundle 不能再继续 LoadAsset 新资源。
Unload(true):对象可能还在,但资源可能没了
c
using UnityEngine; // 引入 Unity 常用类型
public class BundleUnloadTrueExample : MonoBehaviour // 定义 AssetBundle 卸载 true 示例类
{ // 类开始
private AssetBundle bundle; // 保存 AssetBundle 引用
private GameObject enemyPrefab; // 保存从 Bundle 加载出来的 Prefab 资源
private GameObject enemyInstance; // 保存实例化出来的场景对象
public void LoadEnemyAndUnloadAll(string bundlePath) // 定义加载怪物并彻底卸载资源方法
{ // 方法开始
bundle = AssetBundle.LoadFromFile(bundlePath); // 从文件加载 AssetBundle
enemyPrefab = bundle.LoadAsset<GameObject>("Enemy"); // 从 Bundle 中加载 Enemy Prefab
enemyInstance = Instantiate(enemyPrefab); // 实例化 Enemy Prefab 到场景中
bundle.Unload(true); // 卸载 Bundle,并卸载从 Bundle 加载出来的资源对象
} // 方法结束
} // 类结束Unload(true) 后要小心:
场景里的 enemyInstance 不一定马上从 Hierarchy 消失。 但它用到的 Material、Texture、Mesh、AudioClip 等可能被卸载。 结果可能是模型丢失、材质丢失、贴图丢失、显示异常。 所以对象还在,不代表它依赖的资源还完整。
正确释放思路:
如果实例还要继续显示,就不要 Unload(true)。 如果确定不用了,先 Destroy 场景实例。 再释放资源引用计数。 最后卸载 Bundle 和依赖 Bundle。 必要时在合适时机调用 Resources.UnloadUnusedAssets()。
IMPORTANT
面试高分回答: 卸载 AssetBundle 是否影响已经实例化的对象,关键看 Unload 的参数。Unload(false) 只卸载 AssetBundle 容器本身,不会卸载已经 LoadAsset 出来的资源,也不会销毁已经 Instantiate 出来的场景对象,所以实例通常还能正常显示。Unload(true) 会卸载从该 Bundle 加载出来的资源对象,如果场景实例仍然引用这些资源,就可能出现材质、贴图、Mesh 等引用丢失或显示异常。实际项目中我不会在对象还使用资源时随便 Unload(true),而是通过资源管理器维护 Asset 和 Bundle 的引用计数,先销毁实例,再释放资源,最后卸载 Bundle 和依赖包。
Destroy 后对象什么时候真正销毁?
一句话答案:Destroy(obj) 不是立刻把对象从内存里删掉,而是把对象标记为销毁,真正销毁通常会延迟到当前 Update 循环结束后、本帧渲染前处理。运行时正常销毁对象,默认用 Destroy,不要随便用 DestroyImmediate。
流程理解:
调用 Destroy(obj)
对象被标记为销毁
当前函数继续执行
当前帧 Update 逻辑继续走完
Unity 在本帧稍后统一处理销毁
触发 OnDisable / OnDestroy
原生 Unity 对象被释放
C# 包装对象等待 GC 回收代码例子:
c
using UnityEngine; // 引入 Unity 常用类型
public class DestroyExample : MonoBehaviour // 定义销毁示例组件
{ // 类开始
private void Update() // 每帧调用一次
{ // 方法开始
if (Input.GetKeyDown(KeyCode.Space)) // 判断玩家是否按下空格键
{ // if 开始
Destroy(gameObject); // 标记当前 GameObject 在本帧稍后销毁
Debug.Log("Destroy 后,这一行仍然会执行"); // Destroy 不会立刻中断当前方法
} // if 结束
} // 方法结束
private void OnDisable() // 对象被禁用或销毁时调用
{ // 方法开始
Debug.Log("OnDisable 被调用"); // 输出禁用回调日志
} // 方法结束
private void OnDestroy() // 对象真正销毁前后调用
{ // 方法开始
Debug.Log("OnDestroy 被调用"); // 输出销毁回调日志
} // 方法结束
} // 类结束Destroy 和 DestroyImmediate 区别:
Destroy(obj):延迟销毁,运行时常用,安全。 DestroyImmediate(obj):立即销毁,主要用于编辑器脚本,运行时慎用。
c
using UnityEngine; // 引入 Unity 常用类型
public class DestroyImmediateExample : MonoBehaviour // 定义立即销毁示例组件
{ // 类开始
public void RemoveNow(GameObject target) // 定义立即移除对象的方法
{ // 方法开始
DestroyImmediate(target); // 立即销毁对象,运行时一般不推荐这样做
} // 方法结束
} // 类结束为什么 obj == null 有时候很奇怪?
Unity 的对象有两层:
C# 托管壳对象。 Unity 底层原生对象。
Destroy 真正销毁的是 Unity 原生对象。 但 C# 那个包装对象可能还没被 GC 回收。Unity 重载了 == 运算符,所以原生对象销毁后:
c
obj == null可能会返回 true,但它不一定等于普通 C# 意义上的引用立刻变成 null。
工程上怎么做?
调用 Destroy 后,不要继续依赖这个对象做核心逻辑。 如果自己保存了引用,可以主动置空。 在 OnDisable 或 OnDestroy 里取消事件订阅。 如果对象来自对象池,不一定用 Destroy,可能应该走 Release 或 SetActive(false)。 不要把 DestroyImmediate 当成运行时常规销毁手段。
NOTE
面试高分回答:Destroy 在 Unity 中是延迟销毁,它会把对象标记为待销毁,实际销毁通常发生在当前 Update 循环结束后、本帧渲染前。这样可以避免在当前执行栈中立刻删除对象导致生命周期和遍历状态混乱。销毁过程中会触发 OnDisable 和 OnDestroy,适合在里面取消事件订阅、释放引用和做清理。需要注意的是,Unity 对象有 C# 包装对象和底层 Native 对象两层,Native 对象销毁后,C# 包装对象可能还没被 GC 回收,但 Unity 重载了 ==,所以看起来会像 null。运行时一般用 Destroy,DestroyImmediate 主要用于编辑器工具,运行时慎用。
DestroyImmediate 为什么危险?
一句话答案:DestroyImmediate 危险,是因为它会立刻销毁对象,不等 Unity 在本帧末尾统一处理。运行时使用它,可能破坏正在执行的逻辑、遍历状态、生命周期顺序和资源引用。
Destroy 和 DestroyImmediate 的区别:
Destroy(obj) 是延迟销毁。 Unity 会先把对象标记为销毁,通常在本帧稍后统一处理。
DestroyImmediate(obj) 是立即销毁。 调用这一行后,对象马上被销毁,后续代码如果还访问它,就容易出问题。
危险示例:
c
using UnityEngine; // 引入 Unity 常用类型
public class DestroyImmediateDangerExample : MonoBehaviour // 定义 DestroyImmediate 危险示例类
{ // 类开始
public GameObject target; // 定义要销毁的目标对象
private void Update() // 每帧调用一次
{ // 方法开始
if (Input.GetKeyDown(KeyCode.Space)) // 判断是否按下空格键
{ // if 开始
DestroyImmediate(target); // 立即销毁目标对象,运行时不推荐这样做
Debug.Log(target.name); // 危险:target 可能已经被销毁,再访问会出问题
} // if 结束
} // 方法结束
} // 类结束为什么运行时不推荐?
因为 Unity 的很多系统是在一帧中按顺序执行的:
UpdateLateUpdate渲染准备本帧末尾销毁队列处理
Destroy 会把销毁推迟到相对安全的时机。 DestroyImmediate 是中途直接删掉对象,容易让后面的逻辑访问到已经失效的对象。
常见风险:
正在遍历子物体,突然删掉某个子物体,遍历逻辑可能乱。 当前方法后面还要访问对象,结果对象已经没了。 其他组件还以为对象存在,下一步访问时报错。 生命周期回调顺序更难判断。 编辑器脚本里如果操作资源,可能误删场景对象或资产。 如果用了 DestroyImmediate(obj, true),还可能允许销毁资源资产,更要小心。
编辑器里怎么安全用?
编辑器工具里如果确实要立即删除对象,推荐用:
c
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 常用类型
public static class SafeEditorDestroyTool // 定义安全编辑器删除工具类
{ // 类开始
[MenuItem("Tools/Delete Selected Object Safely")] // 添加 Unity 编辑器菜单
public static void DeleteSelectedObjectSafely() // 定义删除选中对象的方法
{ // 方法开始
GameObject selectedObject = Selection.activeGameObject; // 获取当前选中的 GameObject
if (selectedObject == null) // 判断是否没有选中对象
{ // if 开始
return; // 没有选中对象就直接返回
} // if 结束
Undo.DestroyObjectImmediate(selectedObject); // 通过 Undo 系统立即删除对象,支持撤销
} // 方法结束
} // 类结束TIP
面试高分回答:DestroyImmediate 的危险在于它会立刻销毁 Unity 对象,而不是像 Destroy 那样延迟到本帧末尾统一处理。运行时如果立即销毁对象,可能破坏当前调用栈、遍历过程和其他系统的引用状态,比如后续代码还访问这个对象,或者其他组件还依赖它。Unity 推荐运行时使用 Destroy,让引擎在安全时机处理销毁。DestroyImmediate 更多用于编辑器脚本,比如自定义工具清理场景对象;即便在编辑器里,也应该配合 Undo.DestroyObjectImmediate、确认弹窗和严格过滤,避免误删资源或破坏场景。
静态变量在切场景后还在吗?
一句话答案: 静态变量在切场景后还在。因为 static 属于类本身和当前 AppDomain,不属于某个 Scene;切场景销毁的是场景里的 GameObject 和组件,不会自动清空静态字段。
简单例子:
c
using UnityEngine; // 引入 Unity 常用类型
public static class GameState // 定义一个全局静态状态类
{ // 类开始
public static int Score; // 定义静态分数字段,切场景后仍然保留
} // 类结束
public class ScoreTest : MonoBehaviour // 定义测试组件
{ // 类开始
private void Start() // 场景对象启动时调用
{ // 方法开始
GameState.Score += 10; // 修改静态变量
Debug.Log(GameState.Score); // 输出当前静态变量值
} // 方法结束
} // 类结束如果你从 SceneA 切到 SceneB,GameState.Score 不会自动变回 0。 它会继续保留之前的值,除非你手动清理,或者 Unity 发生了域重载。
什么时候会重置?
应用退出再启动。 脚本重新编译。 Unity 发生 Domain Reload。 进入 Play Mode 时,如果开启了 Domain Reload。 你自己写代码手动重置。
Unity 特别容易踩的坑:
如果静态变量保存的是普通数值,比如:
c
static int score
static bool isLogin
static string playerName切场景后它们继续存在,这通常没问题。
但如果静态变量保存的是场景对象引用,比如:
c
static Player player
static GameObject currentEnemy
static Transform target切场景后,原场景对象可能已经被 Destroy,但静态字段还拿着旧引用。这会导致:
访问已销毁对象。 Missing Reference。 资源无法释放。 旧场景对象被静态引用拖住。 下一场景读到脏数据。
推荐重置写法:
c
using UnityEngine; // 引入 Unity 常用类型
public static class GameState // 定义全局游戏状态类
{ // 类开始
public static int Score; // 定义静态分数字段
public static GameObject CurrentPlayer; // 定义静态玩家引用字段
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] // 在运行时子系统注册阶段重置静态数据
private static void ResetStaticState() // 定义重置静态状态方法
{ // 方法开始
Score = 0; // 重置分数
CurrentPlayer = null; // 清空玩家引用
} // 方法结束
} // 类结束这个写法在关闭 Domain Reload 的情况下尤其有用,可以避免上一次 Play Mode 的静态脏数据影响下一次运行。
IMPORTANT
面试高分回答: 静态变量在 Unity 切场景后不会自动清空,因为静态字段属于类型和当前 AppDomain,而不是某个场景对象。LoadScene 卸载旧场景时,会销毁旧场景里的 GameObject 和组件,但不会重置 static 字段。静态变量通常会在应用退出、脚本重编译、Domain Reload 或手动清理时重置。需要特别注意的是,不要长期用静态字段保存场景对象引用,因为切场景后对象可能已经被销毁,静态引用还在,容易造成脏引用、Missing Reference 或资源无法释放。实际项目里我会给全局状态设计明确的初始化和清理入口,必要时用 RuntimeInitializeOnLoadMethod 重置静态数据。
Enter Play Mode Options 关闭域重载有什么坑?
一句话答案: 关闭 Domain Reload 后,进入 Play Mode 会更快,但 static 字段、静态事件、单例、缓存不会自动重置。最典型的坑就是:第一次 Play 正常,第二次 Play 开始出现脏数据、重复回调、旧对象引用。
什么是 Domain Reload? 正常情况下,进入 Play Mode 时,Unity 会重新加载脚本域。这样很多静态状态会被清空,类似重新启动了一次脚本环境。
关闭 Domain Reload 后,Unity 不重新加载脚本域,所以进入 Play 更快。 但代价是:上一次 Play 留下来的静态数据还可能在。
坑一:static 变量不重置
c
using UnityEngine; // 引入 Unity 常用类型
public static class GameState // 定义全局游戏状态类
{ // 类开始
public static int Score; // 定义静态分数
} // 类结束
public class ScoreTest : MonoBehaviour // 定义分数测试组件
{ // 类开始
private void Start() // 对象启动时调用
{ // 方法开始
GameState.Score++; // 每次进入 Play 都让分数加一
Debug.Log(GameState.Score); // 输出当前分数
} // 方法结束
} // 类结束如果关闭 Domain Reload: 第一次 Play 输出 1。 退出 Play 后再进,可能输出 2。 再进一次,可能输出 3。
这就说明 static 没被自动清空。
坑二:static event 重复订阅
c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 常用类型
public static class GameEvents // 定义全局事件类
{ // 类开始
public static event Action OnGameStart; // 定义静态事件
public static void RaiseGameStart() // 定义触发游戏开始事件的方法
{ // 方法开始
OnGameStart?.Invoke(); // 触发所有订阅者
} // 方法结束
} // 类结束
public class Listener : MonoBehaviour // 定义监听者组件
{ // 类开始
private void OnEnable() // 对象启用时调用
{ // 方法开始
GameEvents.OnGameStart += HandleGameStart; // 订阅静态事件
} // 方法结束
private void OnDisable() // 对象禁用时调用
{ // 方法开始
GameEvents.OnGameStart -= HandleGameStart; // 取消订阅静态事件
} // 方法结束
private void HandleGameStart() // 定义事件回调方法
{ // 方法开始
Debug.Log("Game Start"); // 输出游戏开始日志
} // 方法结束
} // 类结束如果有地方忘了 -=,关闭 Domain Reload 后问题会更明显。 静态事件不会自动清空,下一次 Play 可能会残留旧订阅,导致一次事件触发多次,甚至引用已经销毁的对象。
坑三:单例指向旧对象
c
using UnityEngine; // 引入 Unity 常用类型
public class GameManager : MonoBehaviour // 定义游戏管理器组件
{ // 类开始
public static GameManager Instance; // 定义静态单例引用
private void Awake() // 对象唤醒时调用
{ // 方法开始
Instance = this; // 把当前对象保存到静态单例引用
} // 方法结束
} // 类结束关闭 Domain Reload 后,如果 Instance 没清理,它可能保留上一次 Play 的旧引用。 这会导致新一轮 Play 里读到旧对象、已销毁对象,或者单例初始化判断失效。
推荐解决方式:手动重置 static
c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 常用类型
public static class RuntimeStateResetter // 定义运行时状态重置类
{ // 类开始
public static int Score; // 定义静态分数
public static event Action OnGameStart; // 定义静态游戏开始事件
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] // 在运行时子系统注册阶段调用,适合重置静态状态
private static void ResetStaticState() // 定义重置静态状态的方法
{ // 方法开始
Score = 0; // 重置静态分数
OnGameStart = null; // 清空静态事件订阅列表
} // 方法结束
public static void RaiseGameStart() // 定义触发游戏开始事件的方法
{ // 方法开始
OnGameStart?.Invoke(); // 触发游戏开始事件
} // 方法结束
} // 类结束还要注意 Scene Reload: Enter Play Mode Options 里有两个选项:
Reload Domain:是否重载脚本域。 Reload Scene:是否重载场景。
如果只关闭 Reload Domain,场景可能还会重载,但 static 不重置。 如果连 Reload Scene 也关闭,场景对象也可能保留,状态问题会更多。
WARNING
面试高分回答: 关闭 Enter Play Mode Options 里的 Domain Reload 可以减少进入 Play Mode 的等待时间,但它的代价是脚本域不会重新加载,所以静态字段、静态事件、单例、对象池和各种缓存不会自动清空。这样很容易出现第二次 Play 才发生的问题,比如 static 分数没归零、事件重复订阅、单例指向上一次 Play 的旧对象、缓存里还有已销毁对象引用。项目里如果开启这个选项,我会要求所有全局状态都有明确的 Reset 入口,并用 RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration) 在进入 Play 时重置 static 字段、清空静态事件和缓存。同时避免 static 长期保存场景对象引用。
如何排查资源没有释放?
一句话答案: 排查资源没有释放,核心不是先怀疑 Unity,而是找:谁还在引用这个资源。只要还有引用链,UnloadUnusedAssets 也不会把它清掉。
排查流程:
- 先稳定复现:进入场景、打开 UI、加载角色或特效,观察内存上涨。
- 退出场景或关闭 UI 后,确认对象是否真的销毁。
- 手动触发一次清理:
Resources.UnloadUnusedAssets()。 - 用 Memory Profiler 拍两张快照:加载后、清理后。
- 对比快照,看哪些
Texture、Mesh、Material、AudioClip、GameObject还在。 - 看引用链,找到是谁还持有它。
最常见原因:
对象还活着: 比如对象池、隐藏 UI、DontDestroyOnLoad 对象、未销毁特效还引用资源。
全局引用没清: 比如 static 字段、单例缓存、字典缓存、事件没有取消订阅、闭包捕获对象。
加载系统没释放: 比如 Addressables 没 Release,AssetBundle 依赖包没卸,资源管理器引用计数没减。
材质实例泄漏: 比如频繁访问 renderer.material 会生成材质实例,如果不销毁,可能一直留着。
简单清理示例:
c
using System.Collections; // 引入 IEnumerator 协程接口
using UnityEngine; // 引入 Unity 常用类型
public class ResourceCleanExample : MonoBehaviour // 定义资源清理示例组件
{ // 类开始
[SerializeField] private GameObject spawnedObject; // 保存运行时创建出来的对象引用
public void ReleaseObject() // 定义释放对象的方法
{ // 方法开始
if (spawnedObject != null) // 判断对象是否还存在
{ // if 开始
Destroy(spawnedObject); // 销毁场景中的对象实例
spawnedObject = null; // 清空自己保存的引用
} // if 结束
StartCoroutine(UnloadUnusedAssetsLater()); // 启动协程,在合适时机清理未使用资源
} // 方法结束
private IEnumerator UnloadUnusedAssetsLater() // 定义延迟清理未使用资源的协程
{ // 方法开始
yield return null; // 等一帧,让 Destroy 队列先被 Unity 处理
yield return Resources.UnloadUnusedAssets(); // 请求 Unity 卸载没有引用的资源
System.GC.Collect(); // 请求一次 C# GC,清理托管层不再使用的对象
} // 方法结束
} // 类结束Addressables 要重点查:
如果你是这样加载的:
c
Addressables.LoadAssetAsync
Addressables.InstantiateAsync就要确认有没有对应:
c
Addressables.Release
Addressables.ReleaseInstance否则 handle 引用还在,资源就不会释放。
AssetBundle 要重点查:
检查有没有:
bundle.Unload(false) 只卸了包,资源对象还在。 bundle.Unload(true) 太早调用,可能导致对象资源丢失。 依赖 Bundle 没做引用计数,导致依赖包一直不卸。
WARNING
面试高分回答: 我排查资源不释放,一般先用 Profiler 或 Memory Profiler 确认内存确实没有下降,然后构造稳定复现路径,比如加载场景、退出场景、执行资源清理,再对比快照。重点看残留的 Texture、Mesh、Material、AudioClip、Prefab 等对象,并顺着引用链查是谁还持有它。常见原因包括场景对象没销毁、对象池或 DontDestroyOnLoad 还引用、static 缓存没清、事件没取消订阅、Addressables handle 没 Release、AssetBundle 依赖引用计数没减,以及 renderer.material 生成的材质实例没销毁。修复原则是:谁加载谁释放,谁订阅谁取消,谁缓存谁清理,谁实例化谁销毁。