哎,先别急着划走。我知道你现在的痛点:游戏上线了,突然发现一个严重的Bug,或者有个新的活动玩法想马上推送,但应用商店的审核周期漫长得让人想哭。等审核通过,黄花菜都凉了。
热更新(Hot Update)就是来救火的。它允许你在不经过应用商店审核的情况下,直接替换或修改游戏内的资源、脚本甚至部分逻辑。
作为一个在这个坑里摔过无数次、也救过无数次火的“老玩家”,我今天的分享不整那些虚头巴脑的理论,咱们直接上干货,从原理到代码,再到那些让人头秃的坑,咱们一个个掰开揉碎了讲。
一、 热更新的核心原理:我们到底在改什么?
在动手之前,你得先搞清楚“热更”改的到底是什么。很多新手以为热更能改一切,那是误会大了。
1.1 热更的“三六九等”
热更新通常分为三个层级,难度和风险由低到高:
层级一:纯资源替换
- 内容:图片、音频、3D模型、UI配置、文本语言包。
- 原理:游戏启动时,优先从本地缓存加载资源,如果没有再下载。
- 难度:⭐
- 风险:极低,因为不涉及代码逻辑,审核机构通常睁一只眼闭一只眼(尤其是iOS,只要你不把“付费解锁”这种敏感逻辑热更进去)。
- 适用场景:修正错别字、更换Banner图、调整配色、更新剧情文案。
层级二:脚本语言热更(Lua/JavaScript/TypeScript)
- 内容:游戏的逻辑代码。
- 原理:宿主语言(C#/C++)编译成二进制,无法修改;但脚本语言是解释执行的,可以动态加载。
- 难度:⭐⭐⭐
- 风险:中等。iOS对Lua热更相对宽松(只要不被检测到有诱导付费等违规内容),但Android没问题。
- 适用场景:修复Bug、调整数值平衡、新增活动玩法逻辑。
- 主流方案:Cocos2d-x (Lua),Egret (TypeScript),Unity (ILRuntime/HybridCLR),Cocos Creator (TypeScript/Lua)。
层级三:原生代码热更(Native Code)
- 内容:C/C++/ObjC/Swift代码。
- 原理:动态链接库(.so/.dylib)替换。
- 难度:⭐⭐⭐⭐⭐
- 风险:极高。iOS几乎不可能绕过苹果的二进制校验和代码签名机制。Android虽然技术上可行,但各大厂商(小米、华为、OV)的安全加固和Play保护机制非常严格,很容易被查杀。
- 结论:别碰这条线,除非你是做私服或者你愿意和苹果/谷歌的法务部打交道。
1.2 双端差异:为什么iOS和Android要分开说?
- Android:生态开放,允许侧载、允许动态代码执行。只要你别搞恶意软件,玩家和审核都比较宽容。重点是兼容性和下载体验。
- iOS:生态封闭,App Store有严格的《App Store审查指南》。Apple明确禁止“修改应用主要功能或核心体验的代码热更”。但是!他们允许数据更新(如游戏配置、关卡、非核心脚本)。
- 关键点:iOS热更的精髓是“伪装”。你不能让苹果一眼看出你在热更核心代码。通常的做法是将Lua脚本打包成资源文件,或者使用一些被“默认允许”的解释器。
二、 技术选型:先选对工具,再谈开发
在开始写代码之前,你必须确定你的游戏引擎。不同引擎的热更方案天差地别。
2.1 Unity引擎
Unity是最热门的游戏引擎,但其C#代码编译成IL后,热更困难。目前主流方案:
- ILRuntime:纯C#实现,支持iOS AOT限制,可以在运行时加载DLL。适合逻辑层热更。
- HybridCLR:ILRuntime的升级版,支持全量AOT,性能更好,是目前Unity热更的标杆。
- AssetBundle:用于资源热更。
2.2 Cocos Creator
国内手游大量使用Cocos,它对TypeScript和Lua支持很好。
- TypeScript热更:Cocos Creator内置了资源包机制,可以将TS代码打包成
bundle,运行时动态加载。 - Lua热更:使用
Cocos-Play或Quick-Cocos2d-x(老旧方案,不推荐新人用)。
2.3 自研引擎 / C++
如果你用的是自研引擎,那你基本得自己造轮子,或者集成OLUA、Sol2等脚本绑定库。
假设我们以最通用的场景为例:一个基于Cocos Creator 3.x(TypeScript)或Unity + HybridCLR的项目。为了演示清晰,我将重点讲解通用架构,并用伪代码+具体语言示例结合的方式,让你能看懂核心逻辑。
三、 热更新架构设计:五大模块缺一不可
无论什么引擎,一个健壮的热更新系统都必须包含以下五个部分。少一个,上线必出乱子。
- 版本控制模块:决定“要不要更新”、“更新什么”。
- 资源下载模块:决定“怎么下载”、“下载哪里”、“断点续传”。
- 校验模块:决定“文件有没有坏”、“是不是被篡改了”。
- 热更执行模块:决定“怎么应用更新”、“回滚机制”。
- 后台管理后台:决定“怎么发布版本”、“怎么统计成功率”。
3.1 版本控制:manifest.json 是核心
热更的本质是增量更新。我们不会每次都下载整个游戏包,只会下载变化的部分。
我们需要维护一个manifest.json,它描述了当前版本的资源列表、哈希值(Hash)和大小。
{
"app": {
"version": "1.0.2",
"assets": {
"bundle/game/main.bundle": {
"md5": "a1b2c3d4e5f6...",
"compressed": false,
"size": 1024000
},
"bundle/game/config.lua": {
"md5": "f6e5d4c3b2a1...",
"compressed": true,
"size": 51200
}
},
"packageUrl": "https://cdn.yourserver.com/hotfix/1.0.2/"
}
}
逻辑流程:
- 本地读取已有的
manifest.json。 - 从服务器拉取最新的
version.json(只包含版本号和一个指向最新manifest的URL,减少服务器开销)。 - 对比版本:
localVer < serverVer时才需要更新。 - 如果版本相同,但
assets里的md5变了,说明有小版本热更,进入文件比对阶段。 - 生成差异列表:本地有但服务器没有的(删除),服务器有但本地没有或md5不同的(下载)。
3.2 下载模块:多线程与断点续传
这是最容易崩的地方。想象一下,玩家在网络不稳定的4G环境下下载一个50MB的bundle,下载到90%失败了,怎么办?
- 不要重新下:使用断点续传。
- 不要阻塞主线程:必须用异步下载,并且要有进度回调,否则游戏卡死,玩家会以为崩了然后卸载。
Android端特别注意:Android 10及以上版本限制了外部存储访问,下载目录必须使用getExternalFilesDir()或getExternalCacheDir()。
iOS端特别注意:iOS的沙盒机制严格,下载文件必须先下载到临时目录,下载完成后必须移动到持久化目录(如Library/Caches),否则重启后文件丢失。
3.3 校验模块:MD5是底线,RSA是加分项
光有MD5够吗?不够。黑客可以篡改你的服务器返回的manifest.json,把md5指向一个恶意文件。
最佳实践:数字签名
- 服务端生成
manifest.json时,用私钥对文件内容签名,生成signature字段。 - 客户端内置公钥,验证签名。
- 验证通过后,再对比每个文件的MD5。
// TypeScript 伪代码示例:验证流程
async function checkHotUpdate(manifestUrl: string, publicKey: string): Promise<HotUpdateResult> {
const manifest = await download(manifestUrl);
// 1. 验证签名
const isValid = await verifySignature(manifest.signature, manifest.content, publicKey);
if (!isValid) {
throw new Error("Manifest tampered! Security risk.");
}
// 2. 比对版本
if (manifest.version <= localVersion) {
return { needUpdate: false };
}
// 3. 生成差异列表
const diffs = calculateDiff(localManifest, manifest);
return { needUpdate: true, diffs: diffs, newVersion: manifest.version };
}
3.4 热更执行模块:重启是必要的
这是新手最容易忽略的:代码热更,必须重启游戏才能生效!
为什么?因为引擎已经运行了,旧的代码已经在内存里了。你下载了新代码,如果不重启,新代码不会自动加载。
执行流程:
- 下载完成。
- 解压资源(如果是zip格式)。
- 校验解压后的文件MD5。
- 替换本地版本标记。
- 弹出提示框:“更新完成,是否立即重启?”
- 用户点击“确定” -> 调用
Application.Quit()(Unity) 或cc.game.end()(Cocos)。 - 下次启动时,从新的热更目录加载资源。
iOS的特殊处理:
iOS不允许在App内直接替换.app包的内容。所以,iOS的热更资源必须放在Documents或Library/Caches下,引擎启动时需要优先扫描这些目录。这意味着你的游戏框架需要支持“自定义资源路径”。
3.5 后台管理:开发者之友
你需要一个简单的Web后台,用于:
- 上传新版本资源包(自动计算MD5,生成manifest)。
- 发布热更版本(可选:全量强制更新、小量非强制更新)。
- 查看更新日志和统计数据(多少人下载了、多少人失败了)。
四、 双端具体实现差异与代码示例
下面,我们针对Android和iOS的具体坑,给出一些关键代码片段。假设我们使用Unity + C#,因为它是跨平台最通用的场景。如果是Cocos,逻辑类似,只是API不同。
4.1 Android端:文件路径与权限
Android的热更资源通常存放在Application.persistentDataPath。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public class AndroidHotUpdateManager : MonoBehaviour
{
private string hotUpdatePath;
private string manifestPath;
void Start()
{
// Android: 持久化路径,用户卸载后数据保留(可选清理)
// iOS: 同样可用,但建议检查是否是iOS
hotUpdatePath = Path.Combine(Application.persistentDataPath, "HotUpdate");
manifestPath = Path.Combine(hotUpdatePath, "manifest.json");
// 确保目录存在
if (!Directory.Exists(hotUpdatePath))
{
Directory.CreateDirectory(hotUpdatePath);
}
StartCoroutine(DownloadManifest());
}
IEnumerator DownloadManifest()
{
// 假设你有一个服务器地址
string url = "https://your-cdn.com/manifest.json";
using (UnityWebRequest webRequest = UnityWebRequest.Get(url))
{
yield return webRequest.SendWebRequest();
if (webRequest.result == UnityWebRequest.Result.ConnectionError ||
webRequest.result == UnityWebRequest.Result.ProtocolError)
{
Debug.LogError("Manifest download failed: " + webRequest.error);
}
else
{
// 保存manifest到本地热更目录
File.WriteAllText(manifestPath, webRequest.downloadHandler.text);
Debug.Log("Manifest downloaded and saved.");
CheckForUpdates();
}
}
}
void CheckForUpdates()
{
if (File.Exists(manifestPath))
{
// 解析本地manifest和服务器manifest进行比对
// 这里省略解析逻辑,核心是比对MD5
Debug.Log("Checking for updates...");
}
}
}
Android特别注意:
- 权限:Android 6.0+需要动态申请
WRITE_EXTERNAL_STORAGE(如果下载到外部存储)。但强烈建议下载到persistentDataPath,这样不需要权限,且更安全。 - OBB分包:如果资源非常大,Android官方推荐用OBB文件。但OBB修改麻烦,一般小型热更用ZIP包解压到
persistentDataPath更灵活。
4.2 iOS端:沙盒机制与苹果红线
iOS的代码和Android类似,但路径和心态完全不同。
public class IOSEotUpdateManager : MonoBehaviour
{
// iOS的沙盒结构:
// Documents/ - 用户数据,iTunes备份时会包含
// Library/Caches/ - 缓存,不备份,空间不足时可能被清理
// Library/Preferences/ - 偏好设置
private string hotUpdatePath;
void Start()
{
// 【关键】iOS热更资源务必放在Caches,不要放Documents
// 理由:Documents会被iCloud备份,如果热更资源很大,会占用用户iCloud空间,被苹果拒审!
hotUpdatePath = Path.Combine(Application.temporaryCachePath, "HotUpdate");
// 或者使用:
// hotUpdatePath = Path.Combine(Application.persistentDataPath, "HotUpdate");
if (!Directory.Exists(hotUpdatePath))
{
Directory.CreateDirectory(hotUpdatePath);
}
// 启动时检查是否有未解压的zip,如果有,解压
StartCoroutine(ProcessHotUpdate());
}
IEnumerator ProcessHotUpdate()
{
string zipPath = Path.Combine(Application.temporaryCachePath, "update.zip");
if (File.Exists(zipPath))
{
// 解压到热更目录
using (var zip = ZipFile.Open(zipPath, ZipArchiveMode.Read))
{
zip.ExtractToDirectory(hotUpdatePath);
}
// 删除zip
File.Delete(zipPath);
Debug.Log("Hot update unzipped successfully.");
}
// 通知引擎加载热更路径
NotifyEngineToLoadHotUpdate();
}
void NotifyEngineToLoadHotUpdate()
{
// 这里需要调用引擎特定的API
// Unity中,可能需要设置Resource Manager的路径
// 或者在主场景加载前先加载热更Bundle
#if UNITY_IOS
// 发送消息给iOS原生插件,告诉它热更路径,让引擎优先从那里读取
#endif
}
}
iOS红线警告(非常重要):
- 严禁修改核心游戏逻辑:如果你的热更包含了“改变游戏收费机制”、“添加未审核的付费内容”、“修改核心玩法”,一旦被苹果审查人员发现(他们真的会查!),你的App会被直接下架,甚至开发者账号被永久封禁。
- 隐藏热更入口:不要在UI上放明显的“下载补丁”按钮。通常热更是静默的,或者以“游戏优化”、“内容更新”为名。
- 不要使用动态库:iOS不支持
.so的热更(除了某些特定情况,风险极高)。只用资源+脚本。 - 测试真机:模拟器上的路径和真机不同,务必在真机上测试下载和解压。
4.3 通用下载与断点续传(WebGL/全平台)
为了双端通用,建议使用UnityWebRequest的DownloadHandlerBuffer配合Range请求实现断点续传。但这比较复杂,市面上有很多成熟的热更框架,如Unity Remote Config、PlayFab、Cocos Play,建议直接集成,不要重复造轮子。
如果你必须自研,以下是一个简单的断点续传核心逻辑:
IEnumerator DownloadFile(string url, string localPath, long offset, Action<float> onProgress)
{
using (UnityWebRequest www = UnityWebRequest.Get(url))
{
// 设置Range头,实现断点续传
www.SetRequestHeader("Range", $"bytes={offset}-");
// 自定义下载 handler 以追加写入文件,而非覆盖
var downloadHandler = new CustomDownloadHandler(localPath, offset);
www.downloadHandler = downloadHandler;
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError("Download failed: " + www.error);
}
else
{
Debug.Log("Download completed.");
}
}
}
注:CustomDownloadHandler需要自己继承DownloadHandler,在ReceiveContentLength和ReceiveData中处理文件追加写入。这部分代码较长,建议参考开源库。
