王者荣耀和平精英热更新原理:Unity游戏不重装包也能天天更新玩法 从配置到实战一键解决更新失败和下载超时问题
兄弟们,先问个问题:你有没有想过,为什么王者荣耀几乎每周都要更新,但你的安装包只有几百兆,而游戏的资源和文件加起来已经好几个G了?和平精英更是夸张,每次新地图、新皮肤、新载具,你都不用去应用商店重新下载整个包,直接点开游戏就自动更新了——这背后就是热更新技术在支撑。
热更新这个词听起来很高大上,其实说白了就是:游戏不用重装,就能在线更新内容。今天咱们就把这层窗户纸捅破,从原理到代码,从配置到踩坑,一口气讲清楚。
热更新到底是什么玩意儿
你可以把热更新理解成”补丁系统”,但比补丁高级多了。
普通的应用更新是什么逻辑?你打开App Store,看到”更新”按钮,点一下,下载几百兆,安装,重启。整个过程你可能要等几分钟,而且一旦更新失败,整个包就废了,得重新下载。
热更新完全不同。它的核心思想是:主程序(App包)保持不动,把频繁变动的内容拆出去,运行时按需加载。
王者荣耀的主程序包大概也就300-400MB左右,但整个游戏的资源加起来可能超过20GB。这20GB里,哪些东西是每次都要变的?角色皮肤、新英雄模型、地图改动、平衡性调整、新活动文案……这些东西如果都塞进安装包,你每次更新都得下载几个G,玩家早跑路了。
所以游戏的结构就变成了三层:
┌─────────────────────────────────────┐
│ 安装包(App Store下载的固定包) │
│ - 游戏引擎(Unity) │
│ - 核心逻辑代码(Lua/C#) │
│ - 基础资源(启动必须的) │
└─────────────────────────────────────┘
↓ 运行时加载
┌─────────────────────────────────────┐
│ 热更资源层(服务器下发) │
│ - 场景资源(地图、模型、贴图) │
│ - 热更代码(Lua脚本、补丁DLL) │
│ - 活动配置(文案、数值、表格) │
└─────────────────────────────────────┘
↓ 版本管理
┌─────────────────────────────────────┐
│ 版本控制系统 │
│ - 资源MD5校验 │
│ - 增量/全量更新策略 │
│ - 失败回滚机制 │
└─────────────────────────────────────┘
理解了结构,我们再说原理。热更新本质上做了三件事:
第一,把资源和代码分离。 安装包只保留引擎和引导逻辑,具体的玩法内容放在服务器上。
第二,建立资源版本管理。 每个资源文件都有一个唯一的ID(通常是MD5值),服务器记录哪些文件存在、哪些更新了、哪些删除了。
第三,实现差异下载。 客户端知道自己有哪些文件,服务器知道自己有哪些文件,两者一比对,就知道需要下载哪些差异部分。
这三个动作合在一起,就是热更新的全部秘密。
王者荣耀和平精英具体用的什么方案
市面上热更新方案很多,但王者荣耀和和平精英这种级别的国民游戏,用的都是自研或深度定制的框架。从公开的技术分享和逆向分析来看,他们的核心方案是这样的:
资源热更:XAsset + 自研补丁系统
王者荣耀的资源管理用的是类似Unity官方AssetBundle的机制,但做了大量优化。核心流程是这样的:
游戏启动时,客户端会向服务器请求一个manifest文件。这个manifest是个JSON,记录了所有资源的版本信息:
{
"version": "20240315.001",
"platform": "android",
"resources": {
"assetbundle/hero/lixin/newskin.unity3d": {
"md5": "a3f5c8d2e1b4f6a9c7d8e2f1b3a4c5d6",
"size": 52428800,
"isDeleted": false
},
"assetbundle/scene/newmap.unity3d": {
"md5": "b4e6d8f1a2c3e5d7f9b1a3c5e7d9f1b3",
"size": 104857600,
"isDeleted": false
},
"assetbundle/hero/zhangfei/old_skin.unity3d": {
"md5": "c5f7e9a1b2d3f5c7e9a1b3d5f7c9e1a3",
"size": 31457280,
"isDeleted": true
}
}
}
客户端拿到manifest后,会和自己本地存储的manifest做对比,找出差异。这个对比过程非常关键,直接决定了下载量有多大。
代码热更:Lua + IL2CPP热修复
王者荣耀的逻辑层大量使用Lua脚本。Unity本身不支持热更C#代码(因为IL2CPP编译后是原生代码),但Lua天然支持热更——解释型语言嘛,改个文件就行。
和平精英则是混合方案:核心逻辑用C#,但可热更的部分抽成DLL,通过ILRuntime或类似的运行时编译方案实现热更新。
// 伪代码展示热更新代码加载流程
public class HotfixLoader : MonoBehaviour
{
private HotfixManifest manifest;
private Dictionary<string, byte[]> hotfixCache;
// 启动时检查热更
public async void CheckHotfix()
{
// 1. 从服务器拉取热更manifest
manifest = await DownloadManifest(
$"https://hotfix.tencent.com/{version}/manifest.json");
// 2. 比对本地版本
var diffList = CompareLocalAndServer(manifest);
// 3. 下载差异包
foreach (var item in diffList)
{
var bytes = await DownloadFile(item.url);
hotfixCache[item.key] = bytes;
}
// 4. 校验完整性
if (!VerifyIntegrity(hotfixCache))
{
// 校验失败,回退到上一次正常版本
Rollback();
return;
}
// 5. 加载热更内容
LoadHotfix(hotfixCache);
// 6. 标记热更完成
SaveHotfixDone();
}
}
为什么你的游戏经常会更新失败或下载超时
这个问题太普遍了,我刷B站都能刷到一堆人吐槽。咱们来分析一下根本原因。
原因一:网络环境复杂,单次下载超时
热更新需要下载的文件可能很大,动辄几百MB。如果用户在4G网络下,或者WiFi信号弱,下载过程很容易超时。
很多游戏的下载器设计得很简单:就是一个TCP连接,连上服务器,然后下载。一旦网络抖动,连接断开,整个下载就失败了,还得从头开始。这体验太差了。
原因二:断点续传没做好
好的下载器应该支持断点续传——你下了一半网络断了,重新连上之后从断点继续,而不是重新开始。但很多小团队做的热更系统,根本没用断点续传,导致用户每次都要重新下载。
原因三:服务器压力没做好分流
王者荣耀同时在线几千万人,如果所有人都同时去同一个服务器拉取热更包,服务器直接崩给你看。所以大厂都会做CDN分流、P2P辅助下载、地区就近接入等优化。但很多中小团队没有这个能力。
原因四:校验失败后没有友好提示
很多游戏的下载失败后,就弹一个”网络错误,请重试”,用户根本不知道发生了什么。是网络断了?是文件损坏?还是服务器问题?一概不说。
怎么从零搭建一套健壮的热更新系统
咱们不聊虚的,直接上干货。下面是一个完整的Unity热更新框架实现思路,你可以直接参考。
第一步:设计热更架构
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 客户端 │────▶│ 热更服务器 │────▶│ CDN/对象存储 │
│ │ │ │ │ │
│ - 版本检查 │ │ - manifest │ │ - 资源文件 │
│ - 差异计算 │ │ - 差分生成 │ │ - 补丁文件 │
│ - 下载管理 │ │ - 下载调度 │ │ │
│ - 校验加载 │ │ │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ 本地存储 │◀─────────────────────▶│ 补丁服务器 │
│ │ 增量/全量下载 │ - 差分包 │
│ - 本地manifest │ └──────────────┘
│ - 已下载资源
│ - 下载缓存
└─────────────┘
第二步:Manifest设计——热更的”账本”
Manifest是热更新系统的核心。它要回答三个问题:现在版本是什么?有哪些文件?每个文件的版本和大小是多少?
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 热更新Manifest,描述当前版本所有资源的完整信息
/// </summary>
[System.Serializable]
public class HotfixManifest
{
[Header("版本信息")]
public string version; // 版本号,格式:YYYYMMDD.NNN
public string platform; // 平台标识:android / ios
public string buildId; // 构建ID,用于精确追踪
[Header("主程序信息")]
public string mainBundleMd5; // 主程序包的MD5,用于检测主程序是否需要更新
public int mainBundleSize; // 主程序包大小(字节)
[Header("资源列表")]
public Dictionary<string, ResourceEntry> resources = new Dictionary<string, ResourceEntry>();
[Header("下载配置")]
public int maxConcurrentDownloads = 4; // 最大并发下载数
public int downloadTimeout = 60; // 单个文件下载超时(秒)
public bool usePatching = true; // 是否启用差量更新
public string patchServerUrl; // 差量更新服务器地址
/// <summary>
/// 单个资源条目的数据结构
/// </summary>
[System.Serializable]
public class ResourceEntry
{
public string md5; // 文件MD5,用于校验
public int size; // 文件大小(字节)
public string url; // 下载URL(相对于CDN根目录)
public bool isDeleted; // 是否已删除(热更标记)
public bool isRequired; // 是否必须下载(平台/游戏版本要求)
public string group; // 资源分组,便于分批下载
public string tags; // 标签,便于按条件过滤
}
/// <summary>
/// 计算本地与服务器之间的差异
/// </summary>
public HotfixDiff CalculateDiff(LocalManifest local)
{
var diff = new HotfixDiff();
foreach (var kvp in resources)
{
var serverEntry = kvp.Value;
local.resources.TryGetValue(kvp.Key, out var localEntry);
if (localEntry == null)
{
// 本地没有,需要下载
if (!serverEntry.isDeleted)
{
diff.AddNew(kvp.Key, serverEntry);
}
}
else if (localEntry.md5 != serverEntry.md5)
{
// MD5不同,需要更新
if (serverEntry.isDeleted)
{
diff.AddDelete(kvp.Key);
}
else
{
diff.AddUpdate(kvp.Key, serverEntry, localEntry);
}
}
// MD5相同,无需操作
}
// 检查本地有但服务器删除的(安全清理)
foreach (var kvp in local.resources)
{
if (!resources.ContainsKey(kvp.Key) && !kvp.Value.isDeleted)
{
// 本地存在但服务器manifest中已移除
// 这种情况通常需要保留,标记为待清理
diff.AddStale(kvp.Key, kvp.Value);
}
}
return diff;
}
/// <summary>
/// 计算总下载大小,用于进度显示
/// </summary>
public long GetTotalDownloadSize(HotfixDiff diff)
{
long total = 0;
foreach (var entry in diff.NewFiles.Values)
total += entry.size;
foreach (var update in diff.UpdateFiles.Values)
total += update.serverEntry.size;
return total;
}
}
/// <summary>
/// 热更差异结果
/// </summary>
public class HotfixDiff
{
// 需要新下载的文件:文件名 -> ResourceEntry
public Dictionary<string, HotfixManifest.ResourceEntry> NewFiles = new Dictionary<string, HotfixManifest.ResourceEntry>();
// 需要更新的文件:文件名 -> (serverEntry, localEntry)
public Dictionary<string, (HotfixManifest.ResourceEntry serverEntry, HotfixManifest.ResourceEntry localEntry)> UpdateFiles = new Dictionary<string, (HotfixManifest.ResourceEntry, HotfixManifest.ResourceEntry)>();
// 需要删除的文件
public HashSet<string> DeleteFiles = new HashSet<string>();
// 本地存在但服务器已移除的文件(安全清理标记)
public Dictionary<string, HotfixManifest.ResourceEntry> StaleFiles = new Dictionary<string, HotfixManifest.ResourceEntry>();
public void AddNew(string key, HotfixManifest.ResourceEntry entry)
{
NewFiles[key] = entry;
}
public void AddUpdate(string key, HotfixManifest.ResourceEntry serverEntry, HotfixManifest.ResourceEntry localEntry)
{
UpdateFiles[key] = (serverEntry, localEntry);
}
public void AddDelete(string key)
{
DeleteFiles.Add(key);
}
public void AddStale(string key, HotfixManifest.ResourceEntry entry)
{
StaleFiles[key] = entry;
}
public int GetTotalFileCount()
{
return NewFiles.Count + UpdateFiles.Count + DeleteFiles.Count;
}
}
第三步:实现带断点续传的下载器
这是解决下载超时和失败最核心的部分。
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
/// <summary>
/// 支持断点续传、并发控制、失败重试的热更新下载器
/// </summary>
public class HotfixDownloader : MonoBehaviour
{
public static HotfixDownloader Instance { get; private set; }
[Header("下载配置")]
[Tooltip("最大并发下载数")]
public int maxConcurrency = 3;
[Tooltip("单个文件下载超时(秒)")]
public int downloadTimeout = 90;
[Tooltip("失败重试次数")]
public int maxRetryCount = 3;
[Tooltip("重试间隔(秒)")]
public float retryInterval = 2f;
[Header("存储配置")]
[Tooltip("热更资源根目录")]
public string hotfixRootPath;
[Tooltip("临时下载缓存目录")]
public string tempCachePath;
[Tooltip("补丁文件目录(差量更新用)")]
public string patchDirPath;
// 下载状态管理
private int activeDownloads = 0;
private Dictionary<string, DownloadTask> downloadQueue = new Dictionary<string, DownloadTask>();
private Dictionary<string, DownloadTask> downloadingTasks = new Dictionary<string, DownloadTask>();
private Dictionary<string, DownloadTask> completedTasks = new Dictionary<string, DownloadTask>();
private Dictionary<string, DownloadTask> failedTasks = new Dictionary<string, DownloadTask>();
// 回调
public Action<float, float> OnProgressChanged; // 当前进度, 总进度
public Action<string, string> OnFileComplete; // 文件名, 目标路径
public Action<string> OnFileFailed; // 失败的文件名
public Action<bool, string> OnComplete; // 是否成功, 错误信息
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
EnsureDirectories();
}
private void EnsureDirectories()
{
hotfixRootPath = Path.Combine(Application.persistentDataPath, "hotfix");
tempCachePath = Path.Combine(hotfixRootPath, "temp");
patchDirPath = Path.Combine(hotfixRootPath, "patches");
Directory.CreateDirectory(hotfixRootPath);
Directory.CreateDirectory(tempCachePath);
Directory.CreateDirectory(patchDirPath);
}
/// <summary>
/// 开始下载一组文件
/// </summary>
public void StartDownload(HotfixDiff diff, Action<bool, string> onComplete)
{
OnComplete = onComplete;
// 清空之前的状态
downloadQueue.Clear();
downloadingTasks.Clear();
completedTasks.Clear();
failedTasks.Clear();
activeDownloads = 0;
// 加入下载队列
foreach (var kvp in diff.NewFiles)
{
downloadQueue[kvp.Key] = new DownloadTask(
kvp.Key, kvp.Value.url, kvp.Value.size,
TaskType.New);
}
foreach (var kvp in diff.UpdateFiles)
{
downloadQueue[kvp.Key] = new DownloadTask(
kvp.Key, kvp.Value.serverEntry.url, kvp.Value.serverEntry.size,
TaskType.Update, kvp.Value.localEntry);
}
foreach (var key in diff.DeleteFiles)
{
ScheduleDelete(key);
}
// 启动下载协程
StartCoroutine(DownloadLoop());
}
private IEnumerator DownloadLoop()
{
float totalSize = CalculateTotalSize();
long downloadedSize = 0;
int totalFiles = downloadQueue.Count;
int completedCount = 0;
while (downloadQueue.Count > 0 || downloadingTasks.Count > 0)
{
// 尝试启动新的下载(不超过并发限制)
while (downloadQueue.Count > 0 && activeDownloads < maxConcurrency)
{
var kv = downloadQueue.ElementAt(0);
downloadQueue.Remove(kv.Key);
var task = kv.Value;
downloadingTasks[task.fileName] = task;
activeDownloads++;
StartCoroutine(DownloadFile(task, totalSize, () => {
completedCount++;
activeDownloads--;
downloadingTasks.Remove(task.fileName);
// 进度回调
float progress = (float)downloadedSize / totalSize;
OnProgressChanged?.Invoke(progress, 1f);
}));
}
yield return null;
}
// 所有下载完成
if (failedTasks.Count == 0)
{
OnComplete?.Invoke(true, null);
}
else
{
OnComplete?.Invoke(false, $"有 {failedTasks.Count} 个文件下载失败");
}
}
private IEnumerator DownloadFile(DownloadTask task, float totalSize, Action onProgress)
{
int retryCount = 0;
while (retryCount <= maxRetryCount)
{
bool success = false;
string tempPath = null;
try
{
// 检查是否有上次下载的残片(断点续传)
long resumeOffset = GetResumedSize(task);
// 生成临时文件路径
tempPath = Path.Combine(tempCachePath, GetSafeFileName(task.fileName));
// 创建或打开文件
FileStream fs = null;
if (resumeOffset > 0)
{
// 断点续传:追加模式
fs = new FileStream(tempPath, FileMode.Append, FileAccess.Write);
}
else
{
// 从头开始:创建/覆盖
fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write);
}
using (var www = new UnityWebRequest(task.url, UnityWebRequest.kHttpVerbGET))
{
// 设置断点续传
if (resumeOffset > 0)
{
www.SetRequestHeader("Range", $"bytes={resumeOffset}-");
}
www.timeout = downloadTimeout;
// 下载数据
var downloader = new DownloadHandlerBuffer();
www.downloadHandler = downloader;
var sent = www.SendWebRequest();
while (!sent.isDone)
{
// 进度更新
if (onProgress != null)
onProgress();
yield return null;
}
if (www.result == UnityWebRequest.Result.ConnectionError ||
www.result == UnityWebRequest.Result.ProtocolError)
{
Debug.LogWarning($"[{task.fileName}] 下载失败: {www.error}");
www.Dispose();
retryCount++;
if (retryCount <= maxRetryCount)
{
yield return new WaitForSeconds(retryInterval);
}
continue;
}
// 写入文件
byte[] data = downloader.data;
fs.Write(data, 0, data.Length);
fs.Flush();
success = true;
// 记录下载大小(用于断点续传)
SaveResumedSize(task, resumeOffset + data.Length);
}
www?.Dispose();
fs?.Close();
if (success)
{
// 校验MD5
if (!VerifyMd5(tempPath, task.expectedMd5))
{
Debug.LogWarning($"[{task.fileName}] MD5校验失败,重新下载");
File.Delete(tempPath);
retryCount++;
if (retryCount <= maxRetryCount)
{
yield return new WaitForSeconds(retryInterval);
}
continue;
}
// 移动到目标位置
string targetPath = GetTargetPath(task.fileName, task.type);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath));
File.Move(tempPath, targetPath);
completedTasks[task.fileName] = task;
OnFileComplete?.Invoke(task.fileName, targetPath);
}
}
catch (Exception e)
{
Debug.LogError($"[{task.fileName}] 下载异常: {e.Message}");
retryCount++;
if (retryCount <= maxRetryCount)
{
yield return new WaitForSeconds(retryInterval);
}
}
finally
{
// 清理临时文件
if (tempPath != null && File.Exists(tempPath))
{
// 如果成功才保留,失败则删除
if (!completedTasks.ContainsKey(task.fileName) &&
!failedTasks.ContainsKey(task.fileName))
{
File.Delete(tempPath);
}
}
}
if (success) break;
}
if (!success)
{
failedTasks[task.fileName] = task;
OnFileFailed?.Invoke(task.fileName);
}
// 清除断点记录
ClearResumedSize(task);
}
/// <summary>
/// 校验文件MD5
/// </summary>
private bool VerifyMd5(string filePath, string expectedMd5)
{
using (var md5 = MD5.Create())
using (var stream = File.OpenRead(filePath))
{
var hash = md5.ComputeHash(stream);
var hashString = new StringBuilder();
foreach (var b in hash)
hashString.Append(b.ToString("x2"));
return hashString.ToString() == expectedMd5;
}
}
/// <summary>
/// 获取目标文件路径
/// </summary>
private string GetTargetPath(string fileName, TaskType type)
{
// 把文件名中的斜杠转换成路径层级
string relativePath = fileName.Replace('/', Path.DirectorySeparatorChar);
if (type == TaskType.New || type == TaskType.Update)
{
return Path.Combine(hotfixRootPath, relativePath);
}
return Path.Combine(hotfixRootPath, relativePath);
}
private float CalculateTotalSize()
{
float total = 0;
foreach (var task in downloadQueue.Values)
total += task.expectedSize;
foreach (var task in downloadingTasks.Values)
total += task.expectedSize;
return total;
}
// 断点续传相关文件
private Dictionary<string, long> resumeOffsets = new Dictionary<string, long>();
private long GetResumedSize(DownloadTask task)
{
if (resumeOffsets.TryGetValue(task.fileName, out long offset))
return offset;
// 从磁盘读取
string metaPath = Path.Combine(tempCachePath, task.fileName + ".meta");
if (File.Exists(metaPath))
{
try
{
string content = File.ReadAllText(metaPath);
if (long.TryParse(content, out long result))
return result;
}
catch { }
}
return 0;
}
private void SaveResumedSize(DownloadTask task, long size)
{
resumeOffsets[task.fileName] = size;
string metaPath = Path.Combine(tempCachePath, task.fileName + ".meta");
File.WriteAllText(metaPath, size.ToString());
}
private void ClearResumedSize(DownloadTask task)
{
resumeOffsets.Remove(task.fileName);
string metaPath = Path.Combine(tempCachePath, task.fileName + ".meta");
if (File.Exists(metaPath))
File.Delete(metaPath);
}
private void ScheduleDelete(string fileName)
{
string targetPath = GetTargetPath(fileName, TaskType.Delete);
if (File.Exists(targetPath))
{
File.Delete(targetPath);
Debug.Log($"已删除热更文件: {fileName}");
}
}
private string GetSafeFileName(string fileName)
{
return Convert.ToBase64String(Encoding.UTF8.GetBytes(fileName)).Replace('/', '_');
}
}
/// <summary>
/// 下载任务
/// </summary>
public class DownloadTask
{
public string fileName;
public string url;
public long expectedSize;
public string expectedMd5;
public TaskType type;
public HotfixManifest.ResourceEntry localEntry;
public DownloadTask(string fileName, string url, long expectedSize,
TaskType type, HotfixManifest.ResourceEntry localEntry = null)
{
this.fileName = fileName;
this.url = url;
this.expectedSize = expectedSize;
this.expectedMd5 = ""; // 从manifest获取
this.type = type;
this.localEntry = localEntry;
}
}
public enum TaskType { New, Update, Delete, Patch }
第四步:差量更新(Patch)—— 让下载量减少90%
差量更新是网易、腾讯这些大厂都在用的技术。它的核心思想是:不下载完整的文件,只下载两个版本之间的差异。
比如一个100MB的文件,改了其中1MB的内容,差量更新后你只需要下载这个1MB的补丁,而不是整个100MB。
原版文件 (100MB) 新版文件 (100MB)
├── AAAAA... (50MB) ├── AAAAA... (50MB) ← 完全相同
├── BBBB... (20MB) ├── B'B'B'B' (18MB) ← 内容有修改
├── CCCCC... (20MB) ├── CCCCC... (20MB) ← 完全相同
└── DDDD... (10MB) └── D'D'D'D' (12MB) ← 新增了内容
生成补丁的方式(bzip2 + bsdiff算法):
# 服务器端:生成差量补丁(Python脚本示例)
import bsdiff4
def generate_patch(old_file_path, new_file_path, patch_output_path):
"""
生成两个文件之间的差量补丁
bsdiff4 是一个Python库,使用经典的bsdiff算法
"""
with open(old_file_path, 'rb') as f_old:
old_data = f_old.read()
with open(new_file_path, 'rb') as f_new:
new_data = f_new.read()
# 生成补丁
patch_data = bsdiff4.diff(old_data, new_data)
# 保存补丁
with open(patch_output_path, 'wb') as f_patch:
f_patch.write(patch_data)
patch_size = len(patch_data)
original_size = len(new_data)
print(f"原版大小: {original_size / 1024 / 1024:.2f} MB")
print(f"补丁大小: {patch_size / 1024 / 1024:.2f} MB")
print(f"压缩率: {(1 - patch_size / original_size) * 100:.1f}%")
return patch_data
def apply_patch(patch_path, target_file_path, output_path):
"""
客户端:应用差量补丁
注意:客户端需要知道旧文件的MD5,或者服务器同时下发旧文件
实际生产中,通常是把旧文件缓存下来,然后应用补丁
"""
import bsdiff4
with open(patch_path, 'rb') as f_patch:
patch_data = f_patch.read()
with open(target_file_path, 'rb') as f_old:
old_data = f_old.read()
# 应用补丁,生成新文件
new_data = bsdiff4.patch(old_data, patch_data)
with open(output_path, 'wb') as f_new:
f_new.write(new_data)
print(f"补丁应用成功,新文件大小: {len(new_data) / 1024 / 1024:.2f} MB")
在Unity客户端中的应用:
/// <summary>
/// 差量更新管理器
/// 负责下载和应用补丁,而不是完整文件
/// </summary>
public class PatchManager : MonoBehaviour
{
private string patchCacheDir;
public IEnumerator ApplyPatch(string fileName, string patchUrl, string oldMd5)
{
// 1. 找到本地旧文件
string oldFilePath = FindOldFile(fileName, oldMd5);
if (oldFilePath == null)
{
// 找不到旧文件,退回全量下载
Debug.LogWarning($"找不到旧文件 {fileName},退回全量下载");
yield break;
}
// 2. 下载补丁文件
string patchPath = Path.Combine(patchCacheDir, GetSafeFileName(fileName) + ".patch");
using (var www = new UnityWebRequest(patchUrl))
{
www.downloadHandler = new DownloadHandlerBuffer();
yield return www.SendWebRequest();
if (www.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"补丁下载失败: {www.error}");
yield break;
}
File.WriteAllBytes(patchPath, ((DownloadHandlerBuffer)www.downloadHandler).data);
}
// 3. 应用补丁
string newFilePath = Path.Combine(hotfixRootPath, fileName.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(newFilePath));
// 使用bzip2 + bsdiff算法应用补丁
// 注意:这里需要调用Native插件或第三方库
bool success = ApplyBsdiffPatch(oldFilePath, patchPath, newFilePath);
if (!success)
{
Debug.LogError($"补丁应用失败,退回全量下载");
yield break;
}
// 4. 校验新文件MD5
string newMd5 = CalculateMd5(newFilePath);
// TODO: 与服务器下发的MD5对比
// 5. 清理补丁文件
File.Delete(patchPath);
Debug.Log($"差量更新成功: {fileName}");
}
private bool ApplyBsdiffPatch(string oldPath, string patchPath, string newPath)
{
// 实际实现需要调用C++原生库
// 这里用一个伪代码框架展示思路
IntPtr oldPtr = NativeMethods.ReadFile(oldPath);
IntPtr patchPtr = NativeMethods.ReadFile(patchPath);
IntPtr resultPtr = NativeMethods.BsdiffPatch(oldPtr, patchPtr);
NativeMethods.WriteFile(newPath, resultPtr);
NativeMethods.Free(oldPtr);
NativeMethods.Free(patchPtr);
NativeMethods.Free(resultPtr);
return resultPtr != IntPtr.Zero;
}
}
第五步:下载失败和超时的完整解决方案
这是很多玩家最头疼的问题。咱们把每种失败场景都列出来,给出对应解法。
/// <summary>
/// 热更新状态管理器
/// 处理所有异常情况和恢复策略
/// </summary>
public class HotfixStateManager : MonoBehaviour
{
[Header("重试策略配置")]
public int maxTotalRetries = 5;
public float exponentialBackoffBase = 2f; // 指数退避基数
public float maxBackoffDelay = 30f; // 最大退避延迟
[Header("超时配置")]
public int downloadTimeoutSeconds = 90;
public int connectionTimeoutSeconds = 15;
[Header("服务器配置")]
public string[] fallbackServers; // 备用服务器列表
private int totalRetryCount = 0;
private string currentFailureReason;
/// <summary>
/// 处理下载失败,返回是否需要重试以及重试延迟
/// </summary>
public (bool shouldRetry, float delaySeconds, string reason) HandleDownloadFailure(
string fileName,
UnityWebRequest.Result result,
string error)
{
totalRetryCount++;
if (totalRetryCount > maxTotalRetries)
{
currentFailureReason = $"已达到最大重试次数({maxTotalRetries}),无法继续更新";
return (false, 0, currentFailureReason);
}
// 根据不同错误类型决定策略
switch (result)
{
case UnityWebRequest.Result.ConnectionError:
// 网络断开,使用指数退避重试
currentFailureReason = $"网络连接失败: {error}";
return GetExponentialBackoff();
case UnityWebRequest.Result.ProtocolError:
if (error.Contains("404"))
{
// 文件不存在,可能是服务器配置问题,切换备用服务器
currentFailureReason = "主服务器返回404,尝试备用服务器";
SwitchToFallbackServer();
return (true, 0, currentFailureReason);
}
if (error.Contains("503"))
{
// 服务器繁忙,延迟重试
currentFailureReason = "服务器繁忙(503),稍后重试";
return GetExponentialBackoff();
}
break;
case UnityWebRequest.Result.DataProcessingError:
// MD5校验失败,重新下载
currentFailureReason = "文件校验失败,重新下载";
return (true, 0, currentFailureReason);
case UnityWebRequest.Result.Timeout:
// 超时,切换服务器或增加超时时间重试
currentFailureReason = "下载超时,尝试其他服务器";
SwitchToFallbackServer();
return (true, 0, currentFailureReason);
default:
currentFailureReason = $"未知错误: {result} - {error}";
return GetExponentialBackoff();
}
return (false, 0, currentFailureReason);
}
/// <summary>
/// 指数退避重试策略
/// 第1次重试等2秒,第2次等4秒,第3次等8秒...最多30秒
/// </summary>
private (bool, float, string) GetExponentialBackoff()
{
float delay = Mathf.Min(
Mathf.Pow(exponentialBackoffBase, totalRetryCount),
maxBackoffDelay
);
return (true, delay, currentFailureReason);
}
/// <summary>
/// 切换到备用服务器
/// </summary>
private void SwitchToFallbackServer()
{
// TODO: 实现服务器切换逻辑
// 1. 从配置中获取下一个备用服务器
// 2. 更新下载URL前缀
// 3. 记录切换日志
}
/// <summary>
/// 网络不可用时的处理
/// </summary>
public void HandleNetworkUnavailable()
{
// 检查是否有可用的离线缓存
if (HasValidOfflineCache())
{
// 使用离线缓存启动游戏
LoadFromOfflineCache();
}
else
{
// 显示强制更新提示
ShowForcedUpdateDialog();
}
}
private bool HasValidOfflineCache()
{
// 检查本地缓存的版本是否与当前版本兼容
// 逻辑:检查缓存中的manifest版本是否 >= 游戏需要的最低版本
return true; // TODO: 实现具体逻辑
}
private void LoadFromOfflineCache()
{
// 从本地缓存加载资源,跳过热更
// 注意:这会使用旧版资源,某些新功能可能不可用
}
private void ShowForcedUpdateDialog()
{
// 显示全屏更新提示,要求用户必须完成更新才能继续
// 提供"使用WiFi更新"和"继续使用旧版本"两个选项
}
}
实际开发中的常见坑和解决方案
坑一:MD5校验导致资源更新不生效
很多开发者做完热更后发现,改了一个图片,重新打包上传,但游戏里加载的还是旧图。
原因通常是MD5计算方式不一致。服务器算MD5用的是Python的md5sum,客户端算用的是Unity的某个库,结果算出来不一样。
解决方案: 统一MD5计算方式,建议使用标准的MD5算法,并且在服务器和客户端使用相同的输入(比如确保文件编码、换行符一致)。
// 统一使用标准MD5
public static string CalculateMd5(string filePath)
{
using (var md5 = MD5.Create())
using (var stream = File.OpenRead(filePath))
{
var hash = md5.ComputeHash(stream);
var sb = new StringBuilder();
foreach (byte b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
坑二:资源加载路径问题
热更后的资源路径和硬编码的路径不一致,导致Resources.Load找不到文件。
解决方案: 建立统一的路径映射层,所有资源加载都经过这个层:
public class ResourceLoader
{
private Dictionary<string, string> pathMap;
public ResourceLoader()
{
pathMap = new Dictionary<string, string>();
LoadPathMap();
}
private void LoadPathMap()
{
// 从热更配置中加载路径映射
// 格式:{"assets/hero/lixin.unity3d": "hotfix/assetbundle/hero/lixin.unity3d"}
}
public T Load<T>(string assetPath) where T : UnityEngine.Object
{
// 检查是否有热更路径映射
if (pathMap.TryGetValue(assetPath, out string hotfixPath))
{
// 优先从热更目录加载
string fullPath = Path.Combine(hotfixRootPath, hotfixPath);
if (File.Exists(fullPath))
{
return LoadFromBundle<T>(fullPath);
}
}
// 兜底:从Resources目录加载
return Resources.Load<T>(assetPath);
}
}
坑三:iOS代码热更的限制
iOS对代码热更有严格限制。Apple不允许从网络下载可执行代码并在本地运行。这意味着你的核心逻辑代码不能做成热更的。
解决方案: 把需要热更的逻辑全部写成Lua脚本,通过xlua或ToLua等桥接方案调用。这样在iOS上也是合规的。
坑四:大文件下载占用太多流量
有些游戏的热更包动辄几百MB,用户在4G网络下下载会非常痛苦,而且容易产生高额流量费。
解决方案:
- 强制WiFi检测: 检测当前网络类型,蜂窝网络下只下载关键补丁
- 后台下载: 允许用户在WiFi环境下后台静默下载
- 增量更新: 差量补丁能省则省
public class NetworkAwareDownloader
{
public bool CanDownloadOverCellular()
{
// 检测当前网络类型
if (Application.internetReachability == NetworkReachability.NotReachable)
return false;
if (Application.internetReachability == NetworkReachability.ReachableViaCarrierDataNetwork)
{
// 蜂窝网络,需要用户确认
return PlayerPrefs.GetInt("AllowCellularDownload", 0) == 1;
}
return true; // WiFi或有线网络
}
public void DownloadSmart(HotfixDiff diff)
{
bool isCellular = Application.internetReachability ==
NetworkReachability.ReachableViaCarrierDataNetwork;
// 蜂窝网络下只下载小于10MB的文件
List<DownloadTask> tasks = new List<DownloadTask>();
foreach (var kvp in diff.NewFiles)
{
if (isCellular && kvp.Value.size > 10 * 1024 * 1024)
{
// 大文件标记为后台下载
MarkForBackgroundDownload(kvp.Key, kvp.Value);
}
else
{
tasks.Add(new DownloadTask(
kvp.Key, kvp.Value.url, kvp.Value.size,
TaskType.New, kvp.Value));
}
}
StartDownload(tasks);
}
}
总结:热更新不是技术问题,是系统工程
说句实在话,热更新本身的技术原理并不复杂。它的难点在于工程化——怎么让几千万用户同时在线时,热更系统不崩;怎么保证更新过程中用户数据安全;怎么在更新失败时还能让用户正常玩游戏。
王者荣耀和和平精英能做到每周更新不停服,背后是几十人的热更团队在支撑。他们做的事情包括:
- 多机房部署:确保任何一个机房故障都不会影响更新
- 全球CDN:让用户就近下载,减少延迟
- 灰度发布:先给一小部分用户更新,确认没问题再全量推送
- 实时监控:每个用户的更新过程都有日志,出问题能快速定位
- 回滚机制:一旦发现问题,可以一键回滚到上一个版本
如果你正在做自己的游戏热更系统,我的建议是:先跑通最小可行版本,再逐步优化。不要一开始就追求完美,先把下载、校验、加载这几个环节打通,然后在实际使用中不断打磨。
热更新做好了,玩家体验会好很多——他们不用每次更新都等半小时,你的运营活动也能更快上线。这是值得投入的事情。
