Currently, `Xamarin.Android` supports managed assembly compression in
the APK archive if application is bundled (with Mono's `mkbundle`) into
a native shared library. Managed assemblies are compressed using gzip
compression and placed in an array inside the data section of the shared
library. However, support for `mkbundle` is possibly going to be
removed and we realized it is a feature some developers appreciate since
the produced APKs are smaller and the impact on startup time isn't big
enough to worry.
This commit aims to be a replacement for `mkbundle` with a handful of
improvements thrown in. First of all, the compression is performed
using the [managed implementation][0] of the excellent [LZ4][1]
algorithm. This gives us a decent compression ratio and a much faster
(de)compression speed than gzip/zlib offer. Also, assemblies are stored
directly in the APK in their usual directory, which allows us to `mmap`
them on the runtime directly from the APK. The build process calculates
the size required to store the decompressed assemblies and adds a data
section to `libxamarin-app.so` which makes Android allocate all the
required memory when the DSO is loaded, thus removing the need of
dynamic memory allocation and making the startup faster.
Compression is supported only in `Release` builds and is enabled by
default, but it can be turned off by setting the
`$(AndroidEnableAssemblyCompression)` MSBuild property to `False`. If
there's a need to turn compression off for an individual assembly by
adding the `AndroidSkipCompression` metadata item to the assembly in
question using code similar to this, in the application's project file:
<AndroidCustomMetaDataForReferences Include="MyAssembly.dll">
<AndroidSkipCompression>true</AssemblySkipCompression>
</AndroidCustomMetaDataForReferences>
The compressed assemblies still use their original name (e.g.
`Mono.Android.dll`) so that we don't have to perform any string matching
on the runtime in order to detect whether the assembly we are asked to
load is compressed or not. Instead, the compression code prepends a
short header to each .dll file (in pseudo-code):
uint32 magic = 0x5A4C4158; // 'XALZ', little-endian
uint32 index; // Index into an internal assembly descriptor table
uint32 uncompressed_length;
The decompression code looks at the mmapped data and checks whether the
above header is present. If yes, the assembly is decompressed,
otherwise it's loaded as-is.
It is important to remember that the assemblies are compressed on the
build time using LZ4 block compression which requires assembly data to
be entirely loaded into memory (we do this instead of using the LZ4
frame format to make decompression on the run time faster) before
compression. The compression output also requires a separate buffer,
thus memory consumption will roughly be 1.5x the assembly size.
However, since we use a byte buffer pool, memory consumption will not be
a sum of all the assemblies but rather the size of the biggest one in
the set.
~ Application Size ~
A Xamarin.Forms "Hello World" application APK shrunk by 27% with this
commit:
| Before | After | Δ |
|----------|----------|-----------|
| 23305194 | 16813034 | -27,85% |
Size comparison between this commit and APKs created with
`$(BundleAssemblies) == True` depends on the number of enabled ABI
targets in the application. For each ABI, `$(BundleAssemblies) == True`
creates a separate shared library, so the amount of space consumed
increases by the size of the bundle shared library. The new compression
scheme shares the compressed assemblies among all the enabled ABIs, thus
effectively creating smaller multi-ABI APKs.
In the tables below, `Before` refers to the APK created with
`$(BundleAssemblies) == True`, `After` refers to the APK build with the
new compression scheme.
All ABIs enabled:
| Before | After | Δ |
|----------|----------|-----------|
| 27130240 | 16813034 | -38,03% |
Single ABI enabled:
| Before | After | Δ |
|----------|----------|-----------|
| 7783449 | 8746878 | +11,01% |
~ Startup Performance ~
Startup time of the same application isn't affected too much by
decompression (comparison between uncompressed application and one
compressed using the new scheme):
~ Before ~
App configuration: **Release**
Xamarin.Android
- Version: **10.4.100-12**
- Branch: **master**
- Commit: **3f438e46d7b166a3a3ef54c9ffafb5f426760468**
~ After ~
App configuration: **Release**
Xamarin.Android
- Version: **10.4.100-18**
- Branch: **compress-assemblies**
- Commit: **cec90e936478f9afbbc31b43e52164ecd5182c79**
Device
- Model: **Pixel 3 XL**
- Native architecture: **arm64-v8a**
- SDK version: **29**
~ Application Displayed Time ~
| Before | After | Δ | Notes |
| ------- | ------- | -------- | ------------------------------ |
| 795.800 | 793.800 | -0.25% ✓ | preload enabled; 32-bit build |
| 777.100 | 780.500 | +0.44% ✗ | preload disabled; 32-bit build |
| 779.000 | 791.500 | +1.58% ✗ | preload enabled; 64-bit build |
| 776.000 | 781.400 | +0.69% ✗ | preload disabled; 64-bit build |
Comparison of startup times between the `$(BundleAssemblies) == True`
scheme and the new one with the same device and application as
above (once again `Before` refers to the `$(BundleAssemblies)`
application):
| Before | After | Δ | Notes |
| ------- | ------- | -------- | ------------------------------ |
| 855.600 | 793.800 | -7.22% ✓ | preload enabled; 32-bit build |
| 843.000 | 780.500 | -7.41% ✓ | preload disabled; 32-bit build |
| 849.400 | 791.500 | -6.82% ✓ | preload enabled; 64-bit build |
| 841.600 | 781.400 | -7.15% ✓ | preload disabled; 64-bit build |
[0]: https://www.nuget.org/packages/K4os.Compression.LZ4/
[1]: https://github.com/lz4/lz4
[2]: https://quixdb.github.io/squash-benchmark/#results-table