Instagram prides itself on having a lean app. But as the number of engineers and features grows, so do the challenges to size. We start to face issues like:
- increase in application size downloaded from an app store
- increase in cold start time, and more generally, increased time to interaction with different surfaces inside the app
- increase in disk space usage
- decrease in developer velocity from increased complexity of the app or build time
To tackle these issues, we recently enacted an Instagram-wide code modularization effort to create strict isolation between features. We do this on both Android and iOS, but this post will primarily focus on Android. Once a feature is modularized, we can take the feature out of the primary executable file and compile it to the dedicated file and lazily load it during app runtime on-demand. Lazy loading can bring a combination of benefits like improved cold start time (especially on Dalvik) and overall runtime, decreased disk footprint, and better developer velocity if module hotswapping is implemented. This post will walk through how we approach modularization and lazy loading at Instagram, and how you can implement it in your own app with our new open-sourced framework.
What is modularization
Modularization is the process of separating and creating clear boundaries between logical components of code. For Instagram, we’ve been focused on modularizing feature code since it represents a large part of the codebase, and each feature has clear boundaries. Reducing cross-feature dependencies with modularization helps us solve the challenges of a growing app in a couple of ways. One benefit is that it helps us protect our app’s start time from growing as the amount of code in the app increases. Before modularization, a chain of code references from one feature to another might have risked loading in all of that code during app start time. But by breaking apart these references with modularization, we can wait to load code when it’s actually needed using methods like Lazy Loading (see below).
In addition, modularization has other great benefits for overall codebase sanity, build times, developer velocity, Instant Apps, etc.
How to modularize
The goal of modularization is to have a clearly defined interface that separates the implementation of the feature from outside code. Start by auditing all the incoming dependencies to the feature. For each dependency, figure out if it should be removed or kept. Since each case is different, there is no simple way of making this evaluation — but here are some considerations to make:
- Is the dependency a result of misplaced code? Sometimes a dependency exists because there’s a shared library method or misplaced method in the feature that should actually live outside of the feature. If so, move the referenced code out of the feature and into the appropriate place.
- Is there a way to rewrite the dependency to be more generic rather than have it reference feature-specific code?
- Does the dependency exist because we’re accessing some feature-specific logic earlier than necessary? If so, try to defer this logic until later.
- Is the surrounding logic overloaded? If so, simplify the logic into the feature-specific part to make it simpler to reason about the necessity of the dependency.
If the dependency is still necessary after making these considerations, then it belongs as part of the feature’s interface. If we’ve done the modularization well, the feature’s interface should contain just a few critical methods. Some examples are:
- Methods that allow navigating to the feature
- Lifecycle methods that listen for app-wide lifecycle events
- Methods that expose core functionality in the feature.
What is lazy loading
While modularization will decouple a feature from the outside world and create a clean interface to the feature, lazy loading will take this further and compile the feature out of the main executable (dex) file to a separate dex file. Each lazily loaded feature will live in a separate file, which will bring benefits like:
- allowing the feature to load in memory only when really needed instead of on every cold start. It offloads code from the main executable file, which remains smaller and guarantees better cold start time. On Dalvik it offloads methods off the main dex file, decreasing performance penalty of multi dex.
- clustering feature code together in memory, as it lives in one file and provides most optimal execution in terms of memory access
- using less disk space if some modules remain unused because feature code will never be uncompressed
- possibility of having an adaptable app with different sets of features shipped to different users (e.g. non-business users don’t need business features), thus decreasing initial app size
And for developer velocity, we added support for hotswapping of lazy loaded modules, which means developers will be able to keep coding and seeing changes without restarting the app.
When we trigger lazy loading
Generally speaking we load a module when we expect it to be used in the near future. Once the module is loaded it will remain in memory until the next cold start. Module loading may incur a small latency (depending on the size of the module), so different tactics may be applied:
- Loading a module in the background when the user is one click away from the module. This may mean that the module remains unused if the user doesn’t decide to click into that feature or navigates back. But if there’s high probability of clicking into that module, then it’s a good solution.
- Loading a module once the user navigates into the module. If loading latency is small (below 50ms) for the majority of cases (e.g. p99), then we could simply block on loading, and once it’s done navigate into the feature. Otherwise a simple spinner or a progress bar can be displayed so that the app does not appear as frozen.
- Some modules are by nature asynchronous, which eases lazy loading since it will be the part of the asynchronous loading. An example is a video player that runs in a secondary process. Instagram initially shows a screenshot of the video while it loads in the background (often times fetched from the network). Lazy loading would happen in that secondary process and be completely transparent to users.
We open sourced our framework for lazy loading at ig-lazy-module-loader so that other developers can use it in their apps, or simply learn from our implementation. It can be beneficial if you’re hitting a performance penalty of multi-dex on Dalvik or have many features which may remain unused by some users and you want to save disk space.
We recently moved our mobile infrastructure engineering teams (iOS and Android) to New York City. If this blog post got you excited about what we’re doing, we’re hiring — check out our careers page.
Mona Huang and Julian Krzeminski work on Android at Instagram.