Skip to content

资源与场景

场景切换怎么避免黑屏太久?

场景切换怎么避免黑屏太久?

黑屏太久通常不是“画面真的需要黑”,而是主线程在加载场景、初始化对象、加载资源、编译 Shader、GC 或卸载资源时被卡住了。解决思路是:先显示 Loading,再异步加载,准备好后再激活新场景。

scene-transition-avoid-long-black-screen-csharp

核心做法

不要直接同步 LoadScene,要用 LoadSceneAsync。 先淡出旧画面,显示 Loading UI,然后开始异步加载。加载进度到 0.9 时,Unity 表示场景资源基本加载完了,但还没真正激活场景。这个时候可以继续显示 Loading,等预加载、对象池、Shader 预热完成后,再允许激活新场景。

为什么 progress 最大先到 0.9

Unity 的 AsyncOperation.progressallowSceneActivation = false 时,通常会停在 0.9。 这代表“资源加载差不多完成了,但场景还没切过去”。 当你设置 allowSceneActivation = true 后,Unity 才会真正激活新场景。

C# 示例

c
using System.Collections; // 引入协程相关命名空间。
using UnityEngine; // 引入 Unity 引擎基础命名空间。
using UnityEngine.SceneManagement; // 引入场景管理命名空间。
using UnityEngine.UI; // 引入 UI 命名空间,用来操作进度条和文本。
public sealed class SceneLoader : MonoBehaviour // 定义场景加载器。
{ // SceneLoader 类开始。
    [SerializeField] private CanvasGroup _loadingCanvas; // 引用 Loading 界面的 CanvasGroup,用来控制显示和淡入淡出。
    [SerializeField] private Slider _progressSlider; // 引用进度条,用来显示加载进度。
    [SerializeField] private Text _progressText; // 引用进度文本,用来显示百分比。
    public void LoadScene(string sceneName) // 对外提供切换场景方法。
    { // LoadScene 方法开始。
        StartCoroutine(LoadSceneRoutine(sceneName)); // 启动协程来异步切换场景。
    } // LoadScene 方法结束。
    private IEnumerator LoadSceneRoutine(string sceneName) // 定义异步加载场景协程。
    { // LoadSceneRoutine 协程开始。
        yield return FadeLoading(1.0f); // 先淡入 Loading 界面,避免玩家看到黑屏。
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景。
        operation.allowSceneActivation = false; // 暂时不允许 Unity 立刻激活新场景。
        while (operation.progress < 0.9f) // 当加载进度还没到 0.9 时持续等待。
        { // while 循环开始。
            float progress = operation.progress / 0.9f; // 把 Unity 的 0 到 0.9 映射成 UI 的 0 到 1。
            SetProgress(progress); // 更新 Loading 界面进度。
            yield return null; // 等待下一帧,避免阻塞主线程。
        } // while 循环结束。
        SetProgress(1.0f); // 把进度条显示为百分之百。
        yield return PreloadAfterSceneLoaded(); // 在真正激活前做一些预加载或预热工作。
        operation.allowSceneActivation = true; // 允许 Unity 激活新场景。
        yield return null; // 等待一帧,让新场景完成激活。
        yield return FadeLoading(0.0f); // 新场景准备好后淡出 Loading 界面。
    } // LoadSceneRoutine 协程结束。
    private void SetProgress(float progress) // 更新进度条和进度文本。
    { // SetProgress 方法开始。
        if (_progressSlider != null) _progressSlider.value = progress; // 如果进度条存在,就更新进度条数值。
        if (_progressText != null) _progressText.text = Mathf.RoundToInt(progress * 100.0f) + "%"; // 如果进度文本存在,就显示百分比。
    } // SetProgress 方法结束。
    private IEnumerator FadeLoading(float targetAlpha) // 控制 Loading 界面淡入淡出。
    { // FadeLoading 协程开始。
        if (_loadingCanvas == null) yield break; // 如果没有绑定 Loading 界面,就直接结束。
        _loadingCanvas.blocksRaycasts = true; // 淡入淡出期间阻止玩家点击后面的界面。
        float startAlpha = _loadingCanvas.alpha; // 记录开始透明度。
        float time = 0.0f; // 记录当前过渡时间。
        float duration = 0.25f; // 设置淡入淡出持续时间。
        while (time < duration) // 在持续时间内逐帧插值。
        { // while 循环开始。
            time += Time.unscaledDeltaTime; // 使用不受 Time.timeScale 影响的时间累加。
            float t = Mathf.Clamp01(time / duration); // 计算 0 到 1 的插值比例。
            _loadingCanvas.alpha = Mathf.Lerp(startAlpha, targetAlpha, t); // 根据比例更新透明度。
            yield return null; // 等待下一帧。
        } // while 循环结束。
        _loadingCanvas.alpha = targetAlpha; // 最后强制设置为目标透明度。
        _loadingCanvas.blocksRaycasts = targetAlpha > 0.0f; // 如果 Loading 隐藏了,就不再拦截点击。
    } // FadeLoading 协程结束。
    private IEnumerator PreloadAfterSceneLoaded() // 场景资源加载完后、激活前的预处理。
    { // PreloadAfterSceneLoaded 协程开始。
        yield return null; // 示例:等待一帧,让 Loading UI 有机会刷新到百分之百。
        yield return null; // 示例:真实项目里可以在这里分帧创建对象池或预热资源。
    } // PreloadAfterSceneLoaded 协程结束。
} // SceneLoader 类结束。

面试高分说法

TIP

我会避免直接同步切场景,而是做一个轻量 Loading 流程:先淡出旧场景并显示 Loading UI,然后用 LoadSceneAsync 异步加载目标场景,把 allowSceneActivation 设为 false,等进度到 0.9 后继续做资源预加载、对象池创建、Shader 预热等准备工作,最后再允许场景激活并淡入新场景。对于黑屏时间长的问题,还要检查 AwakeStart 里是否有大量同步初始化,能拆分的就分帧执行,能提前加载的资源就提前加载,切换时不要集中触发大量 GC 或 UnloadUnusedAssets

大世界如何分块加载?

大世界如何分块加载?

大世界分块加载的核心思路是:不要一次性把整张地图加载进内存,而是把世界切成很多小块 Chunk,玩家走到哪里,就加载附近几块,远离的块再异步卸载。

open-world-chunk-streaming-csharp

一句话理解

比如整个世界是 10000m x 10000m,你可以切成很多 100m x 100m 的小块。玩家在 (5, 8) 这个块时,只加载周围 3 x 35 x 5 的块,远处看不到也用不到的块就不加载。

常见做法

地图分块:按坐标切成 Chunk_0_0Chunk_0_1Chunk_1_0 这种小场景或资源包。

异步加载:用 LoadSceneAsync 加载分块场景,或者用 Addressables 加载分块资源。

范围控制:玩家附近的块加载,远离玩家超过卸载半径的块卸载。

卸载缓冲:卸载半径要比加载半径大,比如加载半径是 1,卸载半径是 2,避免玩家在边界来回走时反复加载卸载。

分帧初始化:一个 Chunk 加载完后,不要一帧创建所有怪物、采集物、特效、AI,应该分帧处理。

C# 简化代码

c
using System.Collections; // 引入协程命名空间,用来异步加载和卸载 Chunk。
using System.Collections.Generic; // 引入集合命名空间,用来保存已加载和加载中的 Chunk。
using UnityEngine; // 引入 Unity 引擎命名空间,用来使用 Transform 和 Vector2Int。
using UnityEngine.SceneManagement; // 引入场景管理命名空间,用来 Additive 加载分块场景。
public sealed class WorldChunkStreamer : MonoBehaviour // 定义大世界分块加载器。
{ // WorldChunkStreamer 类开始。
    [SerializeField] private Transform _player; // 玩家 Transform,用来计算玩家当前在哪个 Chunk。
    [SerializeField] private int _chunkSize = 100; // 每个 Chunk 的世界尺寸,例如 100 米一块。
    [SerializeField] private int _loadRadius = 1; // 加载半径,1 表示加载玩家周围 3 x 3 个 Chunk。
    [SerializeField] private int _unloadRadius = 2; // 卸载半径,通常要比加载半径大,避免边界抖动。
    [SerializeField] private string _scenePrefix = "Chunk_"; // 分块场景名前缀,例如 Chunk_0_0。
    private readonly HashSet<Vector2Int> _loadedChunks = new HashSet<Vector2Int>(); // 已经加载完成的 Chunk 坐标集合。
    private readonly HashSet<Vector2Int> _loadingChunks = new HashSet<Vector2Int>(); // 正在加载中的 Chunk 坐标集合。
    private Vector2Int _lastPlayerChunk = new Vector2Int(int.MinValue, int.MinValue); // 上一次玩家所在 Chunk,用来避免每帧重复刷新。
    private void Update() // Unity 每帧调用,用来检测玩家是否跨 Chunk。
    { // Update 方法开始。
        if (_player == null) return; // 如果没有绑定玩家,就直接返回。
        Vector2Int currentChunk = WorldToChunk(_player.position); // 根据玩家世界坐标计算当前 Chunk 坐标。
        if (currentChunk == _lastPlayerChunk) return; // 如果玩家还在同一个 Chunk,就不需要重新计算加载范围。
        _lastPlayerChunk = currentChunk; // 记录新的玩家 Chunk 坐标。
        RefreshChunks(currentChunk); // 刷新应该加载和卸载的 Chunk。
    } // Update 方法结束。
    private Vector2Int WorldToChunk(Vector3 worldPosition) // 把世界坐标转换成 Chunk 坐标。
    { // WorldToChunk 方法开始。
        int x = Mathf.FloorToInt(worldPosition.x / _chunkSize); // 用世界 x 坐标除以块大小得到 Chunk x。
        int y = Mathf.FloorToInt(worldPosition.z / _chunkSize); // 用世界 z 坐标除以块大小得到 Chunk y。
        return new Vector2Int(x, y); // 返回二维 Chunk 坐标。
    } // WorldToChunk 方法结束。
    private void RefreshChunks(Vector2Int centerChunk) // 根据玩家所在 Chunk 刷新加载和卸载。
    { // RefreshChunks 方法开始。
        HashSet<Vector2Int> neededChunks = GetChunksInRadius(centerChunk, _loadRadius); // 计算当前需要保持加载的 Chunk 集合。
        foreach (Vector2Int chunk in neededChunks) // 遍历所有需要加载的 Chunk。
        { // foreach 循环开始。
            if (_loadedChunks.Contains(chunk)) continue; // 如果已经加载完成,就跳过。
            if (_loadingChunks.Contains(chunk)) continue; // 如果正在加载中,也跳过。
            StartCoroutine(LoadChunk(chunk)); // 启动协程异步加载这个 Chunk。
        } // foreach 循环结束。
        List<Vector2Int> chunksToUnload = new List<Vector2Int>(); // 创建一个列表,用来收集需要卸载的 Chunk。
        foreach (Vector2Int chunk in _loadedChunks) // 遍历所有已加载 Chunk。
        { // foreach 循环开始。
            int distance = Mathf.Max(Mathf.Abs(chunk.x - centerChunk.x), Mathf.Abs(chunk.y - centerChunk.y)); // 计算这个 Chunk 距离玩家中心 Chunk 有多远。
            if (distance > _unloadRadius) chunksToUnload.Add(chunk); // 如果超过卸载半径,就加入卸载列表。
        } // foreach 循环结束。
        for (int i = 0; i < chunksToUnload.Count; i++) // 遍历所有待卸载 Chunk。
        { // for 循环开始。
            StartCoroutine(UnloadChunk(chunksToUnload[i])); // 启动协程异步卸载 Chunk。
        } // for 循环结束。
    } // RefreshChunks 方法结束。
    private HashSet<Vector2Int> GetChunksInRadius(Vector2Int center, int radius) // 获取中心点周围指定半径内的 Chunk。
    { // GetChunksInRadius 方法开始。
        HashSet<Vector2Int> result = new HashSet<Vector2Int>(); // 创建结果集合。
        for (int x = -radius; x <= radius; x++) // 遍历半径内的 x 偏移。
        { // 外层 for 开始。
            for (int y = -radius; y <= radius; y++) // 遍历半径内的 y 偏移。
            { // 内层 for 开始。
                result.Add(new Vector2Int(center.x + x, center.y + y)); // 把偏移后的 Chunk 坐标加入集合。
            } // 内层 for 结束。
        } // 外层 for 结束。
        return result; // 返回需要加载的 Chunk 集合。
    } // GetChunksInRadius 方法结束。
    private IEnumerator LoadChunk(Vector2Int chunk) // 异步加载一个 Chunk。
    { // LoadChunk 协程开始。
        _loadingChunks.Add(chunk); // 标记这个 Chunk 正在加载中。
        string sceneName = GetSceneName(chunk); // 根据 Chunk 坐标生成场景名。
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive); // 用 Additive 模式异步加载分块场景。
        while (!operation.isDone) // 等待场景加载完成。
        { // while 循环开始。
            yield return null; // 每帧等待,不阻塞主线程。
        } // while 循环结束。
        _loadingChunks.Remove(chunk); // 从加载中集合移除。
        _loadedChunks.Add(chunk); // 加入已加载集合。
        yield return InitializeChunkOverFrames(chunk); // 分帧初始化 Chunk 内的对象。
    } // LoadChunk 协程结束。
    private IEnumerator UnloadChunk(Vector2Int chunk) // 异步卸载一个 Chunk。
    { // UnloadChunk 协程开始。
        if (!_loadedChunks.Contains(chunk)) yield break; // 如果这个 Chunk 没有加载,就直接结束。
        string sceneName = GetSceneName(chunk); // 根据 Chunk 坐标生成场景名。
        AsyncOperation operation = SceneManager.UnloadSceneAsync(sceneName); // 异步卸载分块场景。
        while (operation != null && !operation.isDone) // 等待卸载完成。
        { // while 循环开始。
            yield return null; // 等待下一帧,避免卡住主线程。
        } // while 循环结束。
        _loadedChunks.Remove(chunk); // 从已加载集合移除这个 Chunk。
    } // UnloadChunk 协程结束。
    private IEnumerator InitializeChunkOverFrames(Vector2Int chunk) // 分帧初始化 Chunk 内部内容。
    { // InitializeChunkOverFrames 协程开始。
        yield return null; // 示例:第一帧可以等场景对象 Awake 和 Start 稳定。
        yield return null; // 示例:第二帧可以初始化怪物、采集物、触发器或对象池。
    } // InitializeChunkOverFrames 协程结束。
    private string GetSceneName(Vector2Int chunk) // 根据 Chunk 坐标生成场景名。
    { // GetSceneName 方法开始。
        return _scenePrefix + chunk.x + "_" + chunk.y; // 返回类似 Chunk_3_5 的场景名。
    } // GetSceneName 方法结束。
} // WorldChunkStreamer 类结束。

面试高分说法

TIP

大世界分块加载我会把地图按固定大小切成 Chunk,每个 Chunk 可以是一个 Additive Scene 或 Addressables 资源包。运行时根据玩家所在 Chunk 计算加载半径,只加载玩家附近区域;远离玩家超过卸载半径的 Chunk 异步卸载。卸载半径通常比加载半径大,避免玩家在边界来回移动导致频繁加载卸载。Chunk 加载完后,怪物、采集物、AI、触发器不要一帧全部初始化,而是分帧处理。远处区域用 LOD、低精度地形或只显示视觉层,近处区域才启用碰撞、AI 和交互逻辑。这样可以控制内存、降低加载卡顿,并保持大世界探索的连续性。

资源预加载策略怎么做?

资源预加载策略怎么做?

资源预加载的核心是:预测玩家马上会用到什么资源,提前加载到内存或缓存里,避免真正用到时才卡一下。但预加载不是越多越好,加载太多会占内存,甚至让 Loading 更久。

resource-preload-strategy-csharp

一句话理解

比如进入战斗前,先把主角、敌人、技能特效、音效、战斗 UI 加载好。战斗开始后直接用,就不会因为第一次释放技能才加载特效而卡顿。

预加载分两类

强预加载:不加载完不能进入玩法。 比如战斗必须用到的角色模型、技能特效、核心 UI。

弱预加载:可以后台慢慢加载,加载失败也不影响立刻开始。 比如远处怪物、可能打开的界面、低概率出现的特效。

推荐策略

按场景预加载:进入主城前加载主城 NPC、建筑、主城 UI。

按玩法预加载:进入副本前加载怪物、技能、掉落、战斗 UI。

按距离预加载:大世界里玩家靠近某区域时,提前加载那片区域资源。

按优先级预加载:主角和首屏 UI 优先,远处资源和低频资源延后。

按内存预算释放:缓存要有上限,不用的资源要释放,不然会爆内存。

C# 简化代码

c
using System.Collections; // 引入协程命名空间,用来分帧预加载资源。
using System.Collections.Generic; // 引入集合命名空间,用来保存资源列表和缓存。
using UnityEngine; // 引入 Unity 基础命名空间,用来使用 MonoBehaviour 和 Object。
using UnityEngine.AddressableAssets; // 引入 Addressables 命名空间,用来异步加载资源。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步句柄命名空间,用来保存 Addressables 加载结果。
public enum PreloadPriority // 定义预加载优先级。
{ // PreloadPriority 枚举开始。
    High, // 高优先级,必须尽早加载,比如主角和首屏 UI。
    Normal, // 普通优先级,Loading 中可以加载,比如普通怪物。
    Low // 低优先级,可以进入游戏后后台加载,比如低概率特效。
} // PreloadPriority 枚举结束。
public sealed class PreloadItem // 定义一个预加载资源项。
{ // PreloadItem 类开始。
    public string Address; // Addressables 资源地址。
    public PreloadPriority Priority; // 当前资源的预加载优先级。
    public bool Required; // 是否是强预加载资源。
} // PreloadItem 类结束。
public sealed class ResourcePreloadManager : MonoBehaviour // 定义资源预加载管理器。
{ // ResourcePreloadManager 类开始。
    private readonly Dictionary<string, AsyncOperationHandle<Object>> _cache = new Dictionary<string, AsyncOperationHandle<Object>>(); // 保存已经加载的资源句柄。
    public IEnumerator Preload(List<PreloadItem> items) // 预加载一组资源。
    { // Preload 协程开始。
        items.Sort(ComparePriority); // 按优先级排序,让高优先级资源先加载。
        for (int i = 0; i < items.Count; i++) // 遍历所有预加载资源。
        { // for 循环开始。
            PreloadItem item = items[i]; // 取出当前资源项。
            if (_cache.ContainsKey(item.Address)) continue; // 如果资源已经在缓存里,就不要重复加载。
            AsyncOperationHandle<Object> handle = Addressables.LoadAssetAsync<Object>(item.Address); // 通过 Addressables 异步加载资源。
            if (item.Required) yield return handle; // 如果是强预加载资源,就等待它加载完成。
            else StartCoroutine(WaitAndCache(item.Address, handle)); // 如果是弱预加载资源,就放到后台等待完成。
            if (item.Required && handle.Status == AsyncOperationStatus.Succeeded) _cache[item.Address] = handle; // 强预加载成功后加入缓存。
            if (item.Required && handle.Status != AsyncOperationStatus.Succeeded) Debug.LogError("强预加载失败:" + item.Address); // 强预加载失败要打印错误,方便定位。
            yield return null; // 每加载一个资源后让出一帧,避免连续处理造成卡顿。
        } // for 循环结束。
    } // Preload 协程结束。
    public Object GetCached(string address) // 从缓存中获取资源。
    { // GetCached 方法开始。
        if (!_cache.TryGetValue(address, out AsyncOperationHandle<Object> handle)) return null; // 如果缓存里没有这个资源,就返回空。
        if (handle.Status != AsyncOperationStatus.Succeeded) return null; // 如果资源还没成功加载,也返回空。
        return handle.Result; // 返回已经加载好的资源对象。
    } // GetCached 方法结束。
    public void Release(string address) // 释放某个资源。
    { // Release 方法开始。
        if (!_cache.TryGetValue(address, out AsyncOperationHandle<Object> handle)) return; // 如果缓存里没有这个资源,就直接返回。
        Addressables.Release(handle); // 释放 Addressables 资源句柄。
        _cache.Remove(address); // 从缓存字典中移除这个资源。
    } // Release 方法结束。
    public void ReleaseAll() // 释放所有预加载资源。
    { // ReleaseAll 方法开始。
        foreach (AsyncOperationHandle<Object> handle in _cache.Values) // 遍历缓存里的所有资源句柄。
        { // foreach 循环开始。
            Addressables.Release(handle); // 释放当前资源句柄。
        } // foreach 循环结束。
        _cache.Clear(); // 清空缓存字典。
    } // ReleaseAll 方法结束。
    private IEnumerator WaitAndCache(string address, AsyncOperationHandle<Object> handle) // 等待弱预加载资源完成并加入缓存。
    { // WaitAndCache 协程开始。
        yield return handle; // 等待异步加载完成。
        if (handle.Status == AsyncOperationStatus.Succeeded) _cache[address] = handle; // 如果加载成功,就加入缓存。
        else Debug.LogWarning("弱预加载失败:" + address); // 如果加载失败,就打印警告但不阻塞玩法。
    } // WaitAndCache 协程结束。
    private int ComparePriority(PreloadItem a, PreloadItem b) // 比较两个资源的优先级。
    { // ComparePriority 方法开始。
        return a.Priority.CompareTo(b.Priority); // 枚举值越小优先级越高,所以直接比较。
    } // ComparePriority 方法结束。
} // ResourcePreloadManager 类结束。

实际项目怎么用

进入副本前:强预加载角色、怪物、战斗 UI、核心技能特效。 Loading 过程中:加载普通怪物、场景音效、掉落图标。 进入场景后:后台弱预加载远处区域、低频技能、可能打开的界面。 离开玩法后:释放本玩法专用资源,保留全局常用资源。

面试高分说法

IMPORTANT

资源预加载我会按“使用概率、加载成本、使用时机”来分级。马上要用且加载成本高的资源做强预加载,比如主角、怪物、战斗 UI、核心技能特效;可能会用但不影响首帧体验的资源做弱预加载,放到 Loading 后半段或游戏空闲帧后台加载。预加载资源要有缓存和释放策略,不能只加载不释放。大世界里还可以结合距离和区域 Chunk 做预加载,玩家靠近区域前先加载资源,离开足够远后再释放。这样可以减少运行时卡顿,同时避免内存无限增长。

资源引用计数怎么设计?

资源引用计数怎么设计?

资源引用计数的核心是:同一个资源只加载一份,每多一个系统使用它,引用数加一;每少一个系统使用它,引用数减一;只有引用数变成 0 时,资源才可以真正卸载。

resource-reference-counting-csharp

一句话理解

比如 fire_skill.prefab 同时被战斗系统和技能预览界面使用:

战斗系统加载它,引用数变成 1。 技能预览界面也使用它,引用数变成 2。 预览界面关闭,引用数变成 1,不能卸载。 战斗结束,引用数变成 0,这时才真正释放资源。

为什么需要引用计数

没有引用计数会出现两个大问题:

资源提前卸载:A 系统还在用,B 系统释放时直接卸载,导致 A 报错或显示丢失。

资源永远不卸载:资源加载后没人记录谁在用,最后越堆越多,内存越来越高。

所以资源管理器要做到:统一加载、统一缓存、统一释放、统一统计引用数。

C# 简化代码

c
using System.Collections.Generic; // 引入 Dictionary,用来保存资源缓存表。
using UnityEngine; // 引入 UnityEngine,用来使用 Object 和 Resources。
public sealed class ResourceRecord // 定义资源缓存记录。
{ // ResourceRecord 类开始。
    public string Path; // 资源路径,例如 UI/Icon/Sword。
    public Object Asset; // 已经加载出来的资源对象。
    public int RefCount; // 当前资源引用数量。
} // ResourceRecord 类结束。
public sealed class RefCountResourceManager // 定义引用计数资源管理器。
{ // RefCountResourceManager 类开始。
    private readonly Dictionary<string, ResourceRecord> _records = new Dictionary<string, ResourceRecord>(); // 用路径保存所有已加载资源记录。
    public T Load<T>(string path) where T : Object // 加载资源,并增加引用计数。
    { // Load 方法开始。
        if (_records.TryGetValue(path, out ResourceRecord record)) // 如果缓存中已经有这个资源。
        { // if 开始。
            record.RefCount++; // 引用计数加一。
            return record.Asset as T; // 返回缓存中的资源对象。
        } // if 结束。
        T asset = Resources.Load<T>(path); // 从 Resources 中加载资源,真实项目可替换成 Addressables。
        if (asset == null) return null; // 如果资源加载失败,就返回空。
        ResourceRecord newRecord = new ResourceRecord(); // 创建新的资源记录。
        newRecord.Path = path; // 保存资源路径。
        newRecord.Asset = asset; // 保存资源对象。
        newRecord.RefCount = 1; // 第一次加载,引用计数为一。
        _records.Add(path, newRecord); // 把资源记录加入缓存表。
        return asset; // 返回加载出来的资源。
    } // Load 方法结束。
    public void Release(string path) // 释放一次资源引用。
    { // Release 方法开始。
        if (!_records.TryGetValue(path, out ResourceRecord record)) return; // 如果资源不存在,就直接返回。
        record.RefCount--; // 引用计数减一。
        if (record.RefCount > 0) return; // 如果还有系统在用,就不能卸载。
        Resources.UnloadAsset(record.Asset); // 引用计数归零后,卸载资源对象。
        _records.Remove(path); // 从缓存表中移除资源记录。
    } // Release 方法结束。
    public int GetRefCount(string path) // 获取某个资源当前引用数。
    { // GetRefCount 方法开始。
        if (!_records.TryGetValue(path, out ResourceRecord record)) return 0; // 如果资源没加载,就返回 0。
        return record.RefCount; // 返回当前引用计数。
    } // GetRefCount 方法结束。
} // RefCountResourceManager 类结束。

使用示例

c
using UnityEngine; // 引入 UnityEngine,用来使用 Texture2D。
public sealed class ResourceUseExample // 定义资源使用示例。
{ // ResourceUseExample 类开始。
    private readonly RefCountResourceManager _resourceManager; // 保存资源管理器引用。
    private Texture2D _icon; // 保存当前界面使用的图标资源。
    public ResourceUseExample(RefCountResourceManager resourceManager) // 构造示例对象。
    { // 构造函数开始。
        _resourceManager = resourceManager; // 保存外部传入的资源管理器。
    } // 构造函数结束。
    public void OpenWindow() // 打开界面时调用。
    { // OpenWindow 方法开始。
        _icon = _resourceManager.Load<Texture2D>("UI/Icon/Sword"); // 加载图标资源,并让引用计数加一。
    } // OpenWindow 方法结束。
    public void CloseWindow() // 关闭界面时调用。
    { // CloseWindow 方法开始。
        _resourceManager.Release("UI/Icon/Sword"); // 释放图标资源引用,并让引用计数减一。
        _icon = null; // 清空当前界面对资源的引用。
    } // CloseWindow 方法结束。
} // ResourceUseExample 类结束。

实际项目里要更进一步

如果用 Addressables,它本身就有类似引用计数的机制:你 LoadAssetAsync 得到一个 handle,用完后要 Addressables.Release(handle)。每次加载和释放最好配对。

如果你自己封装资源管理器,记录里一般会保存:

  • address:资源地址。
  • asset:资源对象。
  • handle:异步加载句柄。
  • refCount:引用数量。
  • lastUsedTime:最后使用时间,用于缓存淘汰。
  • isLoading:是否正在加载,避免重复发起加载请求。

面试高分说法

NOTE

资源引用计数我会设计成资源管理器统一维护。加载资源时先查缓存,如果已经加载过,就直接复用并让引用数加一;如果没加载过,就异步加载,加载成功后放入缓存,引用数设为一。释放资源时不会立刻卸载,而是让引用数减一,只有引用数归零时才真正释放资源句柄。这样可以避免同一资源重复加载,也能避免某个系统还在使用资源时被其他系统提前卸载。实际项目中还要处理异步加载中的重复请求、对象池回收时的 Release、依赖资源释放,以及通过调试面板查看每个资源的引用数量。

音效资源怎么管理?

音效资源怎么管理?

音效资源管理的核心是:AudioClip 负责存音频数据,AudioSource 负责播放,AudioMixer 负责统一控制音量;短音效缓存,长音乐按需加载,播放用对象池,离开玩法后释放不用的音频。

audio-resource-management-csharp

核心拆法

BGM:背景音乐,通常一个专用 AudioSource 循环播放,切换时淡入淡出。

SFX:短音效,比如点击、攻击、受击,适合预加载和缓存。

Voice:语音,可能很大,适合按需加载,播放完释放。

UI Sound:按钮音、弹窗音,频率高,适合常驻缓存。

AudioSource Pool:短音效可能同时播放多个,不要每次都创建销毁 AudioSource,用池复用。

C# 简化代码

c
using System.Collections; // 引入协程命名空间,用来等待音效播放结束后回收 AudioSource。
using System.Collections.Generic; // 引入集合命名空间,用来保存缓存和对象池。
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour、AudioClip、AudioSource。
using UnityEngine.Audio; // 引入 AudioMixer 命名空间,用来统一控制音量。
public sealed class AudioManager : MonoBehaviour // 定义音频管理器。
{ // AudioManager 类开始。
    [SerializeField] private AudioMixer _audioMixer; // 引用 AudioMixer,用来控制 BGM、SFX、Voice 的音量。
    [SerializeField] private AudioSource _bgmSource; // 引用背景音乐专用 AudioSource。
    [SerializeField] private int _sfxPoolSize = 12; // 设置短音效 AudioSource 池的初始数量。
    private readonly Queue<AudioSource> _sfxPool = new Queue<AudioSource>(); // 保存可复用的短音效 AudioSource。
    private readonly Dictionary<string, AudioClip> _clipCache = new Dictionary<string, AudioClip>(); // 保存已经加载过的 AudioClip。
    private readonly Dictionary<string, float> _lastPlayTime = new Dictionary<string, float>(); // 保存每个音效上次播放时间,用来做限流。
    private void Awake() // Unity 初始化时调用。
    { // Awake 方法开始。
        if (_bgmSource == null) _bgmSource = gameObject.AddComponent<AudioSource>(); // 如果没有绑定 BGM 声源,就自动创建一个。
        _bgmSource.loop = true; // BGM 通常需要循环播放。
        for (int i = 0; i < _sfxPoolSize; i++) // 根据池大小创建多个短音效声源。
        { // for 循环开始。
            _sfxPool.Enqueue(CreateSfxSource()); // 创建 AudioSource 并放入池中。
        } // for 循环结束。
    } // Awake 方法结束。
    public void PlayBgm(string path) // 播放背景音乐。
    { // PlayBgm 方法开始。
        AudioClip clip = LoadClip(path); // 加载或从缓存中获取 AudioClip。
        if (clip == null) return; // 如果加载失败,就直接返回。
        if (_bgmSource.clip == clip && _bgmSource.isPlaying) return; // 如果正在播放同一首 BGM,就不用重复播放。
        _bgmSource.clip = clip; // 设置 BGM 声源的音频片段。
        _bgmSource.Play(); // 开始播放背景音乐。
    } // PlayBgm 方法结束。
    public void PlaySfx(string path, float volume = 1.0f, float minInterval = 0.05f) // 播放短音效。
    { // PlaySfx 方法开始。
        if (IsTooFrequent(path, minInterval)) return; // 如果同一音效播放太频繁,就直接忽略,避免爆音。
        AudioClip clip = LoadClip(path); // 加载或获取缓存中的音效。
        if (clip == null) return; // 如果音效不存在,就直接返回。
        AudioSource source = GetSfxSource(); // 从对象池取一个 AudioSource。
        source.PlayOneShot(clip, volume); // 播放一次短音效。
        StartCoroutine(ReturnSourceAfter(source, clip.length)); // 等音效播放完后把 AudioSource 放回池中。
    } // PlaySfx 方法结束。
    public void SetMixerVolume(string parameterName, float value01) // 设置 AudioMixer 音量。
    { // SetMixerVolume 方法开始。
        float volume = Mathf.Clamp01(value01); // 把音量限制在 0 到 1。
        float db = Mathf.Lerp(-80.0f, 0.0f, volume); // 把线性音量转换成分贝值。
        _audioMixer.SetFloat(parameterName, db); // 设置 AudioMixer 暴露出来的音量参数。
    } // SetMixerVolume 方法结束。
    public void ClearCache() // 清理音频缓存。
    { // ClearCache 方法开始。
        _clipCache.Clear(); // 清空 AudioClip 缓存引用。
        Resources.UnloadUnusedAssets(); // 让 Unity 卸载没有被引用的资源。
    } // ClearCache 方法结束。
    private AudioClip LoadClip(string path) // 加载 AudioClip。
    { // LoadClip 方法开始。
        if (_clipCache.TryGetValue(path, out AudioClip cachedClip)) return cachedClip; // 如果缓存里已经有,就直接返回。
        AudioClip clip = Resources.Load<AudioClip>(path); // 从 Resources 加载音频,真实项目可替换成 Addressables。
        if (clip != null) _clipCache.Add(path, clip); // 如果加载成功,就加入缓存。
        return clip; // 返回加载结果。
    } // LoadClip 方法结束。
    private bool IsTooFrequent(string path, float minInterval) // 判断音效是否播放过于频繁。
    { // IsTooFrequent 方法开始。
        float now = Time.unscaledTime; // 获取当前真实时间,不受暂停影响。
        if (_lastPlayTime.TryGetValue(path, out float lastTime) && now - lastTime < minInterval) return true; // 如果间隔太短,就认为过于频繁。
        _lastPlayTime[path] = now; // 记录本次播放时间。
        return false; // 返回 false,表示可以播放。
    } // IsTooFrequent 方法结束。
    private AudioSource GetSfxSource() // 获取一个短音效 AudioSource。
    { // GetSfxSource 方法开始。
        if (_sfxPool.Count > 0) return _sfxPool.Dequeue(); // 如果池里有空闲声源,就取出来。
        return CreateSfxSource(); // 如果池不够用,就临时创建一个新的声源。
    } // GetSfxSource 方法结束。
    private AudioSource CreateSfxSource() // 创建一个短音效 AudioSource。
    { // CreateSfxSource 方法开始。
        AudioSource source = gameObject.AddComponent<AudioSource>(); // 在 AudioManager 对象上添加 AudioSource。
        source.loop = false; // 短音效不循环。
        source.playOnAwake = false; // 创建时不要自动播放。
        return source; // 返回创建好的声源。
    } // CreateSfxSource 方法结束。
    private IEnumerator ReturnSourceAfter(AudioSource source, float delay) // 延迟回收 AudioSource。
    { // ReturnSourceAfter 协程开始。
        yield return new WaitForSeconds(delay); // 等待音效播放完成。
        source.Stop(); // 确保声源停止播放。
        _sfxPool.Enqueue(source); // 把声源放回对象池。
    } // ReturnSourceAfter 协程结束。
} // AudioManager 类结束。

实际项目的策略

短音效,比如 UI 点击、普攻、受击,适合在进入界面或进入战斗前预加载。

背景音乐通常用一个专门的 AudioSource 播放,切换 BGM 时做淡出旧音乐、淡入新音乐。

角色语音和剧情语音可能很大,不建议全量常驻内存,通常按剧情段落或角色按需加载。

大量重复音效要做限流,比如一帧内十几个怪同时受击,不一定要播放十几次同一个受击音。

音量不要每个 AudioSource 自己乱调,推荐用 AudioMixer 分组:MasterBGMSFXVoice,设置面板直接调 Mixer 参数。

面试高分说法

NOTE

音效资源我会按类型管理。BGM 用专用 AudioSource 循环播放,并支持淡入淡出;短音效用 AudioSource 对象池播放,避免频繁创建销毁;常用 UI 音效和战斗音效提前加载并缓存;语音和长音频按需加载,播放完根据策略释放。音量控制统一走 AudioMixer,分成 BGM、SFX、Voice 等通道。为了避免性能和体验问题,还要做重复音效限流、资源引用计数、场景切换释放,以及短音效解压、长音乐流式加载等导入设置优化。

特效对象池怎么管理?

特效对象池怎么管理?

特效对象池的核心是:常用特效不要每次 InstantiateDestroy,而是提前创建一批,播放时取出来,播完清理状态并放回池里。

vfx-object-pool-management-csharp

为什么要做特效对象池

战斗里特效非常频繁,比如受击、爆炸、技能命中、治疗、暴击数字。如果每次都 Instantiate,会产生 CPU 开销和 GC;如果每次都 Destroy,还会导致内存和对象生命周期抖动。对象池就是把这些开销提前做掉,运行时复用。

管理思路

按特效类型建池:比如 hit_blood 一个池,skill_fire 一个池,heal_light 一个池。

进入战斗前预热:常用特效提前创建,比如每种创建 10 个。

播放时取对象:设置位置、旋转、父节点,然后播放粒子。

播完自动回收:等待粒子时长结束,执行 StopClear、隐藏对象,放回队列。

池要有上限:不够时可以扩容,但不能无限扩容;超出上限可以丢弃低优先级特效。

C# 示例

c
using System.Collections; // 引入协程命名空间,用来等待特效播放结束后回收。
using System.Collections.Generic; // 引入集合命名空间,用来使用 Queue 和 HashSet。
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour、ParticleSystem、Transform。
public sealed class VfxInstance : MonoBehaviour // 定义单个特效实例脚本。
{ // VfxInstance 类开始。
    private ParticleSystem[] _particles; // 保存当前特效下的所有粒子系统。
    private TrailRenderer[] _trails; // 保存当前特效下的所有拖尾组件。
    private VfxPool _ownerPool; // 保存这个实例所属的对象池。
    private Coroutine _autoRecycleRoutine; // 保存自动回收协程。
    public void Init(VfxPool ownerPool) // 初始化特效实例。
    { // Init 方法开始。
        _ownerPool = ownerPool; // 记录所属对象池。
        _particles = GetComponentsInChildren<ParticleSystem>(true); // 获取所有子物体粒子系统。
        _trails = GetComponentsInChildren<TrailRenderer>(true); // 获取所有子物体拖尾组件。
        gameObject.SetActive(false); // 初始化后先隐藏对象。
    } // Init 方法结束。
    public void Play(Vector3 position, Quaternion rotation, float lifeTime) // 播放特效。
    { // Play 方法开始。
        transform.SetPositionAndRotation(position, rotation); // 设置特效的位置和旋转。
        gameObject.SetActive(true); // 激活特效对象。
        ResetVisualState(); // 播放前清理上一次留下的粒子和拖尾。
        for (int i = 0; i < _particles.Length; i++) // 遍历所有粒子系统。
        { // for 循环开始。
            _particles[i].Play(true); // 播放当前粒子系统和它的子粒子。
        } // for 循环结束。
        if (_autoRecycleRoutine != null) StopCoroutine(_autoRecycleRoutine); // 如果之前有回收协程,就先停止。
        _autoRecycleRoutine = StartCoroutine(AutoRecycle(lifeTime)); // 启动新的自动回收协程。
    } // Play 方法结束。
    public void RecycleNow() // 立刻回收特效。
    { // RecycleNow 方法开始。
        if (_autoRecycleRoutine != null) StopCoroutine(_autoRecycleRoutine); // 停止自动回收协程。
        _autoRecycleRoutine = null; // 清空协程引用。
        ResetVisualState(); // 回收前清理粒子和拖尾残留。
        gameObject.SetActive(false); // 隐藏特效对象。
        _ownerPool.Recycle(this); // 把自己交还给对象池。
    } // RecycleNow 方法结束。
    private IEnumerator AutoRecycle(float lifeTime) // 自动回收协程。
    { // AutoRecycle 协程开始。
        yield return new WaitForSeconds(lifeTime); // 等待特效持续时间结束。
        RecycleNow(); // 时间到了就回收特效。
    } // AutoRecycle 协程结束。
    private void ResetVisualState() // 清理特效视觉状态。
    { // ResetVisualState 方法开始。
        for (int i = 0; i < _particles.Length; i++) // 遍历所有粒子系统。
        { // for 循环开始。
            _particles[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); // 停止并清空粒子,避免残留。
        } // for 循环结束。
        for (int i = 0; i < _trails.Length; i++) // 遍历所有拖尾组件。
        { // for 循环开始。
            _trails[i].Clear(); // 清除拖尾残影,避免下一次播放带着旧尾巴。
        } // for 循环结束。
    } // ResetVisualState 方法结束。
} // VfxInstance 类结束。
public sealed class VfxPool : MonoBehaviour // 定义某一种特效的对象池。
{ // VfxPool 类开始。
    [SerializeField] private VfxInstance _prefab; // 绑定特效实例预制体。
    [SerializeField] private int _prewarmCount = 10; // 进入战斗前预创建数量。
    [SerializeField] private int _maxCount = 30; // 池允许存在的最大实例数量。
    private readonly Queue<VfxInstance> _idleInstances = new Queue<VfxInstance>(); // 保存空闲特效实例。
    private readonly HashSet<VfxInstance> _usingInstances = new HashSet<VfxInstance>(); // 保存正在播放的特效实例。
    private int _totalCount; // 记录当前池里总共创建了多少实例。
    private void Awake() // Unity 初始化时调用。
    { // Awake 方法开始。
        Prewarm(); // 初始化时预热对象池。
    } // Awake 方法结束。
    public VfxInstance Spawn(Vector3 position, Quaternion rotation, float lifeTime) // 播放一个特效。
    { // Spawn 方法开始。
        VfxInstance instance = GetInstance(); // 从池里取一个实例。
        if (instance == null) return null; // 如果池满且没有空闲对象,就返回空。
        _usingInstances.Add(instance); // 把实例加入正在使用集合。
        instance.Play(position, rotation, lifeTime); // 播放这个特效实例。
        return instance; // 返回播放中的特效实例。
    } // Spawn 方法结束。
    public void Recycle(VfxInstance instance) // 回收一个特效实例。
    { // Recycle 方法开始。
        if (instance == null) return; // 如果传入实例为空,就直接返回。
        if (!_usingInstances.Remove(instance)) return; // 如果它不在使用集合里,说明已经回收过了。
        _idleInstances.Enqueue(instance); // 把实例放回空闲队列。
    } // Recycle 方法结束。
    private void Prewarm() // 预热对象池。
    { // Prewarm 方法开始。
        for (int i = 0; i < _prewarmCount; i++) // 按配置数量循环创建实例。
        { // for 循环开始。
            VfxInstance instance = CreateInstance(); // 创建一个新的特效实例。
            _idleInstances.Enqueue(instance); // 把创建好的实例放入空闲队列。
        } // for 循环结束。
    } // Prewarm 方法结束。
    private VfxInstance GetInstance() // 获取一个可播放的实例。
    { // GetInstance 方法开始。
        if (_idleInstances.Count > 0) return _idleInstances.Dequeue(); // 如果有空闲实例,就直接取出。
        if (_totalCount >= _maxCount) return null; // 如果已经达到最大数量,就不再扩容。
        return CreateInstance(); // 如果没达到上限,就创建一个新实例。
    } // GetInstance 方法结束。
    private VfxInstance CreateInstance() // 创建新的特效实例。
    { // CreateInstance 方法开始。
        VfxInstance instance = Instantiate(_prefab, transform); // 实例化特效预制体,并挂到池节点下面。
        instance.Init(this); // 初始化实例并绑定所属对象池。
        _totalCount++; // 总实例数量加一。
        return instance; // 返回创建好的实例。
    } // CreateInstance 方法结束。
} // VfxPool 类结束。

实际项目要注意

特效回收时要清状态:ParticleSystem.Stop(...Clear)TrailRenderer.Clear()、子物体显隐、材质参数、缩放、挂点都要恢复。

同屏特效要限流:比如一帧内 50 个怪同时受击,不一定要播放 50 个完整受击特效,可以合并、降级或丢弃远处低优先级特效。

对象池和资源管理要分开:对象池管理“实例复用”,资源管理器管理“Prefab 和贴图材质的加载释放”。离开战斗时,可以裁剪多余实例并释放低频特效资源。

面试高分说法

NOTE

特效对象池我会按特效 id 建多个池,进入战斗前对常用特效做预热。播放时从池里取出实例,设置位置和旋转,清理旧粒子和拖尾后重新播放;播放结束后自动回收,执行 Stop、Clear、隐藏对象并放回队列。池子要有初始数量和最大数量,避免无限扩容。对于高频特效还要做同屏数量限制、距离裁剪和优先级降级。对象池只负责实例复用,Prefab、贴图、材质这些资源还要交给资源管理器做引用计数和释放。

战斗结束后如何清理资源?

战斗结束后如何清理资源?

战斗结束后的清理不要理解成“全部 Destroy 掉”。更好的顺序是:先停止战斗逻辑,再回收临时对象,再释放战斗专用资源,最后在安全时机做重清理。

battle-end-resource-cleanup-csharp

推荐清理顺序

先停逻辑:停止刷怪、AI、技能、子弹、计时器、协程,避免边清边生成新对象。

再回收对象:怪物、子弹、特效、飘字、血条、掉落物,能回对象池就回对象池。

再释放资源:战斗 UI、怪物模型、技能特效、音效、临时贴图,用引用计数或 Addressables handle 释放。

最后做重清理:Resources.UnloadUnusedAssets()GC.Collect() 很重,不要战斗结束瞬间就乱调,最好放在 Loading、结算后、切场景空档。

C# 示例

c
using System.Collections; // 引入协程命名空间,用来分步骤清理资源。
using System.Collections.Generic; // 引入集合命名空间,用来保存战斗中产生的对象和资源地址。
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour、GameObject、Resources。
using UnityEngine.SceneManagement; // 引入场景管理命名空间,用来卸载战斗 Additive 场景。
public sealed class BattleResourceCleaner : MonoBehaviour // 定义战斗资源清理器。
{ // BattleResourceCleaner 类开始。
    [SerializeField] private string _battleSceneName = "BattleScene"; // 战斗场景名,适合 Additive 加载的战斗场景。
    private readonly List<GameObject> _spawnedObjects = new List<GameObject>(); // 保存战斗中生成的临时对象。
    private readonly List<string> _loadedResourceKeys = new List<string>(); // 保存战斗中加载过的资源 key。
    private readonly List<Coroutine> _runningCoroutines = new List<Coroutine>(); // 保存战斗中启动的协程。
    public void RegisterObject(GameObject obj) // 注册战斗中创建的对象。
    { // RegisterObject 方法开始。
        if (obj == null) return; // 如果对象为空,就直接返回。
        _spawnedObjects.Add(obj); // 把对象加入临时对象列表,方便战斗结束统一回收。
    } // RegisterObject 方法结束。
    public void RegisterResource(string resourceKey) // 注册战斗中加载的资源 key。
    { // RegisterResource 方法开始。
        if (string.IsNullOrEmpty(resourceKey)) return; // 如果资源 key 为空,就直接返回。
        _loadedResourceKeys.Add(resourceKey); // 把资源 key 记录下来,方便战斗结束释放引用。
    } // RegisterResource 方法结束。
    public void RegisterCoroutine(Coroutine coroutine) // 注册战斗中启动的协程。
    { // RegisterCoroutine 方法开始。
        if (coroutine == null) return; // 如果协程为空,就直接返回。
        _runningCoroutines.Add(coroutine); // 把协程加入列表,方便战斗结束停止。
    } // RegisterCoroutine 方法结束。
    public void StartCleanup() // 对外提供战斗结束清理入口。
    { // StartCleanup 方法开始。
        StartCoroutine(CleanupRoutine()); // 启动清理协程,避免一帧内做完所有重操作。
    } // StartCleanup 方法结束。
    private IEnumerator CleanupRoutine() // 定义完整清理流程。
    { // CleanupRoutine 协程开始。
        StopBattleCoroutines(); // 第一步,停止战斗相关协程。
        RecycleRuntimeObjects(); // 第二步,回收战斗临时对象。
        ReleaseBattleResources(); // 第三步,释放战斗专用资源引用。
        yield return UnloadBattleScene(); // 第四步,异步卸载战斗场景。
        yield return null; // 等一帧,让界面和对象销毁流程稳定一下。
        yield return Resources.UnloadUnusedAssets(); // 在安全时机卸载没有引用的资源。
        System.GC.Collect(); // 手动触发 GC,建议只在 Loading 或结算后这种可接受卡顿的时机使用。
    } // CleanupRoutine 协程结束。
    private void StopBattleCoroutines() // 停止战斗中的协程。
    { // StopBattleCoroutines 方法开始。
        for (int i = 0; i < _runningCoroutines.Count; i++) // 遍历所有已注册协程。
        { // for 循环开始。
            if (_runningCoroutines[i] != null) StopCoroutine(_runningCoroutines[i]); // 如果协程存在,就停止它。
        } // for 循环结束。
        _runningCoroutines.Clear(); // 清空协程记录列表。
    } // StopBattleCoroutines 方法结束。
    private void RecycleRuntimeObjects() // 回收战斗运行时对象。
    { // RecycleRuntimeObjects 方法开始。
        for (int i = 0; i < _spawnedObjects.Count; i++) // 遍历战斗中生成的所有对象。
        { // for 循环开始。
            GameObject obj = _spawnedObjects[i]; // 取出当前对象。
            if (obj == null) continue; // 如果对象已经被销毁,就跳过。
            obj.SetActive(false); // 简化示例:隐藏对象,真实项目里应该交给对象池 Recycle。
        } // for 循环结束。
        _spawnedObjects.Clear(); // 清空临时对象列表。
    } // RecycleRuntimeObjects 方法结束。
    private void ReleaseBattleResources() // 释放战斗资源引用。
    { // ReleaseBattleResources 方法开始。
        for (int i = 0; i < _loadedResourceKeys.Count; i++) // 遍历战斗加载过的资源 key。
        { // for 循环开始。
            string key = _loadedResourceKeys[i]; // 取出当前资源 key。
            Debug.Log("释放战斗资源引用:" + key); // 示例输出,真实项目里调用 ResourceManager.Release(key)。
        } // for 循环结束。
        _loadedResourceKeys.Clear(); // 清空资源 key 列表。
    } // ReleaseBattleResources 方法结束。
    private IEnumerator UnloadBattleScene() // 异步卸载战斗场景。
    { // UnloadBattleScene 协程开始。
        Scene scene = SceneManager.GetSceneByName(_battleSceneName); // 根据名字获取战斗场景。
        if (!scene.IsValid()) yield break; // 如果场景不存在,就直接结束。
        if (!scene.isLoaded) yield break; // 如果场景没有加载,就直接结束。
        AsyncOperation operation = SceneManager.UnloadSceneAsync(scene); // 异步卸载战斗场景。
        while (operation != null && !operation.isDone) // 等待卸载完成。
        { // while 循环开始。
            yield return null; // 等待下一帧,避免阻塞主线程。
        } // while 循环结束。
    } // UnloadBattleScene 协程结束。
} // BattleResourceCleaner 类结束。

实际项目里要清哪些

要清:怪物、子弹、召唤物、掉落物、技能特效、受击特效、战斗音效、战斗 UI、血条、飘字、锁定框、Buff、计时器、事件订阅、协程、战斗场景和战斗专用资源。

尤其要注意事件订阅:如果战斗对象订阅了全局事件但没取消,即使对象看起来“没用了”,也可能被事件系统引用着,导致无法释放。

面试高分说法

TIP

战斗结束我不会直接全部 Destroy,而是分层清理。第一步停止战斗逻辑,包括刷怪、AI、技能、计时器和协程;第二步把怪物、子弹、特效、飘字、血条等运行时对象回收到对象池;第三步释放战斗专用资源引用,比如怪物模型、技能特效、战斗 UI、音效资源;第四步卸载战斗 Additive 场景;最后在 Loading 或结算界面这种玩家能接受的时机,再做 UnloadUnusedAssets 和必要的 GC。这样既能避免内存泄漏,也能避免战斗刚结束时突然卡顿。

如何做资源泄漏检查?

如何做资源泄漏检查?

资源泄漏检查的核心是:所有资源加载和释放都要能被追踪,退出玩法或切场景后,看引用计数、对象数量、内存是否能回落。一次内存上涨不一定是泄漏,重复进入退出后持续上涨,才更像泄漏。

resource-leak-check-csharp

检查思路

第一步:统一资源入口。 不要到处直接 Resources.LoadAddressables.LoadAssetAsyncInstantiate,要封装到资源管理器里,这样才能统计谁加载了什么。

第二步:记录加载和释放。 每个资源记录 addressrefCount、加载时间、加载堆栈、是否释放。

第三步:做快照对比。 进入战斗前打一份快照,战斗结束清理后再打一份快照,对比哪些资源还没释放。

第四步:用 Profiler 验证。 Unity Profiler 和 Memory Profiler 可以看对象数量、纹理、材质、Mesh、AudioClip、RenderTexture、Native 内存是否持续上涨。

C# 简化追踪代码

c
using System; // 引入基础命名空间,用来记录时间和异常信息。
using System.Collections.Generic; // 引入集合命名空间,用来保存资源追踪表。
using UnityEngine; // 引入 UnityEngine,用来使用 Debug 和 Object。
public sealed class ResourceTrackInfo // 定义单个资源的追踪信息。
{ // ResourceTrackInfo 类开始。
    public string Address; // 资源地址,例如 UI/Icon/Sword。
    public int RefCount; // 当前引用计数。
    public string LoadStack; // 第一次加载时的调用堆栈。
    public float LastUseTime; // 最后一次使用时间。
} // ResourceTrackInfo 类结束。
public sealed class ResourceLeakTracker // 定义资源泄漏追踪器。
{ // ResourceLeakTracker 类开始。
    private readonly Dictionary<string, ResourceTrackInfo> _records = new Dictionary<string, ResourceTrackInfo>(); // 保存所有资源追踪记录。
    public void TrackLoad(string address) // 记录一次资源加载或引用增加。
    { // TrackLoad 方法开始。
        if (_records.TryGetValue(address, out ResourceTrackInfo info)) // 如果这个资源已经被记录过。
        { // if 开始。
            info.RefCount++; // 引用计数加一。
            info.LastUseTime = Time.realtimeSinceStartup; // 更新最后使用时间。
            return; // 结束方法。
        } // if 结束。
        ResourceTrackInfo newInfo = new ResourceTrackInfo(); // 创建新的追踪记录。
        newInfo.Address = address; // 保存资源地址。
        newInfo.RefCount = 1; // 第一次加载,引用计数为一。
        newInfo.LastUseTime = Time.realtimeSinceStartup; // 记录当前使用时间。
        newInfo.LoadStack = Environment.StackTrace; // 记录加载堆栈,方便定位是谁加载的。
        _records.Add(address, newInfo); // 把记录加入追踪表。
    } // TrackLoad 方法结束。
    public void TrackRelease(string address) // 记录一次资源释放或引用减少。
    { // TrackRelease 方法开始。
        if (!_records.TryGetValue(address, out ResourceTrackInfo info)) // 如果释放了一个没记录过的资源。
        { // if 开始。
            Debug.LogWarning("释放了未追踪资源:" + address); // 打印警告,说明 Load 和 Release 不配对。
            return; // 结束方法。
        } // if 结束。
        info.RefCount--; // 引用计数减一。
        info.LastUseTime = Time.realtimeSinceStartup; // 更新最后使用时间。
        if (info.RefCount > 0) return; // 如果还有引用,就不能从追踪表移除。
        _records.Remove(address); // 引用归零后,从追踪表移除。
    } // TrackRelease 方法结束。
    public void DumpLeaks(string tag) // 打印当前仍未释放的资源。
    { // DumpLeaks 方法开始。
        foreach (ResourceTrackInfo info in _records.Values) // 遍历所有追踪记录。
        { // foreach 循环开始。
            Debug.LogWarning("[" + tag + "] 未释放资源:" + info.Address + " 引用数:" + info.RefCount + "\n加载堆栈:" + info.LoadStack); // 打印资源地址、引用数和加载堆栈。
        } // foreach 循环结束。
    } // DumpLeaks 方法结束。
    public int GetAliveCount() // 获取当前仍然存活的资源数量。
    { // GetAliveCount 方法开始。
        return _records.Count; // 返回追踪表里还没归零的资源数量。
    } // GetAliveCount 方法结束。
} // ResourceLeakTracker 类结束。

怎么接入资源管理器

c
using UnityEngine; // 引入 UnityEngine,用来使用 Object 和 Resources。
public sealed class TrackedResourceManager // 定义带泄漏追踪的资源管理器示例。
{ // TrackedResourceManager 类开始。
    private readonly ResourceLeakTracker _tracker = new ResourceLeakTracker(); // 创建资源泄漏追踪器。
    public T Load<T>(string address) where T : Object // 加载资源。
    { // Load 方法开始。
        T asset = Resources.Load<T>(address); // 从 Resources 加载资源,真实项目可以替换成 Addressables。
        if (asset != null) _tracker.TrackLoad(address); // 加载成功后记录一次引用。
        return asset; // 返回加载出来的资源。
    } // Load 方法结束。
    public void Release(string address) // 释放资源。
    { // Release 方法开始。
        _tracker.TrackRelease(address); // 记录一次引用释放。
    } // Release 方法结束。
    public void DumpLeaksAfterBattle() // 战斗结束后打印泄漏信息。
    { // DumpLeaksAfterBattle 方法开始。
        _tracker.DumpLeaks("BattleEnd"); // 输出战斗结束后仍然没释放的资源。
    } // DumpLeaksAfterBattle 方法结束。
} // TrackedResourceManager 类结束。

常见泄漏点

最常见的是这些:

  • Addressables.LoadAssetAsync 后忘记 Addressables.Release
  • 事件订阅了全局事件,销毁时没取消订阅。
  • 对象池只创建不裁剪,越打越多。
  • 静态字段一直持有场景对象。
  • 临时 MaterialTexture2DRenderTextureComputeBuffer 没释放。
  • NativeArrayNativeList、Job 相关 Native 容器没 Dispose
  • 特效、音效、UI 关闭了,但资源引用计数没减。

面试高分说法

NOTE

资源泄漏检查我会从“统一入口”和“快照对比”两方面做。首先所有资源加载和释放都必须经过资源管理器,记录资源地址、引用计数、加载堆栈和最后使用时间。然后在进入玩法前、退出玩法后各打一份快照,检查哪些资源引用计数没有归零。对于 Unity 项目,我还会结合 Profiler 和 Memory Profiler 看 Texture、Mesh、Material、AudioClip、RenderTexture、NativeArray 等对象数量是否持续上涨。一次上涨不一定是泄漏,要通过循环进入退出同一玩法多次,看内存和对象数量是否持续增长来判断。

如何做配置表热加载?

如何做配置表热加载?

配置表热加载的核心是:不重启游戏,也不重新打包代码,在运行时检测到新配置后,重新解析配置表,通过校验后替换当前配置数据,并通知相关系统刷新。

config-table-hot-reload-csharp

一句话理解

比如策划改了技能表:火球伤害 100 -> 120。 游戏不需要重启,配置管理器重新加载技能表,校验没问题后,把旧技能表替换成新技能表,技能系统下一次查询时就能拿到新数值。

核心流程

  1. 检测新配置:Editor 下可以监听本地文件变化,线上可以通过版本清单、hash、配置包判断是否有新表。
  2. 解析新配置:CSV、JSON、二进制表都可以,先解析到临时数据结构。
  3. 校验新配置:检查 id 是否重复、字段是否合法、跨表引用是否存在。
  4. 原子替换:不要边读边改旧表,而是构建一份新表,成功后一次性替换引用。
  5. 通知系统:比如技能、商店、掉落、任务、UI 刷新缓存。
  6. 失败回滚:如果新表解析失败或校验失败,继续使用旧表。

C# 示例

c
using System; // 引入 Action,用来通知外部系统配置表变化。
using System.Collections.Generic; // 引入 Dictionary 和 List,用来保存配置表数据。
using UnityEngine; // 引入 UnityEngine,用来使用 JsonUtility 和 Debug。
[Serializable] // 标记技能配置可以被 Unity JSON 序列化。
public sealed class SkillConfig // 定义单条技能配置。
{ // SkillConfig 类开始。
    public int Id; // 技能 id,必须唯一。
    public string Name; // 技能名字。
    public int Damage; // 技能伤害。
    public float Cooldown; // 技能冷却时间。
} // SkillConfig 类结束。
[Serializable] // 标记技能配置表可以被 Unity JSON 序列化。
public sealed class SkillConfigTable // 定义技能配置表。
{ // SkillConfigTable 类开始。
    public List<SkillConfig> Skills = new List<SkillConfig>(); // 保存所有技能配置。
} // SkillConfigTable 类结束。
public sealed class ConfigManager // 定义配置管理器。
{ // ConfigManager 类开始。
    private Dictionary<int, SkillConfig> _skillConfigs = new Dictionary<int, SkillConfig>(); // 当前正在使用的技能配置快照。
    public event Action OnConfigReloaded; // 配置热加载成功事件。
    public SkillConfig GetSkill(int skillId) // 根据技能 id 获取技能配置。
    { // GetSkill 方法开始。
        _skillConfigs.TryGetValue(skillId, out SkillConfig config); // 尝试从当前快照里取技能配置。
        return config; // 返回技能配置,找不到时返回 null。
    } // GetSkill 方法结束。
    public bool ReloadSkillConfig(string json) // 热加载技能配置表。
    { // ReloadSkillConfig 方法开始。
        SkillConfigTable newTable = JsonUtility.FromJson<SkillConfigTable>(json); // 把 JSON 解析成临时配置表。
        if (newTable == null) return false; // 如果解析失败,就直接返回 false。
        Dictionary<int, SkillConfig> newMap = BuildSkillMap(newTable); // 把临时配置表构建成新的字典。
        if (newMap == null) return false; // 如果构建或校验失败,就保留旧表并返回 false。
        _skillConfigs = newMap; // 原子替换当前技能配置快照。
        OnConfigReloaded?.Invoke(); // 通知技能系统、UI 系统等配置已经刷新。
        return true; // 返回热加载成功。
    } // ReloadSkillConfig 方法结束。
    private Dictionary<int, SkillConfig> BuildSkillMap(SkillConfigTable table) // 构建并校验技能配置字典。
    { // BuildSkillMap 方法开始。
        Dictionary<int, SkillConfig> map = new Dictionary<int, SkillConfig>(); // 创建新的技能配置字典。
        for (int i = 0; i < table.Skills.Count; i++) // 遍历表里的每一条技能配置。
        { // for 循环开始。
            SkillConfig config = table.Skills[i]; // 取出当前技能配置。
            if (config.Id <= 0) return null; // 技能 id 必须大于 0,否则校验失败。
            if (string.IsNullOrEmpty(config.Name)) return null; // 技能名字不能为空,否则校验失败。
            if (config.Damage < 0) return null; // 技能伤害不能小于 0,否则校验失败。
            if (config.Cooldown < 0.0f) return null; // 技能冷却不能小于 0,否则校验失败。
            if (map.ContainsKey(config.Id)) return null; // 技能 id 不能重复,否则校验失败。
            map.Add(config.Id, config); // 校验通过后加入新字典。
        } // for 循环结束。
        return map; // 返回构建好的新技能配置字典。
    } // BuildSkillMap 方法结束。
} // ConfigManager 类结束。

为什么要“原子替换”

不要直接对旧的 Dictionary 一边清空一边添加。 因为别的系统可能正在读配置,比如技能系统正在遍历技能表,这时你突然清空它,很容易出问题。

更安全的做法是:

先构建 newMap。 校验通过后:_skillConfigs = newMap。 旧表自然不再被新查询使用。

实际项目怎么做

Editor 开发阶段:可以监听本地配置文件变化,策划改表后自动 reload,方便调数值。

线上热更新:客户端先下载版本清单,对比配置表 hash,如果有变化就下载配置包,校验签名和 hash 后再加载。

大型项目:配置表通常会先从 Excel 导出成 JSON、二进制或 FlatBuffer,再由客户端加载。

关键系统:战斗中是否允许立即生效要谨慎。比如技能伤害可以下一次释放生效,但已经创建出来的 Buff、子弹、怪物属性不一定要立刻改,否则会出现表现不一致。

面试高分说法

IMPORTANT

配置表热加载我会设计成“检测版本、解析临时表、校验、原子替换、通知刷新、失败回滚”的流程。配置管理器不会直接修改正在使用的旧表,而是先把新配置解析成临时结构,检查 id 唯一性、字段范围、跨表引用和版本兼容性,全部通过后再一次性替换当前配置快照。替换成功后发事件通知技能、任务、商店、掉落等系统刷新缓存。如果新表解析失败或校验失败,就继续使用旧配置,避免坏表把线上客户端搞崩。

如何做版本兼容?

如何做版本兼容?

版本兼容的核心是:所有会变化的数据都带版本号,启动或加载时先判断版本,能迁移就迁移,不能兼容就阻止进入、强制更新或回滚。

version-compatibility-csharp

哪些地方需要版本兼容

客户端版本:旧客户端代码能力不够时,要强制更新。

资源版本:资源包和依赖要匹配,不能新配置引用旧资源里不存在的 Prefab。

配置表版本:字段结构变化时,要判断 schema 是否兼容。

存档版本:旧存档要能迁移到新结构。

协议版本:客户端和服务器字段要兼容,旧客户端不能因为多了字段就崩。

热更版本:补丁失败时要能回滚到上一个可用版本。

核心策略

向前兼容:新代码能读取旧数据,并把旧数据升级成新数据。

向后兼容:旧代码遇到新字段时尽量忽略,不要直接崩。

强制更新:如果代码能力不够,比如旧客户端完全不认识新玩法协议,就不能硬兼容,必须强更。

失败回滚:新配置、新资源、新补丁加载失败时,继续使用旧版本。

C# 示例:存档版本迁移

c
using System; // 引入基础命名空间,用来使用 Serializable。
using UnityEngine; // 引入 UnityEngine,用来使用 JsonUtility 和 Debug。
[Serializable] // 标记存档可以被 JSON 序列化。
public sealed class PlayerSaveData // 定义玩家存档数据。
{ // PlayerSaveData 类开始。
    public int Version; // 存档版本号。
    public int Level; // 玩家等级。
    public int Gold; // 玩家金币。
    public int Diamond; // 玩家钻石,新版本新增字段。
    public string PlayerName; // 玩家名字,新版本可能新增字段。
} // PlayerSaveData 类结束。
public sealed class SaveVersionMigrator // 定义存档版本迁移器。
{ // SaveVersionMigrator 类开始。
    private const int CurrentVersion = 3; // 当前代码支持的最新存档版本。
    public PlayerSaveData LoadAndMigrate(string json) // 加载并迁移存档。
    { // LoadAndMigrate 方法开始。
        PlayerSaveData data = JsonUtility.FromJson<PlayerSaveData>(json); // 先把 JSON 解析成存档对象。
        if (data == null) return CreateDefaultSave(); // 如果解析失败,就创建默认存档。
        while (data.Version < CurrentVersion) // 如果存档版本低于当前版本,就逐步迁移。
        { // while 循环开始。
            if (data.Version == 0) MigrateV0ToV1(data); // 如果是 0 版本,就迁移到 1 版本。
            else if (data.Version == 1) MigrateV1ToV2(data); // 如果是 1 版本,就迁移到 2 版本。
            else if (data.Version == 2) MigrateV2ToV3(data); // 如果是 2 版本,就迁移到 3 版本。
            else throw new Exception("未知存档版本:" + data.Version); // 如果版本无法识别,就抛出异常。
        } // while 循环结束。
        if (data.Version > CurrentVersion) throw new Exception("存档版本高于客户端版本"); // 如果存档比客户端还新,说明当前客户端无法读取。
        return data; // 返回迁移后的存档。
    } // LoadAndMigrate 方法结束。
    private void MigrateV0ToV1(PlayerSaveData data) // 把 0 版本存档迁移到 1 版本。
    { // MigrateV0ToV1 方法开始。
        if (data.Level <= 0) data.Level = 1; // 老存档可能没有等级,默认给 1 级。
        data.Version = 1; // 更新存档版本到 1。
    } // MigrateV0ToV1 方法结束。
    private void MigrateV1ToV2(PlayerSaveData data) // 把 1 版本存档迁移到 2 版本。
    { // MigrateV1ToV2 方法开始。
        if (data.PlayerName == null) data.PlayerName = "Player"; // 新增玩家名字段时,给旧存档补默认名字。
        data.Version = 2; // 更新存档版本到 2。
    } // MigrateV1ToV2 方法结束。
    private void MigrateV2ToV3(PlayerSaveData data) // 把 2 版本存档迁移到 3 版本。
    { // MigrateV2ToV3 方法开始。
        if (data.Diamond < 0) data.Diamond = 0; // 钻石字段不允许是负数。
        data.Version = 3; // 更新存档版本到 3。
    } // MigrateV2ToV3 方法结束。
    private PlayerSaveData CreateDefaultSave() // 创建默认存档。
    { // CreateDefaultSave 方法开始。
        PlayerSaveData data = new PlayerSaveData(); // 创建新的存档对象。
        data.Version = CurrentVersion; // 设置为当前最新版本。
        data.Level = 1; // 默认等级为 1。
        data.Gold = 0; // 默认金币为 0。
        data.Diamond = 0; // 默认钻石为 0。
        data.PlayerName = "Player"; // 默认玩家名字。
        return data; // 返回默认存档。
    } // CreateDefaultSave 方法结束。
} // SaveVersionMigrator 类结束。

配置表版本兼容怎么做

配置表要有 schemaVersion。 比如技能表从版本 1 到版本 2,新增了 DamageType 字段。旧表没有这个字段时,可以给默认值;如果新表结构完全不适合当前代码,就拒绝加载。

网络协议版本兼容怎么做

协议尽量“只新增字段,不随便删除或改含义”。 旧客户端不认识的新字段可以忽略;服务器发现客户端版本太低,就返回“需要更新”。真正破坏性的协议变化,不要硬兼容,直接强更。

面试高分说法

IMPORTANT

版本兼容我会从客户端、资源、配置、存档、协议几个层面处理。所有数据都带版本号,启动时先检查客户端最低支持版本;资源和配置通过 manifest、hash、schemaVersion 校验;旧存档通过逐版本迁移升级到当前结构;网络协议尽量只新增字段并保留默认值,破坏性变更则要求强制更新。热更和配置更新失败时不能覆盖旧版本,要保留上一个可用版本并支持回滚。这样可以避免旧客户端读新数据崩溃,也能保证线上更新失败时玩家还能正常进入游戏。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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