ActivityResultContracts 替代 startActivityForResult,迁移成本
ActivityResultContracts 替代 startActivityForResult,迁移成本
那个被标记为废弃的 API
AndroidX Activity 1.2.0 开始,startActivityForResult 和 onActivityResult 被正式打上 @Deprecated 标记。Google 的替代方案是 ActivityResultContracts 配合 ActivityResultLauncher。这个变更在官方文档里被描述为"type-safe"和"lifecycle-aware",听起来像是无痛升级。但我在一个存量 40 万行代码的项目里实际做迁移时,发现事情没那么简单。
项目最低支持 API 21,targetSdk 33,依赖的 AndroidX Activity 版本是 1.7.2。迁移动机很直接:lint 检查会把所有 startActivityForResult 调用标红,CI 流水线配置了 abortOnError true,不处理就编不过。但这不是简单的 find-and-replace,我花了差不多三周才理清楚各种边界情况。
注册时机的硬性约束
ActivityResultLauncher 必须在 STARTED 之前注册,这是文档里明确写的,但违反后的崩溃信息足够让人困惑。我在一个 BaseActivity 里尝试懒加载 launcher:
class BaseActivity : AppCompatActivity() {
private var launcher: ActivityResultLauncher<<Intent>? = null
fun ensureLauncher() {
if (launcher == null) {
launcher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result -> ... }
}
}
}某个 Fragment 在 onResume 里调用 ensureLauncher(),然后启动相机。第一次没问题,但进程被杀后恢复时,onResume 在 onStart 之后执行,直接抛 IllegalStateException: LifecycleOwner is attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED.。
这个限制比旧 API 严格得多。startActivityForResult 可以在任何生命周期调用,甚至 onDestroy 里(虽然没意义)。新 API 把注册和启动拆成两步,注册被绑定到 Lifecycle,启动被绑定到 ActivityResultRegistry。我最终把注册挪到 onCreate,但这就导致所有 launcher 必须在初始化时确定回调逻辑,无法像过去那样动态构造 onActivityResult 的处理分支。
更麻烦的是,Fragment 的 registerForActivityResult 实际委托给 FragmentActivity 的 ActivityResultRegistry,但 Fragment 自己的 Lifecycle 状态可能和 Activity 不同步。一个 DialogFragment 在 onCreateDialog 里注册 launcher,如果对话框的显示被延迟到 onResume,同样会触发这个崩溃。我查了 AndroidX Activity 1.7.2 的源码,ActivityResultRegistry 的 register 方法里有段硬检查:
if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
throw new IllegalStateException(...);
}没有配置开关,没有向后兼容的余地。这意味着所有动态创建 UI 的场景(比如根据服务器配置决定显示哪些按钮,每个按钮对应不同 launcher)都必须重构为预注册模式,回调里再用 switch 分发。
回调地狱的重构
旧代码里 onActivityResult 是典型的 God Method:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_CAMERA -> handleCamera(data)
REQUEST_GALLERY -> handleGallery(data)
REQUEST_CROP -> handleCrop(data)
REQUEST_PERMISSION -> handlePermission(data)
// ... 二十几个分支
}
}每个分支的 requestCode 是分散在几十个文件里的常量,有的用 companion object,有的用 const val 顶层声明,还有的硬编码数字。迁移到 ActivityResultContracts 后,每个 contract 需要独立的 launcher 实例,理论上应该拆成多个字段。但一个 Activity 里放二十几个 launcher 字段看起来很蠢,我尝试用 Map 管理:
private val launchers = mutableMapOf<String, ActivityResultLauncher<*>>()
fun <I, O> register(key: String, contract: ActivityResultContract<I, O>, callback: ActivityResultCallback<O>) {
launchers[key] = registerForActivityResult(contract, callback)
}编译通过,运行崩溃。原因是 registerForActivityResult 返回的 ActivityResultLauncher 有泛型约束,Map 用 * 投影后,调用 launch 时类型擦除导致 ClassCastException。更深层的问题是,ActivityResultRegistry 内部用 AtomicInteger 生成随机 requestCode,这个值在注册时确定,存在 mKeyToRc 的 Map 里。如果注册时机不同,requestCode 的分配顺序就不确定,调试时看到 0x10001 这样的数字,完全无法和源码对应。
我最终妥协的方案是:每个 launcher 作为 lateinit var 字段,在 onCreate 里批量初始化,但保留一个中央回调分发器来处理公共逻辑(比如结果码检查、埋点上报)。这实际上没有减少代码量,只是把 onActivityResult 的 when 分支拆成了多个 lambda 定义,分散在 onCreate 的几百行里。
权限请求的特殊陷阱
ActivityResultContracts.RequestPermission() 和 RequestMultiplePermissions() 是迁移的重灾区。旧代码里权限请求经常和 onRequestPermissionsResult 混在一起,手动处理 shouldShowRequestPermissionRationale 的状态机。新 API 的回调只返回 Boolean 或 Map<String, Boolean>,丢失了 PERMISSION_DENIED 和 PERMISSION_DENIED_APP_OP 的区分。
Android 11 引入的 REQUEST_INSTALL_PACKAGES 权限有个特殊行为:用户拒绝两次后,系统不再弹窗,直接返回 false。旧代码里我们通过 shouldShowRequestPermissionRationale 判断是"永久拒绝"还是"临时拒绝",引导用户去设置页。但 ActivityResultContracts 的回调拿不到这个信息,必须额外调用 ActivityCompat.shouldShowRequestPermissionRationale 来补状态。
更隐蔽的是 RequestMultiplePermissions 的返回 Map。如果请求的两个权限中,一个已经授予,一个需要弹窗,用户点击"拒绝且不再询问",返回的 Map 里两个 key 都是 false。但那个已经授予的权限实际状态没变,这个 false 是误导性的。我在 AndroidX Activity 1.7.2 的源码里找到原因:
// ActivityResultContracts.java
public Map<String, Boolean> parseResult(int resultCode, @Nullable Intent intent) {
// ...
for (String permission : permissions) {
grantResults.put(permission,
ActivityCompat.checkSelfPermission(context, permission)
== PackageManager.PERMISSION_GRANTED);
}
}它用 checkSelfPermission 重新查询,而不是直接读取系统返回的 grantResults 数组。这意味着已经授予的权限如果因为某种原因(比如被其他应用通过 adb 撤销)状态变化,Map 会反映最新状态而非弹窗结果。这个行为在文档里没提,我调试了两天才确认不是我们的业务逻辑 bug。
自定义 Contract 的编译期折磨
官方提供的预置 Contract 只有十几个,覆盖不了业务需求。比如我们需要一个带自定义动画的页面跳转,同时返回序列化对象。旧方案是 startActivityForResult 配 Intent 里的 Bundle,新方案理论上应该写自定义 ActivityResultContract。
class CustomContract : ActivityResultContract<<CustomInput, CustomOutput>() {
override fun createIntent(context: Context, input: CustomInput): Intent {
return Intent(context, TargetActivity::class.java).apply {
putExtra("key", input)
}
}
override fun parseResult(resultCode: Int, intent: Intent?): CustomOutput? {
return intent?.getParcelableExtra("result")
}
}看起来简单,但 CustomOutput 是 Parcelable,Kotlin 的 @Parcelize 在 1.5.x 版本有已知问题:如果数据类包含 sealed class 成员,编译生成的 CREATOR 可能缺失。我们的项目 Kotlin 版本是 1.7.20,这个问题已经修复,但 CI 环境用的 Gradle 缓存偶尔命中旧版本的 klib,导致运行时 ClassNotFoundException: CustomOutput$CREATOR。
更头疼的是 createIntent 的签名要求返回非空 Intent,但某些业务场景需要根据运行时状态决定是否跳转。旧代码里可以检查条件后直接 return,不调用 startActivityForResult。新 API 里必须预注册 launcher,无法跳过。我看到的 workaround 是在 createIntent 里返回一个标记为"取消"的 Intent,然后在 parseResult 里识别这个标记返回 null,但这让 contract 的语义变得不纯粹。
还有一个坑是 ActivityResultContract 的泛型参数在 Java 互调用时的类型推断。项目里有部分模块还是 Java,registerForActivityResult 的 lambda 参数在 Java 8 下需要显式类型标注,否则编译器推断为 Object,运行时 ClassCastException。这个不是 Android 的问题,但增加了迁移成本,特别是混合语言项目。
进程死亡与状态恢复的暗面
ActivityResultRegistry 声称自动处理配置变更和进程死亡,但"自动"有边界。它内部用 SavedStateHandle 存储 pending 的 requestCode,在 onSaveInstanceState 时序列化。如果启动的第三方应用(比如系统相机)也触发进程重启,返回时 ActivityResultRegistry 能从 Bundle 恢复状态,重新派发回调。
但我在 Pixel 6 Android 13 上测试出一个 race condition:快速双击启动相机的按钮,会触发两次 launch()。第二次调用时,第一次的启动还没完成,ActivityResultRegistry 的 mLaunchedKeys 集合里已有该 launcher 的 key,直接抛 IllegalStateException: Attempting to launch an unregistered ActivityResultLauncher。这个检查在源码里:
public final void launch(I input, ActivityOptionsCompat options) {
if (!mLaunchedKeys.contains(mKey)) {
// ... 正常流程
} else {
throw new IllegalStateException(...);
}
}防重复启动的设计意图可以理解,但旧 API 的 startActivityForResult 不会崩溃,只是系统忽略第二次调用。新 API 的严格性导致 UI 层必须自己做防抖动,而旧代码没这个需求。更麻烦的是,这个崩溃发生在 launch() 调用时,不在注册时,堆栈信息指向业务代码而非框架,容易误报为业务 bug。
另一个恢复相关的问题是:如果回调 lambda 捕获了 Activity 引用,进程恢复后 lambda 被重新执行,但 Activity 可能是新的实例。这听起来是基本的内存泄漏常识,但旧 onActivityResult 是实例方法,天然绑定当前 Activity。新 API 的 lambda 如果写成:
val launcher = registerForActivityResult(contract) { result ->
findViewById<TextView>(R.id.text).text = result.toString()
}进程恢复后 findViewById 可能返回 null(如果布局还没 inflate),或者更隐蔽地操作了已销毁的 View 层级。我加了一层 lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) 检查,但这又让回调代码膨胀。
与现有架构的冲突
项目里有个自研的页面路由框架,用 APT 生成路由表,核心 API 类似:
Router.from(this)
.to("camera")
.requestCode(REQUEST_CAMERA)
.forResult { result -> ... }
.go()这个 forResult 内部就是 startActivityForResult,回调是动态设置的。迁移到 ActivityResultContracts 后,路由框架的设计前提被推翻:回调必须在注册时确定,不能延迟到 go() 调用时。
我考虑过两种重构方案。方案 A:让路由框架预注册所有可能的 launcher,但路由目标有几百个,不可能每个都注册。方案 B:把 forResult 的回调改为全局注册,通过 requestKey 分发,但这回到了旧 onActivityResult 的模式,只是用 ActivityResultRegistry 的 key 替代了整数 requestCode。
最终方案是折中:路由框架内部维护一个 ActivityResultLauncher<<Intent>,用 StartActivityForResult contract,回调里根据 Intent 的 extra 里的 requestKey 做二次分发。这实际上是用新 API 模拟旧 API,完全没用到"type-safe"的优势,只是为了消除 deprecation warning。
另一个冲突是测试框架。项目用 Robolectric 4.10.3 做单元测试,旧代码测试 onActivityResult 直接调用方法即可。ActivityResultLauncher 需要模拟 ActivityResultRegistry,但 Robolectric 的 ShadowActivity 还没完全支持新 API。我试过用 ActivityScenario 的 recreate() 测试配置变更,但 ActivityResultRegistry 的 SavedStateHandle 在测试环境下有时不触发保存,导致恢复路径覆盖不到。最后部分测试降级为集成测试,用 Espresso 跑真机,CI 时间从 12 分钟涨到 35 分钟。
版本碎片化的现实
AndroidX Activity 1.2.0 引入 ActivityResultContracts,但不同版本的行为有微妙差异。1.4.0 之前,StartIntentSenderForResult 的 contract 有 bug,返回的 ActivityResult 里 resultCode 永远是 0。1.5.0 改了 RequestMultiplePermissions 的返回 Map 的 key 顺序,之前是请求顺序,之后是字母顺序,依赖顺序的测试会挂。
项目里有个模块因为依赖传递,实际用的是 1.3.1,其他模块是 1.7.2。Gradle 的依赖解析选了 1.7.2,但那个模块的代码是按 1.3.1 的行为写的。排查时我用 dependencyInsight 确认版本,又用 adb shell dumpsys package 确认 APK 里实际打包的 classes.dex,发现 AndroidX 的版本一致性检查在编译期不报错,运行时行为差异才能暴露。
还有一个边缘版本:Android 13(API 33)的 photo picker 引入了 ActivityResultContracts.PickVisualMedia(),这是 1.6.0 新增的 contract。但项目支持的最低版本是 API 21,需要用 PickVisualMedia 的 fallback 逻辑,即判断系统是否支持 photo picker,不支持则回退到 ACTION_OPEN_DOCUMENT。这个 fallback 在 1.6.0-alpha01 和 1.6.0 正式版的行为不同,alpha 版本在某些国产 ROM 上触发 SecurityException,正式版加了 try-catch。如果依赖版本没对齐到正式版,线上会崩溃。
性能数据的意外发现
迁移过程中我顺手测了下两种 API 的性能差异。测试设备是 Pixel 5 Android 12,用 Trace.beginSection 和 Trace.endSection 在 Systrace 里标记。
启动一个空 Activity 并立即返回,startActivityForResult 从调用到 onActivityResult 回调的平均耗时是 87ms。ActivityResultLauncher.launch() 的同等路径是 94ms。差距不大,但 registerForActivityResult 本身的耗时在 onCreate 里累积:注册 10 个 launcher 约 12ms,注册 50 个约 68ms。ActivityResultRegistry 内部用 ArrayMap 存储回调,每次注册触发 Lifecycle.addObserver,有额外的同步开销。
更值得关注的是内存。ActivityResultRegistry 为每个 launcher 保留 ActivityResultCallback 的强引用,如果 callback 是匿名内部类,会隐式持有外部 Activity。旧 API 的 onActivityResult 是实例方法,不存在额外的引用链。我用 Android Studio Profiler 对比,迁移后的 Activity 在 onDestroy 后延迟回收的比例从 3% 升到 11%,原因是 ActivityResultRegistry 的 mKeyToCallback 没及时清理。这个在 AndroidX Activity 1.8.0-alpha 里有修复,但正式版还没发布,我们不敢用 alpha。
那个没文档化的 `ActivityResultRegistry`
深入源码后,我发现 ActivityResultRegistry 的设计比表面复杂。它是 ComponentActivity 的一个字段,但可以通过 activity.activityResultRegistry 直接访问,也可以自定义 ActivityResultRegistry 传给 registerForActivityResult 的重载方法。这个重载在文档里几乎没提,但很有用:如果需要在 Application 级别共享某个跨页面的回调,可以构造一个全局 Registry。
但自定义 Registry 需要处理 SavedStateHandle 的持久化,否则进程恢复时状态丢失。我尝试用一个单例 Registry 管理登录页面的结果回调,登录完成后所有页面都能收到通知。这避免了每个页面单独注册,但引入了生命周期错乱:Registry 的 Lifecycle 是 ProcessLifecycleOwner,它的 ON_START 和 ON_STOP 和具体 Activity 不同步。如果用户在登录页按 Home 键,ProcessLifecycle 还在 STARTED 状态,但登录 Activity 已经 STOPPED,此时派发回调到已停止的 Activity 会导致 IllegalStateException。
这个场景没有官方指导,我在 StackOverflow 和 GitHub issue 里搜到的答案互相矛盾。最终放弃全局 Registry,回到每个 Activity 独立注册的老路。
迁移后的代码量变化
用 cloc 统计了迁移前后的 Kotlin 代码行数。旧方案的核心文件:BaseActivity.kt 340 行,PermissionHelper.kt 280 行,分散的 onActivityResult 覆盖约 1200 行。新方案:BaseActivity.kt 膨胀到 520 行(主要是 launcher 注册和分发逻辑),新增 ActivityResultContracts 的自定义 contract 文件 6 个共 400 行,业务层的回调 lambda 约 1500 行。总代码量从 1820 行涨到 2420 行,增幅 33%。
这还没算测试代码的膨胀。旧测试 80 行,新测试 240 行,因为需要构造 ActivityResultRegistry 的 mock 或 fake 实现。Google 的 androidx.activity:activity-testing 库提供了 ActivityResultScenario,但 1.7.2 版本还是 beta,API 不稳定,我们没敢引入。
最后的妥协
三周后,项目里的 startActivityForResult 调用从 847 处降到 0,但 @Suppress("DEPRECATION") 加了 12 处。这些是无法迁移的场景:
Fragment 在 onAttach 里调用 startActivityForResult,SDK 没更新,我们只能在外层包一层不触发 lint 的 wrapper。ActivityResultRegistry,因为 ComponentActivity 的字段不是 public API。ActivityResultLauncher。ActivityResultContracts 在设计上确实解决了旧 API 的一些问题:类型安全、生命周期绑定、配置变更自动恢复。但这些优势在存量项目里的兑现成本很高,特别是当项目有复杂的页面路由、混合语言、大量第三方依赖时。Google 的 deprecation 策略没有提供渐进式迁移路径,lint 的强制红线和 CI 的阻断配置把技术债务变成了紧急任务。
我现在对新项目的建议是:如果从头开始,用 ActivityResultContracts 没问题,但预见到 launcher 数量会膨胀的话,提前设计好注册管理策略。如果是存量项目,评估下 lint 配置能不能放宽,@Suppress 不是耻辱,强行迁移的隐性成本可能更高。那个 33% 的代码量增幅和 CI 时间翻三倍,会在未来每个迭代里持续产生利息。