Mobile App SDK Size Reduction Techniques

So, Why Does Size Matter?
It is a valid question (isn’t it?) that why should we put effort into reducing the size of an SDK, with mobile storage capacities increasing all the time. Surely, how much do a few MBs matter when the device has multiple hundred gigabytes of storage? To put it into perspective, 1 MB is around 0.001% of the storage available on a modern mobile device.
However, it turns out that the download size of an app matters. A blog post by Google published in 2017, analyzing data from the Google Play Store, says that every 6MB increase in app size reduces installs by 1%. Emerging markets such as India are even more conscious about the download sizes, where removing 10MB from an app’s APK size correlates with an increase in install conversion rate by about 2.5%.
An experiment conducted by Twilio, as detailed in their blog post, tried increasing bloat in an app which had steadily growing installs. The install rate, which is the ratio of app installs to page views, decreased at 0.45% per MB increase. They also explain that Apple doesn't allow cellular network usage for downloading an app which is more than 100 MB, significantly decreasing installs.
Android provides Dynamic Feature Modules where features can be delivered on demand and downloaded post install, reducing the size downloaded from the Play Store. However, iOS provides no such mechanism, making size reduction even more important for iOS apps.
Clearly, in the Mobile App World, SDK size matters and smaller is better for a change.
In this blog post, we’re going to look at techniques to reduce SDK size on Android and iOS. This is primarily based on learnings from optimizing NimbleEdge’s SDK that enables running AI models on-device and applies to everything written using C++.
Reducing iOS SDK size
CocoaPods is the most common package manager for integrating third-party libraries in iOS apps. A dependency is called a pod in this framework. A pod can have a podspec file which specifies how that pod is to be integrated.
Some techniques help us in minimizing the app download size after it integrates our pod:
- Share dependencies with the app: CocoaPods allows sharing of dependencies, which should be highly leveraged. For example, the SDK should use the same library for making network requests as the app that is using the SDK.
- Prefer Static Frameworks: iOS frameworks package library code along with other necessary things like headers. Prefer making static frameworks as they are linked into the app through static linking, which yields a smaller binary. This is a common theme, see the below section for more about the why. To do this, add `s.static_framework = true` to Podspec file
- Add C++ compiler flags in podspec file: The Podspec file allows us, the Pod authors, to specify build and link flags of our pod. Flags that help reduce size should be added here, see explanation of these flags in the next section. These are set in the Podspec as:
s.pod_target_xcconfig = {
'OTHER_CFLAGS' => '-Oz -fdata-sections -ffunction-sections -flto',
'OTHER_LDFLAGS' => '-Wl,--gc-sections -flto',
'DEAD_CODE_STRIPPING' => 'YES',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'SWIFT_OPTIMIZATION_LEVEL' => '-Osize',
}
C++ Size Reduction Techniques
Optimising C++ code for binary size runs orthogonal, if not opposite, to optimising for performance. Some surprisingly common modern C++ paradigms lead to binary size bloat. We’ll look at ways of reducing binary size, starting with compiler optimizations.
Compiler is your Best Friend
Adding a few compiler flags gives you the biggest ROI on size versus time spent:
- Use `-Oz` optimization: Yes you read that right, it’s `-Oz` and not `-O3`. This flag aggressively optimizes for size and you can expect a slight performance hit as optimizations like function inlining & loop unrolling would be restricted. This is an example of where performance optimizations go opposite to binary size optimizations.
- Eliminate unused functions and variables: To do this, add `-ffunction-sections -fdata-sections` to compiler flags and `-Wl,--gc-sections` to linker flags. This instructs the compiler to put each function & variable into a different section of the object file. The linker flags tell the linker to garbage collect (gc) the sections that are not referred to by anything, in effect removing unused functions.
- Enable Link Time Optimization (LTO): Add `-flto` to both compiler and linker flags. The linker has an overview of the entire program, enabling optimizations that are simply not possible at the compilation stage which only works with 1 translation unit.
- Reduce Exported Symbols: This applies to a shared library only. Add `-fvisibility=hidden` flag to compiler flags. Functions that need to be called from outside the shared library then need to be marked with default visibility. Doing this reduces the shared library size as the dynamic symbol table shrinks. It also improves load times and allows the optimizer to produce better code, leading to a win in performance as well.
Static Libraries are friendlier than Shared Libraries
Shared or Dynamic Libraries have their uses: they can be shared between multiple programs and can optionally be loaded at runtime using `dlopen`. However, they have a larger binary size footprint than static libraries.
The reasons for this are pretty straightforward. Dynamic libraries need to keep the symbols exposed to the outside world in their dynamic symbol table, which can be quite big if you don’t restrict visibility as mentioned in the previous section. With static libraries, the symbol names can be completely stripped out. Further, LTO kicks in when the static library is linked, further helping in size reduction.
Bloaty is your size reduction assistant
You have applied all the compiler trickery but still your SDK size is a little too much for comfort. It’s now time to roll up your sleeves and get in the code trenches to hand optimize code size.
Bloaty is a size profiler for binaries. It breaks down the size of a binary, which can be an executable or a library, and attributes sizes to symbols or code files. This can be used to guide hand-optimization for size. Working on things contributing most to the size will have the most impact.
Templates are best kept at bay
C++ templates are incredibly useful at writing generic functions and classes, allowing code reuse that wouldn’t be otherwise possible. However, they come with a big cost: Every time a templated function or class is instantiated with a new type, it generates separate code. What this means is that the functions of `std::vector<int>` would live separately in the binary than the functions of `std::vector<long>`. This bloats the final binary size.
Here are a few pointers from our experience, your mileage may vary. These trade off safety for code size, usage of these classes and functions has to be done carefully:
- Use C-style variadic functions instead of variadic templates wherever possible: It’s quite nice to use variadic templates for things like logging. However, each log statement with different types generates new code. Using C-style variadic functions helps with that, at the cost of no type safety.
So, instead of `template<typename... Args> void log(const std::string& message, Args... args)`, we’ll define the function as `void log(const std::string& message, ...)`. The implementation will then have to use `va_start`, `va_arg` and related functions for going through the variadic arguments. - Store data type as a member: Storing the data type as an enum member of the class instead of a type parameter helps in reducing binary size. If any data of the type is stored, a void pointer can be used or perhaps a union.
Exceptions are the “Usual” Enemy
While exceptions are extremely useful to propagate errors out of a deeply nested function and help write cleaner looking code, they have a big cost. Exceptions add stack unwinding code and exception tables to your binary, which significantly increases the binary size.
Disabling exceptions is simple: add `-fno-exceptions` compiler flag. However, your codebase has to be designed in a way that it propagates error using return values rather than exceptions. Depending on the extent of exceptions used, removing the use of exceptions can be a rather big change.
Use shared standard library, but beware Templates
Linking to shared standard libraries like `libc++.so` and `libstdc++.so` can help keep your binary lean. These libraries are present on the device, and so don’t impact the download size of your application. That is a widely known advantage of using shared libraries, however C++ templates add complications.
When you compile a cpp file using `std::vector<int>`, the template class is instantiated from the template present in the `vector` header. These are generated as weak symbols, so only one copy of the instantiation lives when multiple translation units are linked. However, as you may have noticed in this explanation, this code ends up living in your binary!
As far as I know, there is nothing to help this case with the standard library. Of course, there are advantages to this approach as well, a big one being that you can create a container for your custom type too.
For your own shared library, you can explicitly instantiate some common templates (like a C++ library could instantiate `std::vector<int>`) and then you can have an `extern template` declaration in the header. This would lead to no instantiation inside the code that’s including your header, and its binary size won’t increase.
In your shared library, you write `template vector<int>` to explicitly instantiate and then you have a declaration `extern template vector<int>` in your exposed header to prevent implicit instantiations in the code that will use the library.
Hopefully, these techniques help you in reducing and optimizing your SDK sizes - if you're working in this space and have more tricks up your sleeve do reach out to us!