网络部分讲完以后,下一块很适合进入 MyFramework 的资源热更新。
资源热更新真正麻烦的并不是"把文件下载下来",而是一个游戏可能有成千上万个资源文件:
哪些已经是最新的?哪些安装包里就有?哪些缓存里有?哪些必须重新下载?哪些已经从服务器删除?
如果每次启动都重新下载全部资源,热更新也就失去了意义。
MyFramework 的 AssetVersionSystem + GameDownload 做的事情,就是在 StreamingAssets、PersistentData 和远端资源之间进行完整比对,最终只下载真正发生变化的文件。
项目地址:
一、资源实际上同时存在三份
运行时维护了三张文件表:
bash
Dictionary<string, GameFileInfo> mStreamingAssetsFileList;
Dictionary<string, GameFileInfo> mPersistentAssetsFileList;
Dictionary<string, GameFileInfo> mRemoteAssetsFileList;
分别对应:
bash
StreamingAssets
安装包自带资源
PersistentData
热更新后下载到本地的资源
Remote
服务器当前最新资源
每个文件记录的不只是名字,而是:
bash
FileName
FileSize
MD5
所以判断一个文件是不是最新版,不依赖时间戳,而是直接比较:
bash
文件名
+
文件大小
+
MD5
二、加载资源时,到底读哪一份?
真正有意思的是 getFileReadPath()。
假设现在需要:
bash
assetbundle/map/main.ab
在正常热更新模式 SAME_TO_REMOTE 下,框架首先查询远端版本。
如果 PersistentData 中存在同名文件,并且:
bash
persistentInfo.mFileSize == remoteInfo.mFileSize &&
persistentInfo.mMD5 == remoteInfo.mMD5
直接读取:
bash
PersistentData
否则再检查安装包中的 StreamingAssets。
如果安装包里的资源本身就和远端完全一致:
bash
根本不需要重新下载
只有两边都不匹配时才返回:
bash
return null;
告诉 ResourceManager:
本地没有当前版本,需要从服务器下载。
所以资源选择逻辑实际上是:
bash
Remote最新版
↓
PersistentData一致?
↓Yes ↓No
直接读取 Streaming一致?
↓Yes ↓No
直接读取 下载
这一步能避免大量没有意义的重复下载。
三、不是只比较版本号,而是比较每一个文件
框架确实存在:
bash
StreamingAssetsVersion
PersistentDataVersion
RemoteVersion
但版本号主要用于决定整体更新策略。
真正决定哪些资源需要下载的,仍然是文件级 Diff。
更新开始以后拿到:
bash
var streamingFiles =
mAssetVersionSystem.getStreamingAssetsFile();
var persistentFiles =
mAssetVersionSystem.getPersistentAssetsFile();
var remoteFiles =
mAssetVersionSystem.getRemoteAssetsFile();
然后通过:
bash
checkNeedDownloadFile(
mNeedDownloadFileList,
streamingFiles,
persistentFiles,
remoteFiles,
mDynamicDownloadList);
生成最终下载列表。
所以即使远端版本发生变化,也绝不意味着:
bash
版本变了
↓
所有AB重新下载
而是:
bash
版本变了
↓
比较每一个文件
↓
只下载新增或内容变化的文件
四、远端删掉的资源,本地也必须清理
热更新不仅有新增和修改,还有一个经常被忽略的问题:
服务器已经删除的文件怎么办?
框架会先计算:
bash
List<string> deleteFileList =
checkDeleteFile(
persistentFiles,
remoteFiles);
然后真正删除 PersistentData 中的旧文件:
bash
foreach (string fileToDelete in deleteFileList)
{
persistentFiles.Remove(fileToDelete);
string fullPath =
F_PERSISTENT_ASSETS_PATH +
fileToDelete;
deleteFile(fullPath);
mNeedWritePersistentFileList = true;
}
否则项目运行几年以后,玩家手机里可能残留大量已经永远不会再使用的旧 AssetBundle。
StreamingAssets 因为属于安装包内容,运行时不能真的删除,所以这里只会移除文件列表中的记录。
五、连远端 FileList 都不会每次重新下载
要比较资源,首先得拿到远端文件列表。
但如果这个列表本身有几万条,每次启动都重新请求也是浪费。
MyFramework 会保存上一次的远端列表:
bash
string prefsKey = "FileListRemote";
string content =
PlayerPrefs.GetString(prefsKey);
服务器同时提供当前 FileList 的 MD5。
本地直接判断:
bash
remoteFileListMD5 !=
generateFileMD5(
stringToBytes(content))
只有 MD5 不一致时,才真正重新下载 FileList。
也就是:
bash
远端FileList MD5
↓
和本地缓存一致?
↓Yes ↓No
直接复用 重新下载
连"检查更新需要的数据"本身都做了缓存。
六、本地 FileList 也不能无条件相信
有了本地文件列表,理论上可以直接读。
但框架对 PersistentData 又多做了一层校验。
它会实际扫描目录:
bash
findFilesInternal(
path,
fileList,
null,
true);
然后检查:
bash
FileList记录的文件
VS
磁盘真实存在的文件
如果数量或者文件名对不上,就重新扫描生成信息。
原因很现实:
bash
上一次更新中途杀进程
文件被外部删除
文件写入失败
缓存目录异常
都有可能让:
bash
FileList说文件存在
但实际上:
bash
磁盘上已经没有了
所以 FileList 是加速手段,而不是绝对真相。
七、下载完还必须再验一次 MD5
文件请求成功并不代表更新成功。
下载完成以后框架重新构造:
bash
GameFileInfo localInfo = new();
localInfo.mFileName = fileName;
localInfo.mFileSize = bytes.Length;
localInfo.mMD5 =
generateFileMD5(bytes);
再与服务器记录比较:
bash
remoteInfo.mFileSize !=
localInfo.mFileSize
remoteInfo.mMD5 !=
localInfo.mMD5
只要不一致,就认为下载结果不可用。
也就是说更新链路不是:
bash
HTTP返回200
↓
更新完成
而是:
bash
下载成功
↓
文件长度校验
↓
MD5校验
↓
确认和Remote完全一致
↓
才加入本地文件表
八、失败不是无限重试
单个资源下载失败时:
bash
if (mRemainRetryCount > 0)
{
--mRemainRetryCount;
startCheckVersion();
}
else
{
mTipCallback?.Invoke(
DOWNLOAD_TIP.DOWNLOAD_FAILED);
}
默认最多自动重试几次。
MD5 校验失败也是相同思路。
这样避免网络异常时陷入:
bash
下载
失败
下载
失败
下载
失败
......
无限循环。
九、还有一类资源根本不参与启动更新
GameDownload 还有:
bash
List<string> mDynamicDownloadList;
这些目录不会在启动更新阶段统一下载。
而是:
bash
启动游戏
↓
不下载动态资源
真正需要某个资源
↓
本地不存在
↓
再从Remote下载
这非常适合:
bash
低频地图
活动资源
后期章节
大型语音
不一定会进入的玩法
否则玩家第一次安装游戏,就可能被迫下载大量根本用不到的内容。
十、最终只剩下一条资源更新流水线
整个过程串起来其实就是:
bash
读取版本号
↓
获取 Remote FileList
↓
读取 StreamingAssets FileList
↓
读取 PersistentData FileList
↓
校验本地真实文件
↓
删除远端已经不存在的文件
↓
逐文件比较 Size + MD5
↓
排除动态下载资源
↓
生成 NeedDownloadFileList
↓
逐个下载
↓
再次计算 Size + MD5
↓
写入 PersistentData
↓
更新 FileList
↓
写入 VERSION
↓
进入游戏
所以资源热更新真正核心的,并不是"下载"。
而是:
永远知道远端最新资源是什么、本地现在到底有什么,然后只传输两者真正不同的那一小部分。
当项目资源从几百个增长到几万个以后,这种文件级 Diff + 多级本地复用 + MD5 校验 + 动态下载,才真正决定一次资源更新到底是几十 MB,还是只需要下载几百 KB。