Android developers who use lots of C++ code might be familiar with the native library limit that exists in Android versions prior to 4.3. When targeting older Android versions, one must carefully manage the number of libraries in their app to avoid hitting this limit. This is especially tricky because libraries loaded by the system count against this limit, and the number of those libraries can vary between devices.
One solution is to manually combine multiple small libraries into one larger one. However, this usually is not a scalable solution. Combining libraries requires moving source code around and carefully managing compilation settings and dependencies. It can also make code less modular, which can be problematic if your organization is building multiple apps from a shared codebase.
We have developed a more scalable solution to this problem and applied it to most of our Android apps, including Facebook, Instagram, Messenger, Messenger Lite, Moments, and Pages Manager. This solution not only allows us to avoid the native library limit on older Android devices, but it does so without harming performance or increasing app size.
Merging objects with per-app configuration
The first part of the solution is to combine multiple native libraries. Because merging shared objects (
.so files) is impractical, we needed to change the way we link our libraries, which forced us to integrate this feature into our build system Buck. The feature allows each application to specify which libraries should be merged so they can avoid accidentally bringing in unnecessary dependencies. Buck then takes care of collecting all the objects (
.o files) for each merged library and linking them together with the proper dependencies.
This works great, as long as there are no common symbols between the libraries being merged. For example, pure C++ libraries rarely duplicate symbols. However, on Android, many of our libraries use JNI, which means they have exactly one symbol that is duplicated:
JNI_OnLoad. This is the entry point for JNI setup in the library, and almost all of our libraries define it.
Since we have issues with only this one duplicate symbol, we handle it in a special way.
The first step in eliminating the symbol conflict is to make each library rename its
JNI_OnLoad function. This is easy enough with the C preprocessor. Next, we need a way to find all of those renamed functions so we can call them at the appropriate time. We accomplish this with custom ELF sections. (There’s a good description on this blog.) In short, each JNI library to be merged defines a small registration object that includes the library name and a pointer to its
JNI_OnLoad. Then the linker will automatically concatenate them into an array and define a pair of symbols that we can use to find them.
Once all the
JNI_OnLoad function pointers are paired with their library names and placed in an array, our glue code can find and call them when we try to load the original libraries.
Loading the libraries
We want our Java code to continue loading libraries by their original names, because different libraries might be merged in different apps. Therefore, we need to wrap all of our
System.loadLibrary calls with a method that knows how to map the original library names to the merged names. Fortunately, our apps already use SoLoader, so all we had to do was generate some code to let SoLoader look up the proper merged library names. When it first loads a merged native library, we call a custom
JNI_OnLoad that registers each of the original
JNI_OnLoad function pointers as normal JNI methods. Then,
SoLoader is able to call those methods only when the original library is loaded. This prevents us from loading classes earlier than we should.
When implementing this native library merging strategy, we ran into a few unexpected problems. Some libraries might be merged in one app but not in another. In theory, this should be fine: We define
JNI_OnLoad as a weak symbol
that is replaced by our special merge-aware
JNI_OnLoad only if we end up merging that library. However, older versions of Android will refuse to return any weak symbol when calling
dlopen. We got around this by changing the name to
JNI_OnLoad_Weak, then using linker flags to define
JNI_OnLoad as a strong alias for whichever
JNI_OnLoad_Weak ends up being used.
When using custom ELF sections, the
gold linker always outputs the special
__end symbols as global symbols. This is not a problem when producing a single merged library, but when there are multiple libraries, they can end up pointing to each other’s custom sections, which breaks registration. We got around this with some custom code that converts those symbols to private in the dynamic symbol table.
Putting it all together
We have published a repository that serves as a demonstration of these techniques. First, take a look at
refs/heads/initial-code. This is a small codebase showing two apps that have some shared code.
refs/heads/add-jni replaces some of the Java code (really just some string constants) with JNI. Now each of the apps has a few native libraries in it.
refs/heads/merge-libraries is where we turn everything on. Let’s walk through it file by file.
bucklets/DEFS defines some wrappers for our build rules. This is Buck’s main mechanism for extension. It allows expanding one declared rule into multiple physical rules, or (in our case) modifying arguments to a rule. We define two wrappers. The
my_android_binary wrapper automatically applies our project-specific configuration (glue library, code generator, and symbols to make local) whenever a merge map is present. The
my_cxx_library wrapper adds the
allow_jni_merging flag as a shorthand for the tweaks we need to make to
JNI_OnLoad. Note that these tweaks are safe even if the library won’t be merged in all apps.
The changes to the existing code are fairly simple. We just apply
allow_jni_merging to our C++ and change
src/com/facebook/soloader is a simplified version of
SoLoader that has support only for remapping merged library names. It also includes
MergedSoMapping, a compilation stub that can also be used in apps that don’t use merging.
map_code_generator.py is the code generator that will convert the text version of the merged library map into Java code we can use to load the libraries at runtime. It produces a method,
mapLibName, that can report which libraries were merged. For example,
mapLibName("libanimals.so") will return
"libeverything.so". It produces a nested class,
Invoke_JNI_OnLoad, with a number of native methods. Our glue code will bind each one of these to one of the original (pre-merged)
JNI_OnLoad function pointers. Finally, it produces
invokeJniOnload, which can invoke the proper
JNI_OnLoad for a given library, by name. The generated code is used only by SoLoader.
jni_lib_merge.h is included in our C++ files automatically (because we added
allow_jni_merging). It uses the C preprocessor to wrap
JNI_OnLoad, create the registration object in our custom section, and make sure our library can be loaded cleanly, regardless of whether merging is actually enabled. This requires a few tricks. See the comments in that file for details.
jni_lib_merge.c is our glue library, which will automatically be included in every merged library. It’s responsible for defining the real
JNI_OnLoad. When the merged library is loaded, it collects all of the function pointers for the wrappers of the original
JNI_OnLoad functions and registers them with
Invoke_JNI_OnLoad so they can be called at the appropriate time.
apps/BUCK defines the merge map for each app. In this case, we’re merging all libraries into a single
libeverything. However, it’s also possible to merge different subsets of libraries together: for example, one library for everything that’s related during app startup and another for everything that’s needed for camera effects.
Once these changes are made, we can run
buck install //apps:animals and see that the resulting APK has only
libeverything.so, but all the functionality from the original libraries remains.
Having a mechanism for merging native libraries is great, but we also need a policy for determining what to merge. This requires some understanding of the structure of the app. The
scripts/analyze-apk.sh script can help with that process. When run on an APK, it generates an image that shows all native libraries in the app, draws edges between them to represent their dependencies, and colors the ones with
JNI_OnLoad so you know which ones need
allow_jni_merging. Sometimes, a commonly used library will create too many edges to see clearly. In these cases, they can be filtered out by editing the
grep command and rerunning.
Using this graph, we can make some decisions about which libraries to merge. One good choice is to merge all libraries used during app startup. Since they have to be loaded anyway, we might as well load them all together. A cluster of libraries with similar names might be another good candidate. Frequently, it makes sense to merge a library with all of its dependencies, though that can be a mistake if the dependencies are often used on their own. A rule of thumb is that you want to combine as many libraries as possible while minimizing the amount of code that is loaded unnecessarily.
One minor issue that pops up when writing the merge map is that you need to specify it as patterns of build target names. We generate a file at
buck-out/path/to/app#generate_native_lib_merge_map_generated_code/shared_object_targets.txt, which will show the build target that generated each native library.
Implementing native library merging took many steps, but the end result is that native library developers can declare their libraries and dependencies as they please without being aware of it. Each app in our codebase can declare its own
native_library_merge_map to transparently merge libraries based on its own usage patterns. This makes it easy for us to push back the native library limit and let our linker do better inter-library optimizations.