Skip to content
Mustafa Erbay
Technology · 9 min read · görüntülenme Türkçe oku

Mobile App Size: Compile-Time Optimization or Dynamic Packaging?

Should you optimize mobile app size at the compilation level or with dynamic packaging methods? Pros, cons, and more of both approaches…

100%

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.

  1. 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.
  2. 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.User class might be named a.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.
  3. 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:

  1. Always enable R8/ProGuard: If you’re developing for Android, definitely use minifyEnabled true. On iOS, keep compiler optimizations active as well.
  2. Clean up unused code: Regularly review your dependencies and remove libraries or modules you no longer use.
  3. Optimize visual assets: Use images in the correct format (like WebP) and appropriate resolution. Compress images if necessary.
  4. 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.
  5. Test, test, test: Optimizations and dynamic modules can affect app stability. Conduct comprehensive tests after making any changes.
  6. 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.

Paylaş:

Bu yazı faydalı oldu mu?

Yükleniyor...

How was this post?

Frequently Asked Questions

Common questions readers have about this article.

How do I start with build-time size optimization and which tools should I prefer?
In my initial projects, I only modified the Gradle file, but I achieved real savings by enabling shrinkers like R8 and ProGuard. First, I enable `minifyEnabled true` and `shrinkResources true` settings in `build.gradle`, then I add rules to `proguard-rules.pro` to exclude unnecessary packages. I also enable R8's full mode with `android.enableR8.fullMode=true`; this automatically discards dead code and resources. During testing, using APK Analyzer to measure the size difference and see which classes were deleted is very useful. By following these steps, I achieved nearly a 30% reduction in my first attempt.
What is the performance cost when using dynamic packaging, and what are its advantages and disadvantages compared to build-time optimization?
For me, the biggest advantage of dynamic packaging (e.g., Android App Bundle) is that it only downloads the necessary part of device-specific modules, which shortens user download times by up to 40%. However, I had to write additional code for runtime module loading delays and network errors, which can slow down initial performance by a couple of seconds. Build-time optimization, on the other hand, offers a fixed APK; every device receives the same file, so there's no extra delay during installation, but the size is always larger because it includes all resources. In my project, I kept frequently used UI components in the base module and separated rarely used features as dynamic modules; this way, I balanced both size and performance.
What should I do if I encounter issues with R8/ProGuard during the build phase, and how many attempts does it usually take?
The first error I encountered with R8 was the deletion of classes from some reflection-based libraries. I solved the error by adding `-keep` rules to the `proguard-rules.pro` file. Usually, two or three attempts are enough: one attempt to identify the error and examine the logs, a second attempt to protect the relevant classes, and a third attempt to run all test scenarios. Error messages usually appear as 'ClassNotFoundException,' which indicates a missing keep rule. Also, I can get detailed logs with the `./gradlew assembleDebug --stacktrace` command to see which packages are affected. I also recommend adding an APK Analyzer step to the CI pipeline to automate this process.
Is shrinking only the code sufficient to reduce app size, or are resource files and native libraries equally important?
In my experience, code shrinking alone provides a 20-30% improvement, but images, fonts, and native .so files can constitute 50-60% of the total size. Therefore, removing unused drawables with `shrinkResources`, converting to WebP format, and compressing native libraries with `android:extractNativeLibs=false` are critical. Additionally, producing separate APKs for different ABIs (split APK) ensures that each device only receives the .so file it needs, saving several megabytes. In my project, combining these three layers resulted in a total size reduction of 45%; thus, for full optimization, focusing on code, resources, and native components is essential.
ME

Mustafa Erbay

Sistem Mimarisi · Network Uzmanı · Altyapı, Güvenlik ve Yazılım

2006'dan bu yana sistem mimarisi, network, sunucu altyapıları, büyük yapıların kurulumu, yazılım ve sistem güvenliği ekseninde çalışıyorum. Bu blogda sahada karşılığı olan teknik deneyimlerimi paylaşıyorum.

Kişisel Notlar

Bu notlar sadece sizde saklanır. Tarayıcınızda yerel olarak tutulur.

Hazır 0 karakter

Comments

Server-side AI Moderation

Comments are AI-moderated server-side and stored permanently.

?
0/2000

Server-side AI moderation

✉️ Free · No spam · Unsubscribe anytime

Get notified about new posts

New content and technical notes — straight to your inbox.

  • 📌
    Best of the week Single most-worth-reading post
  • 🔧
    Toolbox notes Real tools I used this week
  • 🧠
    Behind-the-scenes Notes that don't make it to blog

We don't spam. Unsubscribe anytime. · Tracked only by Umami (self-hosted, no Google).

Your Reading Stats

0

Posts Read

0m

Reading Time

0

Day Streak

-

Favorite Category

Related Posts