極致優(yōu)化 Android 平臺 APK 的大小
作者 | lipeng
在游戲項(xiàng)目中,當(dāng)我們在打包各個(gè)平臺時(shí),總希望每個(gè)平臺的包能夠最小化便于分發(fā),而且上架某些平臺還有明確的大小要求。對于UE而言,它包含了巨量代碼以及大量的插件,Build階段還將生成反射的膠水代碼,在編譯時(shí)產(chǎn)生了大量的代碼段。以Android平臺為例,將導(dǎo)致libUE4.so的大小急劇增長,對于包體和運(yùn)行時(shí)內(nèi)存都造成了壓力。再加上一些引擎必要和額外帶入的資源也能占據(jù)上百M(fèi),空APK的大小很容易達(dá)到數(shù)百M(fèi)的規(guī)模!不僅僅為了符合上架平臺的要求,從包體和內(nèi)存優(yōu)化的角度,也有必要對UE包的大小進(jìn)行裁剪。
一、包大小分布
在APK內(nèi),游戲相關(guān)的空間占比較大的部分,為下面幾項(xiàng):
- 可執(zhí)行代碼(so - lib/arm64-v8a)
- main.obb.png(游戲內(nèi)資源Pak、DirectoriesToAlwaysStageAsNonUFS的部分)
- 第三方組件拷貝進(jìn)APK內(nèi)的文件
需要分別針對上面列出的三種情況,分別制定具體的優(yōu)化策略。
二、壓縮NativeLibs
當(dāng)APK安裝時(shí),對于NativeLibs有兩種處理方式:
- 安裝時(shí)解壓so到應(yīng)用的內(nèi)部存儲目錄( /data/app/<package_name>/lib/)
- 直接從APK文件中加載so,可以加快安裝過程
而它就引出了一個(gè)問題:如果允許安裝時(shí)解壓,則NativeLibs打包進(jìn)APK內(nèi)是可以被執(zhí)行壓縮的。 對比一下實(shí)際的壓縮與否的大小情況,對APK大小的影響非常大:
壓縮 | 不壓縮 |
對于原生Android而言,是否在安裝時(shí)解壓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的選項(xiàng):
bool bExtractNativeLibs = true;
Ini.GetBool("/Script/AndroidRuntimeSettings.AndroidRuntimeSettings", "bExtractNativeLibs", out bExtractNativeLibs);
需要注意的是,如果是舊版本引擎(4.27及之前),升級了grable升級后(>4.2)后,gradle用useLegacyPackaging取代extractNativeLibs,Manifest里的extractNativeLibs默認(rèn)是false的,所以會導(dǎo)致APK增大。
解決辦法是可以在UPL中強(qiáng)制把值改了:
<addAttribute tag="application" name="android:extractNativeLibs" value="true"/>
注意:它只是控制讓so打進(jìn)APK時(shí)是否執(zhí)行壓縮,并不會實(shí)際減少so的大??!對于可執(zhí)行程序的優(yōu)化,需要繼續(xù)下面的代碼優(yōu)化的部分。
三、代碼體積優(yōu)化
關(guān)于代碼體積優(yōu)化的部分,在Android平臺,核心目標(biāo)是要減少單個(gè)so的大??!并且盡可能地避免對運(yùn)行時(shí)性能的影響。
對NativeLibs大小優(yōu)化思路:
- 減少動態(tài)鏈接庫的數(shù)量,剔除不必要的
- 減少庫內(nèi)部的符號、減少代碼段大小
- 剔除調(diào)試信息
對于所有的so,都可以在編譯/鏈接時(shí)應(yīng)用這些優(yōu)化策略。 但對于UE項(xiàng)目而言,我們能控制的通常也只有引擎和項(xiàng)目的代碼,庫的代碼需要庫的提供者優(yōu)化。所以接下來的優(yōu)化策略,只針對于libUE4.so/libUnreal.so。
1. 減小libUE4.so
在打包時(shí),因?yàn)樾枰獔?zhí)行完整的編譯,并且UE在運(yùn)行時(shí)默認(rèn)是Monolithic的模式,所有的代碼都被編譯到了同一個(gè)可執(zhí)行文件中。
UE基于UBT的編譯過程封裝,以及提供target.cs/build.cs中的配置參數(shù),使我們能夠在一定程度上對引擎和項(xiàng)目代碼進(jìn)行編譯控制,達(dá)到我們優(yōu)化so大小的目的。
對于UE項(xiàng)目而言,優(yōu)化so的大小有以下幾種思路:
- 禁用不必要模塊
- 控制代碼優(yōu)化(控制inline/O3/0z)
- 禁用Module不必要異常處理
- 啟用LTO
- 剔除不需要的導(dǎo)出符號
(1) 禁用模塊
可以把引擎中內(nèi)置的明確不需要使用的模塊在target.cs中關(guān)閉:
// disable modules
bUseChaos = false;
bCompileChaos = false;
bCompileAPEX = false;
同時(shí),需要梳理項(xiàng)目中引入的不必要的運(yùn)行時(shí)插件,減少參與編譯的Module的數(shù)量,從而減少實(shí)際參與編譯的代碼。
(2) 關(guān)閉inline
inline是編譯階段對運(yùn)行時(shí)的執(zhí)行效率優(yōu)化,將函數(shù)調(diào)用直接替換為函數(shù)代碼,而不是常規(guī)的函數(shù)調(diào)用??梢詼p少函數(shù)調(diào)用的開銷,理論上來說可以提高程序的執(zhí)行效率。
但inline會增大.text段的大小,可以酌情關(guān)閉。
- 修改target.cs:bUseInlining = false;(僅在IOS/Linux/Mac/Win有效)
- 修改UBT,在Android編譯時(shí)受bUseInlining控制,添加-fno-inline-functions編譯參數(shù)
if (TargetInfo.Platform == UnrealTargetPlatform.Android)
{
if (bUseInlining)
{
AdditionalCompilerArguments += " -finline-functions";
}else {
AdditionalCompilerArguments += " -fno-inline-functions";
}
}
注意:關(guān)閉inline后,如果某些函數(shù)具有高頻調(diào)用,會帶來一些性能損失;在非高頻情況下,inline與否的性能,這個(gè)需要結(jié)合項(xiàng)目的實(shí)際性能情況進(jìn)行控制。在我的測試結(jié)果中,是否inline對幀率影響微乎其微。
(3) 關(guān)閉異常處理
有些模塊中打開了C++異常處理,但是沒有try/catch的使用:
bEnableExceptions = false;
可以關(guān)掉,能夠減少so內(nèi)的.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的區(qū)別:
- -O3:性能優(yōu)先,積極內(nèi)聯(lián)、循環(huán)展開
- -Oz:體積優(yōu)先,避免內(nèi)聯(lián)、保持循環(huán)
可以根據(jù)項(xiàng)目實(shí)際的性能情況,選擇使用哪種方式。
(5) 啟用LTO
LTO是Link Time Optimization的簡稱,可以在鏈接時(shí)剔除死代碼、優(yōu)化跨模塊的函數(shù)調(diào)用、內(nèi)聯(lián)等。 在引擎的build.cs中可以bAllowLTCG打開,LTCG是LTO的一種實(shí)現(xiàn),但是它也只僅在IOS/Linux/Mac/Win有效(UE4.25)。
支持Android的話,同樣也要修改UBT(AndroidToolChain.cs),給Android添加受bAllowLTCG參數(shù)控制,選擇是否添加-flto=thin的編譯參數(shù),thin是縮減大小與優(yōu)化耗時(shí)的綜合版本。
bAllowLTCG = true; // LTO
if (bAllowLTCG)
{
AdditionalCompilerArguments += " -flto=thin";
}
(6) 剔除導(dǎo)出符號
在編譯so時(shí),除非特殊設(shè)置,所有的函數(shù)和變量都會被導(dǎo)出,用于被其他的so訪問。 但在UE引擎內(nèi),只有極少數(shù)的接口,是明確被外部訪問的(JNI相關(guān)的接口),所以libUE4.so的符號導(dǎo)出絕大部分是浪費(fèi)的,剔除掉符號導(dǎo)出可以大幅降低so的大小和內(nèi)存占用!
現(xiàn)代編譯器提供了version-script的鏈接時(shí)控制機(jī)制,可以通過傳入一個(gè)ldscript文件來控制鏈接時(shí)的符號行為。
需要在編譯過程中先構(gòu)造出一個(gè)ldscript文件,填入符號導(dǎo)出控制代碼,然后在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而言,需要允許導(dǎo)出的只有Java_*/ANativeActivity_onCreate/JNI_OnLoad這三類匹配符號,,其余的均可剔除。
2. 優(yōu)化數(shù)據(jù)
經(jīng)過上面介紹的一系列對代碼體積的優(yōu)化,收益明顯。
(1) so壓縮后大小
前面提到了,NativeLibs進(jìn)APK是可以被壓縮的,所以當(dāng)我們減少了so的原始大小,也能夠減少壓縮后的大小。
經(jīng)過上面的優(yōu)化之后,在Shipping的模式下,so的原始大小從原來的258M減少到了146M! so的壓縮后大小,從74.3M減少到了44.67M,減少了29.63M!可執(zhí)行程序文件顯著減小。
readelf優(yōu)化前后對比(部分?jǐn)?shù)據(jù)):
(2) 內(nèi)存收益
對so大小的優(yōu)化,同時(shí)減少了加載so的內(nèi)存,也能夠獲得額外的內(nèi)存收益。
安卓可以通過dumpsys meminfo來查看整個(gè)包的so占用內(nèi)存情況,包含了所有已加載的so,但可以通過優(yōu)化前后的差值得到實(shí)際的內(nèi)存收益。
優(yōu)化前:
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
優(yōu)化后:
** 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 209284
arm64-v8a Shipping | 優(yōu)化前 | 優(yōu)化后 | 減少 |
libUE4.so大小 | 246 | 145 | 101 |
meminfo(so總內(nèi)存) | 178.165 | 140.04 | 38.125 |
3. 優(yōu)化策略補(bǔ)充
(1) 重定位表壓縮
① SDK 28
在Android的MinSDKVersion大于等于28時(shí)(Android9),可以在編譯和鏈接時(shí)開啟RELR重定位表壓縮。利用相對地址重定位的特點(diǎn),對重定位信息進(jìn)行高效編碼,從而減少存儲空間占用。
開啟方法,需要在編譯階段給Compiler和Linker傳遞參數(shù):
AdditionalCompilerArguments += " -fPIC";
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags";
-Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags 是 Android 特有的鏈接器選項(xiàng),它們是對標(biāo)準(zhǔn) -Wl,-z,relro 和 -Wl,-z,now 的補(bǔ)充和優(yōu)化,特別是針對 Android 系統(tǒng)中動態(tài)鏈接和重定位的處理。 它們主要用于進(jìn)一步減小二進(jìn)制文件大小和改善加載時(shí)間。
驗(yàn)證是否生效,可以使用readelf -d libUE4.so,查看是否存在RELR字段:
優(yōu)化前重定位表的大?。?5.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
優(yōu)化后重定位表的大?。?80K):
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
優(yōu)化后的重定位表大小從25.82M降低到280K,結(jié)果直接體現(xiàn)在so的大小減少了25M,使APK的大小也減少了4M左右,優(yōu)化效果極為明顯。
并且,它對內(nèi)存的優(yōu)化效果也非常顯著:在Development下從190.49M - > 161.06M,減少了29.43M。
優(yōu)化前(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
優(yōu)化后(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
它對運(yùn)行時(shí)性能是正面優(yōu)化而不是降低,因?yàn)樗ㄟ^減少運(yùn)行時(shí)重定位的數(shù)量來提高代碼加載速度和降低內(nèi)存占用。
② SDK 23
如果項(xiàng)目對SDK版本有要求,不能升級到28,也可以用另一種替代壓縮參數(shù),要求SDK版本>=23。
AdditionalCompilerArguments += " -fPIC";
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android";
它也能夠大幅壓縮重定位表的大小(雖然不如RELE到幾百K的級別),并且也能大幅降低so的內(nèi)存占用:
壓縮后(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
運(yùn)行時(shí)的內(nèi)存情況(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內(nèi)存
當(dāng)啟用重定位表壓縮后,Shipping包的總so運(yùn)行時(shí)內(nèi)存降低到了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內(nèi)文件
有一些第三方的插件,會往APK內(nèi)拷貝文件,這也是可以優(yōu)化的部分。
需要分析項(xiàng)目的實(shí)際使用情況處理:
- 剔除不必要的第三方組件
- 對于必須的組件,剔除不需要的文件
(1) 組件裁剪:以GVoice為例
如果項(xiàng)目集成了GCloud的組件,其中會拷貝至APK文件的組件中,GCloudVoice的模型文件占大頭。
在APK內(nèi)assets/GCloudVoice目錄壓縮后占了約13.5M:
- wave_dafx_data.bin 是3d語音 不用3d功能可以移除
- wave_3d_data.bin 是3d語音 不用3d功能可以移除
- cldnn_spkvector.mnn 提取聲紋的,默認(rèn)不使用這個(gè)功能,可以移除
- 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 這個(gè)是用于wwise下的新算法資源文件,如果有打包的大小限制,也可以去掉
可以把項(xiàng)目中未用到功能的模型文件剔除。另外從實(shí)現(xiàn)上,最好不要直接刪除文件,而是修改GVoice_APL.xml的拷貝邏輯實(shí)現(xiàn):
<resourceCopies>
<log text="Start copy res..." />
<!--
author: lipengzha
desc: 只拷貝GVoice的libgvoicensmodel.bin/config.json,其余文件游戲內(nèi)無作用
原始拷貝代碼:
<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) 游戲內(nèi)資源
游戲內(nèi)的資源就是UE引擎或組件依賴的資源/文件,會打包至PAK或拷貝至main.obb內(nèi)的文件。
- PAK內(nèi):游戲內(nèi)的資產(chǎn),需要梳理哪些是非必要的,哪些是可以剔除或進(jìn)行延遲加載的。
- DirectoriesToAlwaysStageAsNonUFS:不進(jìn)PAK,但是會打包進(jìn)main.obb里的
(3) PAK內(nèi)資源
更準(zhǔn)確地描述是:安裝包內(nèi)PAK的資源。
引擎必要的資源都在pakchunk0中,除了pakchunk0外,UE可以把利用PrimaryAssetLabel拆分的Chunk打包至安裝包外。
但對于pakchunk0中的資源或文件,依然要進(jìn)行優(yōu)化:
- 僅保留引擎必要的資源(/Engine中的關(guān)鍵資產(chǎn)、ini、GlobalShader、項(xiàng)目ShaderLibrary、啟動地圖、GameFramework資產(chǎn),等等),在我之前的文章(UE資源管理:引擎打包資源分析)有更詳細(xì)的介紹。
- 剔除非啟動階段必須的資源
- 改造引擎延遲加載部分文件(如L10N本地化語言的加載)
- 拆分啟動階段與游戲內(nèi)資源(如字體),游戲內(nèi)字體單獨(dú)打包且走動態(tài)下載
引擎本身的拆包邏輯也有較大的局限性,比如ShdaerLibrary之類的,默認(rèn)整個(gè)項(xiàng)目生成一個(gè),當(dāng)規(guī)模龐大后,它也將成為優(yōu)化包大小的瓶頸。這部分內(nèi)容的詳情可以查看我之前的另一篇文章(資源管理:重塑UE的包拆分方案)。
除此之外,還需要在資源管理和打包階段,能夠?qū)ndroid的所有資源從安裝包內(nèi)剔除,轉(zhuǎn)為動態(tài)下載/掛載的機(jī)制,并且不能夠影響IOS。 當(dāng)使用諸如PrimaryAssetLabel拆分pak時(shí),引擎為Android提供了內(nèi)置的把Pak從安裝包內(nèi)剔除的方法:
; Config/DefaultEngine.ini
[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings]
+ObbFilters=-*.pak
+ObbFilters=pakchunk0-*
但官方僅在Android平臺有支持,對于其他平臺就沒那么方便了。在之前的文章中曾介紹過,我開發(fā)的HotChunker擴(kuò)展可以很容易地實(shí)現(xiàn)通用的包過濾方案,為全平臺支持自定義的進(jìn)包控制策略。
(4) StageAsNonUFS
在引擎的打包配置中,有一項(xiàng)DirectoriesToAlwaysStageAsNonUFS,它是指定目錄不打包進(jìn)PAK,但是會打包進(jìn)main.obb里的,目前引擎內(nèi)只有Content/Movies目錄會被拷貝至main.obb中。
而在打包時(shí)的讀取的Ini,也是具有層級邏輯的,所以對于打包時(shí)的配置,依然能夠?qū)Σ煌脚_進(jìn)行區(qū)分!如果想要在Android/IOS進(jìn)行區(qū)分,也可以利用這個(gè)機(jī)制做到。
可以把打包策略做如下調(diào)整:除非必要的視頻(如啟動時(shí)立即播放的),可以把其余的游戲內(nèi)MP4單獨(dú)打包時(shí)PAK中,轉(zhuǎn)為動態(tài)下載。
這樣可以大幅減少APK內(nèi)MP4的大小,也能夠使MP4進(jìn)行熱更。
五、優(yōu)化效果
綜合上面多種對包大小優(yōu)化手段后,順利將游戲的APK大小1.23G降低到130M,原始so大小從258M降低到了132M。
運(yùn)行時(shí)內(nèi)存也降低了數(shù)十M!并且包含了完整的第三方組件、游戲功能,資源可走動態(tài)下載,使安裝包本體變成一個(gè)極小化的下載器,便于傳播和分發(fā)。
實(shí)際采用哪些優(yōu)化策略要結(jié)合實(shí)際項(xiàng)目的具體需要,以及對包體大小和性能的平衡來選擇,如inline控制和編譯優(yōu)化級別、資源的極致化裁剪(L10N等)還需要對引擎進(jìn)行改造等。