什么是热更新?
游戏上线后,玩家下载第一个版本(1G左右或者更大),在之后运营的过程中,如果需要更换UI显示,或者修改游戏的逻辑,这个时候,如果不使用热更新,就需要重新打包,然后让玩家重新下载,很显然体验非常不好。 热更新可以在不重新下载客户端的情况下,更新游戏的内容。 如王者荣耀,经常有下载补丁的时候。
如何判断哪些文件需要更新?
为了知道我们需要更新的内容,我们就要知道哪些文件发生了改变,或者新增文件?所以我们需要在本地保存一份 需要热更新文件信息(名称,大小、Md5值)的 配置文件。在添加或改变资源时打新的热更包时我们和这个配置文件进行比较,相同资源名称的Md5值不一致,或者在配置文件中找不到该资源配置,就说明这个资源是发生改变或新增的,需要被加进热更包中。
如何实现热更新?
上面我们知道了哪些文件需要被热更新,那么我们需要把这些文件放到服务器上,并记录这次补丁包的信息(版本信息、第几次热更,以及这些资源的详细信息配置)。用户打开App后会去拉取这个配置文件,并找到最后一次热更的资源信息与本地的资源进行MD5校验,不通过的就加入到热更列表,下载后保存到本地上,下次进入游戏的时候MD5就校验成功不会在出现重新下载服务器资源的情况,至此我们大致的思路就完成了。
资源热更流程图
打包时记录版本信息及所有资源信息
- 包名
- 版本号
- 资源MD5文件信息(name,文件md5,size)
- 保存到本地(Xml文件或二进制文件)
一键生成热更资源
如何生成AB包,以及实现: 一键生成热更资源
热更包及配置文件
数据结构:
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
namespace Hot
{
[Serializable]
public class GameVersion
{
[XmlElement]
public ServerVersionInfo[] ServerInfo;
}
/// <summary>
/// 当前游戏版本对应的所有补丁
/// </summary>
[Serializable]
public class ServerVersionInfo
{
[XmlAttribute]
public string Version;
[XmlElement]
public List<Patches> Patches = new List<Patches>();
}
/// <summary>
/// 一个总补丁包信息
/// </summary>
[Serializable]
public class Patches
{
[XmlAttribute]
public int Version; // 第几次热更
[XmlAttribute]
public string Desc;
[XmlElement]
public List<Patch> patches = new List<Patch>();
}
/// <summary>
/// 单个补丁包信息
/// </summary>
[Serializable]
public class Patch
{
[XmlAttribute]
public string Name;
[XmlAttribute]
public string Url;
[XmlAttribute]
public long Size;
[XmlAttribute]
public string MD5;
}
}
服务器部署
Apache服务器搭建:
我这边使用Apache: Apache Download
下载后将期解压到需要放置的目录下
找到 Apache24/conf/httpd.conf 将 Define SRVROOT改成Apache的解压目录,端口号默认时80,如果被占用可以自行修改
Define SRVROOT "F:\WebServer/Apache24"
运行 httpd.exe文件,测试可以在浏览器下访问 localhost 可以方位代表成功
服务器文件部署:
在 ...\Apache24\htdocs 文件夹下新建存放需要热更的AssetBundle的文件
文件夹0.1: 版本文件夹,
文件夹 1: 第一次需要热更的资源
添加在服务器里添加GameVersion.xml文件:对应上面的 ServerInfo数据结构,每次有新的热更包时就往xml里添加 Patche.xml里的内容,需要回退的话只需要删除对应Patches的补丁配置
<?xml version="1.0"?>
<GameVersion>
<ServerInfo Version="1.0.1">
<Patches Version="1" Desc="测试热更">
<patches Name="AssetBundle" Url="http://127.0.0.1/AssetBundle/1.0.1/1/AssetBundle" Size="1130" MD5="7baa969436d20f0e1b8a41e78d3cb23d" />
<patches Name="audio" Url="http://127.0.0.1/AssetBundle/1.0.1/1/audio" Size="20555034" MD5="f4fc534e2615ca3a0c199e29a4f22df6" />
<patches Name="image" Url="http://127.0.0.1/AssetBundle/1.0.1/1/image" Size="563082" MD5="1da1ee923a73386d0871f0287954ffa4" />
<patches Name="material" Url="http://127.0.0.1/AssetBundle/1.0.1/1/material" Size="8010" MD5="08fc079c4a19f701c3e914e2d591f7a8" />
<patches Name="prefab" Url="http://127.0.0.1/AssetBundle/1.0.1/1/prefab" Size="5034" MD5="90bd7fb6bee13fdabc642bb445dbaa98" />
</Patches>
</ServerInfo>
</GameVersion>
到这里热更新的准备都已完成,接下来就是实现热更流程
文件下载基类
using System;
using System.Collections;
using System.IO;
namespace Hot
{
public abstract class DownloadItemBase
{
protected string url;
public string Url => url;
protected string fileName;
public string FileName => fileName;
protected string fileNameWithoutExt;
public string FileNameWithoutExt => fileNameWithoutExt;
protected string ext;
public string Ext;
protected string fullName;
public string FullName => fullName;
protected string fullNameWithoutExt;
public string FullNameWithoutExt => fullNameWithoutExt;
protected long size;
public long Size => size;
protected bool isLoading = false;
public bool IsLoading = false;
public DownloadItemBase(string savePath,string url,long size)
{
isLoading = false;
this.url = url;
fileNameWithoutExt = Path.GetFileNameWithoutExtension(url);
ext = Path.GetExtension(url);
fileName = Path.GetFileName(url);
fullName = $"{savePath}/{fileName}";
fullNameWithoutExt = $"{savePath}/{fileNameWithoutExt}";
this.size = size;
}
public abstract void Destroy();
public abstract IEnumerator StartDownload(Action<bool> callBack);
public abstract float GetCurProgress();
}
}
AB包文件下载类
using System;
using System.Collections;
using Core.Utlis;
using UnityEngine;
using UnityEngine.Networking;
namespace Hot
{
public class ABDownloadItem:DownloadItemBase
{
private UnityWebRequest webRequest;
public ABDownloadItem(string savePath, string url, long size) : base(savePath, url, size)
{
}
public override void Destroy()
{
webRequest.Dispose();
}
public override IEnumerator StartDownload(Action<bool> callBack = null)
{
webRequest = UnityWebRequest.Get(Url);
webRequest.timeout = 30;
isLoading = true;
yield return webRequest.SendWebRequest();
if (webRequest.result == UnityWebRequest.Result.Success)
{
FileUtils.SaveFile(FullName, webRequest.downloadHandler.data);
if(null != callBack) callBack(true);
}
else
{
Debug.LogError($"download {Url} fail err: {webRequest.error}");
if(null != callBack) callBack(false);
}
isLoading = false;
}
public override float GetCurProgress()
{
return webRequest != null ? webRequest.downloadProgress : 0;
}
}
}
核心热更新管理类
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Build;
using Core.Base;
using Core.Utlis;
using UnityEngine;
using UnityEngine.Networking;
namespace Hot
{
public class HotManager:Singleton<HotManager>
{
private string ServerGameVersionPath = $"{Application.persistentDataPath}/GameVersion.xml";
private string version;
private string packageName;
private GameVersion gameVersion;
private string hotDesc;
public string HotDesc => hotDesc;
// 服务器上需要热更的补丁包
private List<Patch> serverHotPatches = new List<Patch>();
private Dictionary<string,Patch> serverHotPatchDic = new Dictionary<string, Patch>();
// 需要下载的补丁包
private List<Patch> downLoadPatchs = new List<Patch>();
// 下载完成的补丁包
private List<Patch> alreadyPatchLists = new List<Patch>();
private ABDownloadItem curDownloadItem;
private int reloadCount = 0;
private float hotAllSize = 0;
public float HotAllSize => hotAllSize;
private bool isLoading = false;
public bool IsLoading => isLoading;
// 加载完成回调
private Action hotCompeleteHandler;
// 加载失败回调
private Action<List<Patch>> hotFailHandler;
private MonoBehaviour corMono;
public void Init(MonoBehaviour mono)
{
corMono = mono;
}
/// <summary>
/// 检查版本是否需要更新
/// </summary>
/// <param name="callBack"></param>
public void CheckVersionNeedHot(Action<bool> callBack)
{
VersionInfo versionInfo = XmlSerializerOpt.Deserialize<VersionInfo>(PathUtlis.LOCAL_VERSION_PATH);
version = versionInfo.Version;
packageName = versionInfo.PackageName;
corMono.StartCoroutine(LoadServerGameVersion(() =>
{
// 判断是否需要热更
GetServerPatches();
CheckDownloadPatches();
hotAllSize = serverHotPatches.Sum(x => x.Size);
callBack(downLoadPatchs.Count > 0);
}));
}
private IEnumerator LoadServerGameVersion(Action callBack)
{
UnityWebRequest webRequest = UnityWebRequest.Get("http://127.0.0.1/GameVersion.xml");
webRequest.timeout = 30;
yield return webRequest.SendWebRequest();
if (webRequest.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"加载服务器游戏配置失败: {webRequest.error}");
}
else
{
Debug.Log(ServerGameVersionPath);
if(File.Exists(ServerGameVersionPath)) File.Delete(ServerGameVersionPath);
FileUtils.SaveFile(ServerGameVersionPath,webRequest.downloadHandler.data);
gameVersion = XmlSerializerOpt.Deserialize<GameVersion>(ServerGameVersionPath);
}
callBack();
}
private void GetServerPatches()
{
if (gameVersion != null && gameVersion.ServerInfo != null)
{
for (int i = 0; i < gameVersion.ServerInfo.Length; i++)
{
if (gameVersion.ServerInfo[i].Version == version)
{
List<Patches> patches = gameVersion.ServerInfo[i].Patches;
if (patches != null && patches.Count > 0)
{
serverHotPatches = patches[patches.Count - 1].patches;
hotDesc = patches[patches.Count - 1].Desc;
}
break;
}
}
}
}
// 检查需要去下载的补丁
private void CheckDownloadPatches()
{
downLoadPatchs.Clear();
for (int i = 0; i < serverHotPatches.Count; i++)
{
serverHotPatchDic.Add(serverHotPatches[i].Name, serverHotPatches[i]);
AddDownloadPatch(serverHotPatches[i]);
}
}
private void AddDownloadPatch(Patch patch)
{
string savePath = $"{PathUtlis.LocalAssetBundlePath}/{patch.Name}";
if (!File.Exists(savePath))
{
downLoadPatchs.Add(patch);
}
else
{
if (patch.MD5 != MD5Utils.GenerateMD5(savePath))
{
downLoadPatchs.Add(patch);
}
}
}
public void StartHot(Action hotCompeleteHandler,Action<List<Patch>> hotFailHandler)
{
this.hotCompeleteHandler = hotCompeleteHandler;
this.hotFailHandler = hotFailHandler;
corMono.StartCoroutine(StartLoad());
}
private IEnumerator StartLoad(List<Patch> patches = null)
{
if (patches == null)
{
patches = downLoadPatchs;
}
if (!Directory.Exists(PathUtlis.LocalAssetBundlePath))
Directory.CreateDirectory(PathUtlis.LocalAssetBundlePath);
List<ABDownloadItem> downloadItems = new List<ABDownloadItem>();
for (int i = 0; i < patches.Count; i++)
{
downloadItems.Add(new ABDownloadItem(PathUtlis.LocalAssetBundlePath,patches[i].Url,patches[i].Size));
}
isLoading = true;
for (int i = 0; i < downloadItems.Count; i++)
{
ABDownloadItem item = downloadItems[i];
curDownloadItem = item;
yield return corMono.StartCoroutine(item.StartDownload((success) =>
{
if (success)
{
Patch patch = FindPatch(item.FileName);
if (patch != null)
{
if(!alreadyPatchLists.Contains(patch)) alreadyPatchLists.Add(patch);
}
}
else
{
Debug.LogError($"{item.FileName} 下载失败,尝试重新下载");
}
item.Destroy();
}));
}
// 重新比较文件md5,避免文件下载失败
yield return VerifyMD5(downLoadPatchs);
}
//校验下载后的文件
private IEnumerator VerifyMD5(List<Patch> patches)
{
List<Patch> downPatchList = new List<Patch>();
for (int i = 0; i < patches.Count; i++)
{
Patch patch = patches[i];
string savePath = $"{PathUtlis.LocalAssetBundlePath}/{patch.Name}";
if (!File.Exists(savePath))
{
downPatchList.Add(patch);
}
else
{
if (patch.MD5 != MD5Utils.GenerateMD5(savePath))
{
downPatchList.Add(patch);
}
}
}
if (downPatchList.Count > 0)
{
reloadCount++;
if (reloadCount < 5)
{
yield return corMono.StartCoroutine(StartLoad(downPatchList));
}
else
{
isLoading = false;
if (null != hotFailHandler) hotFailHandler(downPatchList);
}
}
else
{
isLoading = false;
if (null != hotCompeleteHandler) hotCompeleteHandler();
}
}
private Patch FindPatch(string name)
{
Patch patch = null;
serverHotPatchDic.TryGetValue(name, out patch);
return patch;
}
public float GetProgress()
{
float loadedSize = alreadyPatchLists.Sum(x => x.Size);
float curloadSize = curDownloadItem.GetCurProgress() * curDownloadItem.Size;
float progress = (loadedSize + curloadSize) / hotAllSize;
return progress;
}
}
}
UI测试效果图
还有个问题,这样下载下来的资源会直接被别人拿走使用,为了数据的安全,我们可以对资源进行加密处理,我使用的是AES,也没有什么难点,就是在一键生成AB包后使用AES对文件加密,然后加载资源的时候使用 字节数组加载,LoadFromMemory的缺点就是多占一份没存,对于没存吃紧的就不适合用了,或者参考:Unity3D加密Assetbundle(不占内存)
private void DecryptAssetBundle()
{
string abPath = Path.Combine(PathUtlis.AssetBundlePath, path);
// 解密被加载的AB包
byte[] result = AESUtils.AESFileDecryptToByte(abPath,"ENCRYPT_KEY");
if (result == null)
{
Debug.LogError($"AES Decrypt {abPath} file fail");
return;
}
AssetBundle asset = AssetBundle.LoadFromMemory(result);
}