Migrating 10 Android apps to target API 36 in one weekend

Migrating 10 Android apps to target API 36 in one weekend

在一个周末内将 10 个 Android 应用迁移至目标 API 36

This post was originally published on androidshin.dev. When the Play Console notice landed, it landed on ten apps at once. Each one said the same thing: I couldn’t submit an update unless it targeted a recent enough Android version, with a hard date of August 31, 2026. 本文最初发布于 androidshin.dev。当 Google Play 管理中心的通知下达时,我旗下的十个应用同时收到了通知。内容如出一辙:除非将目标 Android 版本更新至足够新的级别,否则无法提交更新,截止日期为 2026 年 8 月 31 日。

Seven of the ten also carried a second notice — they had to move to Google Play Billing Library 8. I had a weekend, a to-do list of ten projects built at different times with different tooling, and a strong suspicion that “just bump one number” was a lie. It was. 十个应用中有七个还附带了第二条通知——必须迁移至 Google Play Billing Library 8。我只有一个周末的时间,待办事项清单上有十个在不同时期使用不同工具构建的项目,而且我强烈怀疑“只需改动一个数字”这种说法纯属谎言。事实确实如此。

This is the field log. Not a tidy tutorial — the actual order I did things in, the errors in the order they appeared, and where I burned time. If you’re staring at the same notice, you can skip the traps I walked into. 这是一份实战记录。它不是一篇整洁的教程,而是我实际操作的顺序、错误出现的先后,以及我浪费时间的地方。如果你正盯着同样的通知,可以跳过我踩过的坑。

The version chain nobody mentions up front

没人提前提及的版本链

The requirement reads as “set targetSdk = 36.” What it actually means, once you follow the errors, is closer to a small tooling upgrade per project. Here’s the chain I ended up applying to every app, because each link forces the next: 要求写着“设置 targetSdk = 36”。但当你顺着错误提示操作后,会发现这实际上更接近于每个项目的一次小型工具链升级。以下是我最终应用到每个应用的链条,因为每一环都强制要求下一环的更新:

  • targetSdk = 36 requires compileSdk = 36 — you can’t target an API you don’t compile against.
  • targetSdk = 36 要求 compileSdk = 36 —— 你无法针对一个未编译的 API 进行定位。
  • compileSdk = 36 requires Android Gradle Plugin 8.9.0+. Anything older simply doesn’t know Android 16 exists. My projects ranged from AGP 8.2.2 to 8.7.3; all of them had to move to 8.9.1.
  • compileSdk = 36 要求 Android Gradle Plugin 8.9.0+。任何旧版本根本不知道 Android 16 的存在。我的项目版本从 AGP 8.2.2 到 8.7.3 不等;它们全部必须升级到 8.9.1。
  • AGP 8.9 requires Gradle 8.11.1+. Every wrapper that was on 8.2, 8.7, or 8.9 had to be bumped, or the build stopped with a plain version-mismatch message.
  • AGP 8.9 要求 Gradle 8.11.1+。所有处于 8.2、8.7 或 8.9 的 wrapper 都必须升级,否则构建会因简单的版本不匹配消息而停止。
  • The oldest projects were on Kotlin 1.9.x, which the newer AGP complains about. Moving those to Kotlin 2.0.21 cleared it. And of course the machine needs the Android 16 SDK platform and Build-Tools 36 installed, plus JDK 17 to run AGP 8.x.
  • 最旧的项目使用的是 Kotlin 1.9.x,较新的 AGP 会对此报错。将它们迁移到 Kotlin 2.0.21 后问题解决。当然,开发环境还需要安装 Android 16 SDK 平台和 Build-Tools 36,以及运行 AGP 8.x 所需的 JDK 17。

The thing that made this tedious rather than hard: my ten projects used three different build styles. Some used Groovy build.gradle with a buildscript classpath. Some used Groovy with the plugins {} block. Two used Kotlin DSL build.gradle.kts, and one of those drove everything through a libs.versions.toml version catalog. The edit was the same idea each time, but where the version string lived moved around constantly. 让这件事变得繁琐而非困难的原因是:我的十个项目使用了三种不同的构建风格。有的使用带有 buildscript classpath 的 Groovy build.gradle;有的使用带有 plugins {} 块的 Groovy;有两个使用 Kotlin DSL build.gradle.kts,其中一个通过 libs.versions.toml 版本目录驱动一切。每次修改的思路是一样的,但版本字符串存放的位置却一直在变。

My rule of thumb after ten of these: do the bumps in dependency order (SDK → AGP → Gradle → Kotlin), sync once after each, and let each error message tell you the next step. Trying to change everything at once just merges five error causes into one confusing stack trace. 在处理完这十个项目后,我的经验法则是:按依赖顺序进行升级(SDK → AGP → Gradle → Kotlin),每一步之后同步一次,并让每个错误消息告诉你下一步该做什么。试图一次性修改所有内容只会将五个错误原因合并成一个令人困惑的堆栈跟踪。

The billing change was smaller than I feared

计费变更比我担心的要小

Seven apps needed Play Billing Library 8. I’d blocked out hours for this and it took minutes each, because my integrations were already on the modern ProductDetails API. In v8, exactly one thing broke in each app: the queryProductDetailsAsync callback used to hand back a List<ProductDetails>; now it hands back a QueryProductDetailsResult, and you pull the list off it. 七个应用需要使用 Play Billing Library 8。我为此预留了数小时,但每个应用只花了数分钟,因为我的集成已经使用了现代的 ProductDetails API。在 v8 中,每个应用只有一处代码报错:queryProductDetailsAsync 回调过去返回的是 List<ProductDetails>;现在它返回的是 QueryProductDetailsResult,你需要从中提取列表。

// v8
client.queryProductDetailsAsync(params) { _, result -> 
    result.productDetailsList.forEach { /* ... */ } 
}

Everything else I use — enablePendingPurchases, queryPurchasesAsync, acknowledge, consume, subscription replacement — was untouched. If your billing code predates ProductDetails and still calls querySkuDetailsAsync or the no-argument enablePendingPurchases(), you have real removal work first — those are gone in v8. 我使用的其他所有功能——enablePendingPurchasesqueryPurchasesAsync、确认、消耗、订阅替换——都没有受到影响。如果你的计费代码早于 ProductDetails 且仍在调用 querySkuDetailsAsync 或无参数的 enablePendingPurchases(),你需要先进行真正的移除工作——这些在 v8 中已被移除。

The error that wasn’t a code error #1: a lint wall on release

非代码错误 #1:发布时的 Lint 墙

This is where the weekend stopped being smooth. One app built fine in debug and then died assembling the release with a fatal lint error I hadn’t seen in years: 周末的顺利到此为止。一个应用在 debug 模式下构建正常,但在组装 release 版本时因一个我多年未见的致命 lint 错误而失败:

Error: "pro_active" is translated here but not found in default locale [ExtraTranslation]

Two strings existed in the English translation file but not in the default values/strings.xml. Lint treats that as a crash risk, and release builds run lintVital, which is fatal by default. The strings turned out to be dead leftovers from a “Pro” tier I’d removed long ago. The correct fix wasn’t to suppress the check with a baseline, it was to delete the two orphaned strings. I then wrote a quick script to compare every locale’s keys against the default across all my apps, so this couldn’t ambush me again. 英语翻译文件中存在两个字符串,但在默认的 values/strings.xml 中找不到。Lint 将其视为崩溃风险,而 release 构建会运行 lintVital,默认情况下这是致命的。事实证明,这些字符串是我很久以前移除的“Pro”层级留下的死代码。正确的修复方法不是用 baseline 抑制检查,而是删除这两个孤立的字符串。随后我写了一个简单的脚本,对比所有应用中每个语言环境的键与默认值,这样它就不会再突袭我了。

The error that wasn’t a code error #2: a file lock on Windows

非代码错误 #2:Windows 上的文件锁定

The one that genuinely made me question my changes turned out to have nothing to do with them: 那个真正让我质疑自己修改的地方,结果发现与我的修改毫无关系:

:app:lintVitalAnalyzeRelease ...androidx.compose.runtime.lint.RuntimeIssueRegistry-....jar: The process cannot access the file because it is being used by another process

It looks like a Compose lint failure. It isn’t. It’s Windows telling you another process is holding a lint-cache .jar — usually a stray Gradle daemon, an antivirus scan, or a file-sync client watching the build folder. The fix: gradlew --stop, clean, and rebuild; if it persists, close the IDE and delete the app/build folder, or reboot. I lost twenty minutes reading Compose lint docs before I actually read the second half of the error message. Read the whole line first. 这看起来像是 Compose lint 失败。其实不然。这是 Windows 在告诉你另一个进程占用了 lint 缓存的 .jar 文件——通常是残留的 Gradle 守护进程、杀毒软件扫描或监视构建文件夹的文件同步客户端。解决方法:gradlew --stop,清理并重新构建;如果问题依旧,关闭 IDE 并删除 app/build 文件夹,或者重启。我在阅读 Compose lint 文档上浪费了二十分钟,才真正读完错误信息的后半部分。请务必先读完整行。

Edge-to-edge: the visual surprise

Edge-to-edge:视觉上的惊喜

Targeting recent Android enforces edge-to-edge — the system draws your content behind the status and navigation bars. On my View-based game screens, toolbars slid under the clock and buttons sat under the navigation pill. The fix is to enable it explicitly and consume the insets as padding. 针对较新的 Android 版本会强制执行 edge-to-edge(全屏显示)——系统会将你的内容绘制在状态栏和导航栏之后。在我基于 View 的游戏屏幕上,工具栏滑到了时钟下方,按钮坐在了导航条下方。解决方法是显式启用它,并将 insets 作为 padding 处理。

The Compose screens were mostly fine — Scaffold already applies its insets once you call enableEdgeToEdge(). While I was in the themes, I also deleted android:statusBarColor and android:navigationBarColor, which are deprecated and ignored in edge-to-edge. Compose 屏幕大多没问题——一旦你调用 enableEdgeToEdge()Scaffold 就会自动应用 insets。在调整主题时,我还删除了 android:statusBarColorandroid:navigationBarColor,它们在 edge-to-edge 模式下已被弃用并被忽略。