Ultimate Optimization: Reducing the size of UE Android APKs

极致优化UE Android APK的大小

In game projects, when we package for various platforms, we always hope that the package for each platform can be minimized for easier distribution, and there are specific size requirements for some platforms.

For UE, it contains massive code and numerous plugins, and during the Build phase, it generates a lot of reflection glue code, resulting in a significant increase in code segments during compilation. Taking the Android platform as an example, this leads to a sharp increase in the size of libUE4.so, putting pressure on both the package size and runtime memory.

Moreover, some necessary and additional resources brought in by the engine can take up hundreds of MB, making the size of an empty APK easily reach several hundred MB! Not only to meet the platform’s requirements, but it is also necessary to trim down the size of the UE package from the perspective of package size and memory optimization.

This article will take Android as an example and introduce optimization ideas and practices for the cuttable parts in the UE package from various aspects, optimizing both the APK size and the runtime memory usage of native libraries. The strategies can also be reused on other platforms.

Package Size Distribution

In the APK, the significant portion of space related to the game consists mainly of the following:

  1. Executable code (so - lib/arm64-v8a)
  2. main.obb.png (game resource Pak, part of DirectoriesToAlwaysStageAsNonUFS)
  3. Files of third-party components copied into the APK

Specific optimization strategies need to be formulated for each of the three scenarios listed above.

Compressing NativeLibs

When the APK is installed, there are two processing methods for NativeLibs:

  1. Unzip so to the application’s internal storage directory (/data/app/<package_name>/lib/)
  2. Load so directly from the APK file, which can speed up the installation process

This raises a question: If unzipping during installation is allowed, then the NativeLibs packed into the APK can be executed using compression. Comparing the actual situation of compression versus non-compression shows a significant impact on APK size:

Compression No Compression

For native Android, whether to unpack NativeLibs during installation is controlled by extractNativeLibs in AndroidManifest.xml:

1
<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">

In the new version of the engine, the option bExtractNativeLibs is provided directly in the AndroidRuntimeSettings configuration:

1
2
bool bExtractNativeLibs = true;
Ini.GetBool("/Script/AndroidRuntimeSettings.AndroidRuntimeSettings", "bExtractNativeLibs", out bExtractNativeLibs);

It is worth noting that for older versions of the engine (4.27 and earlier), after upgrading gradle ( > 4.2), gradle uses useLegacyPackaging instead of extractNativeLibs, and the default value of extractNativeLibs in the manifest is false, which will lead to an increase in APK size.

The solution is to forcibly change the value in the UPL:

1
<addAttribute tag="application" name="android:extractNativeLibs" value="true"/>

Note: This only controls whether the so is compressed when packed into the APK and does not actually reduce the size of the so! For optimizing executable programs, it is necessary to continue with the code optimization part below.

Code Size Optimization

Regarding code size optimization on the Android platform, the core goal is to reduce the size of a single so while minimizing any impact on runtime performance.

Optimization ideas for the size of NativeLibs:

  1. Reduce the number of dynamic link libraries and eliminate unnecessary ones.
  2. Reduce symbols within the library and decrease the size of code segments.
  3. Remove debug information.

These optimization strategies can be applied to all sos at compile/link time.
However, for UE projects, the code we can control usually only involves the engine and project code, while the library code needs to be optimized by the library provider. Therefore, the following optimization strategies only apply to libUE4.so/libUnreal.so.

Reducing libUE4.so

During packaging, since a complete compilation needs to be performed, and UE defaults to a Monolithic mode at runtime, all code is compiled into the same executable file. (A previous article detailed this: UE Plugin and Tool Development: Basic Concepts)

The compilation process encapsulated by UBT, along with the provided configuration parameters in target.cs/build.cs, allows us to have some control over the compilation of engine and project code, achieving our goal to optimize so size.

For UE projects, there are several approaches to optimize the so size:

  1. Disable unnecessary modules
  2. Control code optimization (control inline/O3/Oz)
  3. Disable unnecessary exception handling in Modules
  4. Enable LTO
  5. Eliminate unnecessary export symbols

Disabling Modules

You can disable modules in the engine that are clearly not needed in target.cs:

1
2
3
4
// disable modules  
bUseChaos = false;
bCompileChaos = false;
bCompileAPEX = false;

At the same time, you need to sort out unnecessary runtime plugins included in the project to reduce the number of modules participating in compilation, thereby reducing the actual code that is compiled.

Disabling Inline

Inlining is an optimization during the compile phase to enhance runtime execution efficiency by replacing function calls directly with function code instead of conventional function calls. This can reduce the overhead of function calls and theoretically improve program execution efficiency.

However, inlining can increase the size of the .text section, and it can be disabled as appropriate.

  1. Modify target.cs: bUseInlining = false; (only effective on iOS/Linux/Mac/Win)
  2. Modify UBT and control inlining during Android compilation by adding the compilation parameter -fno-inline-functions
1
2
3
4
5
6
7
8
9
if (TargetInfo.Platform == UnrealTargetPlatform.Android)
{
if (bUseInlining)
{
AdditionalCompilerArguments += " -finline-functions";
}else {
AdditionalCompilerArguments += " -fno-inline-functions";
}
}

Note: Disabling inlining may lead to performance loss if certain functions are called frequently; in non-frequent situations, the performance impact of inlining is minimal. In my testing results, the impact of inlining on frame rate is negligible.

Disabling Exception Handling

Some modules have C++ exception handling enabled without using try/catch:

1
bEnableExceptions = false;

It can be turned off, which can reduce the size of .eh_frame within the so.

Using O3/Oz Compilation

In target.cs, you can control the value of bCompileForSize to choose between O3 or Oz for compiling code:

UnrealBuildTool\Platform\Android\AndroidToolChain.cs
1
2
3
4
5
6
7
8
9
10
// optimization level
if (!CompileEnvironment.bOptimizeCode){
Result += " -O0";
}else{
if (CompileEnvironment.bOptimizeForSize){
Result += " -Oz";
}else{
Result += " -O3";
}
}

Differences between O3 and Oz:

  • -O3: performance-oriented, aggressively inlines and unrolls loops
  • -Oz: size-oriented, avoids inlining and maintains loops

You can choose which method to use based on the actual performance situation of the project.

Enabling LTO

LTO stands for Link Time Optimization, which can eliminate dead code, optimize cross-module function calls, inline, etc. In the engine’s build.cs, you can enable bAllowLTCG, which is an implementation of LTO but only effective on iOS/Linux/Mac/Win (UE4.25).

For Android support, you also need to modify UBT (AndroidToolChain.cs), adding a control parameter bAllowLTCG to select whether to include the compilation parameter -flto=thin, where thin is a combination of reduced size and optimization time.

1
2
3
4
5
bAllowLTCG = true; // LTO
if (bAllowLTCG)
{
AdditionalCompilerArguments += " -flto=thin";
}

Eliminating Export Symbols

When compiling so, unless specifically set, all functions and variables will be exported for other sos to access. However, within the UE engine, only a very few interfaces are explicitly accessed from the outside (JNI-related interfaces), so the symbol export of libUE4.so is largely wasted. Eliminating symbol exports can significantly reduce so size and memory usage!

Modern compilers provide a version-script control mechanism at link time, which can control symbol behavior by passing a ldscript file.

You need to construct an ldscript file during the compilation process, fill in the symbol export control code, and then pass it to the linker in target.cs:

1
2
3
4
5
6
string VersionScriptFile = GetVersionScriptFilename();
using (StreamWriter Writer = File.CreateText(VersionScriptFile))
{
Writer.WriteLine("{ global: Java_*; ANativeActivity_onCreate; JNI_OnLoad; local: *; };");
}
AdditionalLinkerArguments += " -Wl,--version-script=\"" + VersionScriptFile + "\"";

For UE, only the symbols matching Java_*/ANativeActivity_onCreate/JNI_OnLoad should be allowed to be exported, and all others can be eliminated.

Optimizing Data

After the series of code size optimizations introduced above, the benefits are apparent.

so Size After Compression

As mentioned earlier, NativeLibs can be compressed when packed into the APK, so by reducing the original size of the so, we can also reduce the size after compression.

After the optimization, in Shipping mode, the original size of the so decreased from 258M to 146M! The size after compression reduced from 74.3M to 44.67M, a reduction of 29.63M! The executable program file has significantly shrunk.

Comparison of readelf before and after optimization (partial data):

arm64-v8a Shipping Before Optimization After Optimization Reduction
.text 104.364 76.56 27.2
.dynsym 14.52 0.0185 14.5
.gnu.version 1.22 0.0016 1.22
.gnu.hash 4.02 0.00047 4.02
.hash 4.72 0.0063 4.71
.dynstr 50.0 0.0198 49.98
.rodata 11.13 6.35 4.78
.rela.dyn 24.76 22.79 1.97
.got 0.89 0.21 0.68

Memory Benefits

Optimizing the size of so also reduces the memory occupied by the loaded so, yielding additional memory benefits.

On Android, you can use dumpsys meminfo to check the memory usage of all loaded sos in the entire package. This includes all loaded sos, but you can derive the actual memory benefits from the differences before and after optimization.

Before Optimization:

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

After Optimization:

1
2
3
4
5
** 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 Before Optimization After Optimization Reduction
libUE4.so Size 246 145 101
meminfo (total so memory) 178.165 140.04 38.125

Additional Optimization Strategies

Relocation Table Compression

SDK 28

When the MinSDKVersion of Android is greater than or equal to 28 (Android 9), you can enable RELR relocation table compression during compile and link time. This efficiently encodes the relocation information using the characteristics of relative address relocation, thereby reducing storage space.

To enable this, you need to pass parameters to the Compiler and Linker during the compilation phase:

1
2
AdditionalCompilerArguments += " -fPIC";  
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags";

-Wl,--pack-dyn-relocs=android+relr,--use-android-relr-tags is an Android-specific linker option that complements and optimizes the standard -Wl,-z,relro and -Wl,-z,now, particularly for dynamic linking and relocation handling in the Android system. They aim to further reduce binary file size and improve loading times.

To verify if this takes effect, you can use readelf -d libUE4.so to check for the existence of the RELR field:

Before Optimization, the size of the relocation table (25.82M):

1
2
3
4
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

After Optimization, the size of the relocation table (280K):

1
2
3
4
5
6
 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

The size of the relocation table after optimization is reduced from 25.82M to 280K, which directly results in a so size reduction of about 25M, also reducing the APK size by around 4M, with extremely noticeable optimization effects.

Moreover, its optimization effect on memory is also very significant: from 190.49M in Development to 161.06M, a reduction of 29.43M.

Before Optimization (Development: 190.49MB):

1
2
3
4
5
** 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

After Optimization (Development: 161.06MB):

1
2
3
4
5
** 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

It positively optimizes runtime performance rather than reducing it, as it improves code loading speed and reduces memory usage by decreasing the number of runtime relocations.

SDK 23

If the project requires a specific SDK version and cannot be upgraded to 28, you can use another alternative compression parameter, requiring SDK version >= 23.

1
2
AdditionalCompilerArguments += " -fPIC"; 
AdditionalLinkerArguments += " -Wl,--pack-dyn-relocs=android";

This also significantly reduces the size of the relocation table (though not as much as RELR down to a few hundred K), and can also greatly lower the memory occupancy of the so:

Compressed after (Development: 3.41M):

1
2
3
4
[ 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

Runtime memory situation (Development: 163.19M), down from the original 190.49M, also reduced by 27.3M, slightly lower than RELR:

1
2
3
4
5
** 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 Memory

After enabling relocation table compression, the total runtime memory of the Shipping package’s so dropped to 134.74M!

1
2
3
4
5
** 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

Resource Trimming

Files in the APK

Some third-party plugins copy files into the APK, which is also an area for optimization.

You need to analyze the actual usage of the project to handle:

  1. Remove unnecessary third-party components
  2. For required components, eliminate unnecessary files

Component Trimming: Taking GVoice as an Example

If the project integrates the GCloud component, model files of GCloudVoice are extensively copied into the APK file.

After compression, the assets/GCloudVoice directory in the APK occupies about 13.5M:

  • wave_dafx_data.bin is for 3D voice. If no 3D functionality is needed, it can be removed.
  • wave_3d_data.bin is also for 3D voice. If no 3D functionality is needed, it can be removed.
  • cldnn_spkvector.mnn is used for voiceprint extraction; this function is not used by default and can be removed.
  • libwxvoiceembed.bin is for civilization voice; if civilization voice is not needed, it can be removed.
  • libgvoicensmodel.bin is the noise suppression algorithm model and cannot be deleted.
  • decoder_v4_small.nn and encoder_v4_small.nn are used for aicodec; if aicodec is not needed, they can be removed.
  • dse_v1.nn, dse_v1_align.nn, dse_v1_mono.nn are resources for the new algorithm used under wwise; if there is a size limit for packaging, they can also be removed.

You can eliminate model files for functionalities that are not utilized in the project. From an implementation perspective, it is best not to delete files directly but to modify the copy logic in GVoice_APL.xml like this:

1
2
3
4
5
6
7
8
9
10
11
<resourceCopies>
<log text="Start copy res..." />
<!--
author: lipengzha
desc: Only copy GVoice's libgvoicensmodel.bin/config.json; other files have no effect in the game.
Original copy code:
<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>

In-game Resources

In-game resources refer to the resources/files dependent on the UE engine or components, packed into PAK or copied into the main.obb.

  • In the PAK: assets that need to be reviewed for necessity, those that can be eliminated or delayed loaded.
  • DirectoriesToAlwaysStageAsNonUFS: not included in PAK, but packed into the main.obb.

Resources in PAK

To be more precise, this refers to the resources in the installation package’s PAK.

All necessary resources for the engine are in pakchunk0. In addition to pakchunk0, UE can package chunks split by utilizing PrimaryAssetLabel into the installation package.

However, resources or files in pakchunk0 still need to be optimized:

  1. Retain only essential engine resources (key assets in /Engine, ini files, GlobalShader, project ShaderLibrary, startup map, GameFramework assets, etc.). My previous article (UE Resource Management: Analysis of Engine Packed Resources) provides more detailed insights.
  2. Remove resources that are not necessary during startup.
  3. Modify the engine to delay loading certain files (like L10N localization language loading).
  4. Separate startup phase from in-game resources (like fonts), packaging in-game fonts separately and using dynamic downloads.

The engine’s own unpacking logic also has significant limitations; for example, ShaderLibrary defaults to generating one for the entire project, which can become a bottleneck for optimizing package size when the scale is large. Details on this content can be found in my earlier article (Resource Management: Restructuring UE’s Package Splitting Solutions).

Additionally, during the resource management and packaging phases, it is possible to remove all resources from the installation package for Android, converting them to a dynamic download/mounting mechanism without affecting iOS.
When using methods like PrimaryAssetLabel to split pak, the engine provides a built-in method for Android to remove Pak from the installation package:

1
2
3
4
; Config/DefaultEngine.ini
[/Script/AndroidRuntimeSettings.AndroidRuntimeSettings]
+ObbFilters=-*.pak
+ObbFilters=pakchunk0-*

However, the official support is limited to Android, and it isn’t as convenient for other platforms. In a previous article, I introduced my developed HotChunker extension, which allows easy implementation of a generic package filtering scheme for cross-platform support of custom inclusion control policies.

StageAsNonUFS

In the engine’s packaging configuration, there is an option called DirectoriesToAlwaysStageAsNonUFS, specifying directories not to be packaged into the PAK but will be included in the main.obb. Currently, only the Content/Movies directory is copied to main.obb.

As for the ini read during packaging, it also has a hierarchical logic, allowing for platform differentiation during packaging configuration! If you want to differentiate between Android/iOS, this mechanism can also be utilized.

You can adjust the packaging strategy as follows: unless necessary videos (like those immediately played at startup), other in-game MP4s can be packed separately into PAK, converted to dynamic downloads.

This can significantly reduce the size of MP4s in the APK, allowing for hot updates.

Optimization Results

By comprehensively applying various package size optimization techniques mentioned above, we successfully reduced the game’s APK size from 1.23G to 130M, and the original so size decreased from 258M to 132M.

Runtime memory has also reduced by several tens of MB! Furthermore, it includes all third-party components, game functionalities, and resources can be dynamically downloaded, turning the main installation package into a minimized downloader, making it easier to spread and distribute.

The specific optimization strategies to adopt depend on the actual needs of the project and the balance between package size and performance, such as inline control and optimization level, as well as extreme resource trimming (like L10N) may require modifications to the engine.

Additional Resources

Historical articles mentioned in this blog:

Additional reading related to UE build systems:

Other external resources:

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:Ultimate Optimization: Reducing the size of UE Android APKs
Author:LIPENGZHA
Publish Date:2025/02/25 09:44
Update Date:2025/03/03 13:12
Word Count:15k Words
Link:https://en.imzlp.com/posts/53079/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!