游戏发售后出现bug不用重新上架教你用热更新技术秒级修复问题UnityCocosCreator手游热更方案完整教程从代码逻辑到资源替换全流程讲解适合独立开发者和游戏团队
一、热更新是什么?为什么它这么重要?
先说个场景。你精心打磨了三个月的独立游戏,终于上线了。结果上线第一天,社区里就开始有人投诉:”为什么玩家移动速度突然变慢了?”或者更致命的问题——”付费弹窗直接闪退!”
这时候,按照传统的发版流程,你需要:修改代码 → 重新编译打包 → 提交商店审核 → 等审核通过 → 用户手动更新。这一套流程下来,短则一周,长则一个月。而这些问题在用户心里,就已经变成了”这游戏不稳定”、”不更新卸载了”。
热更新技术解决的就是这个问题——在用户不卸载、不重新下载整个应用的情况下,动态修复代码和替换资源。
打个比方:传统发版像是在餐厅关门装修,热更新就像是在餐厅营业时,悄悄把坏掉的灯换了,把难吃的菜换了菜单,客人根本不知道发生了什么。
二、热更新的核心原理
热更新的本质其实很简单,就是一个动态加载的概念。
游戏启动时,我们会从服务器上拉取最新的版本信息,对比本地版本。如果有更新,就下载新的代码包和资源包,替换掉旧的内容,然后继续运行。
整个过程分几步:
- 版本检测 — 对比本地和远程的版本号
- 差异计算 — 只下载变化的部分(节省流量)
- 下载资源 — 从服务器下载更新内容
- 解压安装 — 解压并替换到本地目录
- 重启生效 — 部分情况下需要重启游戏
不同类型的引擎实现方式不太一样,下面分别讲Unity和CocosCreator的方案。
三、Unity热更新方案详解
3.1 方案选型
Unity的热更新生态非常丰富,常见的方案有:
- ILRuntime — 纯C#热更,不依赖IL2CPP,适合逻辑热更
- HotReload — 开发时的热重载,不是生产方案
- Unity Addressable — 资源热更的标准方案
- HybridCLR — 目前最火的方案,支持IL2CPP下的C#热更
- AssetBundle — 资源管理的基础方案
对于独立开发者,我推荐HybridCLR + Addressable + AssetBundle的组合,原因:
- HybridCLR让你能用纯C#写热更代码,不需要懂IL
- Addressable负责资源管理
- AssetBundle负责打包和加载
3.2 环境搭建
首先需要在Unity中安装HybridCLR。打开Unity Package Manager,添加一个git依赖:
https://github.com/focus-creative-games/hybridclr.git?path=/packages/com.hybridclr
安装完成后,在Unity菜单中找到 Tools → HybridCLR → 初始化,按照提示完成配置。
对于Addressable,同样在Package Manager中安装:
Window → Package Manager → Add package by name → com.unity.addressables
3.3 代码热更流程
热更代码的核心思路是:把需要热更的逻辑放到独立的Assembly里,编译成dll,上传到服务器,运行时动态加载执行。
第一步:创建热更代码工程
在项目中新建一个文件夹 HybridCLRData,然后在里面创建一个独立的C#工程,专门写热更逻辑。
比如,假设你的游戏有个”玩家移动”功能出bug了:
// Hotfix/MovementFix.cs
// 这是会被热更的代码,不在主程序编译时打包
using UnityEngine;
using HybridCLR;
namespace Game.Hotfix
{
public class MovementFix : IEarlyUpdate
{
// 这个方法会在游戏启动后的早期阶段执行
public void EarlyUpdate()
{
// 修复移动速度bug
FixPlayerMovement();
}
private void FixPlayerMovement()
{
// 找到玩家对象
var player = GameObject.FindWithTag("Player");
if (player != null)
{
// 重新设置正确的移动速度
var controller = player.GetComponent<PlayerMovement>();
if (controller != null)
{
controller.MoveSpeed = 10f; // 修复成正确的速度
Debug.Log("[热更新] 移动速度已修复为: " + controller.MoveSpeed);
}
}
}
}
}
第二步:配置Assembly Definitions
在Unity中,右键点击 Hotfix 文件夹 → Create → Assembly Definition,命名为 Hotfix。
在生成的 .asmdef 文件中,需要添加对HybridCLR的引用:
{
"name": "Hotfix",
"references": [
"GUID:hybridclr_runtime"
],
"defineConstraints": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": []
}
第三步:编写热更入口
在主程序中,你需要一个启动入口来加载热更代码:
// RuntimeInit.cs
using UnityEngine;
using System.Collections;
using HybridCLR;
using System.IO;
public class RuntimeInit : MonoBehaviour
{
[Header("热更配置")]
public string hotfixUrl = "https://your-server.com/hotfix/";
public string versionFileUrl = "https://your-server.com/hotfix/version.json";
public string assetBundleUrl = "https://your-server.com/hotfix/ab/";
private void Start()
{
StartCoroutine(CheckAndDownloadHotfix());
}
private IEnumerator CheckAndDownloadHotfix()
{
// 1. 获取本地版本号
string localVersion = PlayerPrefs.GetString("HotfixVersion", "0");
// 2. 获取远程版本信息
using (var www = new UnityWebRequest(versionFileUrl, UnityWebRequest.MethodGet))
{
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError("[热更新] 版本检查失败: " + www.error);
yield break;
}
var versionInfo = JsonUtility.FromJson<VersionInfo>(www.downloadHandler.text);
// 3. 比较版本
if (versionInfo.version <= localVersion)
{
Debug.Log("[热更新] 当前已是最新版本: " + localVersion);
yield break;
}
Debug.Log("[热更新] 发现新版本: " + versionInfo.version);
// 4. 下载热更包
yield return DownloadHotfixPackage(versionInfo);
// 5. 加载并执行热更代码
yield return LoadAndApplyHotfix();
}
}
private IEnumerator DownloadHotfixPackage(VersionInfo versionInfo)
{
// 下载热更dll
string dllUrl = hotfixUrl + "hotfix_" + versionInfo.version + ".dll";
string dllPath = Path.Combine(Application.persistentDataPath, "hotfix.dll");
using (var www = new UnityWebRequest(dllUrl, UnityWebRequest.MethodGet))
{
var downloadHandler = new DownloadHandlerFile(dllPath);
www.downloadHandler = downloadHandler;
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError("[热更新] DLL下载失败: " + www.error);
yield break;
}
}
// 下载资源包
foreach (var asset in versionInfo.assets)
{
yield return DownloadAsset(asset, versionInfo);
}
// 更新版本号
PlayerPrefs.SetString("HotfixVersion", versionInfo.version);
PlayerPrefs.Save();
Debug.Log("[热更新] 版本更新完成: " + versionInfo.version);
}
private IEnumerator DownloadAsset(AssetInfo asset, VersionInfo versionInfo)
{
string abUrl = assetBundleUrl + asset.path;
string abPath = Path.Combine(Application.persistentDataPath, asset.path);
// 确保目录存在
Directory.CreateDirectory(Path.GetDirectoryName(abPath));
using (var www = new UnityWebRequest(abUrl, UnityWebRequest.MethodGet))
{
var downloadHandler = new DownloadHandlerFile(abPath);
www.downloadHandler = downloadHandler;
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError("[热更新] 资源下载失败: " + asset.path);
}
}
}
private IEnumerator LoadAndApplyHotfix()
{
string dllPath = Path.Combine(Application.persistentDataPath, "hotfix.dll");
if (!File.Exists(dllPath))
{
Debug.LogError("[热更新] 热更文件不存在!");
yield break;
}
// 使用HybridCLR加载热更程序集
byte[] dllBytes = File.ReadAllBytes(dllPath);
var assembly = System.Reflection.Assembly.Load(dllBytes);
if (assembly != null)
{
// 查找并执行热更类
var types = assembly.GetTypes();
foreach (var type in types)
{
// 检查是否实现了IEarlyUpdate接口
if (typeof(IEarlyUpdate).IsAssignableFrom(type))
{
var instance = Activator.CreateInstance(type) as IEarlyUpdate;
if (instance != null)
{
instance.EarlyUpdate();
Debug.Log("[热更新] 已执行热更: " + type.Name);
}
}
}
}
else
{
Debug.LogError("[热更新] 程序集加载失败!");
}
}
}
// 版本信息结构
[System.Serializable]
public class VersionInfo
{
public string version;
public AssetInfo[] assets;
}
[System.Serializable]
public class AssetInfo
{
public string path;
public string hash;
public long size;
}
第四步:IL2CPP下的热更代码编译
关键点来了——在IL2CPP架构下,热更代码需要特殊编译。你需要配置HybridCLR的AOT和热更设置。
在Unity菜单中选择 Tools → HybridCLR → 设置,然后:
- 在 热更新Dll 中,添加你的
HotfixAssembly Definition - 在 AOT补丁 中,选择需要AOT编译的类型
- 点击 生成热更新Dll
这样会生成一个 hybridclr-runtime.dll 和一个 hotfix.dll,前者是运行时需要的基础库,后者是你的热更代码。
3.4 资源热更流程
资源热更比代码热更新简单一些,主要是用Addressable系统来管理。
配置Addressable
- 选中一个Asset,在Inspector中点击 Make Default 或 Make Object Absolute
- 设置Addressable Group,推荐创建一个
Default Local Group - 配置Build和Load路径
资源热更代码
// ResourceManager.cs
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections;
using System.Collections.Generic;
using System.IO;
public class ResourceManager : MonoBehaviour
{
[Header("热更配置")]
public string remoteCatalogUrl = "https://your-server.com/ab/catalog.json";
public string localCatalogPath;
public string remoteBundleUrl = "https://your-server.com/ab/";
public string localBundlePath;
private Dictionary<string, AsyncOperationHandle> _loadingHandles =
new Dictionary<string, AsyncOperationHandle>();
private void Start()
{
localCatalogPath = Path.Combine(Application.persistentDataPath, "catalog.json");
localBundlePath = Path.Combine(Application.persistentDataPath, "bundles");
StartCoroutine(CheckAndLoadCatalog());
}
private IEnumerator CheckAndLoadCatalog()
{
// 检查本地是否有更新后的catalog
if (File.Exists(localCatalogPath))
{
// 对比远程catalog版本
yield return CompareAndDownloadCatalog();
}
else
{
// 首次运行,下载catalog
yield return DownloadCatalog();
}
// 加载catalog并初始化Addressable
yield return InitializeAddressables();
}
private IEnumerator CompareAndDownloadCatalog()
{
// 获取远程catalog的最新信息
using (var www = new UnityWebRequest(remoteCatalogUrl + "?v=" + Time.time, UnityWebRequest.MethodGet))
{
yield return www.SendWebRequest();
if (www.result == UnityWebRequest.Result.Success)
{
var remoteCatalog = JsonUtility.FromJson<CatalogInfo>(www.downloadHandler.text);
var localCatalog = LoadLocalCatalog();
if (remoteCatalog != null && localCatalog != null)
{
// 检查是否需要更新
if (remoteCatalog.catalogHash != localCatalog.catalogHash)
{
Debug.Log("[资源热更新] 发现新catalog,开始更新");
yield return DownloadCatalog();
}
}
}
}
}
private IEnumerator DownloadCatalog()
{
using (var www = new UnityWebRequest(remoteCatalogUrl, UnityWebRequest.MethodGet))
{
var downloadHandler = new DownloadHandlerFile(localCatalogPath);
www.downloadHandler = downloadHandler;
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError("[资源热更新] Catalog下载失败: " + www.error);
}
}
}
private CatalogInfo LoadLocalCatalog()
{
if (!File.Exists(localCatalogPath)) return null;
return JsonUtility.FromJson<CatalogInfo>(File.ReadAllText(localCatalogPath));
}
private IEnumerator InitializeAddressables()
{
// 设置远程catalog路径
Addressables.RuntimeDataPath = Application.persistentDataPath;
// 初始化Addressables
var initOp = Addressables.InitializeAsync();
yield return initOp;
if (initOp.Status == AsyncOperationStatus.Succeeded)
{
Debug.Log("[资源热更新] Addressables初始化成功");
}
else
{
Debug.LogError("[资源热更新] Addressables初始化失败: " + initOp.OperationException);
}
}
// 异步加载资源
public AsyncOperationHandle<T> LoadAssetAsync<T>(string address) where T : UnityEngine.Object
{
if (_loadingHandles.ContainsKey(address))
{
return _loadingHandles[address] as AsyncOperationHandle<T>;
}
var handle = Addressables.LoadAssetAsync<T>(address);
_loadingHandles[address] = handle;
return handle;
}
// 释放资源
public void ReleaseAsset<T>(string address) where T : UnityEngine.Object
{
if (_loadingHandles.ContainsKey(address))
{
Addressables.Release(_loadingHandles[address]);
_loadingHandles.Remove(address);
}
}
// 清理
private void OnDestroy()
{
foreach (var kvp in _loadingHandles)
{
Addressables.Release(kvp.Value);
}
_loadingHandles.Clear();
}
}
[System.Serializable]
public class CatalogInfo
{
public string catalogHash;
public string remoteProviderURL;
public string[] labels;
}
实际使用场景
假设你的游戏有一个”金币数量显示”的UI,之前因为某个bug导致显示错误,现在热更修复:
// CoinDisplayFix.cs — 热更代码
using UnityEngine;
using UnityEngine.UI;
public class CoinDisplayFix : MonoBehaviour
{
public Text coinText;
private void OnEnable()
{
// 修复显示逻辑
UpdateCoinDisplay();
}
public void UpdateCoinDisplay()
{
// 从服务器获取最新的金币数据
int coins = GameData.Instance.Coins;
// 正确的格式化方式
coinText.text = "💰 " + coins.ToString("N0");
// 修复之前的颜色bug
if (coins > 1000)
{
coinText.color = Color.yellow;
}
else
{
coinText.color = Color.white;
}
}
}
这个热更代码会在热更新加载时自动替换掉旧的逻辑,玩家重启游戏就能看到正确的金币显示了。
四、CocosCreator热更新方案详解
CocosCreator的热更新方案和Unity有所不同,主要依赖的是它自带的热更新模块,再加上一些自定义的逻辑。
4.1 CocosCreator热更新架构
CocosCreator的热更新基于** AssetBundle **机制,核心流程:
- 游戏启动 → 检查远程manifest
- 对比版本 → 下载差异包
- 替换本地资源 → 重启或热重载
CocosCreator 3.x 提供了内置的热更新API,我们直接调用即可。
4.2 热更新配置
首先需要在CocosCreator编辑器中配置热更新:
- 打开项目,进入 项目 → 构建发布
- 在 原生 平台选项下,开启 热更新
- 填写 远程服务器地址
- 设置 版本文件路径
4.3 热更新代码实现
基础热更新管理器
// HotUpdateManager.ts
import { _decorator, Component, Node, resources, assetManager,
director, game, CCLog, log, debug, warn, error } from 'cc';
import { AssetBundle } from 'cc';
const { ccclass, property } = _decorator;
interface VersionInfo {
packageUrl: string;
remoteVersionsUrl: string;
remoteManifestUrl: string;
localManifestUrl: string;
version: string;
assets: { [key: string]: { version: string; url: string } };
}
@ccclass('HotUpdateManager')
export class HotUpdateManager extends Component {
@property({ type: String, tooltip: '远程版本文件地址' })
remoteVersionUrl: string = 'https://your-server.com/hotupdate/version.json';
@property({ type: String, tooltip: '本地版本文件地址' })
localVersionUrl: string = 'hotupdate/version.json';
@property({ type: String, tooltip: '远程AssetBundle地址' })
bundleBaseUrl: string = 'https://your-server.com/hotupdate/bundles/';
@property({ type: Number, tooltip: '超时时间(秒)' })
timeout: number = 30;
private _isUpdating: boolean = false;
private _versionInfo: VersionInfo | null = null;
async start() {
// 检查是否需要热更新
await this.checkAndApplyHotUpdate();
}
private async checkAndApplyHotUpdate(): Promise<void> {
if (this._isUpdating) return;
this._isUpdating = true;
log('[热更新] 开始检查热更新...');
try {
// 1. 下载远程版本信息
const remoteVersionInfo = await this.downloadRemoteVersionInfo();
if (!remoteVersionInfo) {
warn('[热更新] 无法获取远程版本信息,跳过热更新');
this._isUpdating = false;
return;
}
// 2. 获取本地版本信息
const localVersionInfo = await this.downloadLocalVersionInfo();
// 3. 比较版本
if (!this.isVersionNewer(remoteVersionInfo, localVersionInfo)) {
log('[热更新] 当前已是最新版本: ' + localVersionInfo.version);
this._isUpdating = false;
return;
}
log('[热更新] 发现新版本: ' + remoteVersionInfo.version);
// 4. 下载更新内容
await this.downloadUpdate(remoteVersionInfo);
// 5. 应用更新
await this.applyUpdate(remoteVersionInfo);
log('[热更新] 热更新完成!');
} catch (error) {
error('[热更新] 热更新失败: ' + error);
} finally {
this._isUpdating = false;
}
}
private async downloadRemoteVersionInfo(): Promise<VersionInfo | null> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', this.remoteVersionUrl);
xhr.timeout = this.timeout * 1000;
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const versionInfo = JSON.parse(xhr.responseText);
resolve(versionInfo);
} catch (e) {
reject(new Error('解析远程版本信息失败: ' + e));
}
} else {
reject(new Error('远程版本信息请求失败,状态码: ' + xhr.status));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('请求超时'));
xhr.send();
});
}
private async downloadLocalVersionInfo(): Promise<VersionInfo | null> {
return new Promise((resolve) => {
const fs = require('fs');
const path = require('path');
const localPath = path.join(cc.sys.localStorage.getItem('hotupdate_path') || '',
'version.json');
try {
if (fs.existsSync(localPath)) {
const content = fs.readFileSync(localPath, 'utf8');
resolve(JSON.parse(content));
} else {
resolve(null);
}
} catch (e) {
resolve(null);
}
});
}
private isVersionNewer(remote: VersionInfo, local: VersionInfo | null): boolean {
if (!local) return true;
return remote.version.localeCompare(local.version) > 0;
}
private async downloadUpdate(versionInfo: VersionInfo): Promise<void> {
log('[热更新] 开始下载更新...');
// 保存远程版本信息到本地
await this.saveVersionInfoToDisk(versionInfo);
// 下载需要更新的资源
const updateList = this.getUpdateList(versionInfo);
let completed = 0;
const total = Object.keys(updateList).length;
for (const [assetName, assetInfo] of Object.entries(updateList)) {
log('[热更新] 下载中: ' + assetName + ' (' + (++completed) + '/' + total + ')');
try {
await this.downloadAsset(assetInfo.url, assetName);
} catch (error) {
warn('[热更新] 下载失败,跳过: ' + assetName + ' - ' + error);
}
}
log('[热更新] 下载完成,共更新 ' + completed + ' 个资源');
}
private getUpdateList(versionInfo: VersionInfo): { [key: string]: { url: string } } {
const localVersionInfo = this.downloadLocalVersionInfo();
const updateList: { [key: string]: { url: string } } = {};
for (const [assetName, assetInfo] of Object.entries(versionInfo.assets)) {
if (!localVersionInfo || localVersionInfo.assets[assetName]?.version !== assetInfo.version) {
updateList[assetName] = {
url: this.bundleBaseUrl + assetInfo.url
};
}
}
return updateList;
}
private async downloadAsset(url: string, assetName: string): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.timeout = this.timeout * 1000;
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
this.saveAssetToDisk(assetName, xhr.response);
resolve();
} else {
reject(new Error('下载失败,状态码: ' + xhr.status));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.ontimeout = () => reject(new Error('请求超时'));
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
log('[热更新] ' + assetName + ' 下载进度: ' + percent.toFixed(1) + '%');
}
};
xhr.send();
});
}
private saveAssetToDisk(assetName: string, data: ArrayBuffer): void {
const fs = require('fs');
const path = require('path');
const savePath = path.join(cc.sys.localStorage.getItem('hotupdate_path') || '',
'assets', assetName);
// 确保目录存在
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(savePath, Buffer.from(data));
log('[热更新] 资源已保存: ' + assetName);
}
private async saveVersionInfoToDisk(versionInfo: VersionInfo): Promise<void> {
const fs = require('fs');
const path = require('path');
const savePath = path.join(cc.sys.localStorage.getItem('hotupdate_path') || '',
'version.json');
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(savePath, JSON.stringify(versionInfo, null, 2));
log('[热更新] 版本信息已保存');
}
private async applyUpdate(versionInfo: VersionInfo): Promise<void> {
log('[热更新] 应用更新...');
// 这里可以根据需要执行一些更新后的逻辑
// 比如重新加载某些场景、刷新资源配置等
// 更新本地版本号
const fs = require('fs');
const path = require('path');
const updatePath = path.join(cc.sys.localStorage.getItem('hotupdate_path') || '',
'update_info.json');
const updateInfo = {
lastUpdateVersion: versionInfo.version,
lastUpdateTimestamp: Date.now()
};
fs.writeFileSync(updatePath, JSON.stringify(updateInfo, null, 2));
log('[热更新] 更新已应用,版本: ' + versionInfo.version);
}
// 强制刷新热更新缓存
public resetHotUpdate(): void {
const fs = require('fs');
const path = require('path');
const hotupdatePath = cc.sys.localStorage.getItem('hotupdate_path');
if (hotupdatePath && fs.existsSync(hotupdatePath)) {
fs.rmSync(hotupdatePath, { recursive: true, force: true });
cc.sys.localStorage.removeItem('hotupdate_path');
log('[热更新] 热更新缓存已重置');
}
}
}
简化版热更新(CocosCreator内置方案)
如果你不想自己写那么多代码,CocosCreator 3.x内置了更简单的方式:
// 使用CocosCreator内置的热更新API
import { _decorator, Component, log, warn, error, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('SimpleHotUpdate')
export class SimpleHotUpdate extends Component {
async start() {
// 检查平台是否为原生平台
if (!sys.isNative) {
log('[热更新] 非原生平台,跳过热更新检查');
return;
}
// 检查是否有热更新URL配置
const remoteUrl = 'https://your-server.com/hotupdate';
if (!remoteUrl) {
warn('[热更新] 未配置远程更新地址');
return;
}
try {
// 使用CocosCreator内置的热更新检查
const result = await this.checkUpdate(remoteUrl);
if (result.hasUpdate) {
log('[热更新] 发现新版本,开始更新');
await this.applyUpdate(remoteUrl, result.version);
} else {
log('[热更新] 当前已是最新版本');
}
} catch (e) {
error('[热更新] 检查更新失败: ' + e);
}
}
private async checkUpdate(remoteUrl: string): Promise<{
hasUpdate: boolean;
version: string;
}> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', remoteUrl + '/version.json');
xhr.timeout = 10000;
xhr.onload = () => {
if (xhr.status === 200) {
const versionInfo = JSON.parse(xhr.responseText);
const localVersion = sys.localStorage.getItem('app_version') || '0.0.0';
resolve({
hasUpdate: this.isVersionNewer(versionInfo.version, localVersion),
version: versionInfo.version
});
} else {
resolve({ hasUpdate: false, version: '' });
}
};
xhr.onerror = () => resolve({ hasUpdate: false, version: '' });
xhr.ontimeout = () => resolve({ hasUpdate: false, version: '' });
xhr.send();
});
}
private isVersionNewer(remote: string, local: string): boolean {
const remoteParts = remote.split('.').map(Number);
const localParts = local.split('.').map(Number);
for (let i = 0; i < Math.max(remoteParts.length, localParts.length); i++) {
const r = remoteParts[i] || 0;
const l = localParts[i] || 0;
if (r > l) return true;
if (r < l) return false;
}
return false;
}
private async applyUpdate(remoteUrl: string, version: string): Promise<void> {
// 这里可以下载并应用更新
// 具体实现取决于你的业务逻辑
log('[热更新] 应用版本: ' + version);
// 保存新版本号
sys.localStorage.setItem('app_version', version);
}
}
4.4 CocosCreator热更新的实际例子
假设你的游戏有个bug:玩家点击按钮后,金币没有正确扣除。热更修复代码如下:
// Hotfix/CoinPurchaseFix.ts — 热更逻辑
import { _decorator, Component, Node, Button, Label,
tween, Tween, log, error } from 'cc';
import { GameData } from '../GameData';
const { ccclass, property } = _decorator;
@ccclass('CoinPurchaseFix')
export class CoinPurchaseFix extends Component {
@property(Label)
coinLabel: Label | null = null;
private _isProcessing: boolean = false;
start() {
// 注册按钮点击事件
const purchaseBtn = this.node.getChildByName('PurchaseBtn')?.component(Button);
if (purchaseBtn) {
purchaseBtn.node.on(Button.EventType.CLICK, this.onPurchaseClick, this);
}
// 修复UI显示
this.updateCoinDisplay();
}
private onPurchaseClick() {
if (this._isProcessing) return;
this._isProcessing = true;
log('[热更新] 处理购买请求...');
// 从服务器验证购买
this.validatePurchase()
.then((isValid) => {
if (isValid) {
// 扣减金币
const cost = 100;
const gameData = GameData.getInstance();
if (gameData.coins >= cost) {
gameData.coins -= cost;
this.updateCoinDisplay();
log('[热更新] 购买成功,剩余金币: ' + gameData.coins);
// 播放购买成功动画
this.playPurchaseSuccess();
} else {
this.showError('金币不足!');
}
} else {
this.showError('验证失败,请重试');
}
})
.catch((err) => {
error('[热更新] 购买处理失败: ' + err);
this.showError('网络错误');
})
.finally(() => {
this._isProcessing = false;
});
}
private async validatePurchase(): Promise<boolean> {
// 模拟服务器验证
return new Promise((resolve) => {
// 这里可以替换成真实的服务器验证逻辑
setTimeout(() => resolve(true), 500);
});
}
private updateCoinDisplay() {
if (this.coinLabel) {
const gameData = GameData.getInstance();
// 修复数字格式化
this.coinLabel.string = '💰 ' + gameData.coins.toLocaleString();
}
}
private playPurchaseSuccess() {
// 播放成功动画
tween(this.node)
.to(0.3, { scale: { x: 1.1, y: 1.1 } })
.to(0.3, { scale: { x: 1, y: 1 } })
.start();
}
private showError(message: string) {
log('[热更新] 显示错误: ' + message);
// 显示错误提示
}
onDestroy() {
// 清理事件监听
const purchaseBtn = this.node.getChildByName('PurchaseBtn')?.component(Button);
if (purchaseBtn) {
purchaseBtn.node.off(Button.EventType.CLICK, this.onPurchaseClick, this);
}
}
}
这段热更代码可以修复原有代码中的多个问题:
- 修复了购买后金币显示不更新的问题
- 添加了防重复点击保护
- 修复了数字格式化问题(之前可能显示为
1000而不是1,000) - 添加了网络错误处理
五、服务器端配置
热更新需要一个服务器来存放更新文件。你可以用任何支持HTTP的文件服务器,比如:
- Nginx — 最常用,配置简单
- GitHub Releases — 适合小型项目
- OSS/CDN — 阿里云、腾讯云的对象存储
目录结构示例
hotupdate/
├── version.json # 版本信息
├── bundles/ # 资源包目录
│ ├── bundle_1.0.0/
│ │ ├── main.bundle
│ │ ├── ui.bundle
│ │ └── audio.bundle
│ └── bundle_1.0.1/
│ ├── main.bundle # 只有变化的部分
│ └── ui.bundle
├── hotfix/ # 代码热更包(Unity)
│ ├── hotfix.dll
│ └── hybridclr-runtime.dll
└── logs/ # 更新日志
└── update_20240101.log
version.json 示例
{
"version": "1.0.1",
"bundleVersion": "1.0.1",
"hash": "a1b2c3d4e5f6",
"assets": {
"main": {
"version": "1.0.1",
"url": "bundles/bundle_1.0.1/main.bundle",
"size": 1048576,
"hash": "abc123"
},
"ui": {
"version": "1.0.1",
"url": "bundles/bundle_1.0.1/ui.bundle",
"size": 524288,
"hash": "def456"
}
},
"changelog": "修复了购买金币不扣减的bug",
"forceUpdate": false
}
六、热更新的常见坑和解决方案
坑1:版本不一致导致崩溃
现象:客户端下载了新版本资源,但代码版本不一致,导致运行时崩溃。
解决:
- 每次更新时,确保代码和资源版本号同步
- 在启动时进行完整性校验(比对hash)
- 如果校验失败,回滚到上一个稳定版本
// 完整性校验
async function verifyIntegrity(hash: string, size: number): Promise<boolean> {
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const filePath = path.join(updatePath, hash + '.bundle');
if (!fs.existsSync(filePath)) return false;
const data = fs.readFileSync(filePath);
const computedHash = crypto.createHash('md5').update(data).digest('hex');
return computedHash === hash && data.length === size;
}
坑2:热更新后功能异常
现象:热更新成功,但某些功能不工作或行为异常。
解决:
- 热更新后增加一个”安全模式”,可以禁用新代码
- 记录详细的更新日志
- 对热更新后的功能进行自动化测试
// 安全模式
if (sys.localStorage.getItem('safe_mode') === 'true') {
log('[热更新] 安全模式已启用,跳过热更新');
return;
}
坑3:用户网络不好导致更新失败
现象:用户在弱网环境下下载更新,超时或中断。
解决:
- 支持断点续传
- 记录下载进度,下次启动时继续
- 设置合理的超时时间
坑4:热更新被绕过
现象:用户不更新,使用旧版本,但旧版本有问题。
解决:
- 在关键接口加版本校验,阻止过低版本访问
- 对于重要bug,可以强制更新(
forceUpdate: true) - 统计各版本的使用率,及时推送更新提醒
七、什么时候需要重启?什么时候可以热重载?
这是一个很实际的问题。一般来说:
需要重启的情况:
- 核心代码逻辑变更(如修改了数据结构)
- 资源大幅变更(如替换了核心美术资源)
- 引擎配置变更
- CocosCreator中修改了节点结构
可以热重载的情况:
- 纯UI逻辑修改
- 数值配置调整
- 简单的bug修复(不改变结构)
- 文字内容替换
对于Unity + HybridCLR的方案,大部分代码热更不需要重启,但资源热更通常需要重启才能完全生效(除非用Addressable动态加载)。
八、总结与建议
热更新技术听起来很复杂,但实际上核心思想很简单:动态加载、差异更新、安全回滚。
对于独立开发者:
- 从小做起 — 先实现最基础的热更,再逐步完善
- 做好备份 — 每次热更新前,保留旧版本的备份
- 灰度发布 — 先对小部分用户推送,观察稳定性后再全量
- 监控和日志 — 记录每次更新的细节,方便排查问题
对于游戏团队:
- 建立流程 — 热更新需要有一套完整的审核和发布流程
- 自动化测试 — 每次热更都要经过自动化测试
- 版本管理 — 严格管理版本号,避免混乱
- 应急方案 — 准备好回滚机制,万一出问题能快速恢复
热更新技术是游戏开发的必备技能。掌握了它,你就能在出现问题时快速响应,而不需要让用户等待漫长的商店审核流程。这对于独立开发者和小型团队来说,尤其重要——毕竟,我们的每一分用户口碑都来之不易。
希望这篇教程能帮到你。如果有任何问题,欢迎交流!
