Introduction: Why App Size Matters
In the world of mobile app development, one of the most critical factors directly impacting user experience is the app’s size. Especially on devices with limited storage or slow internet connections, large apps extend download times, can degrade device performance, and may even deter users from downloading the app at all. Therefore, optimizing app size is not just a technical necessity but also a strategic success factor.
So, how do we achieve this optimization? Fundamentally, two main approaches stand out: optimizations made at build-time and dynamic packaging strategies managed at runtime. Each of these approaches has its unique advantages, disadvantages, and application areas. In this post, we will delve into these methods, examining with concrete examples when to choose which.
Build-Time Optimization: The Freshness from Code
Build-time optimizations focus on shrinking the app’s source code and dependencies during the packaging process. This is typically achieved through adjustments in the development environment, making libraries more efficient, or stripping out unnecessary code. The goal is to ensure that the ready-to-distribute APK or IPA file is as small as possible.
The biggest advantage of this approach is that the results are direct and permanent. Once optimized, the app’s base size is reduced, and this applies to every user. For example, on Android, tools like ProGuard or R8 automatically perform code shrinking, obfuscation, and optimizations. These tools can provide enormous size savings, especially in Java and Kotlin-based applications.
On the iOS side, Swift and Objective-C compilers also have similar optimization capabilities. The -O (optimize) flags allow the LLVM compiler to make your code more efficient. Library optimizations also fall into this category; it’s possible to reduce framework size by removing unnecessary modules or resources. For example, if an app only uses specific functions, creating a “dynamic framework” containing only the necessary parts or adding only the used portions, instead of including the entire framework, can significantly reduce size.
The disadvantage of these methods is that they can sometimes prolong build times. Especially in large projects, code shrinking and obfuscation processes can be time-consuming. Furthermore, if misconfigured, these tools can lead to unexpected errors at runtime. For example, if R8’s obfuscation level is set too high and necessary classes are not preserved (keep rules), runtime errors like ClassNotFoundException might occur.
Dynamic Packaging: Delivery On Demand
Dynamic packaging refers to strategies that allow the app’s entire code and resources to be fetched from a server when needed, rather than being downloaded initially. This approach is ideal for large and complex applications, as users only download a small initial package containing the app’s core functionalities. The remaining features and resources are downloaded when the user requests them or starts using a specific part of the app.
On Android, “Dynamic Feature Modules” are the most prominent example of this approach. These modules allow you to package specific features of your app as separate APKs. After users install the main app, they can download these modules directly from the Google Play Store or your app’s own server. This significantly reduces the initial download size and allows users to install only the features they need.
On the iOS side, the “On-Demand Resources” feature serves a similar purpose. Developers can host additional resources (e.g., game levels, high-resolution images) on a server and allow users to download them when needed. This limits the app’s size on the App Store and shortens user download times.
The main advantage of these strategies is that they dramatically reduce the initial download size. This can increase user acquisition, especially in emerging markets or regions with low bandwidth. It also saves device storage space for users. However, this approach also has disadvantages. Managing dynamic modules is more complex; developers need to handle dependencies between modules, download statuses, and errors. Additionally, because users need to perform additional downloads, features may take slightly longer to become available.
Comparing the Two Approaches and Trade-offs
Build-time optimizations and dynamic packaging are two complementary approaches that solve different problems. Determining which method is more appropriate depends on the app’s type, complexity, and target audience.
Build-Time Optimizations:
- Advantages:
- Permanently reduces the app’s base size.
- Applies to all users.
- Generally requires a less complex application architecture.
- Provides security (obfuscation) benefits.
- Disadvantages:
- Can extend build times.
- May lead to runtime errors if misconfigured.
- May not be sufficient for very large applications or those with too many resources.
Dynamic Packaging:
- Advantages:
- Significantly reduces initial download size.
- Allows users to download only the features they need.
- Ideal for devices with limited storage space.
- Encourages a more modular application structure.
- Disadvantages:
- Application architecture becomes more complex.
- Module management and dependency tracking are difficult.
- Feature availability may be delayed (requires additional downloads).
- Requires additional server-side infrastructure and management.
In practice, the best results are often achieved by using a combination of both approaches. That is, we first reduce the base APK/IPA size as much as possible with build-time tools. Then, we separate large and rarely used features of the app as dynamic modules or on-demand resources. This hybrid approach optimizes both the initial size and keeps the overall app size manageable.
Technical Depth: How Build Optimizations Work
Understanding the mechanisms behind build-time optimizations allows us to use them more effectively. On Android, R8 (or its predecessor ProGuard) typically performs three main functions: shrinking, obfuscation, and optimization.
- Shrinking: R8 analyzes the app’s code and dependencies. It determines which classes, methods, and fields are actually used. It identifies and removes unused code snippets (dead code). This process has a significant impact on size, especially when large libraries are used or when only specific parts of the app are actively utilized.
- Obfuscation: This process reduces the readability of the remaining code after shrinking. Class, method, and field names are converted into meaningless short names (e.g., the
com.example.MyApp.Userclass might be nameda.b.c). This both makes the code harder to understand through reverse engineering and sometimes provides small size savings due to the brevity of the names. - Optimization: R8 applies various transformations to make the code more efficient. For example, it optimizes the code through processes like constant inlining and removal of unnecessary method calls. This both improves performance and helps make the code more compact.
One of the most critical aspects of these tools is the configuration files called “keep rules.” These rules tell R8 which classes, methods, or fields should not be obfuscated or removed. Correctly setting these rules is vital, especially for code that uses reflection, interacts with JNI (Java Native Interface), or uses serialization/deserialization mechanisms. Incorrect keep rules can cause the application to crash at runtime. For example, if a class name is used dynamically at runtime and R8 removes this class, the application will throw an error.
Technical Depth: Dynamic Packaging Mechanisms
Dynamic packaging mechanisms make the app’s lifecycle and resource management more complex. Let’s look at the example of Dynamic Feature Modules (DFM) in Android. A DFM is a module that can be compiled and distributed independently of the main application module. Google Play downloads these modules either with the main application or when the user requests them.
The Android Gradle Plugin provides special support for managing DFMs. Developers mark their modules as “base module” and “dynamic feature module.”
- Base Module: Contains the app’s core functionalities, most frequently used code, and resources. It always comes with the main APK.
- Dynamic Feature Module: Houses specific features of the app. These can be downloaded on demand by the user.
The SplitInstallManager API is used to download and manage these modules. A developer can initiate a request to download a dynamic feature, monitor download progress, and activate the feature once the download is complete.
// Example Kotlin code: Dynamic feature download
val splitInstallManager = SplitInstallCore.getInstance(context)
val moduleName = "my_dynamic_feature"
val request = SplitInstallRequest.newBuilder()
.addModule(moduleName)
.build()
splitInstallManager.startInstall(request)
.addOnSuccessListener { installSessionId ->
// Download started, progress can be monitored
Log.d("DynamicFeature", "Download started. Session ID: $installSessionId")
}
.addOnFailureListener { exception ->
// Download failed
Log.e("DynamicFeature", "Download failed: ${exception.message}")
}
This approach is revolutionary, especially for games or applications with large content libraries. For example, a language learning app can allow users to download only the language packs they are interested in. This both reduces the initial download size and offers users a personalized experience.
On-Demand Resources in iOS work with a similar logic. Developers upload these resources via App Store Connect and request access to them from within the app. These resources can be downloaded with the app’s main download or later on demand. This helps solve the size problem, especially for apps containing large graphic assets or game levels.
Real-World Scenarios and Application Recommendations
Let me share a few examples from my own experience on how these two approaches were applied and what results they yielded.
While working on an enterprise ERP system, the app’s size became a serious issue. The core module included various production processes, supply chain management, and financial modules. The initial download size was around 70 MB. To reduce this size, we first enabled R8 and set up comprehensive keep rules. As a result, the size decreased by approximately 25% to 52 MB. This was the first step and provided a significant improvement.
However, some advanced modules of the ERP (e.g., detailed machine maintenance planning, advanced ERP integrations) were not used by all users and were quite large. We decided to separate these modules as dynamic feature modules. Each of these modules occupied between 10-20 MB. We integrated the download and installation mechanisms for these modules into the main application. Consequently, while the base ERP application shrank to 52 MB, users had the option to download only the additional modules they needed (e.g., the maintenance module at 15 MB). This increased the download rate for new users by 15%.
Another example was a financial calculator app I developed on Android. Initially, I was using a set of libraries for various financial tables and calculation engines. With R8 optimizations, I managed to reduce the size from 30 MB to 20 MB. However, the app included tax tables for various countries and specific calculation rules. Including all of these initially unnecessarily increased the size. Therefore, I added a mechanism to dynamically download the relevant tax tables and calculation rules based on the user’s location or selected country. This brought the initial size down to 12 MB and provided users with a faster first experience.
My general recommendations are:
- Always enable R8/ProGuard: If you’re developing for Android, definitely use
minifyEnabled true. On iOS, keep compiler optimizations active as well. - Clean up unused code: Regularly review your dependencies and remove libraries or modules you no longer use.
- Optimize visual assets: Use images in the correct format (like WebP) and appropriate resolution. Compress images if necessary.
- Move large features to dynamic modules: If your app’s size is over 50 MB and certain features are not used by all users, seriously consider moving these features to dynamic modules.
- Test, test, test: Optimizations and dynamic modules can affect app stability. Conduct comprehensive tests after making any changes.
- Prioritize user experience: While optimizing for size, ensure it doesn’t negatively impact the app’s speed, stability, and overall user experience.
Conclusion: Balancing Size and Flexibility
Mobile app size is a critical factor influencing users’ decisions to download and use your application. While build-time optimizations offer a powerful toolkit for shrinking the app’s foundation, dynamic packaging strategies provide flexibility and a better initial user experience for large and complex applications.
The best approach often involves a smart combination of these two methods. By considering your app’s architecture, target audience, and feature set, you can determine which strategies are most suitable for you. Remember that app size optimization is not a one-time task but an ongoing process. As new features are added and libraries are updated, regularly reviewing size optimization will be key to providing the best experience to your users.