Skip to content

引擎设计题

设计一个 Entity-Component 系统

核心解释

Entity-Component 系统的核心是:Entity 是身份,Component 是数据,System 是逻辑。 它解决的是“继承层级越来越乱”的问题,比如不要写 PlayerEnemyNpcBoss 这种复杂继承,而是把对象拆成组件组合。

csharp-entity-component-system-design

系统怎么拆

Entity:实体,只是一个 ID,比如 1001,代表某个角色、怪物、子弹。 Component:组件,只存数据,比如 PositionVelocityHealthSystem:系统,只写逻辑,比如移动系统、战斗系统、AI 系统。 World / Registry:管理所有实体和组件,像一个小型数据库。 Query:查询拥有某些组件的实体,比如查所有同时拥有 PositionVelocity 的实体。

简单 C# 实现

c
using System; // 引入 Type 类型
using System.Collections.Generic; // 引入 Dictionary、HashSet、IEnumerable

public interface IComponent { } // 定义组件标记接口,所有组件都实现它

public struct Position : IComponent { public float X; public float Y; } // 定义位置组件,只存位置数据

public struct Velocity : IComponent { public float X; public float Y; } // 定义速度组件,只存速度数据

public sealed class World // 定义 ECS 世界,负责管理实体和组件
{ // World 类开始
    private int nextEntityId = 1; // 保存下一个实体 ID
    private readonly HashSet<int> entities = new HashSet<int>(); // 保存当前存在的实体
    private readonly Dictionary<Type, Dictionary<int, IComponent>> components = new Dictionary<Type, Dictionary<int, IComponent>>(); // 按组件类型保存组件表

    public int CreateEntity() // 创建一个实体
    { // CreateEntity 方法开始
        int id = nextEntityId++; // 分配一个新的实体 ID
        entities.Add(id); // 把实体加入世界
        return id; // 返回实体 ID
    } // CreateEntity 方法结束

    public void AddComponent<T>(int entity, T component) where T : IComponent // 给实体添加或覆盖组件
    { // AddComponent 方法开始
        Type type = typeof(T); // 拿到组件类型
        if (!components.TryGetValue(type, out Dictionary<int, IComponent> map)) // 如果还没有这种组件表
        { // if 开始
            map = new Dictionary<int, IComponent>(); // 创建这种组件的存储表
            components[type] = map; // 注册到总组件字典里
        } // if 结束
        map[entity] = component; // 给指定实体保存组件数据
    } // AddComponent 方法结束

    public T GetComponent<T>(int entity) where T : IComponent // 获取实体身上的某个组件
    { // GetComponent 方法开始
        return (T)components[typeof(T)][entity]; // 从组件表里取出并转换成目标类型
    } // GetComponent 方法结束

    public IEnumerable<int> Query<T1, T2>() where T1 : IComponent where T2 : IComponent // 查询同时拥有两个组件的实体
    { // Query 方法开始
        if (!components.TryGetValue(typeof(T1), out Dictionary<int, IComponent> a)) yield break; // 没有第一种组件就结束
        if (!components.TryGetValue(typeof(T2), out Dictionary<int, IComponent> b)) yield break; // 没有第二种组件就结束
        foreach (int entity in a.Keys) // 遍历拥有第一种组件的实体
        { // foreach 开始
            if (b.ContainsKey(entity)) // 判断它是否也拥有第二种组件
            { // if 开始
                yield return entity; // 返回符合条件的实体
            } // if 结束
        } // foreach 结束
    } // Query 方法结束
} // World 类结束

public sealed class MovementSystem // 定义移动系统
{ // MovementSystem 类开始
    public void Update(World world, float deltaTime) // 每帧更新移动逻辑
    { // Update 方法开始
        foreach (int entity in world.Query<Position, Velocity>()) // 找到所有有位置和速度的实体
        { // foreach 开始
            Position position = world.GetComponent<Position>(entity); // 读取位置组件
            Velocity velocity = world.GetComponent<Velocity>(entity); // 读取速度组件
            position.X += velocity.X * deltaTime; // 根据速度更新 X 坐标
            position.Y += velocity.Y * deltaTime; // 根据速度更新 Y 坐标
            world.AddComponent(entity, position); // 把更新后的位置写回世界
        } // foreach 结束
    } // Update 方法结束
} // MovementSystem 类结束

面试高分回答

WARNING

我会把 Entity-Component 系统设计成一个 World 管理实体和组件,Entity 只作为 ID,不放业务逻辑;Component 只存数据,不写 Update;System 通过 Query 找到拥有指定组件组合的实体,然后批量处理。这样角色、怪物、子弹都不需要复杂继承,而是通过组件组合能力,比如有 Position + Velocity 就能移动,有 Health 就能被攻击,有 AIState 就能跑 AI。

需要注意的坑

CAUTION

上面代码是面试手写简化版,IComponent 会产生装箱,真实性能 ECS 会用泛型组件池、连续数组、SparseSet 或 Archetype 来存储组件。 遍历 System 时不要立刻删除实体,最好用 CommandBuffer 延迟删除,避免遍历中修改集合。 Entity ID 最好带版本号,比如 id + version,避免旧 ID 被复用后误操作新对象。

一句话记忆

Entity-Component 系统就是:Entity 是编号,Component 是数据,System 是规则,World 是数据库。

设计一个资源管理系统

核心解释

资源管理系统的核心不是“把资源加载出来”,而是管理资源的完整生命周期: 谁加载了它、依赖了谁、有没有缓存、用了几次、什么时候释放、释放后内存有没有真的下降。

csharp-unity-resource-manager-system-design

系统怎么拆

ResourceManager:统一入口,业务层只调用它,不直接到处加载资源。 Manifest:资源清单,记录资源 ID、路径、包名、依赖、版本、大小。 Loader:加载层,底层可以接 AddressablesAssetBundleResourcesCache:缓存已加载资源,避免同一个资源重复加载。 RefCount:引用计数,记录资源被多少地方使用。 DependencyManager:依赖管理,比如一个角色 Prefab 依赖材质、贴图、动画。 PreloadManager:预加载管理,进入战斗前提前加载角色、技能、特效。 ReleaseManager:释放管理,引用计数为 0 后延迟卸载或立即卸载。 LeakChecker:泄漏检查,统计加载未释放的资源。

一次加载流程

业务层请求资源:LoadAsync("hero_001")。 资源系统查 Manifest,找到路径、包名、依赖资源。 先加载依赖,比如贴图、材质、动画、Shader。 再加载主资源,比如角色 Prefab。 加载完成后放入缓存。 引用计数加一。 返回资源句柄给业务层。 业务用完后调用 Release(handle)。 引用计数减一,减到 0 后进入卸载队列。

简单 C# 示例

c
using System.Collections.Generic; // 引入 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class ResourceRecord // 定义资源记录类
{ // ResourceRecord 类开始
    public Object Asset; // 保存已经加载出来的资源对象
    public int RefCount; // 保存当前资源被引用的次数
} // ResourceRecord 类结束

public sealed class ResourceManager : MonoBehaviour // 定义资源管理器
{ // ResourceManager 类开始
    public static ResourceManager Instance; // 定义全局访问入口

    private readonly Dictionary<string, ResourceRecord> cache = new Dictionary<string, ResourceRecord>(); // 保存资源路径到资源记录的缓存表

    private void Awake() // 初始化时调用
    { // Awake 方法开始
        Instance = this; // 保存单例引用
        DontDestroyOnLoad(gameObject); // 切换场景时不销毁资源管理器
    } // Awake 方法结束

    public T Load<T>(string path) where T : Object // 同步加载资源的简化示例
    { // Load 方法开始
        if (cache.TryGetValue(path, out ResourceRecord record)) // 如果缓存中已经有这个资源
        { // if 开始
            record.RefCount++; // 引用计数加一
            return record.Asset as T; // 返回缓存中的资源对象
        } // if 结束

        T asset = Resources.Load<T>(path); // 示例使用 Resources 加载,真实项目可替换成 Addressables 或 AssetBundle
        if (asset == null) // 如果加载失败
        { // if 开始
            Debug.LogError("资源加载失败:" + path); // 输出资源加载失败日志
            return null; // 返回空对象
        } // if 结束

        ResourceRecord newRecord = new ResourceRecord(); // 创建新的资源记录
        newRecord.Asset = asset; // 保存加载出来的资源
        newRecord.RefCount = 1; // 第一次加载时引用计数为一
        cache[path] = newRecord; // 把资源记录放入缓存表
        return asset; // 返回加载出来的资源
    } // Load 方法结束

    public void Release(string path) // 释放一次资源引用
    { // Release 方法开始
        if (!cache.TryGetValue(path, out ResourceRecord record)) // 如果缓存里没有这个资源
        { // if 开始
            return; // 直接返回,避免错误释放
        } // if 结束

        record.RefCount--; // 引用计数减一
        if (record.RefCount > 0) // 如果还有其他地方正在使用
        { // if 开始
            return; // 暂时不能卸载
        } // if 结束

        cache.Remove(path); // 从缓存表中移除资源记录
        Resources.UnloadUnusedAssets(); // 请求 Unity 卸载已经没有引用的资源
    } // Release 方法结束
} // ResourceManager 类结束

这段代码是面试手写简化版。真实项目里一般不会大量使用 Resources,而是用 AddressablesAssetBundle,并且会把加载做成异步,返回 Handle,释放时也通过 Handle 释放,方便追踪谁加载、谁释放。

面试高分回答

TIP

我会把资源管理系统设计成配置驱动。业务层只通过资源 ID 请求资源,ResourceManager 先查 Manifest,拿到资源路径、包名、依赖和版本信息;然后由 Loader 加载依赖和主资源,加载完成后放入缓存并增加引用计数。释放时不直接卸载,而是先减少引用计数,等计数为 0 后再进入卸载队列。这样可以避免重复加载、避免依赖丢失,也方便做预加载、热更新、资源泄漏检查和内存峰值控制。

容易被问的坑

IMPORTANT

Prefab 资源和实例对象要分开管理:资源是 Prefab,实例是 Instantiate 出来的 GameObject。 AssetBundle 卸载不等于实例对象销毁,已经实例化的对象还可能存在。 引用计数为 0 不一定立刻卸载,频繁卸载再加载反而会卡顿。 切场景时要检查场景资源有没有正确释放。 大资源尽量异步加载,不要在战斗中突然同步加载大贴图、大音频、大特效。

一句话记忆

资源管理系统就是:Manifest 管资源信息,Loader 管加载,Cache 管复用,RefCount 管释放,Profiler 管验证。

设计一个场景管理系统

核心解释

场景管理系统的重点不是简单调用 SceneManager.LoadScene,而是把切场景做成一个完整流程:显示 Loading、禁止输入、清理旧场景、预加载资源、异步加载新场景、初始化系统、恢复输入、失败回退。

csharp-unity-scene-manager-system-design

系统怎么拆

SceneManagerService:统一入口,业务只调用它切场景。 SceneConfig:场景配置,记录场景名、类型、加载模式、预加载资源、BGM。 LoadingView:加载界面,负责遮罩、进度条、提示文本。 PreloadManager:进入场景前预加载角色、特效、UI、音频等资源。 SceneEventCenter:派发切换前、加载中、加载后事件。 SceneStateMachine:管理 IdleLoadingUnloadingReady 状态。 SceneCleaner:负责清理场景绑定 UI、对象池、临时资源、事件监听。 AdditiveSceneManager:管理大世界分块、多场景叠加、公共灯光场景。

一次切场景流程

业务调用:LoadSceneAsync("Battle_001")。 场景系统检查当前是否正在加载,避免重复切场景。 关闭玩家输入,防止加载过程中继续操作。 打开 Loading 页面,显示遮罩和进度条。 通知 UI、音频、资源、对象池等系统准备清理。 卸载旧场景或旧的 Additive 场景块。 根据 SceneConfig 预加载新场景需要的资源。 异步加载新场景。 新场景加载完成后初始化出生点、相机、角色、场景 UI。 关闭 Loading,恢复输入。

简单 C# 示例

c
using System.Collections; // 引入 IEnumerator 协程类型
using UnityEngine; // 引入 Unity 引擎基础类型
using UnityEngine.SceneManagement; // 引入 Unity 场景管理 API

public sealed class GameSceneManager : MonoBehaviour // 定义游戏自己的场景管理器
{ // 类开始
    public static GameSceneManager Instance; // 定义全局单例入口

    private bool isLoading; // 标记当前是否正在切场景

    private void Awake() // 初始化时调用
    { // 方法开始
        Instance = this; // 保存单例引用
        DontDestroyOnLoad(gameObject); // 切场景时不销毁场景管理器
    } // 方法结束

    public void LoadScene(string sceneName) // 对外提供切场景接口
    { // 方法开始
        if (isLoading) // 如果当前正在加载
        { // if 开始
            return; // 直接返回,避免重复切场景
        } // if 结束

        StartCoroutine(LoadSceneRoutine(sceneName)); // 启动异步加载协程
    } // 方法结束

    private IEnumerator LoadSceneRoutine(string sceneName) // 定义切场景协程流程
    { // 协程开始
        isLoading = true; // 标记进入加载状态

        ShowLoadingUI(); // 显示加载界面

        DisablePlayerInput(); // 禁用玩家输入

        yield return PreloadSceneResources(sceneName); // 预加载目标场景资源

        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); // 异步加载目标场景

        operation.allowSceneActivation = false; // 先不立即激活场景,方便控制进度条

        while (operation.progress < 0.9f) // Unity 异步场景加载通常到 0.9 表示基本加载完成
        { // while 开始
            UpdateLoadingProgress(operation.progress); // 更新加载进度显示
            yield return null; // 等待下一帧继续检查
        } // while 结束

        UpdateLoadingProgress(1f); // 把进度条显示到百分百

        operation.allowSceneActivation = true; // 允许 Unity 激活新场景

        yield return null; // 等待一帧,让新场景完成激活

        EnablePlayerInput(); // 恢复玩家输入

        HideLoadingUI(); // 隐藏加载界面

        isLoading = false; // 标记加载流程结束
    } // 协程结束

    private IEnumerator PreloadSceneResources(string sceneName) // 预加载场景资源
    { // 方法开始
        yield return null; // 示例占位,真实项目里会加载角色、UI、音频、特效等资源
    } // 方法结束

    private void ShowLoadingUI() // 显示加载界面
    { // 方法开始
        Debug.Log("显示 Loading"); // 示例日志,真实项目里调用 UIManager 打开 LoadingView
    } // 方法结束

    private void HideLoadingUI() // 隐藏加载界面
    { // 方法开始
        Debug.Log("隐藏 Loading"); // 示例日志,真实项目里调用 UIManager 关闭 LoadingView
    } // 方法结束

    private void UpdateLoadingProgress(float progress) // 更新加载进度
    { // 方法开始
        Debug.Log("加载进度:" + progress); // 示例日志,真实项目里刷新进度条
    } // 方法结束

    private void DisablePlayerInput() // 禁用输入
    { // 方法开始
        Debug.Log("禁用玩家输入"); // 示例日志,真实项目里通知输入系统
    } // 方法结束

    private void EnablePlayerInput() // 恢复输入
    { // 方法开始
        Debug.Log("恢复玩家输入"); // 示例日志,真实项目里通知输入系统
    } // 方法结束
} // 类结束

面试高分回答

TIP

我不会让业务代码直接调用 Unity 的 SceneManager.LoadScene,而是封装一个自己的 SceneManagerService。它通过 SceneConfig 知道目标场景的加载模式、预加载资源、BGM 和初始化规则。切场景时先进入 Loading 状态,关闭输入并通知各系统清理;然后预加载资源,再异步加载场景;加载完成后初始化场景对象、相机、UI、音频,最后关闭 Loading 并恢复输入。对于大世界或公共 UI 场景,我会使用 Additive Scene 管理多个场景块。

容易被追问的点

NOTE

Single 模式适合登录、主城、副本这种互相替换的大场景。 Additive 模式适合大世界分块、公共灯光场景、常驻 UI 场景。 AsyncOperation.progress 通常到 0.9 后会等待场景激活。 allowSceneActivation = false 可以让加载页进度条更平滑。 切场景时要清理场景绑定 UI、事件监听、对象池、临时资源。 加载失败要能回退旧场景,或者进入安全场景,不能卡死在 Loading。

一句话记忆

场景管理系统就是:SceneManager 管流程,SceneConfig 管数据,Loading 管体验,Resource 管预加载和释放,事件通知各系统收尾。

设计一个渲染队列系统

核心解释

渲染队列系统不是普通的“先进先出队列”,而是把一帧里要画的物体收集起来,按渲染规则重新排序,再提交给 GPU

csharp-unity-render-queue-system-design

系统怎么拆

RenderItem:一条渲染项,保存 MeshMaterial、世界矩阵、深度、Layer、队列类型。 RenderQueue:按类型分队列,比如不透明、AlphaTest、透明、UI。 Culling:先剔除看不见的物体,比如视锥外、Layer 不匹配、距离太远。 SortKey:排序键,把队列、材质、Shader、深度等信息组合起来排序。 Batcher:合批模块,把材质和状态相近的物体尽量放一起,减少 SetPassDrawCommand:最终绘制命令,比如设置材质、绑定 Mesh、提交 DrawCall。

排序规则

不透明物体:通常从近到远画,让深度测试尽早挡住后面的像素,减少 Overdraw。 透明物体:通常从远到近画,因为 Alpha Blend 依赖绘制顺序。 相同材质物体:尽量放一起画,减少材质切换和 SetPass Call。 UI / Overlay:通常最后画,保证血条、弹窗、提示能盖在场景上面。

简单 C# 示例

c
using System; // 引入基础类型
using System.Collections.Generic; // 引入 List 集合类型

public enum RenderQueueType // 定义渲染队列类型
{ // 枚举开始
    Opaque = 2000, // 不透明队列,通常先画
    AlphaTest = 2450, // 镂空队列,比如树叶、草
    Transparent = 3000, // 透明队列,比如玻璃、半透明特效
    Overlay = 4000 // 覆盖层队列,比如 UI 或调试绘制
} // 枚举结束

public sealed class RenderItem // 定义一条渲染项
{ // 类开始
    public string Name; // 渲染对象名字,方便调试
    public int MaterialId; // 材质 ID,用来减少材质切换
    public int ShaderId; // Shader ID,用来减少 SetPass
    public float Depth; // 距离相机的深度
    public RenderQueueType Queue; // 当前物体属于哪个渲染队列
} // 类结束

public sealed class RenderQueueSystem // 定义渲染队列系统
{ // 类开始
    private readonly List<RenderItem> items = new List<RenderItem>(); // 保存本帧收集到的渲染项

    public void Submit(RenderItem item) // 提交一个渲染项
    { // 方法开始
        items.Add(item); // 把渲染项加入本帧列表
    } // 方法结束

    public void BuildAndDraw() // 构建渲染顺序并绘制
    { // 方法开始
        items.Sort(CompareRenderItem); // 按渲染规则排序
        foreach (RenderItem item in items) // 遍历排序后的渲染项
        { // foreach 开始
            Draw(item); // 提交绘制命令
        } // foreach 结束
        items.Clear(); // 清空本帧列表,等待下一帧重新收集
    } // 方法结束

    private int CompareRenderItem(RenderItem a, RenderItem b) // 比较两个渲染项的绘制顺序
    { // 方法开始
        int queueResult = a.Queue.CompareTo(b.Queue); // 先比较渲染队列顺序
        if (queueResult != 0) return queueResult; // 队列不同就按队列顺序返回

        bool transparent = a.Queue >= RenderQueueType.Transparent; // 判断当前队列是否属于透明队列
        if (transparent) return b.Depth.CompareTo(a.Depth); // 透明物体从远到近排序

        int shaderResult = a.ShaderId.CompareTo(b.ShaderId); // 不透明物体先按 Shader 排序
        if (shaderResult != 0) return shaderResult; // Shader 不同就按 Shader 排序

        int materialResult = a.MaterialId.CompareTo(b.MaterialId); // 再按材质排序
        if (materialResult != 0) return materialResult; // 材质不同就按材质排序

        return a.Depth.CompareTo(b.Depth); // 最后不透明物体从近到远排序
    } // 方法结束

    private void Draw(RenderItem item) // 模拟绘制一个渲染项
    { // 方法开始
        Console.WriteLine("Draw " + item.Name); // 示例输出,真实引擎里这里会提交 DrawCall
    } // 方法结束
} // 类结束

面试高分回答

CAUTION

我会把渲染队列设计成一帧临时生成的命令列表。每帧先由渲染器收集所有可渲染对象,经过视锥剔除和 Layer 过滤后生成 RenderItem。然后按 Render Queue 分组:不透明、AlphaTest、透明、Overlay。排序时不透明物体优先考虑 Shader 和 Material,减少状态切换,同时从近到远减少 Overdraw;透明物体则从远到近,保证 Alpha Blend 正确。最后再经过合批模块生成 DrawCommand 提交给 GPU。

容易被追问的点

WARNING

渲染队列不是越早提交越早画,而是要按渲染状态排序。 透明物体排序很难完全正确,尤其是互相穿插的透明模型。 不透明物体可以依赖深度缓冲,透明物体通常不能简单写深度。 优化时要关注 DrawCallSetPass CallOverdraw 和 Render Thread。 Unity 里的 Render Queue 数值常见有 Geometry 2000AlphaTest 2450Transparent 3000Overlay 4000

一句话记忆

渲染队列系统就是:先收集,再剔除,再分层,再排序,再合批,最后提交 GPU。

设计一个材质系统

核心解释

材质系统管理的是“一个物体表面怎么被画出来”。 更准确地说,材质 = Shader + 贴图 + 参数 + 渲染状态

csharp-unity-material-system-design

系统怎么拆

MaterialConfig:材质配置,记录 Shader、贴图路径、颜色、金属度、粗糙度、透明模式。 MaterialManager:统一入口,负责创建、缓存、复用和释放材质。 ShaderManager:管理 Shader 和 Shader Variant,避免运行时找不到 Shader。 TextureManager:加载和缓存贴图,比如 BaseMap、NormalMap、MaskMap。 MaterialCache:同一份材质配置只创建一次,减少重复材质。 MaterialInstance:某些对象需要独立材质,比如染色、受击闪白、溶解。 MaterialPropertyBlock:不复制材质,也能给单个 Renderer 设置不同参数。 RenderState:控制 ZWriteZTestBlendCullRenderQueue

材质系统要解决什么问题

第一,避免重复创建材质。 如果每个怪物都 new Material,材质数量会爆炸,内存和 SetPass 都会上升。

第二,区分共享材质和实例材质。 共享材质适合大多数静态表现;实例材质适合每个对象表现不同的情况。

第三,支持单对象差异。 比如同一种怪物,有的红色、有的蓝色,不一定要复制材质,可以用 MaterialPropertyBlock

第四,控制资源生命周期。 材质依赖 Shader 和贴图,释放材质时也要考虑贴图引用和 AssetBundle / Addressables 句柄。

简单 C# 示例

c
using System.Collections.Generic; // 引入 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class MaterialConfig // 定义材质配置类
{ // 类开始
    public string Id; // 材质唯一 ID
    public string ShaderName; // Shader 名字
    public Color BaseColor; // 基础颜色参数
    public Texture MainTexture; // 主贴图资源
} // 类结束

public sealed class MaterialManager // 定义材质管理器
{ // 类开始
    private readonly Dictionary<string, Material> materialCache = new Dictionary<string, Material>(); // 保存材质缓存

    public Material GetMaterial(MaterialConfig config) // 根据配置获取材质
    { // 方法开始
        if (materialCache.TryGetValue(config.Id, out Material cachedMaterial)) // 如果缓存里已经有这个材质
        { // if 开始
            return cachedMaterial; // 直接返回缓存材质
        } // if 结束

        Shader shader = Shader.Find(config.ShaderName); // 根据 Shader 名字查找 Shader
        if (shader == null) // 如果没有找到 Shader
        { // if 开始
            Debug.LogError("找不到 Shader:" + config.ShaderName); // 输出错误日志
            return null; // 返回空材质
        } // if 结束

        Material material = new Material(shader); // 用 Shader 创建材质实例
        material.color = config.BaseColor; // 设置基础颜色
        material.mainTexture = config.MainTexture; // 设置主贴图
        materialCache[config.Id] = material; // 把材质放入缓存
        return material; // 返回新创建的材质
    } // 方法结束

    public void SetColorByPropertyBlock(Renderer renderer, Color color) // 用 PropertyBlock 设置单个对象颜色
    { // 方法开始
        MaterialPropertyBlock block = new MaterialPropertyBlock(); // 创建材质属性块
        renderer.GetPropertyBlock(block); // 读取当前 Renderer 的属性块
        block.SetColor("_BaseColor", color); // 设置单个对象的颜色参数
        renderer.SetPropertyBlock(block); // 把属性块设置回 Renderer
    } // 方法结束

    public void Clear() // 清理所有缓存材质
    { // 方法开始
        foreach (Material material in materialCache.Values) // 遍历所有缓存材质
        { // foreach 开始
            Object.Destroy(material); // 销毁运行时创建的材质
        } // foreach 结束

        materialCache.Clear(); // 清空材质缓存表
    } // 方法结束
} // 类结束

这段代码是面试手写简化版。真实项目里不建议大量依赖 Shader.Find,因为打包后 Shader 可能被裁剪;更好的做法是把 Shader 放进配置、Addressables、ShaderVariantCollection 或常驻资源里统一管理。

面试高分回答

IMPORTANT

我会把材质系统做成配置驱动。业务层只传材质 ID,MaterialManagerMaterialConfig,加载 Shader 和贴图,然后创建或复用 Material。对于完全一样的对象,尽量共享材质,方便合批;如果只是颜色、溶解进度、受击闪白这种单对象参数差异,我优先用 MaterialPropertyBlock,避免复制材质。对于透明材质,还要正确设置 RenderQueue、Blend、ZWrite,否则会有排序和 Overdraw 问题。

容易被追问的坑

renderer.material 会复制一份材质,频繁使用可能导致材质实例变多。 renderer.sharedMaterial 修改的是共享材质,可能影响所有使用它的对象。 MaterialPropertyBlock 适合单对象参数差异,但不是所有 SRP Batcher 场景都无代价。 Shader Keyword 太多会导致 Variant 爆炸,增加包体和加载时间。 贴图通常是材质系统最大的内存来源,要注意压缩格式、分辨率和重复贴图。

一句话记忆

材质系统就是:Shader 决定算法,Texture 提供数据,Material 保存参数,RenderState 控制管线,PropertyBlock 做单对象差异。

设计一个输入系统

核心解释

输入系统的重点是:业务不要直接依赖某个按键,而要依赖抽象后的 Action。 比如角色移动不应该写死 WASD,而是监听 Move;攻击不应该写死鼠标左键,而是监听 Attack

csharp-unity-input-system-design

系统怎么拆

DeviceLayer:设备层,处理键盘、鼠标、手柄、触屏。 BindingConfig:绑定配置,把具体按键映射成游戏行为。 ActionMap:行为表,比如 GameplayUIDialogueVehicleInputState:输入快照,记录当前帧移动方向、跳跃、攻击、交互。 InputDispatcher:输入分发器,把输入事件发给角色、UI、相机、技能系统。 InputBuffer:输入缓存,比如提前按跳跃、攻击,短时间内仍然生效。 RebindingSystem:改键系统,玩家修改按键后保存到本地。 DebugPanel:调试面板,显示当前 ActionMap、输入值、按键状态。

一次输入流程

玩家按下键盘、手柄或屏幕按钮。 输入系统从设备层采样。 根据绑定配置转换成 MoveJumpAttack 等 Action。 生成当前帧 InputState。 根据当前模式判断应该分发给谁。 如果是 Gameplay 模式,就交给角色控制器。 如果是 UI 模式,就交给 UI 导航和按钮。 如果是剧情模式,就屏蔽移动,只允许跳过对话。

简单 C# 示例

c
using System; // 引入 Action 委托类型
using UnityEngine; // 引入 Unity 引擎基础类型

public struct PlayerInputState // 定义玩家输入快照
{ // 结构体开始
    public Vector2 Move; // 保存移动方向
    public bool JumpPressed; // 保存本帧是否按下跳跃
    public bool AttackPressed; // 保存本帧是否按下攻击
    public bool InteractPressed; // 保存本帧是否按下交互
} // 结构体结束

public sealed class GameInputManager : MonoBehaviour // 定义游戏输入管理器
{ // 类开始
    public static GameInputManager Instance; // 定义全局单例入口

    public event Action<PlayerInputState> OnGameplayInput; // 定义玩法输入事件

    private bool gameplayEnabled = true; // 标记当前是否允许玩法输入

    private void Awake() // 初始化时调用
    { // 方法开始
        Instance = this; // 保存单例引用
        DontDestroyOnLoad(gameObject); // 切场景时不销毁输入管理器
    } // 方法结束

    private void Update() // 每帧采样输入
    { // 方法开始
        PlayerInputState state = ReadInputState(); // 读取当前帧输入快照

        if (!gameplayEnabled) // 如果当前不允许玩法输入
        { // if 开始
            return; // 不把输入发给角色逻辑
        } // if 结束

        OnGameplayInput?.Invoke(state); // 把输入快照分发给玩法模块
    } // 方法结束

    private PlayerInputState ReadInputState() // 读取输入快照
    { // 方法开始
        PlayerInputState state = new PlayerInputState(); // 创建输入状态对象
        float x = Input.GetAxisRaw("Horizontal"); // 读取横向输入
        float y = Input.GetAxisRaw("Vertical"); // 读取纵向输入
        state.Move = new Vector2(x, y).normalized; // 保存标准化后的移动方向
        state.JumpPressed = Input.GetKeyDown(KeyCode.Space); // 判断本帧是否按下空格
        state.AttackPressed = Input.GetMouseButtonDown(0); // 判断本帧是否按下鼠标左键
        state.InteractPressed = Input.GetKeyDown(KeyCode.E); // 判断本帧是否按下交互键
        return state; // 返回当前帧输入快照
    } // 方法结束

    public void EnableGameplayInput() // 开启玩法输入
    { // 方法开始
        gameplayEnabled = true; // 允许输入分发给角色逻辑
    } // 方法结束

    public void DisableGameplayInput() // 关闭玩法输入
    { // 方法开始
        gameplayEnabled = false; // 禁止输入分发给角色逻辑
    } // 方法结束
} // 类结束

面试高分回答

IMPORTANT

我会把输入系统设计成分层结构。最底层负责采样设备输入,比如键盘、鼠标、手柄、触屏;中间层通过绑定表把具体按键转换成抽象 Action,比如 MoveJumpAttack;再生成每帧的 InputState 快照;最后根据当前上下文分发给角色、UI、相机或剧情系统。这样 PC、手柄、移动端虚拟摇杆都能走同一套业务逻辑,也方便做改键、输入缓存、禁用输入、网络同步和调试。

容易被追问的点

TIP

打开 UI 时要切到 UI ActionMap,避免角色还在移动。 播放剧情或过场动画时要禁用 Gameplay 输入,只保留跳过或确认。 动作游戏可以做输入缓存,比如落地前提前按跳跃。 帧同步游戏通常同步输入,而不是同步位置结果。 移动端虚拟摇杆和技能按钮也应该转成 Action,而不是单独写一套业务。

一句话记忆

输入系统就是:Device 采样,Binding 映射,Action 表达意图,State 保存快照,Context 控制模式,Dispatcher 分发业务。

设计一个动画状态机

核心解释

动画状态机的核心不是“播哪个动画”,而是管理:当前角色处于什么动作状态、能不能切换、能不能被打断、动画事件什么时候通知逻辑。

csharp-unity-animation-state-machine-design

系统怎么拆

AnimState:动画逻辑状态,比如 IdleMoveAttackHitDeadAnimStateMachine:状态机核心,负责切换状态、判断打断、调用进入和退出。 AnimatorBridge:连接 Unity Animator,负责 CrossFade、设置参数、播放动画。 AnimationEventReceiver:接收动画事件,比如攻击命中帧、脚步声、动画结束。 TransitionRule:切换规则,比如攻击中不能被移动打断,死亡可以打断所有状态。 CharacterLogic:角色业务逻辑,比如输入、技能、受击、死亡,只请求状态切换。

一次攻击动画流程

玩家点击攻击。 角色逻辑请求切到 Attack 状态。 状态机检查当前状态能不能被攻击打断。 旧状态执行 Exit。 新状态执行 Enter。 Animator 播放攻击动画。 动画播到命中帧时,动画事件调用 OnHitFrame。 逻辑层在这一帧做攻击判定。 动画播到结束事件时,切回 IdleMove

简单 C# 示例

c
using System.Collections.Generic; // 引入 Dictionary 集合类型
using UnityEngine; // 引入 Unity 引擎基础类型
public enum AnimStateType // 定义动画状态类型
{ // 枚举开始
    Idle, // 待机状态
    Move, // 移动状态
    Attack, // 攻击状态
    Hit, // 受击状态
    Dead // 死亡状态
} // 枚举结束
public abstract class AnimState // 定义动画状态基类
{ // 类开始
    protected readonly CharacterAnimStateMachine machine; // 保存状态机引用
    public AnimStateType Type { get; private set; } // 保存当前状态类型
    public int Priority { get; private set; } // 保存当前状态优先级
    protected AnimState(CharacterAnimStateMachine machine, AnimStateType type, int priority) // 定义状态构造函数
    { // 构造函数开始
        this.machine = machine; // 保存传入的状态机
        Type = type; // 保存状态类型
        Priority = priority; // 保存状态优先级
    } // 构造函数结束
    public virtual void Enter() { } // 进入状态时调用
    public virtual void Tick() { } // 状态每帧更新时调用
    public virtual void Exit() { } // 退出状态时调用
    public virtual bool CanExitTo(AnimState nextState) // 判断能否切换到目标状态
    { // 方法开始
        return nextState.Priority >= Priority || machine.CanCancelCurrentState; // 高优先级可以打断,或者当前状态进入可取消窗口
    } // 方法结束
} // 类结束
public sealed class IdleState : AnimState // 定义待机状态
{ // 类开始
    public IdleState(CharacterAnimStateMachine machine) : base(machine, AnimStateType.Idle, 0) { } // 初始化待机状态
    public override void Enter() // 进入待机状态
    { // 方法开始
        machine.PlayAnimation("Idle"); // 播放待机动画
    } // 方法结束
} // 类结束
public sealed class AttackState : AnimState // 定义攻击状态
{ // 类开始
    public AttackState(CharacterAnimStateMachine machine) : base(machine, AnimStateType.Attack, 10) { } // 初始化攻击状态
    public override void Enter() // 进入攻击状态
    { // 方法开始
        machine.CanCancelCurrentState = false; // 攻击刚开始时不允许取消
        machine.PlayAnimation("Attack"); // 播放攻击动画
    } // 方法结束
    public void OnHitFrame() // 动画事件调用命中帧
    { // 方法开始
        machine.DoAttackCheck(); // 在命中帧执行攻击判定
    } // 方法结束
    public void OnCancelFrame() // 动画事件调用可取消帧
    { // 方法开始
        machine.CanCancelCurrentState = true; // 进入可取消窗口
    } // 方法结束
    public void OnAnimationEnd() // 动画事件调用动画结束
    { // 方法开始
        machine.ChangeState(AnimStateType.Idle); // 攻击结束后回到待机
    } // 方法结束
} // 类结束
public sealed class CharacterAnimStateMachine : MonoBehaviour // 定义角色动画状态机
{ // 类开始
    [SerializeField] private Animator animator; // 引用 Unity Animator 组件
    private readonly Dictionary<AnimStateType, AnimState> states = new Dictionary<AnimStateType, AnimState>(); // 保存所有状态对象
    private AnimState currentState; // 保存当前状态
    public bool CanCancelCurrentState { get; set; } // 标记当前状态是否允许被取消
    private void Awake() // 初始化时调用
    { // 方法开始
        states[AnimStateType.Idle] = new IdleState(this); // 注册待机状态
        states[AnimStateType.Attack] = new AttackState(this); // 注册攻击状态
        ChangeState(AnimStateType.Idle); // 默认进入待机状态
    } // 方法结束
    private void Update() // 每帧更新
    { // 方法开始
        currentState?.Tick(); // 更新当前状态
    } // 方法结束
    public void ChangeState(AnimStateType type) // 请求切换状态
    { // 方法开始
        AnimState nextState = states[type]; // 根据状态类型找到目标状态
        if (currentState != null && !currentState.CanExitTo(nextState)) // 如果当前状态不允许切换到目标状态
        { // if 开始
            return; // 直接拒绝本次切换
        } // if 结束
        currentState?.Exit(); // 退出旧状态
        currentState = nextState; // 设置新的当前状态
        currentState.Enter(); // 进入新状态
    } // 方法结束
    public void PlayAnimation(string stateName) // 播放 Animator 中的动画状态
    { // 方法开始
        int hash = Animator.StringToHash(stateName); // 把动画状态名转换成 hash
        animator.CrossFade(hash, 0.1f); // 用 CrossFade 平滑切换动画
    } // 方法结束
    public void DoAttackCheck() // 执行攻击判定
    { // 方法开始
        Debug.Log("攻击命中帧,执行伤害检测"); // 示例日志,真实项目里做碰撞盒或范围检测
    } // 方法结束
} // 类结束

设计重点

移动状态适合用 Blend Tree,因为站立、走路、跑步通常是连续变化。 攻击状态要拆出前摇、命中帧、后摇、可取消窗口。 受击状态优先级高于普通移动。 死亡状态一般优先级最高,并且不可退出。 动画事件不要直接改大量业务数据,最好只发信号,再由逻辑层校验执行。 逻辑状态和动画状态要分清:逻辑决定“能不能攻击”,动画负责“看起来怎么播”。

面试高分回答

NOTE

我会把动画状态机分成逻辑层和表现层。逻辑层用状态机管理 IdleMoveAttackHitDead 等状态,每个状态有 EnterTickExit,并且通过优先级和可取消窗口控制打断。表现层通过 AnimatorBridge 去调用 Unity Animator 的 CrossFade 或参数切换。攻击、受击、技能这类动作会用动画事件同步命中帧、可取消帧和结束帧,避免用固定时间硬猜。

一句话记忆

动画状态机就是:状态负责规则,Animator 负责播放,动画事件负责同步,优先级负责打断。

设计一个编辑器属性面板

核心解释

编辑器属性面板本质上是一个数据编辑器:选中对象后,把对象里的字段用可视化控件画出来,用户修改后再写回数据,并且要支持 Undo、保存、校验、Prefab 覆盖、多选编辑

csharp-unity-editor-property-panel-design

系统怎么拆

Selection:决定当前属性面板编辑谁,比如选中的 GameObject、Component、ScriptableObject。 SerializedObject:Unity 编辑器推荐的数据包装层,用它改字段才能支持 Undo、多选、Prefab Override。 SerializedProperty:表示某个具体字段,比如血量、速度、引用、数组。 Editor / CustomEditor:给某个组件或资源写专属 Inspector。 PropertyDrawer:给某种字段类型或 Attribute 写统一绘制逻辑。 Validation:检查非法数据,比如空引用、负数、重复 ID。 Dirty / Save:标记数据被修改,让场景或资源能保存下来。

简单代码示例

c
using UnityEngine; // 引入 Unity 运行时基础类型
#if UNITY_EDITOR // 限制下面的编辑器代码只在 Unity Editor 中编译
using UnityEditor; // 引入 Unity 编辑器 API
#endif // 结束编辑器编译限制

public sealed class CharacterConfig : MonoBehaviour // 定义一个角色配置组件
{ // 类开始
    [SerializeField] private int hp = 100; // 定义生命值字段,并允许 Inspector 显示
    [SerializeField] private float moveSpeed = 5f; // 定义移动速度字段,并允许 Inspector 显示
    [SerializeField] private GameObject weaponPrefab; // 定义武器预制体引用字段
} // 类结束

#if UNITY_EDITOR // 限制自定义 Inspector 只在编辑器环境编译
[CustomEditor(typeof(CharacterConfig))] // 指定这个 Editor 用来绘制 CharacterConfig
public sealed class CharacterConfigEditor : Editor // 定义自定义属性面板类
{ // 类开始
    private SerializedProperty hpProperty; // 保存生命值序列化字段
    private SerializedProperty moveSpeedProperty; // 保存移动速度序列化字段
    private SerializedProperty weaponPrefabProperty; // 保存武器预制体序列化字段

    private void OnEnable() // 面板启用时调用
    { // 方法开始
        hpProperty = serializedObject.FindProperty("hp"); // 根据字段名找到生命值属性
        moveSpeedProperty = serializedObject.FindProperty("moveSpeed"); // 根据字段名找到移动速度属性
        weaponPrefabProperty = serializedObject.FindProperty("weaponPrefab"); // 根据字段名找到武器预制体属性
    } // 方法结束

    public override void OnInspectorGUI() // 绘制自定义 Inspector 面板
    { // 方法开始
        serializedObject.Update(); // 从真实对象同步最新数据到 SerializedObject

        EditorGUILayout.LabelField("角色基础配置", EditorStyles.boldLabel); // 绘制标题文本
        EditorGUILayout.PropertyField(hpProperty); // 绘制生命值字段
        EditorGUILayout.PropertyField(moveSpeedProperty); // 绘制移动速度字段
        EditorGUILayout.PropertyField(weaponPrefabProperty); // 绘制武器预制体字段

        if (hpProperty.intValue <= 0) // 判断生命值是否合法
        { // if 开始
            EditorGUILayout.HelpBox("生命值必须大于 0", MessageType.Error); // 在面板中显示错误提示
        } // if 结束

        if (moveSpeedProperty.floatValue < 0f) // 判断移动速度是否合法
        { // if 开始
            EditorGUILayout.HelpBox("移动速度不能是负数", MessageType.Warning); // 在面板中显示警告提示
        } // if 结束

        if (GUILayout.Button("重置为默认值")) // 绘制一个重置按钮并判断是否被点击
        { // if 开始
            hpProperty.intValue = 100; // 把生命值重置为默认值
            moveSpeedProperty.floatValue = 5f; // 把移动速度重置为默认值
        } // if 结束

        serializedObject.ApplyModifiedProperties(); // 把修改写回真实对象,并支持 Undo 和 Prefab Override
    } // 方法结束
} // 类结束
#endif // 结束编辑器编译限制

面试高分回答

IMPORTANT

我会把编辑器属性面板设计成“数据层和显示层分离”。数据层用 SerializedObjectSerializedProperty 访问字段,保证 Undo、多选编辑、Prefab 覆盖都能正常工作;显示层用 CustomEditorPropertyDrawer 绘制字段、按钮、提示、折叠区域。修改数据时不直接乱改对象字段,而是通过序列化 API 应用修改,并在必要时做数据校验和标脏保存。

容易被追问的点

WARNING

编辑器脚本要放在 Editor 目录,避免被打进运行时代码。 OnInspectorGUI 会频繁调用,里面不要做重 IO、反射扫描或资源加载。 普通字段尽量用 PropertyField,它天然支持 Undo、多选和 Prefab Override。 如果直接改对象字段,要配合 Undo.RecordObjectEditorUtility.SetDirty。 复杂工具面板可以用 EditorWindowUI Toolkit,普通组件面板用 CustomEditor 就够。

一句话记忆

编辑器属性面板就是:Selection 定目标,SerializedObject 管数据,Drawer 管显示,Undo 管撤销,Validation 管正确性,Dirty 管保存。

设计一个脚本绑定系统

核心解释

脚本绑定系统的作用是:把编辑器里挂在对象上的脚本配置,变成运行时真正能执行的脚本实例。 它连接的是两边:一边是场景对象和序列化数据,另一边是脚本对象和生命周期函数。

csharp-unity-script-binding-system-design

系统怎么拆

ScriptRegistry:脚本注册表,记录 scriptId 对应哪个脚本类型、有哪些字段、有哪些可绑定方法。 ScriptComponent:挂在 GameObject 或 Entity 上,保存脚本 ID 和序列化字段。 ScriptFactory:根据 scriptId 创建脚本实例,比如 C# 类、Lua 表、热更类。 BindingContext:上下文注入,把 owner、transform、服务模块传给脚本。 PropertyBinder:字段绑定,把编辑器里填的血量、速度、引用对象写入脚本实例。 LifecycleDispatcher:生命周期调度,统一调用 AwakeStartUpdateOnDestroyEventBinder:事件绑定,把按钮点击、碰撞、动画事件映射到脚本方法。 ErrorHandler:异常隔离,脚本报错时记录日志,避免拖垮主循环。

运行时流程

场景加载后,系统遍历对象上的 ScriptComponent。 读取 scriptId 和序列化字段。 通过 ScriptRegistry 找到脚本类型。 通过 ScriptFactory 创建脚本实例。 注入当前对象上下文,比如 owner、transform。 把序列化字段恢复到脚本实例里。 调用 AwakeOnEnableStart。 每帧由调度器调用脚本的 Update。 对象销毁时调用 OnDestroy,并取消事件订阅。

简单 C# 示例

c
using System; // 引入 Type 和 Activator
using System.Collections.Generic; // 引入 Dictionary 和 List
using UnityEngine; // 引入 Unity 引擎基础类型

public abstract class ScriptBehaviour // 定义脚本基类
{ // 类开始
    public GameObject Owner { get; private set; } // 保存脚本绑定的对象

    public void Bind(GameObject owner) // 绑定脚本所属对象
    { // 方法开始
        Owner = owner; // 保存所属对象引用
    } // 方法结束

    public virtual void Awake() { } // 脚本创建后调用
    public virtual void Start() { } // 脚本第一次更新前调用
    public virtual void Update(float deltaTime) { } // 脚本每帧更新时调用
    public virtual void OnDestroy() { } // 脚本销毁前调用
} // 类结束

public sealed class ScriptBindingSystem // 定义脚本绑定系统
{ // 类开始
    private readonly Dictionary<string, Type> scriptTypes = new Dictionary<string, Type>(); // 保存脚本 ID 到类型的映射
    private readonly List<ScriptBehaviour> runningScripts = new List<ScriptBehaviour>(); // 保存正在运行的脚本实例

    public void Register<T>(string scriptId) where T : ScriptBehaviour, new() // 注册一个脚本类型
    { // 方法开始
        scriptTypes[scriptId] = typeof(T); // 把脚本 ID 和类型记录到注册表
    } // 方法结束

    public ScriptBehaviour Attach(GameObject owner, string scriptId) // 给对象绑定脚本
    { // 方法开始
        if (!scriptTypes.TryGetValue(scriptId, out Type type)) // 如果找不到脚本类型
        { // if 开始
            Debug.LogError("找不到脚本:" + scriptId); // 输出错误日志
            return null; // 返回空对象
        } // if 结束

        ScriptBehaviour script = (ScriptBehaviour)Activator.CreateInstance(type); // 根据类型创建脚本实例
        script.Bind(owner); // 把 GameObject 上下文注入脚本
        script.Awake(); // 调用脚本 Awake 生命周期
        script.Start(); // 调用脚本 Start 生命周期
        runningScripts.Add(script); // 把脚本加入运行列表
        return script; // 返回创建好的脚本实例
    } // 方法结束

    public void Tick(float deltaTime) // 更新所有脚本
    { // 方法开始
        for (int i = 0; i < runningScripts.Count; i++) // 遍历所有运行中的脚本
        { // for 开始
            runningScripts[i].Update(deltaTime); // 调用脚本 Update
        } // for 结束
    } // 方法结束

    public void Detach(ScriptBehaviour script) // 解绑一个脚本
    { // 方法开始
        if (script == null) // 如果脚本为空
        { // if 开始
            return; // 直接返回
        } // if 结束

        script.OnDestroy(); // 调用脚本销毁生命周期
        runningScripts.Remove(script); // 从运行列表中移除脚本
    } // 方法结束
} // 类结束

public sealed class RotateScript : ScriptBehaviour // 定义一个示例旋转脚本
{ // 类开始
    public override void Update(float deltaTime) // 每帧更新旋转
    { // 方法开始
        Owner.transform.Rotate(0f, 90f * deltaTime, 0f); // 让绑定对象绕 Y 轴旋转
    } // 方法结束
} // 类结束

面试高分回答

TIP

我会把脚本绑定系统设计成“编辑器绑定数据”和“运行时脚本实例”分离。编辑器里对象保存的是 scriptId 和序列化字段,而不是直接保存一个临时脚本对象。运行时场景加载后,系统通过 ScriptRegistry 查到脚本类型,用 ScriptFactory 创建实例,再注入 owner、transform、service 等上下文,并把字段数据恢复进去。之后生命周期统一由调度器调用,销毁时统一解绑事件和释放引用。

容易被追问的点

NOTE

不要每帧用反射查方法,脚本类型、字段、方法句柄要缓存。 脚本改名、字段删除、字段类型变化时,要做版本兼容或迁移。 脚本异常要隔离,不能一个脚本报错导致整个游戏主循环崩掉。 Lua 热更脚本还要做 C# API 导出、对象桥接、生命周期转发。 对象销毁时必须取消事件订阅,否则脚本对象可能无法释放。

一句话记忆

脚本绑定系统就是:Registry 识别脚本,Component 保存绑定,Factory 创建实例,Context 注入对象,Lifecycle 调度行为,Destroy 负责解绑。

设计一个热重载系统

核心解释

热重载系统的目标是:程序不重启、场景不重进,就能让修改后的配置、脚本、资源重新生效。 它更偏开发效率和调试效率;热更新更偏线上版本更新。

csharp-unity-hot-reload-system-design

系统怎么拆

FileWatcher:监听配置表、Lua、Shader、资源文件变化。 ReloadQueue:把变化请求放进队列,合并短时间内重复变化。 HotReloadManager:统一调度热重载流程。 ReloadHandler:不同资源类型有不同处理器,比如配置、Lua、Shader、Prefab。 Validator:新版本先校验,成功后才能替换旧版本。 StateMigrator:状态迁移,比如保留角色血量、位置、任务进度。 EventCenter:通知业务模块刷新缓存、界面、表现。 Rollback:失败时继续使用旧版本,不让游戏进入坏状态。

一次热重载流程

文件发生变化。 监听器把变化加入重载队列。 队列延迟一点时间,合并连续保存产生的重复事件。 系统根据文件类型选择重载处理器。 加载新版本数据。 校验新版本是否可用。 校验成功后替换旧版本。 通知相关系统刷新。 如果失败,就保留旧版本并打印错误。

简单 C# 示例

c
using System; // 引入 Action 和 EventArgs 等基础类型
using System.Collections.Generic; // 引入 Queue 和 HashSet 集合类型
using System.IO; // 引入 FileSystemWatcher 文件监听类型
using UnityEngine; // 引入 Unity 引擎基础类型

public sealed class HotReloadManager : MonoBehaviour // 定义热重载管理器
{ // 类开始
    private FileSystemWatcher watcher; // 保存文件监听器
    private readonly Queue<string> reloadQueue = new Queue<string>(); // 保存待重载文件队列
    private readonly HashSet<string> pendingFiles = new HashSet<string>(); // 保存已经进入队列的文件,避免重复加入

    private void Start() // 启动时调用
    { // 方法开始
        string path = Application.dataPath + "/Configs"; // 设置要监听的配置目录
        watcher = new FileSystemWatcher(path); // 创建文件监听器
        watcher.IncludeSubdirectories = true; // 允许监听子目录
        watcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; // 监听文件修改和文件名变化
        watcher.Changed += OnFileChanged; // 绑定文件修改事件
        watcher.Created += OnFileChanged; // 绑定文件创建事件
        watcher.Renamed += OnFileChanged; // 绑定文件重命名事件
        watcher.EnableRaisingEvents = true; // 开始监听文件变化
    } // 方法结束

    private void OnFileChanged(object sender, FileSystemEventArgs e) // 文件变化时调用
    { // 方法开始
        lock (reloadQueue) // 对队列加锁,避免监听线程和主线程同时访问
        { // lock 开始
            if (pendingFiles.Add(e.FullPath)) // 如果这个文件还没进入待处理集合
            { // if 开始
                reloadQueue.Enqueue(e.FullPath); // 把文件加入重载队列
            } // if 结束
        } // lock 结束
    } // 方法结束

    private void Update() // 每帧在 Unity 主线程处理重载
    { // 方法开始
        string file = null; // 准备保存本帧要处理的文件路径

        lock (reloadQueue) // 对队列加锁
        { // lock 开始
            if (reloadQueue.Count > 0) // 如果队列里有待重载文件
            { // if 开始
                file = reloadQueue.Dequeue(); // 取出一个文件
                pendingFiles.Remove(file); // 从待处理集合中移除
            } // if 结束
        } // lock 结束

        if (string.IsNullOrEmpty(file)) // 如果本帧没有文件要处理
        { // if 开始
            return; // 直接返回
        } // if 结束

        ReloadFile(file); // 在主线程执行重载
    } // 方法结束

    private void ReloadFile(string file) // 重载指定文件
    { // 方法开始
        try // 捕获重载异常
        { // try 开始
            string text = File.ReadAllText(file); // 读取文件内容
            bool valid = !string.IsNullOrEmpty(text); // 简单校验文件内容是否为空
            if (!valid) // 如果新文件不合法
            { // if 开始
                Debug.LogError("热重载失败,文件为空:" + file); // 输出失败日志
                return; // 不覆盖旧版本
            } // if 结束
            Debug.Log("热重载成功:" + file); // 输出成功日志
            NotifyReloaded(file); // 通知其他系统刷新
        } // try 结束
        catch (Exception exception) // 捕获异常
        { // catch 开始
            Debug.LogError("热重载异常:" + exception.Message); // 输出异常日志
        } // catch 结束
    } // 方法结束

    private void NotifyReloaded(string file) // 通知重载完成
    { // 方法开始
        Debug.Log("通知业务刷新:" + file); // 示例日志,真实项目里会派发事件
    } // 方法结束

    private void OnDestroy() // 对象销毁时调用
    { // 方法开始
        if (watcher != null) // 如果监听器存在
        { // if 开始
            watcher.Dispose(); // 释放文件监听器
        } // if 结束
    } // 方法结束
} // 类结束

面试高分回答

我会把热重载设计成一个安全的替换流程,而不是文件变了就直接覆盖。系统先监听文件变化,把请求放进队列并做防抖合并;然后根据资源类型选择对应的重载器,比如配置表重新解析、Lua 重新加载、Shader 重新编译、材质贴图重新绑定;新版本加载后必须先校验,校验通过才替换旧版本,并通知相关模块刷新缓存和表现。如果加载失败,就继续使用旧版本,保证运行中的游戏不会被一个错误文件破坏。

容易被追问的点

TIP

配置热重载要处理已有对象,比如怪物已经生成了,数值表更新后是否立刻影响它。 Lua 热重载要尽量做到“状态和函数分离”,否则对象状态容易丢。 C# 运行时代码热重载在 Unity 里限制更多,通常依赖 Editor 域重载、ILRuntime、HybridCLR 或脚本层方案。 资源热重载要考虑已经实例化的对象是否立刻替换表现。 线上热更新必须比编辑器热重载更严格,要有版本校验、下载校验和回滚策略。

一句话记忆

热重载系统就是:Watch 发现变化,Queue 合并请求,Load 加载新版本,Validate 先校验,Swap 再替换,Notify 通知刷新,Rollback 保底。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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