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

Package size optimization: Solution for reusing old-version resources in UE

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

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

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

前言

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

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

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

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

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

本地旧资源

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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等文件的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
virtual bool Initialize(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内部的目录结构就是引擎定义的虚拟路径:

1
2
3
4
5
6
7
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 的自动挂载目录)介绍过的引擎自动挂载的三个目录吗?通过这样的方式就对应起来了,然后走引擎UFS的PlatformFile机制,就可以实现跨平台的包内文件读取了。

而游戏运行时产生的数据目录(Saved等),则不会存在于/data/app/中,它被写入独立的沙盒目录(详见文章UE 源码分析:修改游戏默认的数据存储路径),跟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机制做好优先级,确保补丁比旧版本资源的优先级更高,就能实现文件替换的需求,这一点与热更新完全一致。

PakOrder决定加载优先级

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

如何识别差异?

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

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

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

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

UASSET的导出信息:

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

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

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

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

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

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

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

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

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

打包哪些部分?

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

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

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

  • 基于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,如pakchunk0
  4. 需动态下载的资源
  5. 热更的资源

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

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

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

对热更的影响?

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

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

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

后续的热更新,只需要用HotPatcher对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 热更新:资源的二进制补丁方案,为大版本补丁创建二进制的文件差异,可以在此基础上更大幅度地降低补丁大小。

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

结语

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

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

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

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:包体优化:UE跨版本资源复用的方案
文章作者:查利鹏
发布时间:2026/02/13 09:42
本文字数:5.7k 字
原始链接:https://imzlp.com/posts/99122/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!