乐于分享
好东西不私藏

包体优化:UE跨版本资源复用的方案

包体优化:UE跨版本资源复用的方案

对于游戏项目而言,我们总希望玩家能用最小的成本体验到最新的游戏。它既包含运营时的CDN成本,也包含玩家下载时的流量、时间成本。更小、更快、更省是永恒的追求。

那么,当游戏的安装包更新时(也就是所谓的大版本),玩家是否需要完整再完整下载一遍资源? 当前游戏市场的大型手游,资源体量也非常庞大,能达到10+GB的量级,如果每次换包都完整下载,对于运营成本和用户体验都是比较差的选择。 而在换版本时,玩家本地实际有上个版本的完整资源,如果能把它利用起来,就可以大幅减少需要下载的部分。所以需要一种能够复用旧版本资源的机制!

本篇文章会基于该痛点,分享一种大版本更新后的资源复用方案,使玩家下载的资源最小化,并且易于发布和维护。 在实际的线上项目运营中,取得了极佳的效果

前言

首先,先考虑一个问题:当UE重新打包时,有哪些文件/资源可能会产生变化?

因为UE的构建流程,当完整重新打包时,它相较于上个版本潜在变更的资源就非常复杂。从引擎基础配置到底层渲染、从资产类的属性变动到序列化方式,都有可能会对构建的产物造成差异。

与热更不同的是,热更是在保证引擎基础不变的情况下进行增量,只做Gameplay逻辑更新,不涉及资产序列化的变动。但是当完整重新出包,任何资源都有可能产生变化!

在我之前的文章(虚幻引擎中 Pak 的运行时重组方案[1])中提出过一种基于运行时重组的更新方案,但是它需要在客户端实现pak的创建与加密,会增加客户端的执行压力:差异计算、下载请求分散、本地生成pak、加密等。以及潜在密钥泄露的风险(重组前需要对所有pak进行解密)。

所以我们需要提供一种能够兼顾任何资产变动的方案,并且尽可能利于维护,不增加客户端的计算压力。另外也希望仅在客户端进行解密,这样也可以实现密钥的动态下发,保护未正式发布的预埋资产(参考UE PAK的加密分析与加固策略[2])。

本地旧资源

在正式开始之前,先思考一个问题:当安装包版本从1.x升级到2.0时,本地包含了哪些资产?

让我们以Android为例,从安装行为分析app的安装与UE加载安装包内文件的行为。

当APK安装到手机上时,本质上是把app安装到了下面的路径中(/data/app/ + 包名 + 随机字符):

sagit:/data/app/com.xxx.yyy-zpgq5nHyY9CNxdLbiAdAgQ== # ls -R.├── base.apk├── lib│   └── arm64│       └── libUE4.so│       └── ...└── oat    └── arm64        ├── base.odex        └── base.vdex

而UE的资源文件是存在于apk中assets/main.obb.png文件内的,并不会被解压出来,依然存在于base.apk内:

Archive:  base.apk  Length      Date    Time    Name---------  ---------- -----   ----    42176  2025-12-30 14:09   AndroidManifest.xml 54084235  2025-12-30 14:09   assets/main.obb.png      525  2025-12-30 14:09   assets/...150120496  2025-12-30 14:09   lib/arm64-v8a/libUE4.so   911696  2025-12-30 14:09   lib/arm64-v8a/...      388  2025-12-30 14:09   res/...      375  1970-01-01 08:00   third_party/...     1360  2025-12-30 14:09   META-INF/...      *    2025-12-30 14:09   ...---------                     -------313547787                     789 files

在游戏启动时,会在AndroidPlatformFile初始化时,从base.apk中读取main.obb.png,在引擎中建立虚拟映射结构,从而实现在不解压的情况下读取内部pak等文件的目的。

virtualboolInitialize(IPlatformFile* Inner, const TCHAR* CmdLine)override{    // ...    if (GOBBinAPK)    {        // Open the APK as a ZIP        FZipUnionFile APKZip;        int32 Handle = open(TCHAR_TO_UTF8(*GAPKFilename), O_RDONLY);        if (Handle == -1){ return false; }        FFileHandleAndroid* APKFile = new FFileHandleAndroid(GAPKFilename, Handle);        APKZip.AddPatchFile(MakeShareable(APKFile));        // Now open the OBB in the APK and mount it        if (APKZip.HasEntry("assets/main.obb.png"))        {            auto OBBEntry = APKZip.GetEntry("assets/main.obb.png");            FFileHandleAndroid* OBBFile = static_cast<FFileHandleAndroid*>(new FFileHandleAndroid(*OBBEntry.File, 0, OBBEntry.File->Size()));            check(nullptr != OBBFile);            ZipResource.AddPatchFile(MakeShareable(OBBFile));            // ...        }    }    // ...}

main.obb.png内部的目录结构就是引擎定义的虚拟路径:

Archive:  main.obb.png  Length      Date    Time    Name---------  ---------- -----   ----  5636706  2025-05-14 14:15   FGame/Content/Movies/SplashAll.mp4 48447043  2025-12-30 14:05   FGame/Content/Paks/pakchunk0-Android_ASTC.pak---------                     ------- 54083749                     2 files

还记得博客中之前文章(pak 的自动挂载目录[3])介绍过的引擎自动挂载的三个目录吗?通过这样的方式就对应起来了,然后走引擎UFS的PlatformFile机制,就可以实现跨平台的包内文件读取了。

而游戏运行时产生的数据目录(Saved等),则不会存在于/data/app/中,它被写入独立的沙盒目录(详见文章UE 源码分析:修改游戏默认的数据存储路径[4]),跟app程序目录是脱离的,在升级app时,数据目录会保留。

当APP覆盖安装时,底层产生了下面的变化:

1.APK路径更新:旧的安装路径(/data/app/ + 包名 + 随机字符)会被删除,新的APK会被放入一个新的随机字符目录;2.库文件更新:/lib/ 目录下的 .so 原生库会被全部清空,并根据新 APK 里的内容重新解压;3.重新编译字节码: 系统会删除旧的 .odex 或 .vdex 优化文件,并针对新 APK 重新进行 AOT(预编译)或类验证;

所以,根据上面的分析可以得出一个结论:当APP覆盖安装时,安装包内部的文件都会被替换为新版本,但数据目录依然保留

则对UE而言,更新APP的覆盖安装,会产生下面的结果:

如图所示,在本地会残留1.0版本安装包之外的所有资产(通常是所有动态下载的部分)。

对于大版本资源复用的需求,我们能做的就是在2.0的APP版本基础上,复用1.0安装包之外的资产。

可行性分析

当本地残留了上个APP版本的资源时,对于UE来说意味着什么?

当更换APP版本时,通常都会有底层的变化,如:

修改资源的类:新增、删除属性,修改序列化方式修改引擎配置:造成资产的被动变化修改C++类:对蓝图资产的影响修改底层渲染代码:Shader需要重新编译……

这些都会造成程序与旧的资产无法对齐,如果直接使用,可能会造成崩溃或表现异常。

从UE的底层虚拟文件系统出发,游戏的正确运行依赖加载正确的文件。但对于新app而言,旧版app的资源内一部分资源已经变成错误的了,所以需要通过补丁替换掉不匹配的文件、添加旧版本中不存在的文件。

只需要对补丁和旧版本资产利用引擎本身的Pak Order机制做好优先级,确保补丁比旧版本资源的优先级更高,就能实现文件替换的需求,这一点与热更新完全一致。

从引擎的基础机制上完全可以实现,那么接下来问题关键,是如何识别旧版本残留资源中,哪些是不匹配的部分?

如何识别差异?

对于UFS来说,我们需要让引擎能够读取最新的文件,不管是UASSET还是代码脚本抑或是数据文件,对于UFS来说,他们都只是”文件”,唯一的区别是,对于UASSET在COOK后才产出最终进包的文件列表。 那么,就需要对1.0与2.0这两个版本,所有安装包之外的文件进行差异计算。因为2.0的安装包内已经自带了一部分最新的资源,所以可以忽略。

对于普通文件,直接计算HASH值对于UASSET,需要计算COOKED产物的HASH值

简单地说,需要在打包构建时能够记录每个版本中所有的文件的HASH值,并且还需要能够区分每个文件存在于安装包的内部或外部。基于相同路径文件HASH的差异,来识别文件是否与最新版本的有差异。

我在HotPatcher[5]的导出Release过程中实现了这个过程,之前的版本中只记录了UASSET原始资产的GUID与文件的HASH值,我扩展了这部分,对UASSET还会记录该资产在每个平台的COOKED的文件的HASH值。

UASSET的导出信息:

并且还会记录每个文件所属于哪个pak,这样就能够识别每个资产是否存在与安装包内了。

HotPatcher[6]的热更流程内,每次打包后都需要先生成Release数据。对于大版本补丁的需求而言,可以直接复用两个版本的Release数据进行差异,这样就能够对比出新旧两个版本的所有完整差异了。

只需要基于最新的工程+新旧两个版本的Release数据,就可以实现大版本补丁的差异流程,并且可以直接基于HotPatcher[7]打出完整的补丁。

或许你会问:我直接拿到新旧两个版本中所有的PAK,不是就可以进行差异了吗?那使用HotPatcher[8]的Release的作用是什么呢?

问得好!我来回答这个问题。

理想情况下确实是这样,HotPatcher[9]本质上也是利用完成PAK的数据导出的,所以直接对两个版本中所有的普通文件和UASSET COOKED的差异确实没什么问题。 但大版本之间的差异还有一些例外情况以及维护成本:

1.对于ShaderCodeLibrary,两个版本间它必定会产生差异,完整进补丁会造成大幅浪费(如IOS动辄数百M的大小)2.不仅ShaderCode,所有涉及数据生成的文件都存在这个问题,另一个例子是AssetRegistry数据3.维护每个版本完整的PAK文件,每个版本10+G是版本管理噩梦4.在新版本中删除的文件,需在补丁中标记UFS删除

利用HotPatcher[10]框架内的能力,则可以完全避免这些问题,用极低的维护成本,实现最灵活的版本控制(以40W+规模UASSET的工程为例(约100w个文件),生成的Release数据只有50M)。并且不需要处理任何ShaderCodeLibrary与Regiatry等数据的差异逻辑,一步生成补丁即可发布。

从工程化的角度看,HotPatcher[11]提供的是一种完整链路的优化方案,无需关注内部技术细节,实现全自动化差异、打包与发布。与热更流程完美契合,共用同一套技术框架。

打包哪些部分?

大版本补丁只能基于所有玩家共同的基础版本进行打包。 比如,第一个玩家在1.1,第二个玩家在1.2,第三个玩家1.3版本,他们本地的热更补丁是不一致的。所以,我们只能基于他们共同的版本1.0来作为差异的基础,而不能基于某个热更的版本。

这确实会造成一部分的浪费:2.0新版本里的部分资源或许已经在热更包中存在了,但这是取舍后的选择(当然如果想要做的足够细致,技术实现上也是没问题的),但真实的项目中只能基于维护成本、方案复杂性与收益之间做一个取舍。

除此之外,HotPatcher[12]还实现了一种多阶段分层的差异机制,可以把差异文件最小化:

基于UASSET变动进行初始差异分析,并分析被动变更基于UASSET的差异,转变为基于COOKED文件的差异。当修改了UASSET,但只影响了部分COOK产物,则只会打包变动的那个文件在2的基础上,如果UASSET GUID变化,但COOKED没变化,则会被完全剔除

注意:该机制可同时用在大版本补丁,以及热更补丁流程。

这样就能够把两个版本中,所有实际产生了变化的部分打包成补丁了。

横跨多版本的补丁

简单地说,基于上面的方案,打包大版本补丁就简化为了三个步骤:

1.选择某个旧版本的Release数据2.最新版本的工程+最新版本Release数据3.调用HotPatcher执行打包

只需要控制旧版本的Release数据,就可以打包出横跨多版本的补丁,维护成本也就只是多几个Release数据而已。

运行时流程

当补丁准备好后,需要对运行时下载与加载流程做改造,才能正确适配新版本APP+旧版本资源+大版本补丁。

需要处理两部分事情:

1.检测本地版本的资源完整性、区分下载大版本补丁与完整包2.在新APP + 旧版本资源 + 大版本补丁 + 热更新的基础上,正确处理PakOrder

下载切换

启动时检测本地环境执行下载的流程:

挂载处理PakOrder

最关键的问题是正确处理多种来源的Pak的挂载Order,它是让引擎正确加载的核心要素。

对于大版本补丁而言,存在以下几种来源:

1.旧版本资源2.大版本补丁(可能存在多个,如1.0_to_2.0、2.0_to_3.0,3.0_to_4.0)3.新版本安装包内的PAK,如pakchunk04.需动态下载的资源5.热更的资源

他们的优先级要依次递增,不然就会造成混乱:

对于存在多个大版本补丁累计类型的情况,也要能够正确处理任意版本累计的情况,维护好版本关系与优先级:

基于这样的设计,活跃的玩家(正常跟随版本发布节奏升级),每次都能够享受到下载量级最小的服务。而对于横跨多个版本的玩家回归,可以考虑让玩家下载从本地版本到最新版本的所有补丁,也可以为间隔多个版本的专门打出一个合并的补丁。具体的策略可以根据项目的运营情况决定。

对热更的影响?

没有影响,对于同一个版本而言,存在两种情况:

1.全新安装,下载整包2.从旧版本+大版本补丁升级而来

根据前面的流程,旧版本+大版本补丁,能够对齐最新版本的完整资源。对于热更来说,他们都是基于相同的基线版本进行更新,所以没有区别。

后续的热更新,只需要用HotPatcher[13]对2.0的一致性版本的RELEASE打包差异即可。无需关注客户端是完整安装还是有大版本补丁,在热更层面没有区别,也降低了复杂度。

潜在问题及优化思路

对于大版本补丁而言,本质上是客户端本地多挂载了几个PAK,并且其中有一些文件冗余的部分。除非做运行时重组,冗余的空间才可以清除。 但除了存储空间占用多了之外,我更关注运行时执行效率的问题。

那么大版本补丁,可能会带来哪些运行时执行效率的问题,以及如何处理呢?

首先,因为大版本补丁是一个或多个PAK文件,所以在挂载时会有一些固定内存开销,但这部分较少可以忽略不计。真正需要关注的是随着冗余文件规模增加而增加的部分:PakEntry。

PakEntry对应了PAK中的每一个文件,是UFS用来查找与加载的文件描述。当随着大版本补丁不断更新,会导致本地Pak中冗余的文件越来越多,而在PAK挂载时,PakEntry就会被构造出来具有内存占用,所以它的开销是随着冗余数量增加线性增长的。

它会带来两部分开销:

1.内存占用2.文件查询

所以,基于此方案还需要做一步优化,对于冗余的资产,在运行时剔除它的PakEntry。无论有多少个大版本补丁、内存占用始终与完整包一致,热更也同理

数据展示

本方案已正式应用在上线项目中,在首次测试期间APP换包更新时,具有极佳的数据收益:

通过大版本补丁升级上来,下载量降低了两个数量级。

注意:首测毕竟时间较短,差异没那么大。但它也可以侧面证明:当APP更新不可避免(如致命BUG、严重问题修复)等,利用大版本补丁的机制,同样可以让用户的下载感知最小化,降低流失率。

补充另一份数据,在长期运营隔数个月后正常的换版本节奏时,依然有极佳的效果:

利用大版本补丁方案,把游戏运行时需动态下载的大小降低到了10%左右,减少了90%

综合收益:

1.大幅减少了下载大小、缩短了下载耗时2.CDN峰值骤减(推送预下载+下载耗时减少)3.平摊了CDN的峰值压力 大幅降低了项目运营期的CDN成本。

进一步优化

以上介绍的内容还并不是极致的大小优化,除此之外还可以结合我之前的这篇文章:UE 热更新:资源的二进制补丁方案[14],为大版本补丁创建二进制的文件差异,可以在此基础上更大幅度地降低补丁大小。

因为底层技术都复用HotPatcher热更的流程,所以之前博客中介绍的所有热更新的优化策略,都可以在大版本补丁中使用,实现1+1>2的效果。

结语

本文介绍了一种在新版本APP上复用旧版本资源的方案,能够在换版本时大幅降低下载大小,在线上项目中具有极佳的收益。

我把大版本补丁的部分实现了一个独立的MOD:ReleasePatcher。整套方案+优化策略,都可以在HotPatcher[15]的技术框架内实现,只需要接入HotPatcher[16] + ReleasePatcher(Mod),就能够完整实现大版本补丁的功能。

最新版本的HotPatcher[17]与Mod暂未公开发布,本文可作为工程实践参考。

References

[1] 虚幻引擎中 Pak 的运行时重组方案:https://imzlp.com/posts/12188[2]UE PAK的加密分析与加固策略:https://imzlp.com/posts/88478/[3]pak 的自动挂载目录:https://imzlp.com/posts/16895/#pak%E7%9A%84%E8%87%AA%E5%8A%A8%E6%8C%82%E8%BD%BD%E7%9B%AE%E5%BD%95[4]UE 源码分析:修改游戏默认的数据存储路径:https://imzlp.com/posts/20367[5]HotPatcher:https://imzlp.com/posts/17590/[6]UE 热更新:资源的二进制补丁方案:https://imzlp.com/posts/25136

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 包体优化:UE跨版本资源复用的方案

猜你喜欢

  • 暂无文章