Skip to content

热更新

什么是资源热更新?

一句话回答: 资源热更新就是:不重新安装 App,通过网络下载新的资源包和版本清单,让客户端使用最新的图片、Prefab、音频、配置、动画等资源。

resource-hot-update

零基础理解

正常发版本是:

改资源 -> 重新打包 App -> 玩家重新下载安装包

资源热更新是:

改资源 -> 打成资源包 -> 上传服务器/CDN -> 玩家启动游戏时下载差异资源

比如你游戏里要更新:

新活动 UI
新角色皮肤
新音效
新图标
新配置表
新关卡资源

如果每次都让玩家重新安装 App,很麻烦。 资源热更新就是让玩家打开游戏后自动下载新资源。

它能更新什么

通常可以更新:

Prefab
Texture / Sprite / 图集
Material
AudioClip
AnimationClip
配置表
场景资源
AssetBundle
Addressables 远程资源
Lua 脚本,前提是项目用了 Lua

但要注意:

资源热更新 ≠ 普通 C# 代码热更新

C# 热更新通常是另一个话题,比如:

Lua
ILRuntime
HybridCLR
脚本解释器

面试里一定要分清楚,这很加分。

基本流程

资源热更新一般有 6 步:

1. 客户端启动
2. 请求服务器版本清单
3. 和本地清单对比
4. 找出需要更新的资源包
5. 下载资源并校验
6. 保存到本地缓存,以后优先加载新资源

版本清单大概长这样:

c
{
  "version": "1.0.3",
  "files": [
    {
      "name": "ui_common.ab",
      "hash": "a81f3c",
      "size": 204800,
      "url": "https://cdn.game.com/res/ui_common.ab"
    },
    {
      "name": "activity_summer.ab",
      "hash": "b92d10",
      "size": 5242880,
      "url": "https://cdn.game.com/res/activity_summer.ab"
    }
  ]
}

这里面通常会记录:

资源包名
版本号
Hash / MD5
文件大小
下载地址
依赖关系
平台信息

为什么要 Hash 校验

不能只看版本号。

因为下载可能出现:

断网
下载一半
文件损坏
CDN 缓存异常
写入本地失败

所以下载后要校验:

文件大小对不对
Hash 对不对
MD5 对不对

否则可能导致:

资源加载失败
贴图丢失
Bundle 损坏
游戏进不去

简化版热更新代码思路

下面是一个教学版流程,真实项目会更复杂,但面试讲这个思路就够清楚。

c
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;

public class SimpleHotUpdateManager : MonoBehaviour
{
    // 远程清单地址
    private string remoteManifestUrl = "https://cdn.game.com/manifest.json";

    // 本地资源保存目录
    private string LocalResourceRoot =>
        Path.Combine(Application.persistentDataPath, "HotUpdate");

    public IEnumerator StartHotUpdate()
    {
        // 第一步:下载远程版本清单
        UnityWebRequest manifestRequest = UnityWebRequest.Get(remoteManifestUrl);
        yield return manifestRequest.SendWebRequest();

        if (manifestRequest.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("下载远程清单失败:" + manifestRequest.error);
            yield break;
        }

        string remoteManifestJson = manifestRequest.downloadHandler.text;

        // 第二步:解析清单
        // 这里为了讲思路省略 JSON 解析细节
        // 正式项目会把 JSON 解析成 ManifestData 对象
        Debug.Log("远程清单:" + remoteManifestJson);

        // 第三步:比较本地清单和远程清单
        // 找出 hash 不一致或者本地不存在的资源包
        List<string> needDownloadFiles = CompareManifest();

        // 第四步:下载差异资源
        foreach (string fileUrl in needDownloadFiles)
        {
            yield return DownloadFile(fileUrl);
        }

        // 第五步:下载成功后保存新的本地清单
        // 下次启动就可以用本地清单继续比较
        SaveLocalManifest(remoteManifestJson);

        Debug.Log("资源热更新完成");
    }

    private List<string> CompareManifest()
    {
        // 这里是示例
        // 实际项目要比较:文件名、版本号、hash、size
        return new List<string>
        {
            "https://cdn.game.com/res/ui_common.ab"
        };
    }

    private IEnumerator DownloadFile(string url)
    {
        UnityWebRequest request = UnityWebRequest.Get(url);
        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("资源下载失败:" + request.error);
            yield break;
        }

        byte[] data = request.downloadHandler.data;

        // 从 URL 中取文件名
        string fileName = Path.GetFileName(url);

        // 确保本地目录存在
        if (!Directory.Exists(LocalResourceRoot))
        {
            Directory.CreateDirectory(LocalResourceRoot);
        }

        string savePath = Path.Combine(LocalResourceRoot, fileName);

        // 写入本地缓存
        File.WriteAllBytes(savePath, data);

        // 正式项目这里还要做 Hash 校验
        Debug.Log("资源保存成功:" + savePath);
    }

    private void SaveLocalManifest(string manifestJson)
    {
        if (!Directory.Exists(LocalResourceRoot))
        {
            Directory.CreateDirectory(LocalResourceRoot);
        }

        string manifestPath = Path.Combine(LocalResourceRoot, "manifest.json");

        // 保存本地清单
        File.WriteAllText(manifestPath, manifestJson);
    }
}

资源加载优先级

热更新后,资源一般会有两个来源:

StreamingAssets:
安装包里自带的初始资源。

persistentDataPath:
下载后的热更新资源。

加载时通常是:

先查 persistentDataPath 有没有新资源
有就加载热更资源
没有再加载 StreamingAssets 里的内置资源

简单说:

本地缓存的新资源 优先级高于 首包内置资源

AssetBundle 热更新怎么理解

常见方式:

服务器放 AssetBundle 和 manifest
客户端下载 manifest
比较本地和远程 hash
下载变化的 Bundle
加载时优先从缓存目录加载

加载本地热更 Bundle:

c
using System.IO;
using UnityEngine;

public class HotUpdateBundleLoader
{
    public AssetBundle LoadBundle(string bundleName)
    {
        // 热更新资源目录
        string hotUpdatePath = Path.Combine(
            Application.persistentDataPath,
            "HotUpdate",
            bundleName
        );

        // 首包资源目录
        string builtInPath = Path.Combine(
            Application.streamingAssetsPath,
            bundleName
        );

        // 优先加载热更新目录里的资源
        if (File.Exists(hotUpdatePath))
        {
            return AssetBundle.LoadFromFile(hotUpdatePath);
        }

        // 如果没有热更资源,就加载首包内置资源
        if (File.Exists(builtInPath))
        {
            return AssetBundle.LoadFromFile(builtInPath);
        }

        Debug.LogError("找不到 Bundle:" + bundleName);
        return null;
    }
}

Addressables 热更新怎么理解

如果项目用 Addressables,资源热更新主要是:

远程 Catalog
远程 Bundle
客户端检查 Catalog 更新
下载变化的资源
通过 Address 加载最新资源

面试可以这样说:

Addressables 把资源地址和真实位置解耦。
热更新时更新远程 Catalog 和远程资源包。
客户端通过 Addressables 检查更新并下载依赖。

它比手写 AssetBundle 省很多事:

依赖管理
远程路径
缓存
引用计数
下载依赖

但仍然要注意:

Release
分组策略
版本兼容
失败重试
CDN 缓存

资源热更新常见坑

1. 把资源热更新和代码热更新混为一谈。
资源热更新主要更新资源,C# 热更新是另一个方案。

2. 没有 Hash 校验。
下载坏了也当成功,后面加载就炸。

3. 没有失败重试。
网络波动时玩家卡在更新界面。

4. 没有回滚策略。
新资源有问题,无法切回旧版本。

5. 没有分包策略。
一个小活动改动,结果让玩家下载几百 MB。

6. 依赖没处理好。
只更新了 Prefab,没有更新它依赖的贴图或材质。

7. 没有清理旧资源。
缓存越来越大,占满玩家磁盘空间。

面试高分回答

TIP

“资源热更新是指客户端不重新安装 App,通过服务器下载新的资源清单和资源包,让游戏使用最新资源。一般流程是:启动时请求远程 manifest 或 catalog,和本地版本清单比较,根据 hash 或版本号找出差异资源,下载到 persistentDataPath,校验文件大小和 hash,更新本地清单,之后加载资源时优先使用本地缓存的新资源,没有缓存再回退到首包资源。底层通常用 AssetBundle 或 Addressables 实现。它主要更新 Prefab、贴图、音频、动画、配置、图集等资源,不等同于 C# 代码热更新。实际项目还要处理依赖、断点续传、失败重试、CDN 缓存、回滚和旧资源清理。”

最短记忆版

c
资源热更新:

目的:
不用重新安装 App,也能更新资源。

流程:
拉远程清单;
比较本地清单;
下载差异资源;
校验 Hash;
保存到 persistentDataPath;
优先加载新资源。

实现:
AssetBundle / Addressables / 自研资源系统。

注意:
资源热更新不等于 C# 代码热更新。

什么是代码热更新?

一句话回答: 代码热更新就是:不重新安装 App,通过服务器下发新的逻辑代码,让客户端运行新的游戏逻辑。它常用于修 Bug、改活动逻辑、调整玩法规则。

code-hot-update

零基础理解

资源热更新换的是:

图片
Prefab
音效
动画
配置表
图集

代码热更新换的是:

技能逻辑
活动流程
战斗公式
UI 业务逻辑
Bug 修复逻辑

比如线上出了一个 Bug:

玩家领取奖励时,金币加了两次

如果没有代码热更新,可能要:

修代码 -> 重新打包 -> 提审 -> 玩家更新安装包

如果有代码热更新,可以:

修热更代码 -> 上传服务器 -> 玩家启动时下载 -> 新逻辑生效

它和资源热更新的区别

资源热更新:
换资源,像换衣服、图片、音效。

代码热更新:
换逻辑,像换大脑里的规则。

例子:

把按钮图片换成红色:
资源热更新。

把按钮点击后的奖励逻辑改掉:
代码热更新。

Unity 为什么不能随便热更新 C#

Unity 发布到移动端常见是 IL2CPP

IL2CPP 会把 C# 代码转换成 C++,再编译成平台原生代码。 这带来一个问题:

运行时不能像编辑器那样随便编译新的 C# 代码再执行。

尤其 iOS 等平台对运行时动态执行代码也更敏感。

所以 Unity 代码热更新一般不是“直接下载一个 .cs 文件然后运行”,而是用一些特殊方案:

c
Lua / xLua / ToLua
ILRuntime
HybridCLR
配置驱动逻辑
自研脚本系统

方案 1:Lua 热更新

这是 Unity 游戏里很常见的方案。

思路是:

C# 做底层能力:
网络、资源加载、UI 组件、战斗表现、Unity API 封装。

Lua 做业务逻辑:
活动、界面、任务、战斗规则、奖励发放。

C# 提供一个入口:

c
using UnityEngine;

public class LuaHotfixEntry : MonoBehaviour
{
    private void Start()
    {
        // 伪代码:创建 Lua 虚拟机
        // 实际项目可能用 xLua、ToLua 等框架
        LuaManager.Init();

        // 加载本地或热更目录里的 Lua 脚本
        LuaManager.DoFile("Main.lua");

        // 调用 Lua 的启动函数
        LuaManager.Call("Main.Start");
    }

    private void OnDestroy()
    {
        // 游戏退出或切换环境时,释放 Lua 虚拟机
        LuaManager.Dispose();
    }
}

Lua 逻辑可能长这样:

c
-- Main.lua

Main = {}

function Main.Start()
    print("Lua 热更逻辑启动")

    -- 这里可以写活动逻辑、UI 逻辑、奖励规则等
    RewardSystem.Init()
end

如果要修 Bug,只要更新服务器上的 Lua 文件:

c
Main.lua
RewardSystem.lua
ActivityController.lua

客户端下次启动或重新加载 Lua 时,就能用新逻辑。

Lua 方案优缺点

优点:

成熟
上线项目多
脚本文件小
更新方便
适合业务逻辑频繁变化的游戏

缺点:

C# 和 Lua 交互有成本
需要绑定代码
调试复杂一些
性能不如纯 C#
团队要掌握 Lua
工程结构要管好

方案 2:ILRuntime

ILRuntime 的思路是:

把热更 C# 代码编译成 DLL
客户端下载 DLL
ILRuntime 在运行时解释执行 DLL 里的 IL

你可以理解成:

不是直接让系统运行新 C# 原生代码
而是用一个解释器读懂这些 C# 编译后的指令

大概流程:

热更工程写 C#
编译成 Hotfix.dll
上传服务器
客户端下载 Hotfix.dll
ILRuntime 加载并执行

示意代码:

c
using UnityEngine;

public class ILRuntimeEntry : MonoBehaviour
{
    private void Start()
    {
        // 伪代码:初始化 ILRuntime 环境
        ILRuntimeManager.Init();

        // 从本地缓存或服务器下载目录加载热更 DLL
        ILRuntimeManager.LoadAssembly("Hotfix.dll");

        // 调用热更工程里的入口方法
        ILRuntimeManager.Invoke("Hotfix.GameEntry", "Start");
    }
}

优点:

可以继续用 C# 写热更逻辑
比 Lua 更接近原项目语言
适合 C# 团队

缺点:

解释执行有性能成本
跨域继承、委托、泛型等需要适配
调试和工程配置比普通 C# 复杂

方案 3:HybridCLR

HybridCLR 是现在 Unity 热更新里很常被问到的方案。

它的目标是:

让 IL2CPP 平台也能运行热更新 C# 程序集

你可以粗略理解为:

底包还是 IL2CPP
热更逻辑编译成 DLL
运行时加载热更 DLL
HybridCLR 提供执行这些热更代码的能力

它比传统解释器更接近“原生 C# 开发体验”。

但它也有注意点:

AOT 泛型问题
补充元数据
打包流程
平台兼容
热更程序集和主工程边界
版本管理

面试不需要你把 HybridCLR 所有细节背完,但你要能说:

HybridCLR 常用于 Unity IL2CPP 下的 C# 代码热更新。
它需要处理 AOT 元数据和热更程序集加载问题。

代码热更新一般怎么分层

一个比较合理的架构是:

底层 C# 固定层:
资源系统、网络系统、UI 框架、战斗表现、对象池、音频、输入、SDK。

热更层:
活动逻辑、任务逻辑、UI 业务、奖励规则、部分战斗公式。

不要什么都放热更层。 比如 Unity 原生能力、性能敏感底层、平台 SDK,通常还是放底包更稳定。

版本和安全怎么处理

代码热更新比资源热更新风险更高,所以要做:

版本号
Hash 校验
文件大小校验
签名校验
失败重试
回滚机制
灰度发布
兼容性检查

比如:

热更代码依赖了新底包里的 C# 类
但玩家还没更新底包
结果运行时报错

所以要有版本约束:

Hotfix v10 只能运行在 App v1.2.0 以上

一个简单的热更新检查流程

c
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class CodeHotUpdateChecker : MonoBehaviour
{
    private const string LocalCodeVersion = "1.0.0";
    private const string RemoteManifestUrl = "https://cdn.game.com/code_manifest.json";

    private IEnumerator Start()
    {
        // 下载远程代码版本清单
        UnityWebRequest request = UnityWebRequest.Get(RemoteManifestUrl);
        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("代码热更清单下载失败:" + request.error);

            // 下载失败时,可以继续使用本地旧代码
            yield break;
        }

        string manifestJson = request.downloadHandler.text;

        // 正式项目这里会解析 JSON,拿到远程版本、Hash、下载地址等
        Debug.Log("远程代码清单:" + manifestJson);

        // 比较本地版本和远程版本
        // 如果远程版本更新,就下载热更脚本或热更 DLL
        // 下载完成后要做 Hash / 签名校验
        // 校验通过后再交给 Lua / ILRuntime / HybridCLR 执行
    }
}

注意:这只是流程示例,不是完整框架代码。

常见坑

1. 把资源热更新和代码热更新混为一谈。
资源热更新换资源,代码热更新换逻辑。

2. 以为 Unity 能直接下载 .cs 文件运行。
正式移动端一般不能这么理解。

3. 热更代码依赖了新底包 API。
老版本客户端没有这个 API,就会崩。

4. 没有校验和回滚。
下载坏了或者新逻辑有 Bug,玩家直接进不去。

5. 热更层写得太重。
所有逻辑都塞进热更层,性能、调试、维护都会变差。

6. 忽略平台政策和安全。
代码下发比资源下发更敏感,要谨慎设计。

面试高分回答

可以这样说:

TIP

“代码热更新是指客户端不重新安装 App,通过服务器下载新的逻辑脚本或热更程序集,在客户端已有的运行环境中执行,从而修复线上 Bug 或更新玩法逻辑。它和资源热更新不同,资源热更新主要更新 Prefab、贴图、音频、配置等资源,而代码热更新更新的是业务逻辑。Unity 里常见方案有 Lua/xLua、ILRuntime、HybridCLR。Lua 是通过 Lua 虚拟机执行脚本逻辑;ILRuntime 是解释执行热更 DLL 的 IL;HybridCLR 则常用于 IL2CPP 平台运行 C# 热更程序集。实际项目要注意底包和热更层边界、版本兼容、Hash 和签名校验、失败回滚、平台限制和性能问题。”

最短记忆版

c
代码热更新:
不用重装 App,下载新逻辑,让客户端执行。

能改:
活动逻辑、UI 业务、技能公式、奖励规则、Bug 修复。

不能简单理解为:
下载 .cs 文件直接跑。

常见方案:
Lua / xLua;
ILRuntime;
HybridCLR。

重点风险:
平台限制;
安全校验;
版本兼容;
回滚机制;
性能和调试成本。

Lua 热更方案大概怎么工作?

一句话回答: Lua 热更方案大概是:App 底包里先放好 C# 框架和 Lua 虚拟机,业务逻辑写在 Lua 脚本里;客户端启动时从服务器下载新的 Lua 文件,再由 Lua 虚拟机解释执行新逻辑。

lua-hot-update-workflow

零基础理解

你可以把 Unity 项目分成两层:

C# 底层能力:
资源加载、网络、UI 框架、对象池、音频、SDK、Unity API 封装。

Lua 业务逻辑:
活动、任务、UI 点击逻辑、奖励规则、战斗公式、部分玩法流程。

C# 像“发动机和骨架”,Lua 像“可替换的业务规则”。

上线后如果要改一个活动逻辑,不一定重新发 App,只要:

修改 Lua 脚本
上传服务器
客户端下载新 Lua
Lua 虚拟机执行新脚本

新逻辑就生效了。

大概工作流程

Lua 热更一般是这几步:

c
1. App 安装包里预置 Lua 运行环境,比如 xLua / ToLua。
2. App 启动时请求服务器热更清单。
3. 比较本地 Lua 版本和服务器 Lua 版本。
4. 下载变化的 Lua 文件或 Lua 资源包。
5. 做 Hash / MD5 / 签名校验。
6. 保存到 persistentDataPath。
7. LuaManager 优先加载热更目录里的 Lua。
8. 没有热更脚本时,再加载包内默认 Lua。
9. 执行业务入口,比如 Main.lua。

核心就是:

热更目录的新 Lua 优先级 > 安装包内置 Lua

为什么 Lua 可以热更

因为 Lua 是脚本语言,不需要像 C# 那样提前编译进 App 的原生代码里。 Unity 里会内置一个 Lua 虚拟机,也就是 Lua VM。

它负责:

读取 Lua 文件
解释 Lua 代码
执行 Lua 函数
管理 Lua 表和变量
和 C# 互相调用

所以你更新的是:

.lua 文件

而不是直接更新 Unity 原生 C# 代码。

C# 和 Lua 怎么配合

一般是:

c
C# 提供能力
Lua 调用能力

比如 C# 提供:

c
public class UIService
{
    public void OpenPanel(string panelName)
    {
        // C# 负责真正打开 Unity UI
        Debug.Log("打开界面:" + panelName);
    }
}

Lua 里调用:

c
-- Lua 负责业务规则
function OnBagButtonClick()
    -- 调用 C# 暴露出来的 UI 能力
    CS.UIService.Instance:OpenPanel("BagPanel")
end

真实项目里需要 xLua / ToLua 生成绑定代码,让 Lua 能安全、高效地访问 C# 类型。

一个简化版 LuaManager

下面用 xLua 的风格举例,代码里加了注释。真实项目会更完整,比如加密、版本、缓存、错误上报等。

c
using System.IO;
using UnityEngine;
using XLua;

public class LuaManager : MonoBehaviour
{
    public static LuaManager Instance { get; private set; }

    private LuaEnv luaEnv;

    private string HotfixLuaRoot =>
        Path.Combine(Application.persistentDataPath, "Lua");

    private void Awake()
    {
        Instance = this;

        // 创建 Lua 虚拟机
        luaEnv = new LuaEnv();

        // 自定义 Lua 文件加载器
        // 当 Lua require("Main") 时,会走这里找文件
        luaEnv.AddLoader(CustomLuaLoader);
    }

    public void StartLua()
    {
        // 执行业务入口
        // Main.lua 可以来自热更目录,也可以来自包内默认目录
        luaEnv.DoString("require('Main')");
    }

    private byte[] CustomLuaLoader(ref string fileName)
    {
        // Lua 的 require("UI.BagPanel") 通常会转成路径 UI/BagPanel.lua
        string luaPath = fileName.Replace(".", "/") + ".lua";

        // 第一优先级:热更目录
        string hotfixPath = Path.Combine(HotfixLuaRoot, luaPath);

        if (File.Exists(hotfixPath))
        {
            // 如果热更目录里有这个 Lua,就加载热更版本
            return File.ReadAllBytes(hotfixPath);
        }

        // 第二优先级:Resources 内置 Lua
        // 注意:Resources.Load 不需要写扩展名
        string resourcePath = "Lua/" + fileName.Replace(".", "/");
        TextAsset textAsset = Resources.Load<TextAsset>(resourcePath);

        if (textAsset != null)
        {
            return textAsset.bytes;
        }

        Debug.LogError("找不到 Lua 文件:" + fileName);
        return null;
    }

    private void Update()
    {
        // LuaEnv.Tick 可以让 xLua 做一些内部清理
        luaEnv?.Tick();
    }

    private void OnDestroy()
    {
        // 释放 Lua 虚拟机
        luaEnv?.Dispose();
        luaEnv = null;
    }
}

Lua 入口脚本示例

c
-- Main.lua

print("Lua Main 启动")

Game = {}

function Game.Start()
    print("进入 Lua 业务逻辑")

    -- 初始化 UI、活动、任务等系统
    -- UIManager.Init()
    -- ActivityManager.Init()
    -- QuestManager.Init()
end

Game.Start()

如果线上要修 Lua 逻辑,只要把新的 Main.lua 或相关 Lua 文件下载到:

c
Application.persistentDataPath/Lua/

下次 require 时就会优先读热更版本。

热更下载流程示例

c
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;

public class LuaHotUpdateDownloader : MonoBehaviour
{
    private string luaRoot =>
        Path.Combine(Application.persistentDataPath, "Lua");

    public IEnumerator DownloadLuaFile(string url, string relativePath)
    {
        UnityWebRequest request = UnityWebRequest.Get(url);

        // 发起下载请求
        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("Lua 下载失败:" + request.error);
            yield break;
        }

        byte[] data = request.downloadHandler.data;

        // 正式项目这里要做 Hash / 签名校验
        // 防止脚本下载损坏或被篡改

        string savePath = Path.Combine(luaRoot, relativePath);
        string directory = Path.GetDirectoryName(savePath);

        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }

        // 保存 Lua 文件到热更目录
        File.WriteAllBytes(savePath, data);

        Debug.Log("Lua 下载完成:" + savePath);
    }
}

Lua 热更能改什么

适合放 Lua 的:

UI 业务逻辑
活动规则
任务流程
奖励领取
商城逻辑
技能公式
副本规则
新手引导
配置驱动的玩法

不太适合全放 Lua 的:

高频性能敏感逻辑
复杂物理计算
底层渲染逻辑
平台 SDK
资源底层加载
大量 Unity API 直接操作

更合理的分层是:

C# 做稳定高性能底层
Lua 做变化快的业务层

面试里要讲的重点:绑定

Lua 本身不认识 Unity 的 GameObjectTransformButton

所以需要绑定层:

Lua 调 C#:
Lua 调用 C# 暴露的类和方法。

C# 调 Lua:
C# 在按钮点击、网络回包、生命周期事件里回调 Lua 函数。

例如:

c
using UnityEngine;
using UnityEngine.UI;
using XLua;

public class LuaButtonBinder : MonoBehaviour
{
    [SerializeField] private Button button;

    private LuaFunction luaClickFunction;

    public void Bind(LuaFunction clickFunction)
    {
        luaClickFunction = clickFunction;

        // C# 按钮点击时,转发给 Lua 函数
        button.onClick.AddListener(OnClick);
    }

    private void OnClick()
    {
        // 调用 Lua 的点击逻辑
        luaClickFunction?.Call();
    }

    private void OnDestroy()
    {
        // 取消按钮监听,避免对象销毁后仍然被事件引用
        button.onClick.RemoveListener(OnClick);

        // 释放 Lua 函数引用,避免 Lua 对象泄漏
        luaClickFunction?.Dispose();
        luaClickFunction = null;
    }
}

常见坑

1. 以为 Lua 热更就是直接替换 C#。
不对,Lua 是脚本逻辑层,C# 底层能力仍然要预先打进包。

2. 热更 Lua 依赖了底包没有的 C# 接口。
老版本 App 里没有这个接口,Lua 调用就会报错。

3. C# 和 Lua 互相引用不释放。
可能造成内存泄漏,比如 LuaFunction、事件、委托没清。

4. 频繁跨语言调用。
C# 和 Lua 互调有成本,高频 Update 里大量调用要小心。

5. 没有 Hash 和签名校验。
脚本下载坏了或被篡改,风险很高。

6. 没有回滚。
新 Lua 有 Bug,玩家可能直接卡死在启动流程。

面试高分回答

NOTE

“Lua 热更一般是在 Unity 底包里预先集成 Lua 虚拟机和 C# 到 Lua 的绑定层,比如 xLua 或 ToLua。底层稳定能力,比如资源、网络、UI 框架、对象池、SDK 等由 C# 实现并随 App 打包;变化频繁的业务逻辑放在 Lua 脚本里。客户端启动时拉取远程 Lua 版本清单,和本地清单比较,下载变化的 Lua 文件,校验 Hash 或签名后保存到 persistentDataPath。LuaManager 加载脚本时优先查热更目录,没有再读包内默认 Lua。运行时 C# 可以调用 Lua 的业务入口,Lua 也可以通过绑定层调用 C# 暴露的接口。真正难点是 C# 和 Lua 的边界设计、版本兼容、内存释放、跨语言调用性能、安全校验和回滚。”

最短记忆版

c
Lua 热更方案:

底包:
C# 框架 + Lua VM + 绑定层。

服务器:
下发 Lua 脚本和版本清单。

客户端:
比较版本;
下载 Lua;
校验 Hash;
保存到 persistentDataPath;
优先加载热更 Lua。

分工:
C# 做底层能力;
Lua 做业务逻辑。

难点:
绑定、性能、内存、版本兼容、安全、回滚。

ILRuntime 是什么?

一句话回答:ILRuntime 是 Unity 里常见的 C# 代码热更新方案。它的做法是:把热更代码编译成 Hotfix.dll,客户端下载后交给 ILRuntime 解释执行里面的 IL 指令,从而让新的业务逻辑生效。

ilruntime-explained

零基础理解

普通 C# 代码打包后,尤其是 Unity 移动端常见的 IL2CPP,会被编译进 App。上线后你不能简单下载一个 .cs 文件直接执行。

ILRuntime 换了个思路:

主工程:
提前打进 App,里面放 Unity 框架、资源系统、UI 系统、ILRuntime 环境。

热更工程:
单独写 C# 业务逻辑,编译成 Hotfix.dll。

运行时:
客户端下载 Hotfix.dll,ILRuntime 读取 DLL 里的 IL 指令并解释执行。

所以它不是“重新编译原来的 C#”,而是:

用解释器执行热更 DLL。

它大概怎么工作

流程是:

1. 写一个热更 C# 工程。
2. 编译出 Hotfix.dll。
3. 上传到服务器。
4. 客户端启动时检查版本。
5. 下载新的 Hotfix.dll。
6. 校验 Hash / 大小 / 签名。
7. ILRuntime 加载 DLL。
8. 主工程调用热更入口方法。

比如热更 DLL 里有:

c
namespace Hotfix
{
    public class GameEntry
    {
        public static void Start()
        {
            // 这里是热更业务逻辑
        }
    }
}

主工程就可以通过 ILRuntime 调用它。

简化版加载代码

下面是思路代码,真实项目还要加下载、校验、错误处理、绑定注册等。

c
using System.IO;
using UnityEngine;
using ILRuntime.Runtime.Enviorment;

public class ILRuntimeManager : MonoBehaviour
{
    private AppDomain appDomain;

    private void Start()
    {
        // 创建 ILRuntime 的运行域
        // 热更 DLL 会加载到这个 AppDomain 里解释执行
        appDomain = new AppDomain();

        // 加载热更 DLL
        LoadHotfixAssembly();

        // 调用热更入口
        StartHotfixLogic();
    }

    private void LoadHotfixAssembly()
    {
        // 示例路径:真实项目一般从 persistentDataPath 读取下载后的 DLL
        string dllPath = Path.Combine(Application.persistentDataPath, "Hotfix.dll");

        if (!File.Exists(dllPath))
        {
            Debug.LogError("找不到热更 DLL:" + dllPath);
            return;
        }

        byte[] dllBytes = File.ReadAllBytes(dllPath);

        // 用 MemoryStream 把 DLL 字节交给 ILRuntime
        using MemoryStream dllStream = new MemoryStream(dllBytes);

        // 加载热更程序集
        appDomain.LoadAssembly(dllStream);

        Debug.Log("Hotfix.dll 加载完成");
    }

    private void StartHotfixLogic()
    {
        // 调用热更 DLL 中的静态方法
        // 类型名:Hotfix.GameEntry
        // 方法名:Start
        appDomain.Invoke(
            "Hotfix.GameEntry",
            "Start",
            null,
            null
        );
    }
}

为什么需要 CLR Binding

ILRuntime 可以解释执行 C# IL,但热更代码经常要调用主工程里的 C# 方法。

比如热更层调用:

UIManager.Instance.Open("BagPanel");

如果每次都靠反射找方法,会慢。 所以 ILRuntime 通常会生成 CLR Binding

它的作用是:

把热更层调用主工程方法的过程变快
减少反射开销
提升跨域调用性能

面试可以这样说:

ILRuntime 解释执行本身有成本,热更层调用主工程方法也有跨域成本,所以项目里通常会生成 CLR Binding 优化调用。

为什么需要 Adapter

如果热更 DLL 里的类要:

继承主工程的类
实现主工程的接口
被主工程当成某种类型调用

就可能需要适配器,也就是 Adapter。

比如主工程定义接口:

c
public interface IHotfixModule
{
    void Start();
    void Update();
    void Dispose();
}

热更工程实现:

c
namespace Hotfix
{
    public class BattleModule : IHotfixModule
    {
        public void Start()
        {
            // 战斗模块启动逻辑
        }

        public void Update()
        {
            // 战斗模块每帧逻辑
        }

        public void Dispose()
        {
            // 战斗模块释放逻辑
        }
    }
}

这种跨域继承和接口实现,通常要通过 ILRuntime 的 Adapter 机制处理。

项目怎么分层

比较合理的分法是:

主工程:
资源系统、网络系统、UI 框架、对象池、音频、SDK、Unity API 封装。

热更工程:
活动逻辑、UI 业务逻辑、任务逻辑、奖励规则、战斗公式、部分玩法流程。

不要把所有东西都放进热更层。

因为:

解释执行比原生 C# 慢
跨域调用有成本
调试更复杂
底层能力频繁变化会导致兼容问题

ILRuntime 和 Lua 的区别

Lua:
业务逻辑写 Lua,靠 Lua VM 执行。
优点是成熟、脚本小、热更方便。
缺点是 C# 和 Lua 是两种语言。

ILRuntime:
业务逻辑仍然写 C#,编译成 DLL,由 ILRuntime 解释 IL。
优点是团队可以继续用 C# 写热更逻辑。
缺点是解释执行、适配、泛型、调试、性能都要处理。

一句话:

Lua 是换语言做热更。
ILRuntime 是用 C# DLL 做热更。

常见坑

c
1. 以为 ILRuntime 可以直接更新底包里的所有 C#。
不对,主工程已经编译进 App,热更通常是通过预留入口调用 Hotfix.dll。

2. 热更 DLL 调用了老底包不存在的 API。
老版本客户端没有这个类或方法,会运行失败。

3. 高频逻辑大量跨域调用。
比如每帧大量从热更层调用 Unity API,性能会差。

4. 没生成 CLR Binding。
大量反射调用会更慢。

5. Adapter 没处理好。
跨域继承、接口、委托可能出问题。

6. 没做版本、Hash、签名和回滚。
DLL 下载损坏或版本不兼容,玩家可能进不去游戏。

面试高分回答

CAUTION

“ILRuntime 是 Unity 中用于 C# 热更新的一种方案。它通常把变化频繁的业务逻辑放到独立的热更工程里,编译成 Hotfix.dll。客户端启动时从服务器下载新的 DLL,校验后加载到 ILRuntime 的 AppDomain 中,由 ILRuntime 解释执行 DLL 里的 IL 指令。主工程提供资源、网络、UI、SDK 等稳定能力,热更层通过预留接口调用这些能力。相比 Lua,ILRuntime 的优势是热更逻辑仍然可以用 C# 写;缺点是解释执行和跨域调用有性能成本,需要处理 CLR Binding、Adapter、AOT 泛型、版本兼容、安全校验和回滚。”

最短记忆版

c
ILRuntime:
C# 热更 DLL 的 IL 解释器。

流程:
写热更 C#;
编译 Hotfix.dll;
客户端下载;
ILRuntime 加载;
AppDomain.Invoke 调用入口。

优点:
热更逻辑继续写 C#。

缺点:
解释执行有成本;
跨域调用要优化;
Adapter / CLR Binding / AOT 兼容要处理。

面试重点:
它不是直接下载 .cs 跑,
而是解释执行热更 DLL 里的 IL。

HybridCLR 是什么?

一句话回答:HybridCLR 是 Unity 的 C# 代码热更新方案。它把 IL2CPP 从纯 AOT 运行时扩展成 AOT + Interpreter 混合运行时,让游戏在运行时可以加载新的 C# 热更 DLL,并执行里面的新逻辑。

hybridclr-explained

零基础理解

Unity 移动端常用 IL2CPP

C# 代码 -> IL -> C++ -> 原生机器码

这叫 AOT,也就是提前编译。

问题是:

App 已经上线了
C# 代码已经被编进安装包
你不能简单下载一个新的 .cs 文件直接执行

HybridCLR 做的事情是:

底包里接入 HybridCLR 运行时
热更代码单独编译成 Hotfix.dll
客户端下载新的 Hotfix.dll
运行时 Assembly.Load 加载 DLL
HybridCLR 解释执行新 DLL 里的 IL 代码

所以它的核心不是“下载 .cs 文件”,而是:

运行时加载热更 DLL。

为什么叫 HybridCLR

Hybrid 是混合的意思。 CLR 可以理解成 C# 运行时。

它“混合”的是:

AOT:
底包里已经编译好的原生代码,性能好。

Interpreter:
热更 DLL 里的新 IL 代码,由解释器执行。

所以可以这样记:

c
HybridCLR = AOT + Interpreter

官方文档也把它描述为把 IL2CPP 从纯 AOT 运行时扩展成混合 AOT + Interpreter 运行时,从而支持动态加载程序集。参考:HybridCLR 官方介绍。

基本工作流程

1. 主工程接入 HybridCLR。
2. 稳定底层代码随 App 打进底包。
3. 变化频繁的业务逻辑放到热更程序集。
4. 热更程序集编译成 Hotfix.dll。
5. 上传服务器。
6. 客户端启动时检查版本。
7. 下载新的 Hotfix.dll。
8. 校验 Hash / 签名。
9. 加载补充元数据。
10. Assembly.Load 加载热更 DLL。
11. 调用热更入口,新逻辑生效。

最简加载热更 DLL 代码

c
using System;
using System.IO;
using System.Reflection;
using UnityEngine;

public class HybridCLREntry : MonoBehaviour
{
    private Assembly hotfixAssembly;

    private void Start()
    {
        // 加载热更 DLL
        LoadHotfixAssembly();

        // 调用热更入口
        RunHotfixMain();
    }

    private void LoadHotfixAssembly()
    {
        // 示例:热更 DLL 下载后保存在 persistentDataPath
        string dllPath = Path.Combine(Application.persistentDataPath, "Hotfix.dll");

        if (!File.Exists(dllPath))
        {
            Debug.LogError("找不到热更 DLL:" + dllPath);
            return;
        }

        // 读取 DLL 字节
        byte[] dllBytes = File.ReadAllBytes(dllPath);

        // HybridCLR 支持通过 Assembly.Load 加载热更程序集
        // 官方文档也推荐这种加载方式
        hotfixAssembly = Assembly.Load(dllBytes);

        // Assembly.Load 内部会复制字节数据
        // 加载后不需要再长期保存 dllBytes,避免浪费内存
        Debug.Log("热更 DLL 加载完成");
    }

    private void RunHotfixMain()
    {
        if (hotfixAssembly == null)
            return;

        // 获取热更程序集里的入口类
        Type entryType = hotfixAssembly.GetType("Hotfix.GameEntry");

        if (entryType == null)
        {
            Debug.LogError("找不到热更入口类 Hotfix.GameEntry");
            return;
        }

        // 获取入口方法
        MethodInfo startMethod = entryType.GetMethod(
            "Start",
            BindingFlags.Public | BindingFlags.Static
        );

        if (startMethod == null)
        {
            Debug.LogError("找不到热更入口方法 Start");
            return;
        }

        // 调用热更入口
        startMethod.Invoke(null, null);
    }
}

热更工程里可能是:

c
namespace Hotfix
{
    public static class GameEntry
    {
        public static void Start()
        {
            UnityEngine.Debug.Log("HybridCLR 热更逻辑启动");
        }
    }
}

补充元数据是什么

这是 HybridCLR 面试重点。

IL2CPP 是 AOT,它会提前生成用到的代码。 但热更 DLL 里可能出现底包没有提前生成的泛型用法,比如:

c
List<HotfixItem>
Dictionary<int, HotfixSkill>
async / await 生成的状态机泛型

如果 AOT 代码里没准备好相关泛型实现,就可能报:

c
MissingMethodException
AOT generic method not instantiated

HybridCLR 的解决方案之一是:补充元数据

意思是:

把一些 AOT 程序集的元数据信息加载进来
让运行时能补足泛型方法需要的信息

示意代码:

c
using System.Collections.Generic;
using UnityEngine;
using HybridCLR;

public static class AOTMetadataLoader
{
    public static void LoadMetadata()
    {
        // 这些 DLL 名称只是示例
        // 实际项目要根据 HybridCLR 生成的 AOTGenericReferences 来决定
        List<string> aotDllList = new List<string>
        {
            "mscorlib.dll",
            "System.dll",
            "System.Core.dll",
            "UnityEngine.CoreModule.dll"
        };

        foreach (string dllName in aotDllList)
        {
            // 示例:从 StreamingAssets 或热更资源包中读取 AOT 元数据 DLL
            byte[] dllBytes = LoadDllBytes(dllName);

            if (dllBytes == null)
            {
                Debug.LogWarning("找不到 AOT 元数据 DLL:" + dllName);
                continue;
            }

            // 给 AOT 程序集补充元数据
            // SuperSet 模式表示补充 DLL 可以是裁剪后 DLL 的超集
            int result = RuntimeApi.LoadMetadataForAOTAssembly(
                dllBytes,
                HomologousImageMode.SuperSet
            );

            Debug.Log($"加载 AOT 元数据:{dllName}, result={result}");
        }
    }

    private static byte[] LoadDllBytes(string dllName)
    {
        // 这里写成示意
        // 真实项目通常从 AssetBundle、Addressables、StreamingAssets 或 persistentDataPath 读取
        return null;
    }
}

注意:补充元数据不是加载热更逻辑

这个很重要。

c
Assembly.Load(Hotfix.dll):
加载热更代码。

LoadMetadataForAOTAssembly(AOT dll):
补充 AOT 泛型等需要的元数据。

它们不是一回事。

HybridCLR 和 ILRuntime 的区别

ILRuntime

独立 IL 解释器
热更 DLL 在 ILRuntime AppDomain 里运行
和主工程有跨域调用、Adapter、CLR Binding 等问题

HybridCLR

扩展 IL2CPP 运行时
热更程序集更像普通 C# 程序集
更接近原生 C# 工作流
热更代码和 AOT 代码互调更自然

简单记:

c
ILRuntime:外接一个 IL 解释执行环境。
HybridCLR:改造 IL2CPP,让它自己具备热更执行能力。

HybridCLR 和 Lua 的区别

Lua

业务逻辑写 Lua
需要 Lua VM
C# 和 Lua 是两套语言
需要绑定和跨语言调用

HybridCLR

业务逻辑继续写 C#
热更代码编译成 DLL
团队不用切换到 Lua 语言

所以 HybridCLR 的优势是:

C# 开发体验更统一
类型系统更接近原项目
面向对象、泛型、接口、反射等用起来更自然

它能热更什么

适合热更:

UI 业务逻辑
活动逻辑
任务逻辑
战斗公式
奖励规则
部分玩法模块
Bug 修复逻辑
配置驱动的 C# 逻辑

不建议乱热更:

底层渲染系统
平台 SDK
高频性能敏感核心
Unity 引擎本身
底包里完全不存在的 Native 能力

热更代码如果依赖了新底包才有的接口,老客户端没有,还是会出问题。

常见坑

c
1. 以为 HybridCLR 可以随便更新所有 C#。
不对,底包没有预留的 Native 能力或接口,老版本客户端仍然不能调用。

2. 忘记补充 AOT 元数据。
泛型、async、Dictionary、List 等场景可能出 AOT 泛型问题。

3. 补充元数据越多越好。
不一定,元数据会增加包体、热更大小和运行时内存。

4. 没有版本兼容。
Hotfix.dll 依赖了新底包 API,但玩家还是旧 App,会运行失败。

5. 没有 Hash / 签名 / 回滚。
DLL 损坏或被篡改,风险比资源更高。

6. 把所有逻辑都塞热更层。
热更方便,但工程边界、性能和维护都会变差。

面试高分回答

NOTE

“HybridCLR 是 Unity IL2CPP 平台的 C# 热更新方案。它扩展了 IL2CPP 运行时,把纯 AOT 运行时变成 AOT + Interpreter 混合运行时。主工程里的稳定系统会随 App 打进底包,变化频繁的业务逻辑编译成热更 DLL,客户端下载后通过 Assembly.Load 动态加载并执行。它相比 ILRuntime 更接近原生 C# 开发方式,热更代码和 AOT 代码互调更自然。使用时重点要处理 AOT 泛型问题,通常需要补充元数据,比如调用 RuntimeApi.LoadMetadataForAOTAssembly;同时还要做好版本兼容、Hash 校验、签名、回滚和热更层边界设计。”

最短记忆版

c
HybridCLR:
Unity C# 热更新方案。

核心:
把 IL2CPP 从纯 AOT 变成 AOT + Interpreter。

流程:
热更 C# 编译成 Hotfix.dll;
客户端下载;
校验;
补充 AOT 元数据;
Assembly.Load;
调用热更入口。

重点:
不是下载 .cs 直接跑;
不是资源热更;
要处理 AOT 泛型和补充元数据;
要注意版本兼容、安全和回滚。

参考官方资料: HybridCLR IntroductionLoading and RunningAOT Generics

热更资源如何做版本对比?

一句话回答: 热更资源版本对比通常不是只比一个 version,而是让客户端下载服务器的 Manifest 清单,然后和本地 Manifest文件名、Hash、Size、平台、依赖逐项对比,找出需要下载、删除、保留的资源。

hot-update-version-compare

零基础理解

你可以把 Manifest 想成“资源账本”。

本地账本说:

c
ui_common.ab
hash = aaa111
size = 1024

服务器账本说:

c
ui_common.ab
hash = bbb222
size = 2048

说明这个资源变了,就要下载。

如果本地没有:

c
activity_summer.ab

但服务器有,说明这是新活动资源,也要下载。

如果本地有一个旧资源:

c
old_activity.ab

但服务器清单已经没有了,就说明它可能是废弃资源,可以找合适时机清理。

Manifest 一般包含什么

一个资源清单大概长这样:

c
{
  "appVersion": "1.2.0",
  "resVersion": "1008",
  "platform": "Android",
  "files": [
    {
      "name": "ui_common.ab",
      "hash": "A81F3C9D",
      "size": 204800,
      "url": "https://cdn.game.com/android/ui_common.ab",
      "dependencies": []
    },
    {
      "name": "activity_summer.ab",
      "hash": "B92D10EE",
      "size": 5242880,
      "url": "https://cdn.game.com/android/activity_summer.ab",
      "dependencies": ["ui_common.ab"]
    }
  ]
}

重点字段:

c
appVersion:
这个资源清单兼容哪个 App 底包版本。

resVersion:
资源整体版本号,用来快速判断有没有更新。

platform:
Android、iOS、Windows 资源包不能混用。

name:
资源包文件名。

hash:
资源内容指纹,判断文件有没有变化。

size:
文件大小,可用于下载前显示大小,也可做基础校验。

url:
下载地址。

dependencies:
依赖列表,比如活动包依赖公共 UI 包。

对比规则

最常见规则是:

远程有,本地没有:
需要下载。

远程有,本地也有,但 hash 不一样:
需要重新下载。

远程有,本地也有,hash 一样:
跳过,不用下载。

本地有,远程没有:
说明旧资源废弃,可以清理。

平台不一致:
不能用,必须请求对应平台清单。

App 版本不兼容:
提示玩家更新安装包。

注意:Hash 比版本号更可靠。

版本号只是告诉你“这一批资源可能有变化”。 真正决定某个文件要不要下载,通常看 hash

核心对比代码

下面是一个教学版版本对比器,重点看逻辑。

c
using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class ResourceManifest
{
    // App 底包版本,比如 1.2.0
    public string appVersion;

    // 资源整体版本,比如 1008
    public string resVersion;

    // 平台,比如 Android / iOS
    public string platform;

    // 文件列表
    public List<ResourceFileInfo> files = new();
}

[Serializable]
public class ResourceFileInfo
{
    // 文件名,比如 ui_common.ab
    public string name;

    // 文件内容 Hash,比如 MD5 / SHA1
    public string hash;

    // 文件大小,单位 byte
    public long size;

    // 下载地址
    public string url;

    // 依赖资源列表
    public List<string> dependencies = new();
}

public class VersionCompareResult
{
    // 需要下载的文件
    public List<ResourceFileInfo> needDownload = new();

    // 可以删除的旧文件
    public List<ResourceFileInfo> needDelete = new();

    // 不需要变化的文件
    public List<ResourceFileInfo> unchanged = new();

    // 总下载大小
    public long totalDownloadSize;
}

public static class HotUpdateVersionComparer
{
    public static VersionCompareResult Compare(
        ResourceManifest localManifest,
        ResourceManifest remoteManifest)
    {
        VersionCompareResult result = new VersionCompareResult();

        // 先检查平台,避免 Android 用到 iOS 资源
        if (localManifest.platform != remoteManifest.platform)
        {
            Debug.LogError("平台不一致,不能使用这个远程清单");
            return result;
        }

        // 把本地文件列表转成字典,方便按文件名查找
        Dictionary<string, ResourceFileInfo> localFiles = new();

        foreach (ResourceFileInfo file in localManifest.files)
        {
            localFiles[file.name] = file;
        }

        // 把远程文件列表转成字典,后面用于查找废弃文件
        Dictionary<string, ResourceFileInfo> remoteFiles = new();

        foreach (ResourceFileInfo remoteFile in remoteManifest.files)
        {
            remoteFiles[remoteFile.name] = remoteFile;

            // 本地没有这个文件,说明是新资源,需要下载
            if (!localFiles.TryGetValue(remoteFile.name, out ResourceFileInfo localFile))
            {
                result.needDownload.Add(remoteFile);
                result.totalDownloadSize += remoteFile.size;
                continue;
            }

            // 本地有,但 Hash 不一样,说明文件内容变了,需要重新下载
            if (localFile.hash != remoteFile.hash)
            {
                result.needDownload.Add(remoteFile);
                result.totalDownloadSize += remoteFile.size;
                continue;
            }

            // Hash 一样,说明这个资源没变
            result.unchanged.Add(remoteFile);
        }

        // 找出本地有、远程没有的旧资源
        foreach (ResourceFileInfo localFile in localManifest.files)
        {
            if (!remoteFiles.ContainsKey(localFile.name))
            {
                result.needDelete.Add(localFile);
            }
        }

        return result;
    }
}

下载后为什么还要校验

对比只是告诉你:

应该下载哪些文件

但下载过程中可能发生:

断网
文件只下载一半
CDN 返回旧文件
磁盘写入失败
文件被篡改

所以下载完成后必须再校验:

size 是否一致
hash 是否一致

示例:

c
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;

public static class FileHashUtility
{
    public static string CalculateMD5(string filePath)
    {
        if (!File.Exists(filePath))
        {
            return string.Empty;
        }

        using FileStream stream = File.OpenRead(filePath);
        using MD5 md5 = MD5.Create();

        byte[] hashBytes = md5.ComputeHash(stream);

        StringBuilder builder = new StringBuilder();

        foreach (byte b in hashBytes)
        {
            // 转成 16 进制字符串
            builder.Append(b.ToString("x2"));
        }

        return builder.ToString();
    }

    public static bool CheckFile(string filePath, long expectedSize, string expectedHash)
    {
        FileInfo fileInfo = new FileInfo(filePath);

        // 先检查大小,便宜又快
        if (!fileInfo.Exists || fileInfo.Length != expectedSize)
        {
            return false;
        }

        // 再检查 Hash,确认文件内容真的正确
        string actualHash = CalculateMD5(filePath);

        return actualHash == expectedHash;
    }
}

正确下载流程

不要直接覆盖正式文件。 更安全的是:

下载到 .tmp 临时文件
校验 size 和 hash
通过后再替换正式文件
全部文件成功后再保存新 Manifest
失败就继续用旧资源

示例流程:

c
using System.Collections;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;

public class HotUpdateDownloader
{
    private readonly string saveRoot;

    public HotUpdateDownloader(string saveRoot)
    {
        this.saveRoot = saveRoot;
    }

    public IEnumerator DownloadFile(ResourceFileInfo file)
    {
        string finalPath = Path.Combine(saveRoot, file.name);
        string tempPath = finalPath + ".tmp";

        // 确保目录存在
        string directory = Path.GetDirectoryName(finalPath);

        if (!Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }

        UnityWebRequest request = UnityWebRequest.Get(file.url);

        yield return request.SendWebRequest();

        if (request.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("下载失败:" + file.name + " error=" + request.error);
            yield break;
        }

        // 先写入临时文件,不污染正式资源
        File.WriteAllBytes(tempPath, request.downloadHandler.data);

        // 校验临时文件
        bool valid = FileHashUtility.CheckFile(tempPath, file.size, file.hash);

        if (!valid)
        {
            Debug.LogError("文件校验失败:" + file.name);

            // 校验失败就删掉临时文件
            if (File.Exists(tempPath))
            {
                File.Delete(tempPath);
            }

            yield break;
        }

        // 校验通过后,再替换正式文件
        if (File.Exists(finalPath))
        {
            File.Delete(finalPath);
        }

        File.Move(tempPath, finalPath);

        Debug.Log("文件下载并校验成功:" + file.name);
    }
}

什么时候更新本地 Manifest

这个点很重要。

不要这样做:

下载一个文件成功
立刻把本地 Manifest 改成最新

因为如果后面下载失败,客户端会以为自己已经是最新版本,但实际资源不完整。

更安全的流程是:

1. 下载远程 Manifest。
2. 算出差异列表。
3. 下载所有差异文件到临时文件。
4. 每个文件都校验通过。
5. 替换正式资源文件。
6. 最后才保存新的本地 Manifest。

失败时:

不要更新本地 Manifest
继续使用旧资源
下次启动继续尝试更新

Addressables 里怎么理解

如果用 Addressables,版本对比通常体现在:

Catalog 更新
Bundle Hash 变化
DownloadDependenciesAsync 下载差异资源

你可以这样说:

Addressables 本质上也是通过 Catalog 记录资源地址、Hash、依赖和远程位置。
客户端检查远程 Catalog 是否更新,更新后根据依赖下载变化的 Bundle。

也就是说:

手写热更:
自己维护 Manifest。

Addressables:
Unity 帮你维护 Catalog。

常见坑

1. 只比较 resVersion。
版本号能快速判断有无更新,但不能证明每个文件正确。

2. 不做 Hash 校验。
下载坏了也当成功,后面加载 Bundle 会失败。

3. Android / iOS 清单混用。
不同平台 Bundle 不兼容。

4. 先更新 Manifest,后下载资源。
如果中途失败,会出现“清单是新的,文件是旧的”。

5. 不处理废弃资源。
缓存越来越大,占玩家磁盘。

6. 不处理 App 兼容版本。
新资源依赖新底包 API,老 App 可能跑不起来。

7. 没有回滚。
远程资源有问题时,无法切回上一版。

面试高分回答

TIP

“热更资源版本对比一般通过 Manifest 完成。客户端本地会保存一份已下载资源清单,服务器也会提供当前最新清单。启动时客户端拉取远程 Manifest,先检查平台、渠道、App 兼容版本,再按文件名逐项比较本地和远程资源的 Hash、Size、版本和依赖关系。本地没有的文件需要下载,Hash 不一致的文件需要重新下载,Hash 一致的文件跳过,本地有但远程没有的文件可以进入清理列表。下载时先写入临时文件,下载完成后校验 Size 和 Hash,全部校验成功后再替换正式文件,并最后更新本地 Manifest。这样可以避免半包、脏缓存和清单文件不一致的问题。”

最短记忆版

c
热更资源版本对比:

1. 本地 Manifest 记录已拥有资源。
2. 服务器 Manifest 记录最新资源。
3. 按文件名建立字典。
4. 本地没有:下载。
5. Hash 不同:下载。
6. Hash 相同:跳过。
7. 远程没有:可删除。
8. 下载后校验 Size 和 Hash。
9. 全部成功后再更新本地 Manifest。

重点:
不要只比版本号;
Hash 才是判断文件变化的关键。

Patch 包如何设计?

一句话回答: Patch 包就是“从某个旧资源版本升级到目标资源版本的差异包”。设计时要包含:fromVersion、toVersion、变化文件、删除列表、Hash、Size、依赖、签名、安装顺序和回滚策略

patch-package-design

零基础理解

假设玩家本地是:

资源版本 1001

服务器最新是:

资源版本 1005

你有两种更新方式:

全量包:
直接下载 1005 的全部资源。
优点是简单稳定,缺点是大。

增量 Patch 包:
只下载 1001 到 1005 之间变化的资源。
优点是小,缺点是要处理安装、校验、删除、回滚。

Patch 包可以理解成:

我要把客户端从 A 版本修补到 B 版本,需要新增哪些文件、替换哪些文件、删除哪些旧文件。

Patch 包里应该有什么

一个 Patch 包通常包含:

c
patch_manifest.json
新增资源文件
修改后的资源文件
delete_list 删除列表
依赖信息
Hash / Size 校验信息
签名信息

示例:

c
{
  "fromVersion": "1001",
  "toVersion": "1005",
  "appMinVersion": "1.2.0",
  "platform": "Android",
  "patchHash": "9a8b7c",
  "patchSize": 10485760,
  "files": [
    {
      "name": "ui_common.ab",
      "hash": "A81F3C9D",
      "size": 204800,
      "action": "replace"
    },
    {
      "name": "activity_summer.ab",
      "hash": "B92D10EE",
      "size": 5242880,
      "action": "add"
    }
  ],
  "deleteList": [
    "activity_old.ab"
  ]
}

安装流程

正确流程不要直接覆盖正式资源。

应该这样:

1. 下载 Patch 包到临时目录。
2. 校验 Patch 包整体 Hash。
3. 解压到 staging 临时安装目录。
4. 校验每个文件的 Size 和 Hash。
5. 备份当前 Manifest。
6. 替换新增和修改文件。
7. 删除 deleteList 中的废弃文件。
8. 保存新的本地 Manifest。
9. 清理临时目录。

核心原则:

全部成功,才算更新成功。
中途失败,继续使用旧版本。

为什么要 staging 目录

不要边下载边覆盖正式文件。

否则可能出现:

下载了一半断网
旧文件被覆盖了
新文件又不完整
客户端下次启动直接坏掉

所以要用:

c
temp 下载目录
staging 解压目录
live 正式资源目录

安装成功后再替换正式目录里的文件。

代码示例:Patch 数据结构

c
using System;
using System.Collections.Generic;

[Serializable]
public class PatchManifest
{
    // 从哪个资源版本升级
    public string fromVersion;

    // 升级到哪个资源版本
    public string toVersion;

    // 最低兼容 App 版本
    public string appMinVersion;

    // 平台,比如 Android / iOS
    public string platform;

    // Patch 包整体 Hash
    public string patchHash;

    // Patch 包大小
    public long patchSize;

    // 新增或替换的文件
    public List<PatchFileInfo> files = new();

    // 需要删除的旧文件
    public List<string> deleteList = new();
}

[Serializable]
public class PatchFileInfo
{
    // 文件名,比如 ui_common.ab
    public string name;

    // 文件 Hash,用于校验内容是否正确
    public string hash;

    // 文件大小
    public long size;

    // add 或 replace
    public string action;
}

代码示例:安装 Patch

c
using System.Collections;
using System.IO;
using UnityEngine;

public class PatchInstaller
{
    private readonly string liveRoot;
    private readonly string stagingRoot;

    public PatchInstaller(string liveRoot, string stagingRoot)
    {
        this.liveRoot = liveRoot;
        this.stagingRoot = stagingRoot;
    }

    public IEnumerator InstallPatch(PatchManifest patch)
    {
        // 第一步:确认临时目录里的文件都校验通过
        foreach (PatchFileInfo file in patch.files)
        {
            string stagingPath = Path.Combine(stagingRoot, file.name);

            // 文件不存在,说明 Patch 包不完整
            if (!File.Exists(stagingPath))
            {
                Debug.LogError("Patch 文件不存在:" + file.name);
                yield break;
            }

            // 校验 Size 和 Hash
            bool valid = FileHashUtility.CheckFile(
                stagingPath,
                file.size,
                file.hash
            );

            if (!valid)
            {
                Debug.LogError("Patch 文件校验失败:" + file.name);
                yield break;
            }

            yield return null;
        }

        // 第二步:替换新增和修改文件
        foreach (PatchFileInfo file in patch.files)
        {
            string stagingPath = Path.Combine(stagingRoot, file.name);
            string livePath = Path.Combine(liveRoot, file.name);

            string directory = Path.GetDirectoryName(livePath);

            if (!Directory.Exists(directory))
            {
                Directory.CreateDirectory(directory);
            }

            // 如果正式目录已有旧文件,先删掉
            if (File.Exists(livePath))
            {
                File.Delete(livePath);
            }

            // 把校验通过的文件移动到正式目录
            File.Move(stagingPath, livePath);

            yield return null;
        }

        // 第三步:删除废弃资源
        foreach (string deleteFile in patch.deleteList)
        {
            string path = Path.Combine(liveRoot, deleteFile);

            if (File.Exists(path))
            {
                File.Delete(path);
            }

            yield return null;
        }

        // 第四步:最后才保存新的 Manifest
        // 注意:不能一开始就改 Manifest,否则中途失败会导致清单和文件不一致
        SaveLocalManifest(patch);

        Debug.Log("Patch 安装完成:" + patch.toVersion);
    }

    private void SaveLocalManifest(PatchManifest patch)
    {
        // 这里写保存本地 Manifest 的逻辑
        // 正式项目会保存完整资源清单,而不只是 PatchManifest
    }
}

Patch 选择策略

服务器可能提供多条路径:

1001 -> 1002
1002 -> 1003
1003 -> 1005

1001 -> 1005

full_1005

客户端可以选择:

如果本地版本离最新版本很近:
下载增量 Patch。

如果本地版本太旧:
下载全量包更稳。

如果增量 Patch 缺失或校验失败:
兜底下载全量包。

不要只追求最小包体。 有时候连续下 5 个小 Patch,比下一个全量包更容易失败。

Patch 包设计重点

c
1. fromVersion 和 toVersion 必须明确
否则客户端不知道这个包能不能用。

2. 平台必须区分
Android 和 iOS 的 AssetBundle 不能混用。

3. App 兼容版本必须检查
新资源如果依赖新底包,旧 App 不能直接用。

4. Patch 包要有整体 Hash
确认包本身没坏。

5. 每个文件也要有 Hash
确认解压出来的文件没坏。

6. deleteList 必须有
否则旧资源会越积越多。

7. 安装必须原子化
要么全部成功,要么回到旧版本。

8. 要有兜底全量包
增量坏了也能修复客户端。

常见坑

c
1. 直接覆盖正式文件。
下载中断会污染本地缓存。

2. 先更新 Manifest,再安装文件。
中途失败会导致清单是新的,文件是旧的。

3. 只做 Patch 包 Hash,不做文件 Hash。
解压后单个文件损坏发现不了。

4. 没有 deleteList。
旧资源越来越多,占满磁盘。

5. 没有回滚。
新 Patch 有问题,玩家启动不了。

6. 多平台共用 Patch。
Android / iOS / Windows 资源格式可能不同。

7. 版本跨度太大仍强行增量。
路径复杂,失败率更高,不如全量兜底。

面试高分回答

TIP

“Patch 包设计的核心是版本路径和原子安装。一个 Patch 包应该明确 fromVersiontoVersion,并包含新增和修改的资源文件、删除列表、每个文件的 Hash 和 Size、Patch 包整体 Hash、平台和 App 兼容版本。客户端启动时根据本地版本和服务器版本选择合适的 Patch 路径,版本太旧时可以直接下载全量包。安装时不能直接覆盖正式文件,而是先下载到临时目录,解压到 staging 目录,逐文件校验通过后再替换正式资源,最后才更新本地 Manifest。失败时不改 Manifest,继续使用旧资源,并支持回滚或全量包兜底。”

最短记忆版

c
Patch 包设计:

必须有:
fromVersion;
toVersion;
changedFiles;
deleteList;
hash;
size;
platform;
appMinVersion;
dependencies。

安装流程:
下载临时包;
校验 Patch;
解压 staging;
校验文件;
替换正式文件;
删除废弃文件;
最后更新 Manifest。

重点:
不要直接覆盖;
不要先改 Manifest;
失败要能回滚;
增量失败要有全量包兜底。

热更新失败如何回滚?

一句话回答: 热更新失败回滚的核心是:不要让坏包污染正式资源目录。下载和校验在临时目录完成,安装前备份旧版本,安装时写 updating 标记,启动成功后才写 confirmed;任何阶段失败,都恢复旧 Manifest 和旧资源。

hot-update-rollback

零基础理解

你可以把热更新想成“换发动机零件”。

错误做法是:

新零件还没检查,就直接拆旧零件。
结果新零件坏了,旧零件也没了,车启动不了。

正确做法是:

先把新零件放临时区
检查没问题
备份旧零件
替换新零件
启动确认成功
最后再清理旧零件

对应到 Unity 热更新就是:

tmp:
下载临时文件。

staging:
解压和校验目录。

live:
当前正式使用的资源目录。

backup:
旧版本备份目录。

失败分几种

第一种:下载失败。

网络断了
CDN 失败
下载一半

处理方式:

删掉 tmp
不动 live
继续使用旧资源
下次启动重试

第二种:校验失败。

size 不对
hash 不一致
签名不合法

处理方式:

拒绝安装
删掉 staging
不更新 Manifest
继续旧版本

第三种:安装中断。

替换文件替到一半崩溃
手机没电
磁盘空间不足
写文件失败

处理方式:

启动时发现 updating 标记
说明上次没安装完
用 backup 恢复旧文件和旧 Manifest

第四种:启动失败。

新资源依赖缺失
新 Lua 报错
新 DLL 不兼容
新 Bundle 加载失败

处理方式:

如果新版本还没 confirmed
就回滚到上一个稳定版本

关键设计:两阶段提交

热更新安装不要直接认为“文件复制完就成功”。 更稳的是分两个阶段:

阶段 1:安装中
写入 updating 标记。

阶段 2:启动验证成功
写入 confirmed 标记。

如果下次启动发现:

有 updating
没有 confirmed

说明上次更新没有真正成功,要回滚。

状态文件示例

c
{
  "currentVersion": "1001",
  "targetVersion": "1005",
  "state": "updating",
  "backupVersion": "1001"
}

成功后改成:

c
{
  "currentVersion": "1005",
  "targetVersion": "1005",
  "state": "confirmed",
  "backupVersion": "1001"
}

代码示例:更新状态

c
using System;
using System.IO;
using UnityEngine;

[Serializable]
public class UpdateState
{
    public string currentVersion;
    public string targetVersion;

    // idle / updating / confirmed
    public string state;

    public string backupVersion;
}

public class UpdateStateStore
{
    private readonly string statePath;

    public UpdateStateStore(string statePath)
    {
        this.statePath = statePath;
    }

    public void Save(UpdateState state)
    {
        // 把状态保存为 JSON
        string json = JsonUtility.ToJson(state, true);

        // 写入状态文件
        File.WriteAllText(statePath, json);
    }

    public UpdateState Load()
    {
        if (!File.Exists(statePath))
            return null;

        string json = File.ReadAllText(statePath);
        return JsonUtility.FromJson<UpdateState>(json);
    }
}

启动时先检查是否需要回滚

游戏启动很早的时候就应该检查:

c
上次是不是更新到一半?
上次新版本是不是没确认成功?
using UnityEngine;

public class HotUpdateBootChecker
{
    private readonly UpdateStateStore stateStore;
    private readonly HotUpdateRollback rollback;

    public HotUpdateBootChecker(UpdateStateStore stateStore, HotUpdateRollback rollback)
    {
        this.stateStore = stateStore;
        this.rollback = rollback;
    }

    public void CheckBeforeEnterGame()
    {
        UpdateState state = stateStore.Load();

        if (state == null)
            return;

        // 如果上次处于 updating,说明安装没有完整确认成功
        if (state.state == "updating")
        {
            Debug.LogWarning("检测到上次热更新未完成,开始回滚");

            // 回滚到备份版本
            rollback.RollbackToBackup(state.backupVersion);

            return;
        }

        // confirmed 表示上次版本已经确认成功
        Debug.Log("当前热更新状态正常:" + state.currentVersion);
    }
}

代码示例:回滚恢复

c
using System.IO;
using UnityEngine;

public class HotUpdateRollback
{
    private readonly string liveRoot;
    private readonly string backupRoot;
    private readonly string manifestPath;

    public HotUpdateRollback(string liveRoot, string backupRoot, string manifestPath)
    {
        this.liveRoot = liveRoot;
        this.backupRoot = backupRoot;
        this.manifestPath = manifestPath;
    }

    public void RollbackToBackup(string backupVersion)
    {
        string versionBackupRoot = Path.Combine(backupRoot, backupVersion);

        if (!Directory.Exists(versionBackupRoot))
        {
            Debug.LogError("找不到备份目录,无法回滚:" + versionBackupRoot);
            return;
        }

        // 恢复备份文件
        CopyDirectory(versionBackupRoot, liveRoot);

        // 恢复旧 Manifest
        string backupManifest = Path.Combine(versionBackupRoot, "manifest.json");

        if (File.Exists(backupManifest))
        {
            File.Copy(backupManifest, manifestPath, true);
        }

        Debug.Log("回滚完成,恢复到版本:" + backupVersion);
    }

    private void CopyDirectory(string sourceDir, string targetDir)
    {
        if (!Directory.Exists(targetDir))
        {
            Directory.CreateDirectory(targetDir);
        }

        foreach (string sourceFile in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
        {
            // 计算相对路径
            string relativePath = sourceFile.Substring(sourceDir.Length + 1);

            string targetFile = Path.Combine(targetDir, relativePath);
            string targetFolder = Path.GetDirectoryName(targetFile);

            if (!Directory.Exists(targetFolder))
            {
                Directory.CreateDirectory(targetFolder);
            }

            // 覆盖恢复旧文件
            File.Copy(sourceFile, targetFile, true);
        }
    }
}

安装 Patch 前怎么备份

不一定要备份整个资源目录,空间太大。 实际项目常见两种策略:

策略 1:
备份完整上一个版本,最稳,但占空间。

策略 2:
只备份这次会被替换或删除的文件,加上旧 Manifest,省空间。

示例思路:

c
using System.Collections.Generic;
using System.IO;

public class PatchBackup
{
    public void BackupFiles(
        string liveRoot,
        string backupRoot,
        string manifestPath,
        List<string> filesWillChange)
    {
        if (!Directory.Exists(backupRoot))
        {
            Directory.CreateDirectory(backupRoot);
        }

        // 先备份旧 Manifest
        if (File.Exists(manifestPath))
        {
            File.Copy(
                manifestPath,
                Path.Combine(backupRoot, "manifest.json"),
                true
            );
        }

        // 只备份本次会被替换或删除的文件
        foreach (string relativePath in filesWillChange)
        {
            string sourcePath = Path.Combine(liveRoot, relativePath);

            if (!File.Exists(sourcePath))
                continue;

            string targetPath = Path.Combine(backupRoot, relativePath);
            string targetDir = Path.GetDirectoryName(targetPath);

            if (!Directory.Exists(targetDir))
            {
                Directory.CreateDirectory(targetDir);
            }

            File.Copy(sourcePath, targetPath, true);
        }
    }
}

什么时候写 confirmed

不要一安装完就马上清备份。 更稳的是:

安装完成
进入游戏启动流程
加载关键资源
加载核心配置
Lua / DLL 初始化成功
主界面或登录界面正常出现
再写 confirmed

示例:

c
public class HotUpdateConfirm
{
    private readonly UpdateStateStore stateStore;

    public HotUpdateConfirm(UpdateStateStore stateStore)
    {
        this.stateStore = stateStore;
    }

    public void ConfirmVersion(string version)
    {
        // 只有核心流程跑通以后,才确认新版本可用
        UpdateState state = new UpdateState
        {
            currentVersion = version,
            targetVersion = version,
            state = "confirmed",
            backupVersion = ""
        };

        stateStore.Save(state);
    }
}

服务器也要支持回滚

客户端回滚只能救单个玩家。 如果新版本资源已经大面积出问题,服务器要能:

把远程 Manifest 指回旧稳定版本
下架有问题的 Patch
发布修复 Patch
让客户端下次拉清单时回到稳定版本

比如:

最新版本 1005 出问题
服务器把推荐版本改回 1004
客户端启动拉到 1004 清单
发现本地 1005 不在稳定列表
执行降级或重新下载 1004 全量包

常见坑

1. 直接覆盖 live 目录。
更新失败后没有干净旧版本。

2. 先写新 Manifest,再替换文件。
中途失败会导致清单和文件不一致。

3. 没有 updating 标记。
启动时不知道上次是否安装到一半。

4. 安装完成立刻删除 backup。
结果新资源启动失败,没法回滚。

5. 只回滚资源,不回滚 Manifest。
下次启动仍然以为自己是新版本。

6. 不做远程回滚。
线上出问题只能让玩家等新包,非常被动。

7. 没有全量包兜底。
Patch 损坏或版本跨度太大时,客户端无法自修复。

面试高分回答

WARNING

“热更新回滚要把下载、校验、安装、启动验证分阶段处理。下载和解压都在临时目录完成,Hash、Size、签名全部校验通过后才进入安装阶段。安装前备份旧 Manifest 和本次会被替换或删除的文件,然后写入 updating 状态,再把 staging 文件替换到 live 目录。新版本不能安装完成就立即认为成功,而是要等下一次启动核心资源、配置、Lua 或热更 DLL 初始化成功后,再写 confirmed 标记并清理备份。如果启动时发现存在 updating 但没有 confirmed,说明上次更新失败,就用 backup 恢复旧文件和旧 Manifest。服务器侧也要支持下架错误 Patch、回滚远程 Manifest 或提供全量包兜底。”

最短记忆版

c
热更失败回滚:

下载失败:
删 tmp,不动 live。

校验失败:
拒绝安装,不更新 Manifest。

安装失败:
检测 updating,用 backup 恢复。

启动失败:
没有 confirmed,就回滚旧版本。

关键机制:
tmp / staging / live / backup;
updating / confirmed;
旧 Manifest 备份;
原子替换;
全量包兜底;
服务器远程回滚。

首包和热更包怎么拆?

一句话回答: 首包和热更包的拆分原则是:首包只放启动、登录、更新器、首体验和兜底必需资源;热更包放大资源、常变资源、低频资源、活动资源和按需玩法资源;公共依赖单独抽 common 包。

initial-vs-hot-update-package-split

零基础理解

你可以把首包理解成“玩家下载安装时必须带上的行李”。

首包太大:

下载慢
安装慢
商店审核压力大
玩家流失

首包太小:

游戏启动不了
登录界面缺图
热更新器自己都跑不起来
断网没有兜底

所以首包不是越小越好,而是:

小,但必须能启动;
小,但必须能修复自己;
小,但必须能完成第一体验。

热更包就是:

玩家安装后,再根据版本、玩法、活动、场景按需下载的资源。

首包应该放什么

首包通常放:

启动场景
Loading 场景
登录界面
更新界面
错误提示界面
网络请求基础代码
热更新框架
资源管理框架
基础字体
基础图集
基础 Shader
默认配置
兜底资源
新手第一段必须使用的资源

重点是:热更新系统本身依赖的资源,必须在首包里。

比如:

更新进度条图片
更新失败弹窗
重试按钮
基础字体
网络错误提示

这些不能放热更包里,因为还没更新成功时你就需要它们。

热更包应该放什么

热更包适合放:

活动资源
新角色
新皮肤
大语音包
高清贴图
大地图块
副本资源
商城资源
抽卡资源
低频 UI
节日 UI
后续章节
多语言语音
可选高清资源

这些资源有几个特点:

体积大
不是启动必需
不是所有玩家都会用
经常变化
需要线上更新
可以失败重试
可以按需下载

比如:

春节活动资源,不应该塞进全年版本首包。

一个常见拆包结构

可以这样拆:

c
base
启动、登录、更新器、Loading、基础字体、基础 Shader。

common_ui
通用按钮、弹窗底图、金币图标、品质框、公共字体。

common_shader
常用 Shader、公共材质。

ui_bag
背包界面资源。

ui_shop
商城界面资源。

battle_common
战斗公共特效、战斗 UI、通用音效。

role_knight
骑士角色模型、贴图、动画。

city_main
主城资源。

dungeon_001
副本 1 资源。

activity_summer
夏日活动资源。

voice_cn
中文语音包。

hd_texture
高清资源包。

判断资源放首包还是热更包

可以按 5 个问题判断。

1. 没有它游戏能启动吗?
不能:放首包。

2. 没有它能登录和显示更新界面吗?
不能:放首包。

3. 新玩家第一段流程必须用吗?
必须用:考虑放首包或登录前预下载。

4. 它体积大吗?
大且非必需:放热更。

5. 它经常变化吗?
经常变化:放热更。

一句很实用的话:

启动必需进首包,体验必需可预置,玩法资源按需下,活动资源热更下。

下载策略怎么配

不是所有热更包都一样处理。

可以分成:

强制更新:
不下不能进游戏,比如核心配置、公共资源、热更代码。

登录前预下载:
登录后马上要用,比如主界面、基础 UI、主城资源。

进入前下载:
点开某个玩法前下载,比如副本、活动、商城高清资源。

后台预下载:
玩家在主界面停留时悄悄下,比如后续地图、语音包。

可选下载:
高清材质包、多语言语音包。

代码示例:用配置描述拆包策略

c
using System;
using UnityEngine;

public enum PackageLocation
{
    BuiltIn,     // 首包内置
    Remote      // 热更远程下载
}

public enum DownloadPolicy
{
    RequiredBeforeLogin,   // 登录前必须下载
    RequiredBeforeEnter,   // 进入玩法前下载
    BackgroundPreload,     // 后台预下载
    Optional               // 可选下载
}

[Serializable]
public class ResourcePackageConfig
{
    // 包名,比如 common_ui、activity_summer
    public string packageName;

    // 是首包内置,还是远程热更
    public PackageLocation location;

    // 下载策略
    public DownloadPolicy downloadPolicy;

    // 是否是公共依赖
    public bool isCommonDependency;

    // 包大小,用于更新界面展示
    public long size;

    // 资源版本
    public string version;

    // Hash 用于校验
    public string hash;
}

使用时可以这样判断:

c
using UnityEngine;

public static class PackageDecisionHelper
{
    public static bool NeedDownloadBeforeLogin(ResourcePackageConfig config)
    {
        // 首包内置资源不需要下载
        if (config.location == PackageLocation.BuiltIn)
            return false;

        // 登录前必须下载的热更包
        return config.downloadPolicy == DownloadPolicy.RequiredBeforeLogin;
    }

    public static bool CanDownloadWhenEnterFeature(ResourcePackageConfig config)
    {
        // 某些玩法资源可以在玩家点击进入时再下载
        return config.downloadPolicy == DownloadPolicy.RequiredBeforeEnter;
    }

    public static bool IsOptionalPackage(ResourcePackageConfig config)
    {
        // 比如高清材质、多语言语音
        return config.downloadPolicy == DownloadPolicy.Optional;
    }
}

Addressables 项目怎么拆

如果用 Addressables,可以按 Group 拆:

c
BuiltIn_Base
首包资源,Build Path 和 Load Path 指向本地。

Remote_Common
公共远程资源。

Remote_UI_Bag
背包远程资源。

Remote_UI_Shop
商城远程资源。

Remote_Battle
战斗远程资源。

Remote_Activity_Summer
活动远程资源。

基本原则:

c
首包 Group:
启动必须用,体积小,稳定。

远程 Group:
体积大,常变化,按需下载。

公共 Group:
多个业务都依赖,避免重复打包。

公共资源一定要单独拆

比如:

c
背包 UI 用 coin.png
商城 UI 也用 coin.png
任务 UI 也用 coin.png

如果 coin.png 没有放公共包,可能会被多个业务包重复打包。

正确做法:

c
coin.png -> common_ui
bag_panel -> ui_bag
shop_panel -> ui_shop

这样:

c
ui_bag 依赖 common_ui
ui_shop 依赖 common_ui

减少重复下载和重复占用。

常见坑

1. 首包太大。
玩家下载慢,转化率低。

2. 首包太小。
更新器、登录、错误页缺资源,断网直接黑屏。

3. 公共依赖没抽出来。
多个热更包重复包含同一张图或同一个材质。

4. 所有资源一个大热更包。
只更新一个活动,也要下载几百 MB。

5. 拆得太碎。
Bundle 太多,请求太多,加载管理复杂。

6. 活动资源放首包。
活动过期后仍然占玩家安装包体积。

7. 没有兜底资源。
热更失败时无法显示错误提示和重试按钮。

面试高分回答

TIP

“首包和热更包拆分要围绕启动链路、首体验、资源体积、更新频率和复用关系来设计。首包只放启动、登录、Loading、更新器、基础字体、基础 Shader、兜底 UI、默认配置和新手初期必需资源,保证客户端即使没有热更资源也能启动、显示更新界面和进行失败重试。热更包则放体积大、变化频繁、低频使用或活动相关资源,比如角色、皮肤、语音、地图块、副本、活动 UI 等。多个模块共用的图标、字体、材质、Shader 要抽到 common 包,避免重复打包。下载策略上可以分为登录前强制下载、进入玩法前下载、后台预加载和可选下载。版本太旧或增量失败时要有全量包兜底。”

最短记忆版

c
首包放:
启动、登录、Loading、更新器、基础字体、基础 Shader、兜底 UI、默认配置、首体验必需资源。

热更包放:
大资源、常变资源、低频资源、活动资源、角色皮肤、语音、地图、副本。

公共包放:
多个模块共用的图标、字体、材质、Shader。

下载策略:
登录前强制;
进入前下载;
后台预下载;
可选下载。

核心原则:
首包保启动和自修复;
热更包保灵活和小首包;
common 包避免重复打包。

线上热更要注意哪些风险?

一句话回答: 线上热更最大的风险不是“下不下来”,而是:下错包、半包、坏包、版本不兼容、安装一半崩、没有灰度、没有回滚,最后把线上玩家带进不可恢复状态。

online-hot-update-risks

零基础理解

热更像给已经开出去的车“空中换零件”。 所以不能只考虑:

我能不能下载资源?

更要考虑:

下载坏了怎么办?
版本不匹配怎么办?
安装到一半崩了怎么办?
新资源导致启动失败怎么办?
推给所有玩家后出事故怎么办?

风险 1:版本兼容风险

比如热更资源依赖新底包:

Hotfix.dll 调用了 C# 新接口
Lua 调用了新 UI API
Prefab 挂了新脚本
资源依赖新 Shader

但玩家 App 还是旧版本,就可能崩。

所以 Manifest 里要有:

c
appMinVersion
platform
channel
resVersion

示例判断:

c
public static bool CanUseRemoteManifest(
    string currentAppVersion,
    string currentPlatform,
    string manifestMinAppVersion,
    string manifestPlatform)
{
    // 平台不一致,绝对不能用
    if (currentPlatform != manifestPlatform)
        return false;

    // 当前 App 版本低于资源要求,不能热更
    // 正式项目不要直接字符串比较,要用语义化版本比较
    if (string.Compare(currentAppVersion, manifestMinAppVersion) < 0)
        return false;

    return true;
}

风险 2:包完整性风险

下载可能坏:

断网
下载一半
CDN 缓存旧文件
磁盘写入失败
文件被篡改

所以要校验:

c
Size
Hash / MD5 / SHA
签名

不要直接下载完就用。

c
public static bool IsFileValid(string filePath, long expectedSize, string expectedHash)
{
    // 先检查文件是否存在
    if (!System.IO.File.Exists(filePath))
        return false;

    var info = new System.IO.FileInfo(filePath);

    // 先比大小,便宜又快
    if (info.Length != expectedSize)
        return false;

    // 再比 Hash,确认内容真的一致
    string actualHash = FileHashUtility.CalculateMD5(filePath);

    return actualHash == expectedHash;
}

风险 3:半安装风险

最危险的做法是:

边下载边覆盖正式资源

如果中途崩溃,就会出现:

Manifest 是新的
部分资源是新的
部分资源还是旧的
客户端状态混乱

正确做法:

tmp 下载
staging 解压和校验
backup 备份旧资源
live 正式资源目录

最后再原子替换。

风险 4:没有回滚

上线一定要假设新包可能有问题。

至少要支持:

客户端本地回滚:
安装失败或启动失败时恢复旧 Manifest 和旧资源。

服务器远程回滚:
把远程清单指回上一个稳定版本。

全量包兜底:
增量 Patch 异常时,重新下载完整稳定资源。

记住:

没有回滚的热更,是把风险推给玩家。

风险 5:灰度发布风险

不要一上来推给 100% 玩家。

比较稳的流程:

内部测试
小渠道测试
1% 灰度
10% 灰度
50% 灰度
100% 全量

每一步看指标:

启动成功率
更新失败率
下载失败率
崩溃率
登录成功率
关键资源加载失败率
Lua / DLL 异常率

如果异常升高,立刻停。

风险 6:安全风险

热更尤其是代码热更风险更高。

要注意:

HTTPS
Hash 校验
签名校验
白名单域名
防篡改
防中间人攻击
错误上报
敏感逻辑不要完全信任客户端

资源包坏了可能只是贴图丢失。 热更代码坏了可能直接导致:

游戏进不去
逻辑异常
奖励漏洞
安全风险

风险 7:依赖不完整

比如你只更新了:

c
ActivityPanel.prefab

但漏了:

c
activity_icon.png
activity_font.asset
activity_shader
common_ui.ab

结果就是:

c
图片丢失
字体丢失
材质变粉
Bundle 加载失败

Manifest 里要记录依赖,下载时要确保依赖完整。

风险 8:磁盘和弱网风险

玩家可能:

空间不足
网络很差
下载中断
切后台
杀进程
没 Wi-Fi

所以要有:

下载前检查剩余空间
显示下载大小
失败重试
断点续传
后台恢复
临时文件清理
弱网提示

风险 9:体验风险

热更包太大会让玩家烦。

要避免:

一进游戏就强制下载几 GB
进度条不动
失败没有重试
没有剩余大小提示
没有 Wi-Fi 提示
下载完还卡很久

更好的策略:

核心资源登录前强更
玩法资源进入前下载
大资源后台预下载
高清包可选下载

风险 10:平台和合规风险

不要把平台规则当小事。 资源热更和代码热更都要注意平台要求、包审核规则、隐私和安全边界。

面试里可以保守地说:

资源热更和代码热更都要遵守平台政策,尤其代码热更要更谨慎,不能绕过审核做违规能力下发。

一个比较稳的线上热更流程

1. 构建资源包和 Manifest。
2. 自动化检查依赖、Hash、大小、平台。
3. 上传 CDN。
4. 先发布到测试环境。
5. 客户端拉远程 Manifest。
6. 检查 appVersion、platform、channel。
7. 计算下载列表。
8. 下载到 tmp。
9. 解压到 staging。
10. 校验每个文件。
11. 写 updating 状态。
12. 备份旧 Manifest 和被替换文件。
13. 替换到 live。
14. 启动验证成功后写 confirmed。
15. 灰度扩大。
16. 监控异常。
17. 出问题远程回滚或全量包兜底。

面试高分回答

IMPORTANT

“线上热更要重点控制版本兼容、包完整性、原子安装、回滚和灰度风险。Manifest 里必须区分平台、渠道、App 最低兼容版本、资源版本、Hash、Size 和依赖关系,避免旧 App 加载新资源。下载资源不能直接覆盖正式目录,而要先写临时文件,校验 Hash、Size 和签名后解压到 staging,再备份旧 Manifest 和被替换文件,最后原子替换到 live 目录。安装过程中要有 updating 状态,启动成功后才写 confirmed;如果下载、校验、安装或启动任一阶段失败,就回滚旧资源和旧 Manifest。发布时不能一次推全量,要灰度、监控启动成功率、崩溃率、下载失败率,并保留远程开关、回滚 Manifest 和全量包兜底。”

最短记忆版

c
线上热更风险:

版本:
App 版本、平台、渠道不匹配。

完整性:
半包、坏包、Hash 不一致。

安装:
Manifest 和文件不一致。

依赖:
只更新目标资源,漏了公共依赖。

安全:
资源或代码被篡改。

体验:
包太大、弱网失败、无重试。

发布:
没有灰度,没有监控,没有远程开关。

兜底:
没有回滚,没有全量包修复。

最打动面试官的一句:热更系统的价值不是“能更新”,而是“更新失败也不会把客户端搞坏,线上出事能快速止血”。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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