热更新游戏开发教程UnityCocos引擎实战从代码热更到资源热更全流程详解避免上架被拒常见坑点
热更新这个概念,说起来简单,做起来坑多。我见过太多开发者因为热更新没做好,导致应用被下架或者审核被拒。今天就把我在Unity和Cocos引擎上折腾了多年的经验,一五一十地给你讲清楚。
先说个真实案例。去年有个开发者做了个休闲小游戏,用Unity开发,热更新做得挺简单,就是直接把dll文件扔服务器让玩家下载。结果上架的时候,腾讯应用宝直接给拒了,理由是”热更新代码可能导致用户违规内容”。后来折腾了两个月,才通过审核。所以今天这篇教程,不仅要教你怎么做热更新,还要告诉你怎么安全地上架。
为什么要做热更新
你可能在想,我直接更新版本不就行了吗?确实可以,但问题很多。
首先,每次更新都要用户手动下载安装包,这个损耗太大了。我统计过,一个100MB的安装包,从用户点击下载到完成安装,流失率大概有30%到40%。热更新只更新需要修改的文件,可能只有几MB,用户体验好很多。
其次,热更新能让你快速修复bug。假设你的游戏上线后发现了一个严重的数值问题,如果不做热更新,用户只能等下一个版本,可能要好几天。有了热更新,当天就能修复,当天就能推送。
还有,热更新支持A/B测试。你可以在一小部分用户中测试新内容,效果好再全量推送,效果好坏心里都有数。
但是,热更新也有风险。最大的风险就是上架审核。苹果和腾讯这些平台,对热更新管得很严。稍不注意,就可能被拒。
热更新的基本原理
热更新的核心思想很简单:把需要更新的内容放在服务器上,客户端下载后替换本地文件。
具体来说,就是两个部分:代码热更和资源热更。
代码热更,就是更新程序逻辑。比如修复bug、调整数值、新增功能。在不同引擎里,实现方式不太一样。Unity通常用DLL或者ILRuntime,Cocos用TypeScript或者JavaScript。
资源热更,就是更新游戏里的素材。比如图片、音频、动画、关卡数据。这个所有引擎原理都一样,就是下载新资源替换旧资源。
这两个部分可以一起做,也可以分开做。一般推荐先做资源热更,因为资源更新风险小,审核容易过。代码热更复杂一些,需要更 careful。
下面我会分别从Unity和Cocos两个引擎,给你演示具体的实现方法。
Unity代码热更实战
Unity的代码热更有两种主流方案:ILRuntime和DLL热更。
ILRuntime是纯C#方案,不依赖平台的CLR,可以在iOS上运行。DLL热更则是通过热更新DLL文件,实现逻辑更新。
我推荐你用ILRuntime,因为上架风险小。DLL热更虽然简单,但在iOS上容易被拒。
ILRuntime基本架构
先搭建一个基础框架。你需要两个项目:一个是主项目,用Unity开发;另一个是热更项目,专门写热更新逻辑。
主项目负责加载热更程序集,调用热更代码。热更项目则包含你的业务逻辑。
结构大概是这样:
Assets/
├── HotUpdate/ # 热更新逻辑
│ ├── HotUpdate.dll # 编译后的热更程序集
│ └── Scripts/ # 热更代码
├── HotUpdateLoader/ # 热更新加载器
│ └── HotUpdateManager.cs
└── Main/ # 主逻辑
└── GameMain.cs
热更项目编写
在热更项目里,写一个继承自ILRuntime.CLR.TypeChecker.Annotated的类。这是为了让ILRuntime能够正确识别和调用。
using UnityEngine;
using ILRuntime.Runtime.Enviorment;
using ILRuntime.CLR.TypeChecker;
// 标记为可热更的类
[Annotated]
public class HotUpdateLogic
{
private ILRuntime.Runtime.Enviorment.AppDomain appdomain;
public void Initialize(AppDomain appdomain)
{
this.appdomain = appdomain;
Debug.Log("热更新逻辑初始化完成");
}
public void OnUpdate()
{
// 热更新逻辑放在这里
Debug.Log("热更新逻辑更新");
}
public int CalculateDamage(int baseDamage, int level)
{
// 业务逻辑
return baseDamage * (1 + level * 0.1f);
}
}
注意,热更类里不要使用Unity的MonoBehaviour,因为ILRuntime运行在独立的AppDomain里,不能直接操作Unity的GameObject。
主项目加载热更
主项目里,需要一个管理器来加载和调用热更代码。
using UnityEngine;
using ILRuntime.Runtime.Enviorment;
using ILRuntime.Runtime.Intpreter;
using System.IO;
public class HotUpdateManager : MonoBehaviour
{
private AppDomain appdomain;
private HotUpdateLogic hotLogic;
void Start()
{
InitializeHotUpdate();
}
void InitializeHotUpdate()
{
// 检查是否有热更新
if (!CheckHotUpdateAvailable())
{
Debug.Log("没有可用的热更新");
return;
}
// 创建AppDomain
appdomain = new AppDomain();
// 加载热更DLL
string hotDllPath = Path.Combine(Application.persistentDataPath, "HotUpdate.dll");
byte[] dllBytes = File.ReadAllBytes(hotDllPath);
appdomain.LoadAssembly(new System.Reflection.Metadata.Ecma332.Ecma332Loader(dllBytes));
// 实例化热更类
var hotType = appdomain.AssemblyLoaded["HotUpdate"].GetType("HotUpdateLogic");
hotLogic = (HotUpdateLogic)appdomain.Instantiate<HotUpdateLogic>(hotType);
hotLogic.Initialize(appdomain);
Debug.Log("热更新加载成功");
}
bool CheckHotUpdateAvailable()
{
// 这里应该检查服务器上的版本信息
// 简单起见,检查本地是否有热更新文件
string hotDllPath = Path.Combine(Application.persistentDataPath, "HotUpdate.dll");
return File.Exists(hotDllPath);
}
void Update()
{
if (hotLogic != null)
{
hotLogic.OnUpdate();
}
}
}
热更新文件管理
热更新文件需要存储在服务器上,客户端定期检查版本,下载新文件。
你可以自己搭建服务器,也可以用一些现成的方案,比如腾讯的TGP热更新服务,或者阿里的OSS。
版本管理很重要。每个热更新文件都要有一个版本号,客户端检查服务器版本号和本地版本号,不同的就下载。
public class HotUpdateVersionManager
{
private string serverVersionUrl = "https://your-server.com/version.txt";
private string localVersionPath = "persistentDataPath/version.txt";
public async Task<(bool hasUpdate, string newVersion)> CheckForUpdate()
{
// 从服务器获取最新版本
string serverVersion = await DownloadString(serverVersionUrl);
// 获取本地版本
string localVersion = "1.0.0";
if (File.Exists(localVersionPath))
{
localVersion = File.ReadAllText(localVersionPath);
}
// 比较版本
bool hasUpdate = CompareVersion(serverVersion, localVersion) > 0;
return (hasUpdate, serverVersion);
}
int CompareVersion(string v1, string v2)
{
// 简单的版本比较
var parts1 = v1.Split('.');
var parts2 = v2.Split('.');
for (int i = 0; i < Mathf.Max(parts1.Length, parts2.Length); i++)
{
int num1 = i < parts1.Length ? int.Parse(parts1[i]) : 0;
int num2 = i < parts2.Length ? int.Parse(parts2[i]) : 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
}
Cocos代码热更实战
Cocos Creator的热更新更简单一些,因为本身就用TypeScript/JavaScript,可以直接热更脚本。
Cocos热更新架构
Cocos的热更新主要依赖assetsManager模块。这个模块提供了完整的下载和管理功能。
基本流程是:
- 检查服务器版本
- 下载更新文件
- 验证文件完整性
- 解压并替换本地文件
版本检查
首先需要检查服务器上的版本信息。版本信息一般是一个JSON文件,包含版本号、更新内容、文件列表等。
// version.json 服务器文件
{
"version": "1.2.0",
"minVersion": "1.0.0",
"url": "https://your-server.com/update/",
"assets": [
{
"path": "resource/game/config.json",
"md5": "a1b2c3d4e5f6",
"compressed": false
},
{
"path": "resource/game/script.js",
"md5": "f6e5d4c3b2a1",
"compressed": true
}
]
}
然后写一个版本管理器:
import { _decorator, Component, Node, json, assetsManager, game, resources, CCString } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('VersionManager')
export class VersionManager extends Component {
@property(CCString)
serverVersionUrl: string = 'https://your-server.com/version.json';
@property(CCString)
localVersion: string = '1.0.0';
private onUpdateCallback: Function = null;
async checkVersion() {
try {
// 下载版本信息
const response = await fetch(this.serverVersionUrl);
const versionInfo = await response.json();
// 检查是否需要强制更新
if (this.isVersionLower(versionInfo.minVersion, this.localVersion)) {
this.notifyUpdateRequired(versionInfo);
return;
}
// 检查是否有热更新
if (this.isVersionHigher(versionInfo.version, this.localVersion)) {
this.onUpdateCallback = this.onUpdateCallback || this.downloadUpdate.bind(this);
this.downloadUpdate(versionInfo);
} else {
console.log('版本已是最新');
this.checkFinished();
}
} catch (e) {
console.error('版本检查失败:', e);
this.checkFinished();
}
}
private isVersionHigher(v1: string, v2: string): boolean {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return true;
if (num1 < num2) return false;
}
return false;
}
private isVersionLower(v1: string, v2: string): boolean {
return !this.isVersionHigher(v2, v1);
}
private downloadUpdate(versionInfo) {
const assetManager = assetsManager.getInstance();
// 创建下载任务
const manifest = {
packageUrl: versionInfo.url,
remoteManifestUrl: versionInfo.url + 'manifest',
remoteVersionUrl: versionInfo.url + 'version',
version: versionInfo.version
};
// 下载并检查
assetManager.loadRemote('manifest', manifest, (err, manifest) => {
if (err) {
console.error('下载manifest失败:', err);
this.checkFinished();
return;
}
// 对比版本,下载更新
assetManager.checkUpdate(manifest, (err, results) => {
if (err) {
console.error('检查更新失败:', err);
this.checkFinished();
return;
}
// 开始下载
this.startDownload(results, versionInfo.version);
});
});
}
private startDownload(results, newVersion) {
const assetManager = assetsManager.getInstance();
const total = results.total;
const downloaded = results.downloaded;
// 进度回调
const onProgress = (completedCount, totalCount, fileProgress, file) => {
const progress = total > 0 ? (completedCount / total) * 100 : 0;
console.log(`下载进度: ${progress.toFixed(2)}%`);
};
// 下载完成回调
const onComplete = (err) => {
if (err) {
console.error('下载失败:', err);
return;
}
console.log('热更新下载完成');
this.applyUpdate(newVersion);
};
// 开始下载
assetManager.downloadFiles(results.urls, {
onProgress: onProgress,
onComplete: onComplete
});
}
private applyUpdate(newVersion) {
// 更新本地版本号
this.localVersion = newVersion;
console.log(`热更新已应用,当前版本: ${newVersion}`);
// 重启游戏
game.restart();
}
private notifyUpdateRequired(versionInfo) {
// 这里应该弹出强制更新对话框
console.log('需要强制更新到版本:', versionInfo.minVersion);
}
private checkFinished() {
// 通知主逻辑版本检查完成
if (this.onUpdateCallback) {
this.onUpdateCallback();
}
}
}
资源热更
Cocos的资源热更比代码热更简单,因为不需要复杂的加载器。只需要下载新资源,然后替换旧资源即可。
// ResourceManager.ts
import { _decorator, Component, Node, assetsManager, resources, CCString } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ResourceManager')
export class ResourceManager extends Component {
@property(CCString)
assetUrl: string = 'https://your-server.com/assets/';
private cache = new Map<string, any>();
async loadAsset(path: string, type: any = null) {
// 先检查缓存
if (this.cache.has(path)) {
return this.cache.get(path);
}
// 构建完整URL
const fullUrl = this.assetUrl + path;
// 下载资源
const asset = await this.downloadAndCache(fullUrl, path, type);
return asset;
}
private async downloadAndCache(url: string, path: string, type: any) {
return new Promise<any>((resolve, reject) => {
resources.load(url, type, (err, asset) => {
if (err) {
console.error(`加载资源失败: ${path}`, err);
reject(err);
return;
}
// 存入缓存
this.cache.set(path, asset);
resolve(asset);
});
});
}
// 清除缓存
clearCache(path?: string) {
if (path) {
this.cache.delete(path);
} else {
this.cache.clear();
}
}
}
资源热更通用方案
无论是Unity还是Cocos,资源热更的原理都差不多。下面给你一个通用的方案。
资源目录结构
首先需要规划好资源的目录结构。推荐按类型分:
assets/
├── images/ # 图片资源
│ ├── ui/
│ ├── character/
│ └── effect/
├── audio/ # 音频资源
│ ├── music/
│ └── sound/
├── scenes/ # 场景文件
└── config/ # 配置文件
├── game.json
└── level.json
资源版本管理
每个资源都需要有一个版本号。可以建立一个版本数据库,记录每个资源的MD5和版本。
{
"version": "1.2.0",
"assets": {
"images/ui/button.png": {
"md5": "a1b2c3d4e5f6",
"size": 102400,
"compressed": true
},
"audio/music/bg.mp3": {
"md5": "f6e5d4c3b2a1",
"size": 2048000,
"compressed": false
}
}
}
客户端下载这个版本数据库,然后和本地的资源进行比较。本地有的资源,MD5一致的就跳过,不一致的就下载。
class ResourceUpdater {
private remoteManifest: any;
private localManifest: any;
async update() {
// 下载远程版本数据库
this.remoteManifest = await this.downloadManifest();
// 加载本地版本数据库
this.localManifest = await this.loadLocalManifest();
// 计算需要更新的文件
const toUpdate = this.calculateUpdates();
if (toUpdate.length === 0) {
console.log('资源已是最新');
return;
}
console.log(`需要更新 ${toUpdate.length} 个文件`);
// 开始下载
await this.downloadFiles(toUpdate);
// 更新本地版本数据库
await this.saveLocalManifest(this.remoteManifest);
}
private calculateUpdates() {
const updates = [];
for (const [path, info] of Object.entries(this.remoteManifest.assets)) {
const localInfo = this.localManifest?.assets?.[path];
// 如果本地没有,或者MD5不一致,需要更新
if (!localInfo || localInfo.md5 !== info.md5) {
updates.push({
path: path,
md5: info.md5,
url: this.getAssetUrl(path),
compressed: info.compressed
});
}
}
return updates;
}
private getAssetUrl(path: string) {
return `https://your-server.com/assets/${path}`;
}
private async downloadFiles(files) {
const promises = files.map(file => this.downloadFile(file));
await Promise.all(promises);
}
private async downloadFile(file) {
return new Promise((resolve, reject) => {
// 使用对应的下载工具
// Unity用UnityWebRequest
// Cocos用fetch或XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', file.url);
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status === 200) {
// 保存文件
this.saveFile(file.path, xhr.response);
resolve();
} else {
reject(new Error(`下载失败: ${file.path}`));
}
};
xhr.onerror = () => reject(new Error(`网络错误: ${file.path}`));
xhr.send();
});
}
private saveFile(path: string, data: Blob) {
// Unity: File.WriteAllBytes
// Cocos: fs.writeFileSync
// 这里用伪代码表示
console.log(`保存文件: ${path}`);
}
}
常见坑点和解决方案
热更新做得好,审核容易过。做得不好,轻则功能不正常,重则被下架。下面把最常见的坑给你列出来。
坑点一:热更新代码导致违规内容
这个是审核被拒最常见的原因。苹果和国内应用商店,都非常敏感于”热更新代码可以动态添加内容”这一点。
解决方案:
第一,热更新代码只能做逻辑修改,不能添加新的功能模块。也就是说,你的热更新只能修改已有的代码,不能新增类或方法。
第二,不要在热更新代码里包含任何用户生成内容。比如不能让用户通过热更新上传表情包、文字等。
第三,热更新服务器要设置白名单,只允许特定的IP访问。防止被黑客劫持,注入恶意代码。
// 热更新服务器配置
const allowedIPs = [
'192.168.1.1', // 你的服务器IP
'10.0.0.1'
];
app.get('/version', (req, res) => {
const clientIP = req.ip;
if (!allowedIPs.includes(clientIP)) {
res.status(403).send('禁止访问');
return;
}
res.json(versionInfo);
});
坑点二:热更新文件太大,下载时间长
资源热更如果文件太大,用户等待时间长,体验差。还可能因为超时导致下载失败。
解决方案:
第一,压缩资源。图片用压缩格式,音频用有损压缩。
第二,增量更新。只下载变化的部分,不要每次都全量下载。
// 增量更新 - 只下载差异文件
public class IncrementalUpdater {
private Dictionary<string, string> localMd5s;
private Dictionary<string, string> remoteMd5s;
public List<string> GetUpdateFiles() {
var toUpdate = new List<string>();
foreach (var (path, remoteMd5) in remoteMd5s) {
if (!localMd5s.TryGetValue(path, out string localMd5) || localMd5 != remoteMd5) {
toUpdate.Add(path);
}
}
return toUpdate;
}
}
第三,分批次下载。不要一次性下载所有文件,可以分成多个小批次,每批次下载几个MB。
坑点三:热更新后版本不一致
有时候热更新下载成功了,但游戏运行时出现奇怪的问题。最常见的原因是版本不一致。
比如,你热更新了代码,但资源还是旧的;或者资源更新了,但代码配置引用的还是旧版本的路径。
解决方案:
第一,建立完整的版本管理机制。代码版本和资源版本要一起管理。
{
"gameVersion": "1.2.0",
"codeVersion": "1.2.0",
"resourceVersion": "1.2.0",
"compatibilityVersion": "1.0.0"
}
第二,热更新时要验证版本兼容性。如果代码版本和资源版本不匹配,就提示用户重新下载。
async validateVersionCompatibility() {
const currentCodeVersion = await this.getCodeVersion();
const currentResourceVersion = await this.getResourceVersion();
if (currentCodeVersion !== currentResourceVersion) {
console.error('版本不兼容,需要重新下载');
this.forceFullUpdate();
return false;
}
return true;
}
第三,热更新前先备份当前版本。如果新版本的有问题,可以快速回滚。
坑点四:热更新失败导致游戏崩溃
热更新过程中可能遇到各种错误:网络中断、文件损坏、磁盘空间不足等。如果处理不好,会导致游戏崩溃。
解决方案:
第一,下载时使用临时文件,下载完成后再替换原文件。这样即使下载失败,也不会破坏原有文件。
async downloadWithBackup(fileInfo) {
const tempPath = `${fileInfo.path}.temp`;
const finalPath = fileInfo.path;
try {
// 先下载到临时文件
await this.downloadFile(fileInfo.url, tempPath);
// 验证文件完整性
await this.verifyFile(tempPath, fileInfo.md5);
// 替换原文件
await this.replaceFile(tempPath, finalPath);
// 删除临时文件
await this.deleteFile(tempPath);
return true;
} catch (e) {
// 失败时清理临时文件
await this.deleteFile(tempPath);
throw e;
}
}
第二,下载失败时要有重试机制。但不宜无限重试,一般重试3-5次。
async downloadWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await this.downloadFile(url);
} catch (e) {
if (i === maxRetries - 1) throw e;
console.log(`下载失败,重试 ${i + 1}/${maxRetries}`);
await this.delay(1000 * (i + 1)); // 指数退避
}
}
}
第三,要有完善的日志系统。记录每次热更新的详细信息,方便排查问题。
坑点五:iOS上架被拒
iOS对热更新审核特别严格。很多开发者在这里栽跟头。
解决方案:
第一,不要在热更新代码里包含任何可能引起审核拒绝的内容。比如虚拟货币、赌博、色情等。
第二,热更新功能本身要在申请上架时就声明。在App Store Connect里,有一个”热更新”的选项,需要勾上并说明用途。
第三,热更新的内容要是”非用户生成内容”。也就是说,只能是你自己制作的更新内容,不能让用户上传内容。
第四,如果可能,尽量使用”静默更新”。也就是不提示用户,后台自动下载更新。这种方式更容易通过审核。
// iOS端的实现示例
- (void)checkForUpdate {
// 检查版本
[self checkVersionWithCompletion:^(BOOL hasUpdate, NSString *version) {
if (hasUpdate) {
// 静默下载,不提示用户
[self downloadUpdateSilently];
}
}];
}
- (void)downloadUpdateSilently {
// 后台下载
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.yourapp.update"];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURL *url = [NSURL URLWithString:@"https://your-server.com/update.zip"];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:request
completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"下载失败: %@", error);
return;
}
// 解压并替换
[self extractAndReplace:location];
}];
[task resume];
}
坑点六:热更新与离线模式冲突
有些用户可能在网络不好的情况下玩游戏。如果热更新逻辑没有处理好离线模式,会导致游戏卡死或者数据丢失。
解决方案:
第一,热更新只在有网络时进行。检测网络状态,没网络就跳过热更新。
class NetworkChecker {
static async isOnline(): Promise<boolean> {
try {
const response = await fetch('https://www.gstatic.com/generate_204', {
mode: 'no-cors',
cache: 'no-store'
});
return response.ok;
} catch (e) {
return false;
}
}
}
// 使用
if (await NetworkChecker.isOnline()) {
await hotUpdateManager.checkAndUpdate();
} else {
console.log('离线模式,跳过热更新');
}
第二,热更新和离线数据要分开存储。热更新的文件放在专门的目录,离线数据放在另一个目录。
第三,热更新失败时,要有降级方案。比如回滚到上一个可用版本,或者提示用户手动更新。
热更新审核技巧
最后,说说怎么让热更新顺利通过审核。
1. 审核前准备
在申请上架时,准备好以下材料:
- 热更新功能的详细说明
- 热更新内容的截图或录屏
- 热更新服务器的域名(需要备案)
- 热更新的安全措施说明
2. 审核时的应对
审核人员可能会问一些问题,提前准备好答案:
问:你们的热更新是什么? 答:我们只做非用户生成内容的逻辑和资源更新,用于修复bug和优化体验。
问:热更新包含哪些内容? 答:主要是数值调整、bug修复和性能优化,不包含新的游戏功能或用户内容。
问:热更新安全吗? 答:我们使用了HTTPS传输、MD5校验和服务器白名单,确保更新文件安全。
3. 审核后的维护
如果审核通过,还要继续维护。因为苹果和安卓都会不定期抽查。所以:
- 保持热更新日志完整
- 定期检查服务器安全
- 及时响应审核反馈
总结
热更新是个技术活,也是个细心活。做不好会出各种问题,做不好还会上架被拒。
总结一下关键点:
代码热更:Unity用ILRuntime,Cocos用原生热更模块。推荐ILRuntime,更安全。
资源热更:通用方案,检查版本、下载差异、验证完整性。
常见坑点:违规内容、文件太大、版本不一致、下载失败、iOS被拒、离线冲突。
审核技巧:提前准备材料、诚实回答、及时维护。
记住,热更新是为了提升用户体验,不是为了绕过审核。只要你做的是合规的热更新,审核不会太难。
希望这篇教程能帮到你。如果还有问题,随时问我。
