Tools & Performance

Type safe keep rules and DEX optimization

Speakers

About

About the talk

Android apps often ship under-optimized due to overly conservative keep rules often from third-party libraries, leading to bloated APKs, slower cold starts, and degraded runtime performance.

With Google Play’s new performance thresholds for DEX optimization, app visibility now directly depends on staying ahead of the optimization curve.

This talk introduces a modern R8 workflow to tackle rule bloat. You will learn to:

  • Use the R8 config analyzer and skill to identify legacy rule overhead.
  • Transition from broad wildcard keep rules to precise, type-safe, generated annotations.
  • Prevent optimization regressions with new Android CLI tools.

Summary

  • Google Play enforces app-quality thresholds requiring more than 25% code optimization. Meeting them reduces memory usage while improving start-up speed and ANR rates.
  • The R8 Configuration Analyzer generates an HTML report showing which keep rules retain code. It highlights subsumed and unused rules and measures optimization potential before compilation.
  • Overly broad keep rules, often introduced to prevent reflection crashes or inherited from libraries, undermine app optimization. A single package-wide wildcard rule can keep dozens of unused classes.
  • Jetpack Keep uses lint to detect reflection and generate precise annotations and conditional keep rules directly from code. It exports standard consumer rules compatible with older R8 versions.
  • Android CLI inspects app bundles and AARs using Google Play's exact logic without an upload. It flags problematic rules and exports JSON reports for automated regression testing.

Resources

Gemini Notebook

Topics

  • dex-optimization
  • r8
  • keep-rules
  • jetpack-keep
  • android-cli
  • app-performance

Transcript

Introduction to DEX optimization

  • Hi, everyone!

  • My name is Breana, I'm on the Android DevRel team.

  • And I'm Chris Craik, I'm on the Android Performance team.

  • Welcome to type-safe keep rules and DEX optimization.

  • Today, we're going to talk about what DEX optimization is, why you should care, and also, some exciting, recent updates.

  • For a long time, DEX optimization has been treated as something that's just kind of nice to have.

  • So, maybe you'd configure it once and then just leave it in place, or maybe you'd get around to it when you had some dedicated time.

  • However, with the mobile industry navigating significant hardware supply constraints that are altering device memory availability, a well-optimized DEX is essential for your app to use less memory, start faster, reduce ANRs, and improve rendering and run-time performance, overall.

  • To maintain a healthy Android ecosystem and to ensure that your apps continue to deliver premium experiences, Google Play announced the new app-quality requirement with performance thresholds that go into enforcement in February.

  • This includes thresholds for DEX code optimization.

  • Apps need to achieve above 25% for shrinking, optimization, and obfuscation.

  • So, what does this mean for you as an Android developer?

  • Not only does unoptimized DEX code negatively impact the user experience, it may also have implications for your app's visibility and publishing capabilities on the Play Store.

  • However, you're in the right place, and by the end of this talk, you'll have the tools to not only meet, but exceed these requirements.

  • DEX code optimization is achievable with R8, Android's app optimizer.

  • And while R8 isn't strictly mandated if another app optimizer can get your app above these thresholds, it is the recommended path that's built directly into Android's toolchain.

  • And while a well-optimized DEX, app bundle does use less memory, the primary benefits here are still performance-related: fewer ANRs, faster start-up, and improved run-time performance.

  • And optimizing your app has real, proven, positive outcomes for your app and for your users.

  • For example, by adopting R8 full mode and refining their keep rules, Grab was able to unlock 78% optimization, 79% shrinking, and 82% obfuscation for their app.

  • And you might be looking at this slide and thinking, "Okay, those numbers are pretty high, but what does this actually translate to?"

  • As a result of these improvements, combined with Baseline Profiles, they reduced ANRs by 32% and improved start-up by 35%.

  • When top apps first adopt R8, they see an average decrease in DEX size of 38% and a 16% decrease in user-perceived ANRs.

  • And if you're already using R8, we have great news for you.

  • For apps that have already enabled R8, and then take steps to further improve optimization, shrinking, and obfuscation levels to greater than 50%, they see, on average, an additional 25% decrease in DEX size, a 4% decrease in start-up time, and an additional 17% decrease in user-perceived ANRs, which is on top of the 16% that we showed in that previous slide.

  • So, today we want to walk you through how you can achieve numbers like this.

  • We'll cover the tools that can help you optimize your DEX code and move from overly broad and misconfigured keep rules to ones that are specific and narrow.

  • First, we also want to mention that R8 has been around for many years.

  • And we acknowledge that while it has enabled high-impact optimization, it still takes a lot of care to configure.

  • Reflective calls mean that inputs are unpredictable, and can lead to run-time crashes.

  • And this has tempted both app teams and library teams in the past to add overly broad keep rules just to play things safe.

  • We know that 41% of top apps are below the 25% optimization levels defined by Google Play, but we also know that 13% of top apps are already achieving three times that threshold.

  • And this is apps from across a wide variety of industries and user journeys that are able to achieve this number.

  • And high optimization scores are achievable for your app, as long as you have the right tools and the right visibility to manage both your own keep rules and those that are inherited from libraries.

  • And before we dive into the new tooling and improvements that will help with this, we also wanted to highlight a few other R8 improvements from this past year that also make this optimization just a bit easier.

  • First, new app templates include a package scope, which is useful for ensuring a minimum level of optimization right from the jump.

  • The R8 configuration analyzer, which we'll talk about in just a second, is now supported on older versions of AGP.

  • And it's also unofficially supported for Bazel.

  • And in AGP 9.2, coroutine primitives got two times as fast.

  • And as a result of the R8 improvements that were made in AGP 9.2, Tinder saw a 13% decrease in ANRs after they upgraded from version 9.0.1.

  • And AGP 9.3 also brings a redesigned R8 API with clearer naming, simpler defaults for default rules and resource shrinking, and AGP source directory support for keep rules.

    android {
        buildTypes {
            release {
                optimization {
                    // no need to specify default
                    // rules and resource shrinking
                    enabled = true
                }
            }
        }
        ...
    }
    android {
        buildTypes {
            release {
                // Enables code optimizations.
                isMinifyEnabled = true
    
                // Enables resource shrinking.
                isShrinkResources = true
    
                proguardFiles(
                    // Default rules.
                    getDefaultProguardFile(
                        "proguard-android-optimize.txt"
                    ),
                    ... // list out each file with keep rules
                )
                ...
            }
        }
        ...
    }
  • The updates that we're going to cover today are also improvements to the developer experience.

  • We'll start with how to leverage the R8 Configuration Analyzer and the R8 skill to identify legacy rule overhead and determine a path to improvement.

  • Then, Chris will cover how to transition from broad, wildcard keep rules to the new precise, type-safe, auto-generated annotations.

  • And finally, we'll look at how to use new CLI validation tools to ensure aggressive optimization without introducing crashes.

Overview of R8 configuration analyzer

  • So, starting with the R8 Config Analyzer, this is the ideal place to start your DEX optimization investigation.

  • If you haven't used the tool, this is what it looks like.

  • It's an HTML report that visualizes information on your R8 configuration.

  • This report is for my app, which includes a library called Analytics Bloat.

  • And as you can guess, this is not a real app or a real library, but it's highlighted there in blue at the top.

  • The Config Analyzer measures the following: shrinking, which is the percentage of classes, methods, and fields that R8 isn't prevented from shrinking by keep rules.

  • And this is calculated after R8 performs its first shrinking pass.

  • It also measures optimization, which is the percentage of code that is open to whole program optimizations like method inlining and class merging.

  • And obfuscation, which is the percentage of members that can be obfuscated or renamed to shorter identifiers.

  • I also want to mention that because the R8 Config Analyzer calculates these scores before the final optimizations and DEX compilation, the values that you see in this report for optimization, shrinking, and obfuscation won't match the values that you see in Play Console exactly.

  • And this is because Play measures the final DEX output.

  • And the reason for this discrepancy is to ensure that when you're developing, you can access a fast, local feedback loop.

  • But the scores that you see in the R8 Config Analyzer are still really helpful for validating DEX optimization potential before you upload to the Play Store.

  • The report also includes the Keep Rule Analysis table, which is that main view that you see when you enter the report.

  • And from there, you can click into any rule and see the exact number of classes, methods, and fields that a specific rule is keeping so that you can see which rules are keeping more than they should.

  • It also has subsumed rules, which is what is zoomed in on here.

  • And these are rules that overlap with a broader rule.

  • And these are highlighted so you can remove the broad one and just keep the more narrow one.

  • And it also shows you unused rules, which are rules that can be deleted without risks to the stability of your app.

  • So, the Config Analyzer was designed to address a few problems starting with legacy rule accumulation.

  • Previously, without per-rule visibility, it was kind of risky to delete old rules.

  • So, teams would kind of leave them alone or just add broader wildcards.

  • Second, it helps you discover library rule blind spots.

  • So, when you add an SDK historically, it's been hard to tell if a rule that's bundled with that library is overly broad or if it's even needed, say, 10 or so versions later.

  • And we'll also cover more on libraries in a bit.

  • And third, it helps you assess optimization impact.

  • Previously, there wasn't an easy way to tell if a rule was keeping two classes or 2,000.

  • Let's take a look at how R8 works in the context of my app and that library we looked at earlier.

  • So, here I have an app module and three libraries.

  • And pay close attention to library three on the right.

  • That is the Analytics Bloat library, which includes a package-wide keep rule.

  • First, R8 identifies the root entry point from all merged keep rules, which are highlighted here in yellow.

  • And generally, these should be the ma- main entry points of the app, like classes in the manifest.

  • And because of the Analytics Bloat library's wildcard keep rule, every single class in the library is treated as an entry point.

  • Next, R8 traces reachability across the call graph from those roots.

  • Any class that's reachable from a root is marked for retention in green, while unreferenced classes are classified as dead code and they're marked in red here.

  • R8 then performs tree shaking and strips away the unreferenced dead code.

  • So, after this tree shaking, or the first shrinking pass, this is what remains.

  • And our app has a shrinking score of 42.5%, which is above the 25% threshold from Google Play, but it's still not right where we'd want it to be.

  • And this is largely due to the package-wide keep rule from the analytics library.

  • And that makes it so only 17 of the remaining 40 classes can be further shrunk.

  • And the point that I want to make here with this is that one bad keep rule can cause major problems for your app's optimization.

  • So with this in mind, let's quickly focus on libraries.

  • This past year, our team has been investing a lot in understanding how library consumer keep rules are impacting optimization for the ecosystem.

  • Many libraries will ship with overly broad rules in the name of stability.

  • Or they'll also specify broad rules in their documentation for app developers to add to their app themselves.

  • But shipping with broad rules isn't necessary for stability, and it negatively impacts performance, where the costs are primarily yours as the app developers to bear.

  • We've seen cases where the entire library is preserved by a package-wide keep rule like the analytics bloat library, and in the case of that library, this had far-reaching impacts on my app's optimization potential.

  • I do also want to mention that even our own SDKs at Google were not immune to this, and more than a dozen Google libraries have fixed overly broad keep rules in the past year.

  • So if you want to use the config analyzer, it's integrated directly into AGP 9.3, but it's also available for older versions if you upgrade your R8 version.

  • So running this command will generate that HTML report that we showed, showing your keep rules, impacts on shrinking, optimization, obfuscation for individual rules and your app as a whole.

  • And if you're running a full R8 release build, the report is actually automatically generated and can be found at this file path.

  • The benefit of this standalone task is that it takes less time to generate than the full build.

Case study: WHOOP optimization

  • The team at WHOOP, which might actually be here somewhere, ran the config analyzer and got shrinking at 74%, obfuscation at 74%, and optimization at 0.

  • And as you can imagine, the team was really surprised to see this optimization value.

  • The visibility from the config analyzer helped them discover that their build was using a legacy ProGuard file that disabled optimization, and so by switching to the updated version, they were able to enable optimization for their app.

  • This led to 55% optimization and a 4% reduction in APK size.

  • Combined with Baseline Profiles, they improved cold start times by 38%, and they reduced excessive frozen frames by 28%.

  • The screen on the left is without Baseline Profiles, and the screen on the right is with, and as you can see, app startup is faster with the addition of Baseline Profiles.

  • Let that play one more time.

  • Cool.

  • This link will take you to the guide for the config analyzer, and manually reviewing the report is great, but as you can imagine, depending on the state of your app's R8 configuration, it could take a lot of time.

  • So to speed things up, we have an R8 Analyzer skill.

    $ android skills list
    $ android skills add r8-analyzer
  • You can ask the agent, analyze my R8 configuration, and not only does the skill generate the R8 configuration analyzer report, it also points you towards the most impactful rules.

  • Here's an example output, and just to call out a few details, it notes the 42% optimization score, which means that 52%-- 58%, rather, of the codebase can't be optimized by R8.

  • And if we look at the keep rule section specifically, the package-wide keep rule for the analytics bloat library prevents 22% of the codebase from optimization, and the skill also recommends that we refine this keep rule since, as we already know, it is too broad.

  • So now you're able to diagnose what is causing app bloat and blocking DEX optimization using the R8 config analyzer and the skill.

Challenges of reflection in DEX optimization

  • But once you've find an overly broad keep rule, how do you replace it safely without accidentally introducing an unexpected crash?

  • Going to hand it off to Chris, who will show you how to transition from legacy keep rules to the new type-safe auto-generated keep annotations.

  • Thanks, Breana.

  • All right, so let's talk about how we got here, right?

  • With this bloated analytics library, with all these keep rules that are-- with this keep rule that is keeping basically everything, the core problem that we're hitting is reflection.

  • So, let's take a look at a little bit of the reflective code just as a little reminder here.

    fun createWithReflection(className: String): MyObject {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as MyObject
    }
  • This is a an object instantiation function that just is inflating a particular object, and doing so reflectively.

  • Now, the downside to this, in the context of DEX optimization, is that it's delaying all of these static calls until runtime.

  • By just simply looking at the code, depending on where this class name is coming from, our R8 optimizer isn't able to see what's happening here.

  • These are the situations that break Dex optimization by default, so typically you'd have to add a Keep rule.

  • Now, ideally you just avoid this, right?

  • Like ideally we just avoid reflection entirely, especially now in 2026 where we have amazing codegen tools for serialization, for dependency injection, that can just entirely eliminate this category of problem.

  • But it's not all of it, right?

  • We've got reflection still somewhat necessary in certain cases.

  • So, let's let's dive into how this exactly happened in this bloated library.

  • So, here's one particular example, and this is a common one, is an optional dependency.

    // Problem 1 - optional dependency
    fun tryInitCrashReporter(context: Context): EventTransport? {
       return runCatching {
           Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
               .getDeclaredConstructor(Context::class.java)
               .newInstance(context) as? EventTransport
       }.getOrNull()
    }
    
    // Problem 2 - dynamically instantiate subclass
    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
    
    // Problem 3 - serialization
    fun serializeEvent(event: UserActionEvent): JSONObject {
       val json = JSONObject()
       for (field in event.javaClass.declaredFields) {
           field.isAccessible = true
           json.put(field.name, field.get(event))
       }
       return json
    }
  • Here, we're inflating a particular type that's not accessible on the library classpath, so we have to do it with a string.

  • We're This is referencing this particular class that may or may not be present at the time that the library was built, so very reasonably this library is using reflection to find a member inside of a related library.

  • Another case we have here is dynamically instantiating subclasses.

  • So, especially if it's something coming out of a manifest file or something being sent from something server-side, we are informing the local app, "Hey, please inf- inflate this thing, create this thing late."

  • And then, this third category that we see here is embarrassingly serialization.

  • I mean, I have a JSON serialization that's happening by iterating through all the members.

  • Now, this is absolutely disgusting, but, you know, this kind of stuff sticks around sometimes.

  • Doesn't always get fixed the moment that it needs to.

  • But let's talk about what the result of this is, right?

  • As these reflective calls pile up, you just get more motivation to kind of do the easy thing, and optimize for developer experience over your user experience.

  • I mean, that feels pretty bad, so I'll add a to-do, and it'll be fine, you know.

    # TODO: clean this up
    -keep com.example.analytics.bloat.** { * };
  • Some Someday, I'll I'll come around and fix it, right?

  • So, let's talk about how what we're supposed to do, what we were supposed to do at least before today, how you would write an ideal Keep rule here.

    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • Okay, buckle up.

  • So, the first part here is that we want to keep the particular class that we're returning from this reflective function.

    ### Keep the EventFilter
    -keep class com.example.analytics.bloat.EventFilter {
        *
    }
  • Actually, no, wait.

  • You know what?

  • This is actually not correct.

  • This is a Kotlin class, I need to make sure this is stringly-typed, let me get the KT in there.

    ### Keep the actual EventFilter
    -keep class com.example.analytics.bloat.EventFilterKt {
        *
    }
  • And in fact, the important thing here is that I'm not actually returning an EventFilter, I'm actually returning a subclass of EventFilter, so I need to keep classes that extend EventFilter in order to be able to inflate this correctly.

    ### Keep the EventFilter subclasses
    -keep class ** extends com.example.analytics.bloat.EventFilterKt {
        *
    }
  • And really, if I want to do this more efficiently Now, that would have worked, but to make it more efficient, as a good library developer, what should I even go further to do?

  • Well, I should say -keepclasseswithmembers, and only keep those classes that have this particular <init> constructor that I'm using to inflate the object.

    ### Keep the EventFilter subclasses, if they have this constructor
    -keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • And really, if I want to be really nice, I'll add allowaccessmodification to allow even further optimization to happen on all the APIs that are being targeted by this Keep rule because I, you know, don't want to prevent further optimization from R8, I want to try and be a good citizen.

    ### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
    -keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
       void <init>();
    }
  • And then, the last step that, like, almost nobody does, because it's an extra bit of work, but it's great for a library, is making it conditional.

    ### -if makes this conditional, critical for libraries
    -if class com.example.analytics.bloat.EventToolsKt {
        class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
    }
    ### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
    -keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
        void <init>();
    }
  • Now, conceptually, you do not need to keep all of those subclasses unless you're actually calling into this function, and that's what this if statement does at the top of a Keep rule.

  • But, you know, I could ask for a show of hands, but I know almost nobody does this, right?

  • Like, actually writing it, making it conditional.

  • So, what if we could make this, you know, a little bit better?

  • Let's Let's first look at see, like, okay.

  • Well, maybe there's a different way to do this.

  • So, we talked through like, what if I write a narrow, manually written Keep rule?

  • Well, it's possible technically, but it's hard to write, it's hard to read, and it's very difficult to maintain because there're lots of little stringly-typed bits in there that are easy to break when something underneath it happens.

  • Underneath something underneath it changes.

  • And we have alternatives.

  • We have annotations like @Keep.

  • Now, @Keep has been in the AndroidX annotations package for a long time, but it's actually kind of discouraged these days.

    @androidx.annotation.Keep
    class EventFilter { … }
    
    @androidx.annotation.Keep
    class SizeBasedEventFilter { … }
    
    @androidx.annotation.Keep
    class DropLogsEventFilter { … }
  • It's not a great solution for library developers, and, in fact, it's kind of banned internally, because while it is easier to maintain, it is very inflexible, and it doesn't feat- it doesn't feature some of those optimizations that I was talking about before.

  • It kind of can't.

  • It's It doesn't allow for that call site an- analysis.

  • Now, if we look at that particular case that we had from before, "Oh, well, I can just put @Keep on my EventFilter, I'm good, right?"

    @Keep
    class EventFilter { … }
  • Well, no.

  • @Keep doesn't actually target subclasses, it only targets that class.

    @Keep
    class EventFilter { … }
    
    @Keep
    class SizeBasedEventFilter { … }
    
    @Keep
    class DropLogsEventFilter { … }
  • So, actually I need to target all the subclasses, and now I'm actually writing more @Keep annotations than I would have been with that one single Keep rule that was hard to write.

  • So, both of these have problems, right?

  • They're easy to forget, you find out about them late, and they are not conditional on the caller by default.

  • Especially for libraries, we want a better way.

  • We want there to be an easier way to get things kind of right out-of-the-box.

  • And one of the big problems that we see here is these things are living far away from the reflection that we're actually protecting against.

  • Ultimately, the solution that we want is something that lives right against the, you know, naughty reflection code here that is actually inflating the targeted things.

Introduction to Jetpack Keep

  • So let's start talking about Jetpack Keep, the new library and plugin from AndroidX that is designed to help exactly this problem.

  • So, to use-- so the whole goal of Jetpack Keep is to first start by generating keep rules from annotations, but even going further than that, generating annotations directly from the reflection that's already in your codebase.

  • So, the setup is very simple.

    plugins {
       id("com.android.library")
       id("androidx.annotation.keep")
    }
    
    dependencies {
        implementation("androidx.annotation.keep:keep")
        ...
    }
  • It's a Gradle plugin, it's an annotation library.

  • But let's look at how this actually run-- runs at build time.

  • So, if I have this previous example where I'm inflating a subclass the- of a known type, what do I have to do?

  • I just run lintFix.

    ./gradlew analyticsbloat:lintFix
  • Lint here analyzes all the reflection that's happening inside of this method, and it says, "Hey, you need this annotation."

    @UsesReflectionToConstruct(
       classConstant = EventFilter::class,
       parameterTypes = []
    )
    fun createEventFilter(className: String): EventFilter {
       return Class.forName(className)
           .getDeclaredConstructor()
           .newInstance() as? EventFilter
    }
  • This annotation exactly expresses what the reflection inside this call is doing.

  • It says, "I am inflating a class of this type."

  • In this particular case, EventFilter is a known class.

  • I'm not using a string to reference it because I don't have to.

  • That's additional safety, and it-- I'm even encoding the parameterTypes so that it says exactly which constructor is being kept.

  • But what does this do?

  • Like, how does this inform an eventual R8 build?

  • Well, it generates the keep rule, and, you know, if you saw before, this is the exact same ideal keep rule that we had before.

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.EventTools {
        class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
    }
    -keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
        void <init>();
    }
  • It is targeting the exact same classes, the subclasses, and it is conditional on this reflection point.

  • And I don't have to maintain a-- I don't have to care about annotating all of the subclasses.

  • I don't have to keep a separate keep rule file anywhere else.

  • This is a much easier way to manage your keep rules.

  • Let's look back at that optional dependency example.

    @UsesReflectionToConstruct(
       className = "com.example.analytics.bloat.crash.CrashReporterTransport",
       parameterTypes = [Context::class]
    )
    fun tryInitCrashReporter(context: Context): EventTransport? {
       return runCatching {
           Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
               .getDeclaredConstructor(Context::class.java)
               .newInstance(context) as? EventTransport
       }.getOrNull()
    }
  • Now, there, the class wasn't on the class path, but I can still reference it.

  • I just do so by using a string, but everything else, the parameter types, they can be referenced by class references, so I do.

  • And again, we generate the ideal keep rule for this case.

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.CrashConnection {
        class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
    }
    -keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
        void <init>(android.content.Context);
    }
  • It's conditional, it is specific, and it is targeted, and it enables optimizations wherever possible on those targeted classes.

  • Here's reflection on fields, and in this particular case, I was iterating on all the fields inside an object, but here, again, we handle that just fine by saying target all fields with this star operator.

    // TODO: migrate to Kotlinx Serialization or Moshi codegen.
    @UsesReflectionToAccessField(
       classConstant = UserActionEvent::class,
       fieldName = "*"
    )
    fun serializeEvent(event: UserActionEvent): JSONObject {
       val json = JSONObject()
       for (field in event.javaClass.declaredFields) {
           field.isAccessible = true
           json.put(field.name, field.get(event))
       }
       return json
    }
  • That allows me to target all fields, and look, it generates the exact keep rule that you would expect it to for this, you know, incredibly unfortunate reflection that shouldn't be in my app, but, hey, I'll come back and clean that up, I totally promise.

    # Generated Keep Rule
    
    -if class com.example.analytics.bloat.serialization.EventSerializer {
        class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
    }
    -keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
        <fields>;
    }
  • So, what does Jetpack Keep do?

  • It cuts keep rule maintenance drastically.

  • For everything that you can automatically generate, it just removes it from your area of concern and your area of risk for maintaining keep rules.

  • It also gives you the de-- determinism of lint.

  • It's not just fixing things for you as you're iterating through and trying to migrate, it is catching new reflection and catching reflective changes anytime code changes, and that safety is absolutely critical.

  • Can I use this?

  • So, what are the dependencies here?

  • The plugin and lint require AGP 8.7.

  • That should be almost everybody.

  • But it's also very flexible about where you can use this.

  • You can use this inside apps.

  • You can use this inside Android libraries, AARs, KMP libraries, and JARs-- a little bit of manual wiring in that case.

  • But the key feature here is that this is safe to use as a library developer.

  • Even if somebody is using an ancient version of R8 or even a third-party tool that is consuming your library, we still export actual standard consumer rules so that anybody can still use you and doesn't have to worry about like consuming some sort of new incompatible format.

Analyzing apps with Android CLI

  • So, now that I've talked about what Jetpack Keep can do for helping you optimize and helping you worry less about protecting your reflective code calls, let's talk about the new features that we've we've recently launched in Android CLI for kind of double checking on the last release stage.

  • So, Breana mentioned before that there were tools in that the that Play Findings will tell you information about your sizing information, your your DEX size, your optimization percentages.

  • Well, now we've started to make that available also through Android CLI.

  • It uses the exact same logic that Play Store does, so without having to upload a bundle to Play Store, you can just on CLI have it analyze your bundle.

    $ android analyze <mybundle.aab>
    $ android analyze <mybundle.apk>
  • And you get the exact same information reported to you directly and immediately with local analysis.

  • You can see here in this particular case, this app is getting a warning about having very low rate of optimization, and it's also being told, "Hey, this is your DEX size, and here are your optimization percentages," exactly like Play Store would tell you.

  • But also helpfully here, we have a JSON output so that if you want to write a script to automate regression testing against any of these values drastically dropping, because, again, these are the numbers that Play enforces, you want to keep track of them, you can do that really easily with this JSON value-- these JSON reports.

    {
      "optimization": {
        "bundleAnalysisResult": {
          "agpVersion": "9.3.2",
          "r8Json": {
            "version": "9.3.16",
            "options": {
              "isRepackageClassesEnabled": true,
              "isProguardCompatibilityModeEnabled": false
            },
            "stats": {
              "shrinkingRatio": 0.2306,
              "optimizationRatio": 0.2351,
              "obfuscationRatio": 0.2322
            },
            ...
          }
        "mappingObfuscationRatio": 0.21631891,
        "dexHeuristicObfuscationRatio": 0.25118626,
        "uncompressedDexSizeBytes": "16198152"
      }
  • Here we see the exact same numbers are present as well.

  • Now this is not the only feature of android analyze.

  • android analyze can also run on libraries.

    $ android analyze <library.aar>
    $ android analyze <library.jar>
  • And as Breana mentioned, there's a lot of problems with libraries running with shipping with unfortunate keep rules inside of them.

  • So, let's use this to actually look at some of these problems.

  • So, when I run this on a particular AAR that I have, I can see, hey, this library has a number of problems just highlighted at the very top.

  • This library has a high keep ratio, this means that it's keeping too much with unbounded keep rules.

  • It contains broad keep rules that prevent itself from being optimized, and there's some numbers below that tell you exactly how bad that is, and it also contains keep rules which, perhaps most scary of all of these, aren't targeting anything, which probably means that they're targeting somebody else in some kind of unbounded way.

  • Very dangerous code smells.

  • So, you can check all of that in an AAR, especially as a library publisher, this is a great way to make sure that you are not shipping bad keep rules, and that you don't let them leak in when somebody maybe not as expert in writing keep rules is working on the library.

  • Here, you can see that we also output a keep ratio.

  • This is a good way to think about your library's overall quality.

  • If I just optimize a individual library completely standalone, no references, with R8, I should see that being zero bytes, right?

  • Like it should be basically empty because there's no nothing retained inside of it.

  • That would be the ideal.

  • In this case, it's not.

  • You can see the R8 and D8 size directly.

  • And in fact, we also highlight some of the worst keep rules just to make them a little bit more visible to you.

  • This also has a JSON format, so again, whether you're an app developer trying to gate library updates or if you're a library developer trying to prevent big regressions in your library quality, you can do that that same analysis and prevent these these problems from happening with a simple script that wraps Android CLI.

    {
      "optimization": {
        "libraryAnalysisResult": {
          "dex": {
            "d8CompileResult": {
              "dexSizeBytes": 4523352
            },
            "r8CompileResult": {
              "dexSizeBytes": 3266596,
              "configuration": {
                "usedPackageWideKeepRules": [
                  {
                    "source": "-keep class com.example.analytics.bloat.** { *; }",
                    "keptItemCount": 125579,
                  },
                  ...
                ],
                "unusedPackageWideKeepRules": [
                  {
                    "source": "-keep class some.other.library.** { *; }",
                  },
                  ...
                ]
              },
              ...
    }
  • So, if you do find a library's problem, first, please try and update it.

  • As Breana mentioned earlier, we've fixed more than a dozen Google libraries to fix some very unfortunate keep rules and there other libraries out there that have had fixes, they just aren't getting updated by the apps that use them.

  • It's a great great first step.

  • Second of all, you know, then you can look for an issue and reach out to the library developer and, you know, possibly attach an analyzer report that tells them, "Hey, you're keeping too much.

  • Please help me optimize my app."

Key takeaways and resources

  • So, to kind of recap here, we've talked about the R8 Config Analyzer, which helps you look through everything that's happening in your R8 build, everything that is informing what is optimizable and what is not.

  • We've talked about Jetpack Keep, the new library that helps you write keep rules automatically without lifting a finger.

  • And Android CLI, the new tool to help you check and make sure, double check against the last step, and make sure you, your agent, junior engineer on your team hasn't ruined anything for your consumers.

  • So, if you'd like to learn more, there's documentation for R8, the config analyzer, and Jetpack Keep.

  • And there's documentation on Android CLI that talks about the analyze command and how to use it.

  • Thank you very much.

Code

Code snippets

Kotlin Script snippet at 05:15 in "Introduction to DEX optimization"
android {
    buildTypes {
        release {
            optimization {
                // no need to specify default
                // rules and resource shrinking
                enabled = true
            }
        }
    }
    ...
}
Kotlin Script snippet at 05:15 in "Introduction to DEX optimization"
android {
    buildTypes {
        release {
            // Enables code optimizations.
            isMinifyEnabled = true

            // Enables resource shrinking.
            isShrinkResources = true

            proguardFiles(
                // Default rules.
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                ... // list out each file with keep rules
            )
            ...
        }
    }
    ...
}
Shell snippet at 13:19 in "Case study: WHOOP optimization"
$ android skills list
$ android skills add r8-analyzer
Kotlin snippet at 14:41 in "Challenges of reflection in DEX optimization"
fun createWithReflection(className: String): MyObject {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as MyObject
}
Kotlin snippet at 15:40 in "Challenges of reflection in DEX optimization"
// Problem 1 - optional dependency
fun tryInitCrashReporter(context: Context): EventTransport? {
   return runCatching {
       Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
           .getDeclaredConstructor(Context::class.java)
           .newInstance(context) as? EventTransport
   }.getOrNull()
}

// Problem 2 - dynamically instantiate subclass
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}

// Problem 3 - serialization
fun serializeEvent(event: UserActionEvent): JSONObject {
   val json = JSONObject()
   for (field in event.javaClass.declaredFields) {
       field.isAccessible = true
       json.put(field.name, field.get(event))
   }
   return json
}
Shell snippet at 16:52 in "Challenges of reflection in DEX optimization"
# TODO: clean this up
-keep com.example.analytics.bloat.** { * };
Kotlin snippet at 16:59 in "Challenges of reflection in DEX optimization"
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
Shell snippet at 17:07 in "Challenges of reflection in DEX optimization"
### Keep the EventFilter
-keep class com.example.analytics.bloat.EventFilter {
    *
}
Shell snippet at 17:15 in "Challenges of reflection in DEX optimization"
### Keep the actual EventFilter
-keep class com.example.analytics.bloat.EventFilterKt {
    *
}
Shell snippet at 17:19 in "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses
-keep class ** extends com.example.analytics.bloat.EventFilterKt {
    *
}
Shell snippet at 17:40 in "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses, if they have this constructor
-keepclasseswithmembers class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
Shell snippet at 17:50 in "Challenges of reflection in DEX optimization"
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
   void <init>();
}
Shell snippet at 18:03 in "Challenges of reflection in DEX optimization"
### -if makes this conditional, critical for libraries
-if class com.example.analytics.bloat.EventToolsKt {
    class com.example.analytics.bloat.EventFilterKt createEventFilter(java.lang.String);
}
### Keep the EventFilter subclasses, if they have this constructor, allow more optimization
-keepclasseswithmembers,allowaccessmodification class ** extends com.example.analytics.bloat.EventFilterKt {
    void <init>();
}
Kotlin snippet at 18:57 in "Challenges of reflection in DEX optimization"
@androidx.annotation.Keep
class EventFilter { … }

@androidx.annotation.Keep
class SizeBasedEventFilter { … }

@androidx.annotation.Keep
class DropLogsEventFilter { … }
Kotlin snippet at 19:22 in "Challenges of reflection in DEX optimization"
@Keep
class EventFilter { … }
Kotlin snippet at 19:29 in "Challenges of reflection in DEX optimization"
@Keep
class EventFilter { … }

@Keep
class SizeBasedEventFilter { … }

@Keep
class DropLogsEventFilter { … }
Kotlin Script snippet at 20:38 in "Introduction to Jetpack Keep"
plugins {
   id("com.android.library")
   id("androidx.annotation.keep")
}

dependencies {
    implementation("androidx.annotation.keep:keep")
    ...
}
Shell snippet at 20:54 in "Introduction to Jetpack Keep"
./gradlew analyticsbloat:lintFix
Kotlin snippet at 20:55 in "Introduction to Jetpack Keep"
@UsesReflectionToConstruct(
   classConstant = EventFilter::class,
   parameterTypes = []
)
fun createEventFilter(className: String): EventFilter {
   return Class.forName(className)
       .getDeclaredConstructor()
       .newInstance() as? EventFilter
}
Shell snippet at 21:25 in "Introduction to Jetpack Keep"
# Generated Keep Rule

-if class com.example.analytics.bloat.EventTools {
    class com.example.analytics.bloat.EventFilter createEventFilter(java.lang.String);
}
-keepclasseswithmembers,allowaccessmodification class ** extends class com.example.analytics.bloat.EventFilter {
    void <init>();
}
Kotlin snippet at 21:52 in "Introduction to Jetpack Keep"
@UsesReflectionToConstruct(
   className = "com.example.analytics.bloat.crash.CrashReporterTransport",
   parameterTypes = [Context::class]
)
fun tryInitCrashReporter(context: Context): EventTransport? {
   return runCatching {
       Class.forName("com.example.analytics.bloat.crash.CrashReporterTransport")
           .getDeclaredConstructor(Context::class.java)
           .newInstance(context) as? EventTransport
   }.getOrNull()
}
Shell snippet at 22:05 in "Introduction to Jetpack Keep"
# Generated Keep Rule

-if class com.example.analytics.bloat.CrashConnection {
    class com.example.analytics.bloat.EventTransport tryInitCrashReporter(android.content.Context);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.crash.CrashReporterTransport {
    void <init>(android.content.Context);
}
Kotlin snippet at 22:16 in "Introduction to Jetpack Keep"
// TODO: migrate to Kotlinx Serialization or Moshi codegen.
@UsesReflectionToAccessField(
   classConstant = UserActionEvent::class,
   fieldName = "*"
)
fun serializeEvent(event: UserActionEvent): JSONObject {
   val json = JSONObject()
   for (field in event.javaClass.declaredFields) {
       field.isAccessible = true
       json.put(field.name, field.get(event))
   }
   return json
}
Shell snippet at 22:27 in "Introduction to Jetpack Keep"
# Generated Keep Rule

-if class com.example.analytics.bloat.serialization.EventSerializer {
    class org.json.JSONObject serializeEvent(com.example.analytics.bloat.model.UserActionEvent);
}
-keepclasseswithmembers,allowaccessmodification class com.example.analytics.bloat.model.UserActionEvent {
    <fields>;
}
Shell snippet at 24:22 in "Analyzing apps with Android CLI"
$ android analyze <mybundle.aab>
$ android analyze <mybundle.apk>
JSON snippet at 24:52 in "Analyzing apps with Android CLI"
{
  "optimization": {
    "bundleAnalysisResult": {
      "agpVersion": "9.3.2",
      "r8Json": {
        "version": "9.3.16",
        "options": {
          "isRepackageClassesEnabled": true,
          "isProguardCompatibilityModeEnabled": false
        },
        "stats": {
          "shrinkingRatio": 0.2306,
          "optimizationRatio": 0.2351,
          "obfuscationRatio": 0.2322
        },
        ...
      }
    "mappingObfuscationRatio": 0.21631891,
    "dexHeuristicObfuscationRatio": 0.25118626,
    "uncompressedDexSizeBytes": "16198152"
  }
Shell snippet at 25:17 in "Analyzing apps with Android CLI"
$ android analyze <library.aar>
$ android analyze <library.jar>
JSON snippet at 26:46 in "Analyzing apps with Android CLI"
{
  "optimization": {
    "libraryAnalysisResult": {
      "dex": {
        "d8CompileResult": {
          "dexSizeBytes": 4523352
        },
        "r8CompileResult": {
          "dexSizeBytes": 3266596,
          "configuration": {
            "usedPackageWideKeepRules": [
              {
                "source": "-keep class com.example.analytics.bloat.** { *; }",
                "keptItemCount": 125579,
              },
              ...
            ],
            "unusedPackageWideKeepRules": [
              {
                "source": "-keep class some.other.library.** { *; }",
              },
              ...
            ]
          },
          ...
}