極致優化 Android 平臺 APK 的大小
作者 | lipeng
在游戲項目中,當我們在打包各個平臺時,總希望每個平臺的包能夠最小化便于分發,而且上架某些平臺還有明確的大小要求。對于UE而言,它包含了巨量代碼以及大量的插件,Build階段還將生成反射的膠水代碼,在編譯時產生了大量的代碼段。以Android平臺為例,將導致libUE4.so的大小急劇增長,對于包體和運行時內存都造成了壓力。再加上一些引擎必要和額外帶入的資源也能占據上百M,空APK的大小很容易達到數百M的規模!不僅僅為了符合上架平臺的要求,從包體和內存優化的角度,也有必要對UE包的大小進行裁剪。

一、包大小分布
在APK內,游戲相關的空間占比較大的部分,為下面幾項:
- 可執行代碼(so - lib/arm64-v8a)
- main.obb.png(游戲內資源Pak、DirectoriesToAlwaysStageAsNonUFS的部分)
- 第三方組件拷貝進APK內的文件
需要分別針對上面列出的三種情況,分別制定具體的優化策略。
二、壓縮NativeLibs
當APK安裝時,對于NativeLibs有兩種處理方式:
- 安裝時解壓so到應用的內部存儲目錄( /data/app/<package_name>/lib/)
- 直接從APK文件中加載so,可以加快安裝過程
而它就引出了一個問題:如果允許安裝時解壓,則NativeLibs打包進APK內是可以被執行壓縮的。 對比一下實際的壓縮與否的大小情況,對APK大小的影響非常大:
壓縮 | 不壓縮 |
|
|
對于原生Android而言,是否在安裝時解壓NativeLibs是由AndroidManifest.xml中的extractNativeLibs控制的:
<application android:allowBackup="true" android:appComponentFactory="android.support.v4.app.CoreComponentFactory" android:debuggable="true" android:extractNativeLibs="false" android:hardwareAccelerated="true" android:hasCode="true" android:icon="@drawable/icon" android:label="@string/app_name" android:name="com.epicgames.ue4.GameApplication" android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true">在新版引擎中,在AndroidRuntimeSettings配置中直接提供了bExtractNativeLibs的選項:
bool bExtractNativeLibs = true;
Ini.GetBool("/Script/AndroidRuntimeSettings.AndroidRuntimeSettings", "bExtractNativeLibs", out bExtractNativeLibs);需要注意的是,如果是舊版本引擎(4.27及之前),升級了grable升級后(>4.2)后,gradle用useLegacyPackaging取代extractNativeLibs,Manifest里的extractNativeLibs默認是false的,所以會導致APK增大。
解決辦法是可以在UPL中強制把值改了:
<addAttribute tag="application" name="android:extractNativeLibs" value="true"/>注意:它只是控制讓so打進APK時是否執行壓縮,并不會實際減少so的大??!對于可執行程序的優化,需要繼續下面的代碼優化的部分。
三、代碼體積優化
關于代碼體積優化的部分,在Android平臺,核心目標是要減少單個so的大?。〔⑶冶M可能地避免對運行時性能的影響。

對NativeLibs大小優化思路:
- 減少動態鏈接庫的數量,剔除不必要的
- 減少庫內部的符號、減少代碼段大小
- 剔除調試信息
對于所有的so,都可以在編譯/鏈接時應用這些優化策略。 但對于UE項目而言,我們能控制的通常也只有引擎和項目的代碼,庫的代碼需要庫的提供者優化。所以接下來的優化策略,只針對于libUE4.so/libUnreal.so。
1. 減小libUE4.so
在打包時,因為需要執行完整的編譯,并且UE在運行時默認是Monolithic的模式,所有的代碼都被編譯到了同一個可執行文件中。
UE基于UBT的編譯過程封裝,以及提供target.cs/build.cs中的配置參數,使我們能夠在一定程度上對引擎和項目代碼進行編譯控制,達到我們優化so大小的目的。
對于UE項目而言,優化so的大小有以下幾種思路:
- 禁用不必要模塊
- 控制代碼優化(控制inline/O3/0z)
- 禁用Module不必要異常處理
- 啟用LTO
- 剔除不需要的導出符號
(1) 禁用模塊
可以把引擎中內置的明確不需要使用的模塊在target.cs中關閉:
// disable modules
bUseChaos = false;
bCompileChaos = false;
bCompileAPEX = false;同時,需要梳理項目中引入的不必要的運行時插件,減少參與編譯的Module的數量,從而減少實際參與編譯的代碼。
(2) 關閉inline
inline是編譯階段對運行時的執行效率優化,將函數調用直接替換為函數代碼,而不是常規的函數調用??梢詼p少函數調用的開銷,理論上來說可以提高程序的執行效率。
但inline會增大.text段的大小,可以酌情關閉。
- 修改target.cs:bUseInlining = false;(僅在IOS/Linux/Mac/Win有效)
- 修改UBT,在Android編譯時受bUseInlining控制,添加-fno-inline-functions編譯參數
if (TargetInfo.Platform == UnrealTargetPlatform.Android)
{
if (bUseInlining)
{
AdditionalCompilerArguments += " -finline-functions";
}else {
AdditionalCompilerArguments += " -fno-inline-functions";
}
}注意:關閉inline后,如果某些函數具有高頻調用,會帶來一些性能損失;在非高頻情況下,inline與否的性能,這個需要結合項目的實際性能情況進行控制。在我的測試結果中,是否inline對幀率影響微乎其微。
(3) 關閉異常處理
有些模塊中打開了C++異常處理,但是沒有try/catch的使用:
bEnableExceptions = false;可以關掉,能夠減少so內的.eh_frame的大小。
(4) 使用O3/Oz編譯
在target.cs中控制bCompileForSize的值,可以選擇使用O3或Oz編譯代碼:
// optimization level
if (!CompileEnvironment.bOptimizeCode){
Result += " -O0";
}else{
if (CompileEnvironment.bOptimizeForSize){
Result += " -Oz";
}else{
Result += " -O3";
}
}O3和Oz的區別:
- -O3:性能優先,積極內聯、循環展開
- -Oz:體積優先,避免內聯、保持循環
可以根據項目實際的性能情況,選擇使用哪種方式。
(5) 啟用LTO
LTO是Link Time Optimization的簡稱,可以在鏈接時剔除死代碼、優化跨模塊的函數調用、內聯等。 在引擎的build.cs中可以bAllowLTCG打開,LTCG是LTO的一種實現,但是它也只僅在IOS/Linux/Mac/Win有效(UE4.25)。
支持Android的話,同樣也要修改UBT(AndroidToolChain.cs),給Android添加受bAllowLTCG參數控制,選擇是否添加-flto=thin的編譯參數,thin是縮減大小與優化耗時的綜合版本。
bAllowLTCG = true; // LTO
if (bAllowLTCG)
{
AdditionalCompilerArguments += " -flto=thin";
}(6) 剔除導出符號
在編譯so時,除非特殊設置,所有的函數和變量都會被導出,用于被其他的so訪問。 但在UE引擎內,只有極少數的接口,是明確被外部訪問的(JNI相關的接口),所以libUE4.so的符號導出絕大部分是浪費的,剔除掉符號導出可以大幅降低so的大小和內存占用!
現代編譯器提供了version-script的鏈接時控制機制,可以通過傳入一個ldscript文件來控制鏈接時的符號行為。
需要在編譯過程中先構造出一個ldscript文件,填入符號導出控制代碼,然后在target.cs中,傳遞給Linker:
string VersionScriptFile = GetVersionScriptFilename();
using (StreamWriter Writer = File.CreateText(VersionScriptFile))
{
Writer.WriteLine("{ global: Java_*; ANativeActivity_onCreate; JNI_OnLoad; local: *; };");
}
AdditionalLinkerArguments += " -Wl,--version-script=\"" + VersionScriptFile + "\"";對于UE而言,需要允許導出的只有Java_*/ANativeActivity_onCreate/JNI_OnLoad這三類匹配符號,,其余的均可剔除。
2. 優化數據
經過上面介紹的一系列對代碼體積的優化,收益明顯。
(1) so壓縮后大小
前面提到了,NativeLibs進APK是可以被壓縮的,所以當我們減少了so的原始大小,也能夠減少壓縮后的大小。

經過上面的優化之后,在Shipping的模式下,so的原始大小從原來的258M減少到了146M! so的壓縮后大小,從74.3M減少到了44.67M,減少了29.63M!可執行程序文件顯著減小。
readelf優化前后對比(部分數據):

(2) 內存收益
對so大小的優化,同時減少了加載so的內存,也能夠獲得額外的內存收益。
安卓可以通過dumpsys meminfo來查看整個包的so占用內存情況,包含了所有已加載的so,但可以通過優化前后的差值得到實際的內存收益。
優化前:
127|PD2324:/ $ dumpsys meminfo com.xxx.yyy
dumpsys meminfo com.xxx.yyy
Applications Memory Usage (in Kilobytes):
Uptime: 501711593 Realtime: 544369467
** MEMINFO in pid 23677 [com.xxx.yyy] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 178165 14024 159220 5 245292優化后:
** MEMINFO in pid 31482 [com.xxx.yyy] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 144041 13208 125644 5 209284arm64-v8a Shipping | 優化前 | 優化后 | 減少 |
libUE4.so大小 | 246 | 145 | 101 |
meminfo(so總內存) | 178.165 | 140.04 | 38.125 |
3. 優化策略補充
(1) 重定位表壓縮
① SDK 28
在Android的MinSDKVersion大于等于28時(Android9),可以在編譯和鏈接時開啟RELR重定位表壓縮。利用相對地址重定位的特點,對重定位信息進行高效編碼,從而減少存儲空間占用。
開啟方法,需要在編譯階段給Compiler和Linker傳遞參數:
AdditionalCompilerArguments += " -fPIC";
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags";-Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags 是 Android 特有的鏈接器選項,它們是對標準 -Wl,-z,relro 和 -Wl,-z,now 的補充和優化,特別是針對 Android 系統中動態鏈接和重定位的處理。 它們主要用于進一步減小二進制文件大小和改善加載時間。
驗證是否生效,可以使用readelf -d libUE4.so,查看是否存在RELR字段:

優化前重定位表的大小(25.82M):
8 .rela.dyn 0189c708 000000000000c720 000000000000c720 0000c720 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .rela.plt 00004338 00000000018a8e28 00000000018a8e28 018a8e28 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA優化后重定位表的大小(280K):
8 .rela.dyn 00013852 000000000000c6d8 000000000000c6d8 0000c6d8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .relr.dyn 0002cca8 000000000001ff30 000000000001ff30 0001ff30 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
10 .rela.plt 00004320 000000000004cbd8 000000000004cbd8 0004cbd8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA優化后的重定位表大小從25.82M降低到280K,結果直接體現在so的大小減少了25M,使APK的大小也減少了4M左右,優化效果極為明顯。

并且,它對內存的優化效果也非常顯著:在Development下從190.49M - > 161.06M,減少了29.43M。
優化前(Development:190.49MB):
** MEMINFO in pid 16293 [com.xxx.yyy] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 190490 49692 136896 9 255392優化后(Development:161.06MB):
** MEMINFO in pid 16294 [com.xxx.yyy] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 161066 13740 142832 9 227500它對運行時性能是正面優化而不是降低,因為它通過減少運行時重定位的數量來提高代碼加載速度和降低內存占用。
② SDK 23
如果項目對SDK版本有要求,不能升級到28,也可以用另一種替代壓縮參數,要求SDK版本>=23。
AdditionalCompilerArguments += " -fPIC";
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android";它也能夠大幅壓縮重定位表的大?。m然不如RELE到幾百K的級別),并且也能大幅降低so的內存占用:
壓縮后(Development:3.41M):
[ 8] .rela.dyn LOOS+0x2 000000000000aca0 0000aca0
000000000033d20e 0000000000000001 A 3 0 8
[ 9] .rela.plt RELA 0000000000347eb0 00347eb0
0000000000004320 0000000000000018 A 3 21 8運行時的內存情況(Development:163.19M),相較于原始190.49M,也降低了27.3M,比RELR略低:
** MEMINFO in pid 11492 [com.tencent.tmgp.fmgame] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 163196 14104 145228 5 228248③ Shipping內存
當啟用重定位表壓縮后,Shipping包的總so運行時內存降低到了134.74M!
** MEMINFO in pid 13929 [com.xxx.yyy] **
Pss Private Private SwapPss Rss Heap Heap Heap
Total Dirty Clean Dirty Total Size Alloc Free
------ ------ ------ ------ ------ ------ ------ ------
.so mmap 134743 12968 118532 5 198692四、資源裁剪
1. APK內文件
有一些第三方的插件,會往APK內拷貝文件,這也是可以優化的部分。
需要分析項目的實際使用情況處理:
- 剔除不必要的第三方組件
- 對于必須的組件,剔除不需要的文件
(1) 組件裁剪:以GVoice為例
如果項目集成了GCloud的組件,其中會拷貝至APK文件的組件中,GCloudVoice的模型文件占大頭。
在APK內assets/GCloudVoice目錄壓縮后占了約13.5M:

- wave_dafx_data.bin 是3d語音 不用3d功能可以移除
- wave_3d_data.bin 是3d語音 不用3d功能可以移除
- cldnn_spkvector.mnn 提取聲紋的,默認不使用這個功能,可以移除
- libwxvoiceembed.bin 是文明語音的 不用文明語音可以移除
- libgvoicensmodel.bin 是噪聲抑制算法模型,不能刪
- decoder_v4_small.nn、encoder_v4_small.nn aicodec用的 不用aicodec的話可以刪除
- dse_v1.nn、dse_v1_align.nn、dse_v1_mono.nn 這個是用于wwise下的新算法資源文件,如果有打包的大小限制,也可以去掉
可以把項目中未用到功能的模型文件剔除。另外從實現上,最好不要直接刪除文件,而是修改GVoice_APL.xml的拷貝邏輯實現:
<resourceCopies>
<log text="Start copy res..." />
<!--
author: lipengzha
desc: 只拷貝GVoice的libgvoicensmodel.bin/config.json,其余文件游戲內無作用
原始拷貝代碼:
<copyDir src="$S(PluginDir)/../GVoiceLib/Android/assets/" dst="$S(BuildDir)/assets"/>
-->
<copyFile src="$S(PluginDir)/../GVoiceLib/Android/assets/libgvoicensmodel.bin" dst="$S(BuildDir)/assets/libgvoicensmodel.bin" force="true"/>
<copyFile src="$S(PluginDir)/../GVoiceLib/Android/assets/config.json" dst="$S(BuildDir)/assets/config.json" force="true"/>
</resourceCopies>(2) 游戲內資源
游戲內的資源就是UE引擎或組件依賴的資源/文件,會打包至PAK或拷貝至main.obb內的文件。
- PAK內:游戲內的資產,需要梳理哪些是非必要的,哪些是可以剔除或進行延遲加載的。
- DirectoriesToAlwaysStageAsNonUFS:不進PAK,但是會打包進main.obb里的
(3) PAK內資源
更準確地描述是:安裝包內PAK的資源。
引擎必要的資源都在pakchunk0中,除了pakchunk0外,UE可以把利用PrimaryAssetLabel拆分的Chunk打包至安裝包外。
但對于pakchunk0中的資源或文件,依然要進行優化:
- 僅保留引擎必要的資源(/Engine中的關鍵資產、ini、GlobalShader、項目ShaderLibrary、啟動地圖、GameFramework資產,等等),在我之前的文章(UE資源管理:引擎打包資源分析)有更詳細的介紹。
- 剔除非啟動階段必須的資源
- 改造引擎延遲加載部分文件(如L10N本地化語言的加載)
- 拆分啟動階段與游戲內資源(如字體),游戲內字體單獨打包且走動態下載
引擎本身的拆包邏輯也有較大的局限性,比如ShdaerLibrary之類的,默認整個項目生成一個,當規模龐大后,它也將成為優化包大小的瓶頸。這部分內容的詳情可以查看我之前的另一篇文章(資源管理:重塑UE的包拆分方案)。
除此之外,還需要在資源管理和打包階段,能夠將Android的所有資源從安裝包內剔除,轉為動態下載/掛載的機制,并且不能夠影響IOS。 當使用諸如PrimaryAssetLabel拆分pak時,引擎為Android提供了內置的把Pak從安裝包內剔除的方法:
; Config/DefaultEngine.ini
[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings]
+ObbFilters=-*.pak
+ObbFilters=pakchunk0-*但官方僅在Android平臺有支持,對于其他平臺就沒那么方便了。在之前的文章中曾介紹過,我開發的HotChunker擴展可以很容易地實現通用的包過濾方案,為全平臺支持自定義的進包控制策略。
(4) StageAsNonUFS
在引擎的打包配置中,有一項DirectoriesToAlwaysStageAsNonUFS,它是指定目錄不打包進PAK,但是會打包進main.obb里的,目前引擎內只有Content/Movies目錄會被拷貝至main.obb中。
而在打包時的讀取的Ini,也是具有層級邏輯的,所以對于打包時的配置,依然能夠對不同平臺進行區分!如果想要在Android/IOS進行區分,也可以利用這個機制做到。
可以把打包策略做如下調整:除非必要的視頻(如啟動時立即播放的),可以把其余的游戲內MP4單獨打包時PAK中,轉為動態下載。
這樣可以大幅減少APK內MP4的大小,也能夠使MP4進行熱更。
五、優化效果
綜合上面多種對包大小優化手段后,順利將游戲的APK大小1.23G降低到130M,原始so大小從258M降低到了132M。

運行時內存也降低了數十M!并且包含了完整的第三方組件、游戲功能,資源可走動態下載,使安裝包本體變成一個極小化的下載器,便于傳播和分發。
實際采用哪些優化策略要結合實際項目的具體需要,以及對包體大小和性能的平衡來選擇,如inline控制和編譯優化級別、資源的極致化裁剪(L10N等)還需要對引擎進行改造等。































