Unity 资源热更新极限拆解:几万个文件,怎么做到只下载真正变动的那几个?

网络部分讲完以后,下一块很适合进入 MyFramework 的资源热更新。

资源热更新真正麻烦的并不是"把文件下载下来",而是一个游戏可能有成千上万个资源文件:

哪些已经是最新的?哪些安装包里就有?哪些缓存里有?哪些必须重新下载?哪些已经从服务器删除?

如果每次启动都重新下载全部资源,热更新也就失去了意义。

MyFramework 的 AssetVersionSystem + GameDownload 做的事情,就是在 StreamingAssetsPersistentData 和远端资源之间进行完整比对,最终只下载真正发生变化的文件。

项目地址:

github.com/ZHOURUIH/My...

一、资源实际上同时存在三份

运行时维护了三张文件表:

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。

相关推荐
SmalBox1 天前
01-05-认知篇-基础-Unity资源管理痛点分析
unity3d·游戏开发
_zhourui_h_2 天前
写完 EasyECS 1.1.0,我为什么还是没把它做成完整 ECS?
unity3d
SmalBox2 天前
01-04-认知篇-基础-Unity Addressable Assets全面解析
unity3d·游戏开发
SmalBox3 天前
01-03-认知篇-基础-Unity原生AssetBundle全面解析
unity3d·游戏开发
fujisheng6613 天前
FUI 编译期装配实践:从反射注册到 Source Generator
c#·unity3d
fujisheng6614 天前
Unity UI 生命周期状态机:处理 Covered、异步竞态与事务回滚
c#·unity3d
_zhourui_h_4 天前
EasyECS 最慢的地方,居然是 Resize
unity3d
fujisheng6614 天前
Unity 异步 UI 实战:取消令牌、版本校验与旧句柄隔离
c#·unity3d
SmalBox4 天前
01-02-认知篇-基础-Unity资源管理发展史
unity3d·游戏开发