说真的,做游戏最让人头秃的事情之一是什么?不是写不出炫酷的特效,也不是调不通复杂的物理引擎,而是——上线后发现了一个严重的Bug,或者想加个新活动,结果还得等应用商店审核那几天?
想象一下这个场景:周五晚上你刚发版,周一早上运营跑过来说“那个充值按钮有点问题,今天得修”。如果你没有热更新,你就得改代码、打包、上传、审核、通过……这一套流程跑下来,黄花菜都凉了,玩家早就骂声一片退游了。
热更新,简单来说,就是让游戏在不重新安装、不经过应用商店审核的情况下,动态地替换或修复游戏内的内容。这对于手游,尤其是长线运营的游戏来说,简直就是救命稻草。
很多新手听到“热更新”就头疼,觉得这涉及底层、涉及IL2CPP、涉及复杂的插件。其实,只要思路理清了,从最简单的“读取本地资源”到复杂的“热修复C#代码逻辑”,完全是可以一步步实现的。今天我就把这套流程掰开了、揉碎了,用大白话带你走一遍。
第一步:理解核心逻辑——你的游戏到底在运行什么?
在动手写代码之前,你得先搞清楚,Unity的游戏运行时到底由哪几部分组成。这就好比一家餐厅,有固定的装修(预制体Prefab、贴图),有固定的菜单(脚本逻辑),还有每天采购的新鲜食材(数据文件)。
- 静态资源(Assets):模型、贴图、音频、预制体。这些文件太大了,而且变化相对没那么频繁,但一旦要变,就必须更新。
- 脚本逻辑(Code):C#代码。这是游戏的“大脑”,决定了玩法。
- 数据文件(Data):JSON、Excel解析后的数据。这是“食谱”,决定了数值和配置。
热更新的核心,就是动态地把服务器上的“新装修”、“新菜单”或“新食材”加载进来,覆盖掉本地的旧版本。
第二步:搭建最基础的资源热更框架
我们先不碰代码逻辑,先搞定最朴素的资源替换。这里我们用一个非常经典且低耦合的方案:Manifest + 增量下载。
虽然市面上有AssetBundle、Addressables等强大工具,但对于初学者,理解底层原理比直接套用插件更重要。我们先模拟一个“最小可行产品”。
2.1 资源的打包与Manifest生成
首先,你需要明白,Unity打包出来的AssetBundle(资源包)必须有一个“身份证”,告诉我们这个包里有什么文件,以及它的MD5值(用于校验是否被篡改)。
我们写一个编辑器脚本,用来打包资源并生成manifest.json。这个JSON文件就是我们的“目录”。
using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public class BuildAssetBundles {
// 简单的目录结构定义
[MenuItem("BuildTools/Build Asset Bundles")]
public static void BuildAB()
{
string targetPath = Application.streamingAssetsPath + "/AssetBundles";
if (!Directory.Exists(targetPath))
Directory.CreateDirectory(targetPath);
// 清除旧的缓存,确保重新打包
BuildPipeline.CleanAssetBundleBuild();
// 假设我们有两个资源:一个角色模型,一个UI背景
// 在真实项目中,这里应该通过API遍历所有设置了AssetBundleName的资源
List<AssetBundleBuild> builds = new List<AssetBundleBuild>();
// 示例:打包角色
builds.Add(new AssetBundleBuild {
assetNames = new string[] { "Assets/Characters/Hero.prefab" },
assetBundleName = "characters/hero.ab"
});
// 示例:打包UI背景
builds.Add(new AssetBundleBuild {
assetNames = new string[] { "Assets/UI/Background.png" },
assetBundleName = "ui/background.ab"
});
// 开始打包
BuildPipeline.BuildAssetBundles(targetPath, builds.ToArray(), BuildAssetBundleOptions.None, EditorUserBuildSettings.activeBuildTarget);
// 生成Manifest
GenerateManifest(targetPath, builds);
Debug.Log("打包完成,Manifest已生成!");
}
private static void GenerateManifest(string abPath, List<AssetBundleBuild> builds)
{
// 我们这里简化处理,实际项目中Manifest需要记录所有依赖关系
// 这里用一个简单的字典来模拟Manifest的结构
Dictionary<string, object> manifest = new Dictionary<string, object>();
List<object> entries = new List<object>();
foreach (var build in builds)
{
foreach (string assetName in build.assetNames)
{
// 计算MD5
string abFileName = build.assetBundleName + ".unity3d"; // Unity打包默认扩展名
string abFilePath = Path.Combine(abPath, abFileName);
string md5 = GetMD5(abFilePath);
// 构建条目
var entry = new Dictionary<string, string> {
{ "name", Path.GetFileName(assetName) },
{ "bundle", build.assetBundleName },
{ "hash", md5 },
{ "size", new FileInfo(abFilePath).Length.ToString() }
};
entries.Add(entry);
}
}
manifest.Add("version", "1.0");
manifest.Add("assets", entries);
string manifestJson = JsonUtility.ToJson(manifest, true);
File.WriteAllText(Path.Combine(abPath, "manifest.json"), manifestJson);
}
private static string GetMD5(string filePath)
{
using (MD5 md5 = MD5.Create())
{
using (FileStream stream = File.OpenRead(filePath))
{
byte[] hashBytes = md5.ComputeHash(stream);
StringBuilder sb = new StringBuilder();
foreach (byte b in hashBytes)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
}
}
关键点解析:
- MD5校验:这是热更新安全的核心。如果黑客修改了你的AB包,MD5对不上,游戏就不应该加载它。
- Manifest.json:这是客户端下载更新时的“导航图”。客户端下载这个JSON,对比本地MD5,决定哪些文件需要下载。
2.2 客户端的下载与更新逻辑
现在,假设服务器上已经有了新的manifest.json和新的AB包。客户端该怎么干活呢?
我们需要一个ResourceManager,它负责:
- 下载最新的Manifest。
- 对比本地资源MD5。
- 下载需要更新的AB包。
- 使用
AssetBundle.LoadFromFileAsync加载资源。
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using UnityEngine;
using UnityEngine.Networking; // 注意:在较新版本Unity中可能推荐使用UnityWebRequest
public class ResourceManager : MonoBehaviour
{
// 假设这是你服务器上的资源地址
[SerializeField] private string serverBaseUrl = "https://mygame.com/updates/";
[SerializeField] private string localPath = "AssetBundles";
// 记录已加载的Bundle,避免重复加载
private Dictionary<string, AssetBundle> _loadedBundles = new Dictionary<string, AssetBundle>();
public IEnumerator StartUpdateCheck()
{
// 1. 下载远程Manifest
using (UnityWebRequest webRequest = UnityWebRequest.Get(serverBaseUrl + "manifest.json"))
{
yield return webRequest.SendWebRequest();
if (webRequest.result != UnityWebRequest.Result.Success)
{
Debug.LogError("Manifest下载失败: " + webRequest.error);
yield break;
}
string remoteManifestText = webRequest.downloadHandler.text;
// 解析JSON获取资源列表 (这里简化,实际需用Newtonsoft.Json或JsonUtility反序列化)
var remoteEntries = ParseManifest(remoteManifestText);
// 2. 检查本地Manifest
string localManifestPath = Path.Combine(Application.streamingAssetsPath, localPath, "manifest.json");
var localEntries = new Dictionary<string, string>(); // key: 文件名, value: md5
if (File.Exists(localManifestPath))
{
localEntries = ParseManifest(File.ReadAllText(localManifestPath));
}
// 3. 比对差异,决定下载
foreach (var entry in remoteEntries)
{
string fileName = entry.name;
string remoteMd5 = entry.hash;
string localMd5 = localEntries.ContainsKey(fileName) ? localEntries[fileName] : "";
if (remoteMd5 != localMd5)
{
// 需要更新
yield return DownloadAndSaveAB(serverBaseUrl + entry.bundle + ".unity3d", localPath, entry.bundle);
}
}
// 更新本地Manifest
File.WriteAllText(Path.Combine(Application.streamingAssetsPath, localPath, "manifest.json"), remoteManifestText);
Debug.Log("资源更新检查完成");
}
}
// 加载资源的通用方法
public IEnumerator LoadAssetAsync<T>(string bundleName, string assetName, System.Action<T> onLoadComplete) where T : Object
{
string bundlePath = Path.Combine(Application.streamingAssetsPath, localPath, bundleName + ".unity3d");
// 如果之前加载过,直接复用
if (!_loadedBundles.TryGetValue(bundleName, out AssetBundle bundle))
{
using (UnityWebRequest www = UnityWebRequestAssetBundle.GetAssetBundle(bundlePath))
{
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError(www.error);
yield break;
}
bundle = DownloadHandlerAssetBundle.GetContent(www);
_loadedBundles.Add(bundleName, bundle);
}
}
// 异步加载具体资源
var request = bundle.LoadAssetAsync<T>(assetName);
yield return request;
onLoadComplete?.Invoke(request.result);
}
// 简单的解析辅助函数 (实际项目中请用专业的JSON解析库)
private Dictionary<string, string> ParseManifest(string json)
{
// 这里为了演示,手动解析是非常繁琐的。
// 实际上,建议引入 Newtonsoft.Json 来解析 manifest.json 中的 assets 数组
// 伪代码逻辑:遍历json,提取每个asset的name和hash
return new Dictionary<string, string>();
}
private IEnumerator DownloadAndSaveAB(string url, string relativePath, string bundleName)
{
string fullPath = Path.Combine(Path.Combine(Application.streamingAssetsPath, relativePath), bundleName + ".unity3d");
// 注意:StreamingAssets在Android/iOS上是只读的或者打包在apk里了
// 生产环境中,通常下载到 persistentDataPath (沙盒路径) 进行读写
// 这里为了演示简单,假设我们直接覆盖StreamingAssets (仅在编辑器模拟有效)
// 真实移动端请务必下载到 Application.persistentDataPath
using (UnityWebRequest www = UnityWebRequest.Get(url))
{
yield return www.SendWebRequest();
if (www.result == UnityWebRequest.Result.Success)
{
byte[] results = www.downloadHandler.data;
File.WriteAllBytes(fullPath, results);
Debug.Log($"下载并保存成功: {bundleName}");
}
}
}
}
这里有一个新手必踩的坑:
在Android和iOS上,Application.streamingAssetsPath是只读的(或者说,在手机上它指向的是APK内部的一个虚拟路径,你无法直接写入)。所以,真正的工程实践中,你必须将AB包下载到Application.persistentDataPath,然后从那里加载。上面的代码为了易懂做了简化,但在实战中,路径处理是第一步要解决的。
第三步:进阶——代码热修复(Hotfix)
资源热更解决了美术和资源的问题,但逻辑Bug(比如伤害算错了、状态机卡住了)怎么修?C#代码编译后是DLL,没法直接替换。
这时候,我们需要引入一个神器:DOTween 或者更底层的 Patching技术。但对于零基础且想深入理解原理的同学,我推荐先看一个更轻量级的概念——ScriptableObject热更,或者使用业界成熟的方案如Unity官方推荐的ILRuntime,或者腾讯的TUA。
不过,为了不让你一开始就被复杂的框架吓跑,我们先讲一个“概念级”的代码热更思路,这在很多小型独立游戏中非常有效。
3.1 方案一:ScriptableObject作为逻辑配置(适合数据驱动逻辑)
如果你的Bug是“数值错了”或者“某个行为参数错了”,你不需要重编代码,只需要重编数据。
比如,英雄的攻击力配置在AssetDatabase里:
[CreateAssetMenu(fileName = "HeroData", menuName = "Game/HeroData")]
public class HeroData : ScriptableObject
{
public int attackPower = 100;
public string skillName = "Fireball";
}
打包时,把这个ScriptableObject序列化成一个JSON文件放在服务器上。游戏启动时,去服务器下载这个JSON,反序列化后覆盖本地的HeroData对象中的字段。
public class DataHotfixManager {
public void ApplyPatch(string patchJson) {
var patch = JsonUtility.FromJson<HeroPatch>(patchJson);
if (patch == null) return;
// 获取本地的HeroData实例
var heroData = Resources.Load<HeroData>("HeroData");
if (heroData == null) return;
// 动态修改数据字段
if (patch.newAttackPower > 0)
heroData.attackPower = patch.newAttackPower;
if (!string.IsNullOrEmpty(patch.newSkillName))
heroData.skillName = patch.newSkillName;
Debug.Log("数据热更成功,攻击力变为: " + heroData.attackPower);
}
}
[System.Serializable]
public class HeroPatch {
public int newAttackPower;
public string newSkillName;
}
这种方法虽然不能修复代码逻辑错误,但能解决90%的“数值策划填错了”或者“活动参数临时调整”的需求,而且完全不需要重新上架。
3.2 方案二:使用 ILRuntime 实现真正的C#代码热更
如果你真的需要修改C#逻辑(比如修复了一个死循环,或者改了一个判断条件),就需要ILRuntime。
ILRuntime 是一个在C#环境下实现的CLR(公共语言运行时)。简单说,它不运行Unity编译出来的原生DLL,而是运行一个独立的、可以通过网络下载的DLL。
为什么要这样做? 因为Unity打包APP时,C#代码会被编译成IL(中间语言),再被AOT(提前编译)成原生代码(尤其是IL2CPP模式下)。原生代码是改不了的。但是,ILRuntime通过模拟一个CLR环境,运行的是解释执行的IL代码。这意味着,你可以随时下载一个新的DLL,扔进这个模拟器里跑,而不需要重新编译原生代码。
实战流程:
- 新建一个C# Class Library项目(注意:不是Unity项目,是普通的.NET类库)。
- 在这个类库里写你的核心逻辑代码。
- 打包成DLL。
- 在Unity项目中引用ILRuntime插件。
- 在Unity中加载这个DLL,并调用其中的逻辑。
当发现Bug时:
- 修改那个C# Class Library项目里的代码。
- 重新打包成DLL(假设版本号为v2)。
- 上传到服务器。
- 游戏内检测到有新DLL,下载下来,热替换掉旧的CLR实例。
- Bug修复,无需重新上架。
虽然听起来很完美,但ILRuntime也有局限:
- 性能开销:比原生代码慢,因为它是解释执行的。
- 兼容性:某些底层API(如直接操作内存、特定硬件驱动)可能无法在ILRuntime中运行。
- 维护成本:你需要维护两个项目(Unity项目 + C#逻辑库项目)。
对于零基础开发者,我建议:先用资源热更+ScriptableObject数据热更覆盖90%的场景。只有当逻辑Bug确实无法通过数据配置规避,且对性能要求不那么极端时,再引入ILRuntime或类似的AOT方案。
第四步:版本管理与兼容性——别把玩家坑了
热更新最可怕的不是Bug,而是更新后游戏跑不起来了。比如,你更新了AB包,里面的资源版本是新的,但本地代码版本是旧的,导致资源加载报错。
为了解决这个问题,你必须建立严格的版本控制系统。
4.1 版本号策略
你需要维护两个版本号:
- AppVersion:应用商店的版本号(如 1.0.0)。这个通常随着上架更新而变。
- HotfixVersion:热更版本号(如 1.0.1-hotfix.1)。这个可以在内部快速迭代。
在你的manifest.json里,除了记录资源的MD5,还要记录一个全局的hotfix_version。
{
"version": "1.0.1-hotfix.1",
"assets": [...]
}
4.2 兼容性检测
在玩家启动游戏时,你的ResourceManager不仅要检查资源MD5,还要检查hotfix_version。
逻辑如下:
- Case A: 客户端版本号 < 服务器版本号。-> 触发完整更新流程(下载所有差异资源)。
- Case B: 客户端版本号 == 服务器版本号。-> 正常游戏。
- Case C: 客户端版本号 > 服务器版本号(理论上不会发生,除非玩家作弊或你搞错了)。-> 强制报错,禁止进入游戏。
此外,还要考虑旧版本客户端的兼容。如果你更新了一个新的UI特效AB包,里面的Shader是新的,但老版本玩家手机里的Shader还在用旧的,会报错。 解决办法:灰度更新和兼容性标记。在Manifest里为每个资源标记支持的最低客户端版本。如果检测到客户端版本过低,就提供一份“兼容包”或者提示玩家升级游戏。
第五步:给你的实战 checklist
好了,理论讲完了
