游戏上线后,玩家下载第一个版本(1G左右或者更大),在之后运营的过程中,如果需要更换UI显示,或者修改游戏的逻辑,这个时候,如果不使用热更新,就需要重新打包,然后让玩家重新下载,很显然体验非常不好。 热更新可以在不重新下载客户端的情况下,更新游戏的内容。 如王者荣耀,经常有下载补丁的时候。
为了知道我们需要更新的内容,我们就要知道哪些文件发生了改变,或者新增文件?所以我们需要在本地保存一份 需要热更新文件信息(名称,大小、Md5值)的 配置文件。在添加或改变资源时打新的热更包时我们和这个配置文件进行比较,相同资源名称的Md5值不一致,或者在配置文件中找不到该资源配置,就说明这个资源是发生改变或新增的,需要被加进热更包中。
如何实现热更新?
上面我们知道了哪些文件需要被热更新,那么我们需要把这些文件放到服务器上,并记录这次补丁包的信息(版本信息、第几次热更,以及这些资源的详细信息配置)。用户打开App后会去拉取这个配置文件,并找到最后一次热更的资源信息与本地的资源进行MD5校验,不通过的就加入到热更列表,下载后保存到本地上,下次进入游戏的时候MD5就校验成功不会在出现重新下载服务器资源的情况,至此我们大致的思路就完成了。
如何生成AB包,以及实现: 一键生成热更资源
数据结构:
using System;
using System.Collections.Generic;
using System.Xml.Serialization;
namespace Hot
{
[Serializable]
public class GameVersion
{
[XmlElement]
public ServerVersionInfo[] ServerInfo;
}
///
/// 当前游戏版本对应的所有补丁
///
[Serializable]
public class ServerVersionInfo
{
[XmlAttribute]
public string Version;
[XmlElement]
public List Patches = new List();
}
///
/// 一个总补丁包信息
///
[Serializable]
public class Patches
{
[XmlAttribute]
public int Version; // 第几次热更
[XmlAttribute]
public string Desc;
[XmlElement]
public List patches = new List();
}
///
/// 单个补丁包信息
///
[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的补丁配置
文件下载基类
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 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 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
{
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 serverHotPatches = new List();
private Dictionary serverHotPatchDic = new Dictionary();
// 需要下载的补丁包
private List downLoadPatchs = new List();
// 下载完成的补丁包
private List alreadyPatchLists = new List();
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> hotFailHandler;
private MonoBehaviour corMono;
public void Init(MonoBehaviour mono)
{
corMono = mono;
}
///
/// 检查版本是否需要更新
///
///
public void CheckVersionNeedHot(Action callBack)
{
VersionInfo versionInfo = XmlSerializerOpt.Deserialize(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(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 = 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> hotFailHandler)
{
this.hotCompeleteHandler = hotCompeleteHandler;
this.hotFailHandler = hotFailHandler;
corMono.StartCoroutine(StartLoad());
}
private IEnumerator StartLoad(List patches = null)
{
if (patches == null)
{
patches = downLoadPatchs;
}
if (!Directory.Exists(PathUtlis.LocalAssetBundlePath))
Directory.CreateDirectory(PathUtlis.LocalAssetBundlePath);
List downloadItems = new List();
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 patches)
{
List downPatchList = new List();
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);
}