Embracing AGP 9 and JDK 21: A Gradual Path to Android Build Optimization

As Android developers, major AGP (Android Gradle Plugin) updates are always significant. AGP 9 in particular promises a stricter, faster build environment, moving away from ambiguous configurations.

Instead of waiting for its full release and then scrambling to fix issues, why not start preparing your project now, gradually aligning it with "AGP 9 standards" through your gradle.properties file?

 

🤔 Why JDK 21 and AGP 9 Now? (The Ultimate Synergy)

When transitioning to AGP 9, updating to JDK 21 isn't just a "requirement"; it's a powerful "booster" that dramatically enhances your development experience.

  • Performance Synchronization: JDK 21's improved resource management, including features like Virtual Threads, allows Gradle to fully leverage its parallel build capabilities, leading to more stable and efficient builds.
  • Language Specification Alignment: By targeting Java 21, you bridge potential gaps in type inference and bytecode generation in mixed Java/Kotlin projects, especially as Kotlin 2.x gains traction.
  • Precision R8 Optimization: AGP 9 is optimized to parse and transform class files generated by JDK 21. This means that even with stricter settings, R8 can more accurately understand modern code structures, reducing the need for excessive keep rules while safely shrinking code.

This combination offers the kind of seamless experience you get from pairing the latest OS with the latest CPU.

 

🤔 Prepare with gradle.properties: 10 Flags to Enable Today

The strategy is simple: enable one flag at a time, fix any errors that arise, and then move to the next. This iterative approach is the most reliable way to prepare for AGP 9.

1. Structure Enforcement (Clean Up Your Project)

  • android.uniquePackageNames=true: Prevents duplicate package names across modules, eliminating resource conflicts.
  • android.usesSdkInManifest.disallowed=true: Enforces placing minSdk, targetSdk, etc., in build.gradle instead of AndroidManifest.xml.
  • android.defaults.buildfeatures.resvalues=true: Explicitly controls the generation of resValue entries.

2. Build Speed Enhancements

  • android.enableAppCompileTimeRClass=true: Uses lightweight R classes during app compilation, significantly improving build times for large projects.
  • android.sdk.defaultTargetSdkToCompileSdkIfUnset=true: Automatically sets targetSdk to compileSdk if unspecified, preventing inconsistent behavior.
  • android.dependency.useConstraints=true: Utilizes Gradle's "Constraints" feature for dependency resolution, making library version management more robust.

3. Aggressive R8 / Optimization Settings (The Biggest Hurdle)

  • android.r8.strictFullModeForKeepRules=true: Enables R8's Full Mode. This maximizes optimization but requires precise keep rules for code that relies on reflection, potentially leading to crashes if not handled correctly.
  • android.r8.optimizedResourceShrinking=true: Employs a more advanced algorithm for removing unused resources, leading to smaller app sizes.

4. Next-Gen Defaults

  • android.builtInKotlin=true: Prioritizes AGP's built-in Kotlin support.
  • android.newDsl=false: Use this to maintain the current DSL while preparing for future changes.

 

🤔 Conclusion: One Flag at a Time for a Smoother Future

The AGP 9 update is akin to a major cleanup. Attempting it all at once can be overwhelming, but tackling it gradually makes it incredibly rewarding.

Why not start with android.uniquePackageNames=true? With each flag you enable, your project will move closer to a more modern, robust, and efficient build environment.

👉 AGP 9.0 移行ガイド:新旧コード比較で見るモダンビルド設定


Why Kotlin Channels Are the Natural Solution for Preventing "Double Execution" of Events

In Android app development, handling "one-shot events"—such as screen navigation or displaying toasts—has traditionally been a tricky area. The issue of processes running a second time after a screen rotation (Activity recreation) is a common headache for developers.

However, once you understand the mechanics of Channel, you will see that it is the most natural and effective solution to this problem.

 

🤔 Channels Have No "Inventory": Retrieve It, and It’s Gone

The decisive difference between Channel and its counterparts like StateFlow (or LiveData) lies in how they treat data.

  • StateFlow (The Bulletin Board): Data is "held" as a state. Even after someone looks at the data, it remains there. Any new observer will see the same, most recent data immediately (this is called being "sticky").
  • Channel (The Pipe/Mailbox): Data is meant to "pass through." The moment the receiver (Consumer) calls receive or collect, that specific piece of data is removed (consumed) from the Channel.

This concept of "consumption" is the essence of a Channel. Just like taking a letter out of a physical mailbox leaves the box empty, an event that has been processed once does not linger in memory.

 

🤔 How It Prevents Unintended Re-execution During Screen Rotation

This "disappears upon retrieval" behavior is incredibly effective during the recreation of Activities or Fragments. Let's look at the specific flow:

  • Event Fired: The ViewModel executes channel.send("Error").
  • Consumption: The UI (Activity A) collects this and shows a Toast. At this exact moment, the Channel becomes empty.
  • Screen Rotation: Activity A is destroyed, and Activity B is newly created.
  • Re-subscription: The new Activity B begins to collect from the channel again.

If this were StateFlow, the last piece of data ("Error") would still be there, causing Activity B to receive it immediately and trigger a second Toast.

However, the Channel is empty. Activity B simply enters a state of waiting for new events. It is impossible for the past event to trigger a malfunction.

 

🤔 Implementation Tip: receiveAsFlow and Buffering

In actual production code, it is standard practice to not expose the raw Channel, but rather expose it as a Flow to the receiver.


// ViewModel
// Use a buffer to prevent dropping events if the collector isn't ready immediately
private val _events = Channel<Event>(Channel.BUFFERED)
val events = _events.receiveAsFlow() // Exposed as a Flow

// UI (Activity/Fragment)
lifecycleScope.launch {
    viewModel.events.collect { event ->
        // Processing the event here consumes it from the Channel
        handleEvent(event)
    }
}

Using Channel.BUFFERED here is highly recommended. This ensures that if an event fires while the UI is not ready (e.g., the app is in the background), the event is held temporarily in memory. As soon as the UI resumes and calls collect, the event is delivered and consumed safely.

 

🤔 Summary: Channel is the Best Practice for "Disposable" Events

The "Receive = Consume" behavior of a Channel is not a side effect; it is the core feature designed for managing tasks that should only happen once.

The rule of thumb is simple:

  • If you need to hold a state (like UI text or loading status), use StateFlow.
  • If you need to consume an event (like navigation or errors), use Channel.

By strictly following this distinction, you can achieve safe, clean event handling without relying on complex flags or workarounds.

👉 【Android】How to handle UI interactions with event processing


Why are updates to Kotlin, Compose, and KSP such a hassle?

In Android development, you're constantly dealing with the same set of three: Kotlin, the Compose Compiler, and KSP.

They seem like a friendly group, but their update schedules are always completely different! You upgrade Kotlin, and the Compose Compiler isn't compatible. You change something, and KSP throws a build error because of an internal API change...

To better manage this "dependency triangle" situation, the main idea is to use Renovate's configuration to treat them as a single unit. The simple plan is: "Raise all Kotlin ecosystem dependencies at the same time!"

 

🧑🏻‍💻 Brief overview of the renovate.json file


{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:base",
    "group:all",
    ":dependencyDashboard",
    "schedule:daily"
  ],
  "baseBranches": ["main"],
  "commitMessageExtra": "{{{currentValue}}} to {{#if isPinDigest}}{{{newDigestShort}}}{{else}}{{#if isMajor}}{{prettyNewMajor}}{{else}}{{#if isSingleVersion}}{{prettyNewVersion}}{{else}}{{#if newValue}}{{{newValue}}}{{else}}{{{newDigestShort}}}{{/if}}{{/if}}{{/if}}{{/if}}",
  "packageRules": [
    {
      "matchPackagePatterns": ["androidx.compose.compiler:compiler"],
      "groupName": "kotlin"
    },
    {
      "matchPackagePatterns": ["org.jetbrains.kotlin.*"],
      "groupName": "kotlin"
    },
    {
      "matchPackagePatterns": ["com.google.devtools.ksp"],
      "groupName": "kotlin"
    }
  ]
}

👉 architecture-samples/renovate.json at main · android/architecture-samples

Roughly summarized, here are the key points:

  • groupName: "kotlin" to bundle dependencies This setting specifies that the three elements—the Compose Compiler, Kotlin, and KSP—should be treated as belonging to the "same group." This allows Renovate to update them all together at once.
  • schedule: daily for a calm update pace This checks for updates once a day. You'll receive pull requests (PRs) on a daily basis, preventing a huge influx of dependency updates all at once, which makes things much easier to manage.
  • commitMessageExtra to see changes at a glance The version difference, like "2.0.10 → 2.0.20," is automatically added to the PR title. It's a small tweak, but surprisingly useful.

Setting up your configuration this way significantly reduces the tragedy of "Kotlin got updated, but Compose broke..."

 

🧑🏻‍💻 What We Found While Using It

Once this setup is in place, you can feel much more confident testing updates for everything Kotlin-related. Renovate diligently checks daily, automatically creating a PR whenever a new version drops.

But there's one small warning:

The Compose Compiler sometimes takes a little extra time to catch up to the latest Kotlin version. So, don't just merge the PR when you see it—it's highly recommended to verify the CI status first.

KSP is similar; because it depends on Kotlin's internal workings, it's safer to update it along with Kotlin and run your tests together.

 

🧑🏻‍💻 Summary: Teach Renovate that "These Three Are a Set"

The configuration we discussed treats the trio of Kotlin, the Compose Compiler, and KSP as a single group.

  • Bundle all Kotlin-related dependencies for simultaneous updates.
  • Check for updates at a manageable daily pace.
  • See version differences directly in the PR title.

Just implementing this significantly reduces the problems caused by versions getting out of sync and breaking your build.

💡 Key Takeaway: Use Renovate less as an "automatic update tool" and more as a "dependency rulebook."

We simply need to tell Kotlin, Compose, and KSP to cooperate and "work together."

👉 Kotlin・Compose・KSP の更新、どうしてこんなに面倒なの?