阅读 1167

优雅地封装 Activity Result API,完美地替代 startActivityForResult()

前言

之前写了两篇文章讲了 ViewBinding 的封装,这是 Jetpack 的一个组件,用于替代 findViewById、ButterKnife、KAE。不过用到了些 Kotlin 相对进阶点的用法,可能有些不太熟悉 Kotlin 的小伙伴看不太懂封装的代码。

所以这次来讲些简单一点的封装,来封装 Jetpack 的另一个组件——Activity Result API。这是官方用于替代 startActivityForResult()onActivityResult() 的工具,能替代但是不够好用,有些小伙伴看了后还是选择写 startActivityForResult()。需要封装优化一下用法,但是推出大半年了个人没看到比较好用的封装。最初有很多人会用拓展函数进行封装,而在 activity-ktx:1.2.0-beta02 版本之后,调用注册方法的时机必须在 onStart() 之前,原来的拓展函数就不适用了,在这之后就没看到有人封装了。

个人对 Activity Result API 的封装思考了很久,已经尽量做到在 Kotlin 和 Java 都足够地好用,可以完美替代 startActivityForResult() 了。下面带着大家一起来封装 Activity Result API。

基础用法

首先要了解 Activity Result API 的用法。

添加依赖:

dependencies {
    implementation "androidx.activity:activity-ktx:1.2.4"
}
复制代码

在 ComponentActivity 或 Fragment 中调用 Activity Result API 提供的 registerForActivityResult() 方法注册结果回调(在 onStart() 之前调用)。该方法接收 ActivityResultContract 和 ActivityResultCallback 参数,返回可以启动另一个 activity 的 ActivityResultLauncher 对象。

val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
  // Handle the returned Uri
}
复制代码

ActivityResultContract 协议类定义生成结果所需的输入类型以及结果的输出类型,Activity Result API 已经提供了很多默认的协议类,方便大家实现请求权限、拍照等常见操作。

只是注册回调并不会启动另一个 activity ,还要调用 ActivityResultLauncher#launch() 方法才会启动。传入协议类定义的输入参数,当用户完成后续 activity 的操作并返回时,将执行 ActivityResultCallback 中的 onActivityResult()回调方法。

getContent.launch("image/*")
复制代码

完整的使用代码:

val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
  // Handle the returned Uri
}

override fun onCreate(savedInstanceState: Bundle?) {
  // ...
  selectButton.setOnClickListener {
    getContent.launch("image/*")
  }
}
复制代码

ActivityResultContracts 提供了许多默认的协议类:

协议类作用
StartActivityForResult()通用协议
TakePicturePreview()拍照预览,返回 Bitmap
TakePicture()拍照,返回 Uri
TakeVideo()录像,返回 Uri
GetContent()获取单个内容文件
GetMultipleContents()获取多个内容文件
RequestPermission()请求单个权限
RequestMultiplePermissions()请求多个权限
CreateDocument()创建文档
OpenDocument()打开单个文档
OpenMultipleDocuments()打开多个文档
OpenDocumentTree()打开文档目录
PickContact()选择联系人

我们还可以自定义协议类,继承 ActivityResultContract,定义输入和输出类。如果不需要任何输入,可使用 Void 或 Unit 作为输入类型。需要实现两个方法,用于创建与 startActivityForResult() 配合使用的 Intent 和解析输出的结果。

class PickRingtone : ActivityResultContract<Int, Uri?>() {
  override fun createIntent(context: Context, ringtoneType: Int) =
    Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
      putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, ringtoneType)
    }

  override fun parseResult(resultCode: Int, result: Intent?) : Uri? {
    if (resultCode != Activity.RESULT_OK) {
      return null
    }
    return result?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
  }
}
复制代码

自定义协议类实现后,就能调用 registerForActivityResult()launch() 方法进行使用。

val pickRingtone = registerForActivityResult(PickRingtone()) { uri: Uri? ->
  // Handle the returned Uri
}
复制代码
pickRingtone.launch(ringtoneType)
复制代码

不想自定义协议类的话,可以使用通用的协议 ActivityResultContracts.StartActivityForResult(),实现类似于之前 startActivityForResult() 的功能。

val startForResult = registerForActivityResult(StartActivityForResult()) { result: ActivityResult ->
  if (result.resultCode == Activity.RESULT_OK) {
      val intent = result.intent
      // Handle the Intent
  }
}
复制代码
startForResult.launch(Intent(this, InputTextActivity::class.java))
复制代码

封装思路

为什么要封装?

看完上面的用法,不知道大家会不会和我初次了解的时候一样,感觉比原来复杂很多。

主要是引入的新概念比较多,原来只需要了解 startActivityForResult()onActivityResult() 的用法,现在要了解一大堆类是做什么的,学习成本高了不少。

用法也有些奇怪,比如官方示例用注册方法得到一个叫 getContent 对象,这更像是函数的命名,还要用这个对象去调用 launch() 方法,代码阅读起来总感觉怪怪的。

而且有个地方个人觉得不是很好,callback 居然在 registerForActivityResult() 方法里传。个人觉得 callback 在 launch() 方法里传更符合习惯,逻辑也更加连贯,代码阅读性更好。最好改成下面的用法,启动后就接着处理结果的逻辑。

getContent.launch("image/*") { uri: Uri? ->
  // Handle the returned Uri
}
复制代码

所以还是有必要对 Activity Result API 进行封装的。

怎么封装?

首先是修改 callback 传参的位置,实现思路也比较简单,重载 launch() 方法加一个 callback 参数,用个变量缓存起来。在回调的时候拿缓存的 callback 对象去执行。

private var callback: ActivityResultCallback<O>? = null

fun launch(input: I?, callback: ActivityResultCallback<O>) {
  this.callback = callback
  launcher.launch(input)
}
复制代码

由于需要缓存 callback 对象,还要写一个类来持有该缓存变量。

有一个不好处理的问题是 registerForActivityResult() 需要的 onStart() 之前调用。可以通过 lifecycle 在 onCreate() 的时候自动注册,但是个人思考了好久并没有想到更优的实现方式。就是获取 lifecycleOwner 观察声明周期自动注册,也是需要在 onStart() 之前调用,那为什么不直接执行注册方法呢?所以个人改变了思路,不纠结于自动注册,而是简化注册的代码。

前面说了需要再写一个类缓存 callback 对象,使用一个类的时候有个方法基本会用到,就是构造函数。我们可以在创建对象的时候进行注册。

注册方法需要 callback 和协议类对象两个参数,callback 是从 launch() 方法得到,而协议类对象就需要传了。这样用起来个人觉得还不够友好,综合考虑后决定用继承的方式把协议类对象给“隐藏”了。

最终得到以下的基类。

public class BaseActivityResultLauncher<I, O> {

  private final ActivityResultLauncher<I> launcher;
  private ActivityResultCallback<O> callback;

  public BaseActivityResultLauncher(ActivityResultCaller caller, ActivityResultContract<I, O> contract) {
    launcher = caller.registerForActivityResult(contract, (result) -> {
      if (callback != null) {
        callback.onActivityResult(result);
        callback = null;
      }
    });
  }

  public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback<O> callback) {
    this.callback = callback;
    launcher.launch(input);
  }
}
复制代码

改用了 Java 代码来实现,返回的结果可以判空也可以不判空,比如返回数组的时候一定不为空,只是数组大小为 0 。用 Kotlin 实现的话要写两个不同名的方法来应对这个情况,使用起来并不是很方便。

这是多增加一个封装的步骤来简化后续的使用,原本只是继承 ActivityResultContract 实现协议类,现在还需要再写一个启动器类继承 BaseActivityResultLauncher

比如用前面获取图片的示例,我们再封装一个 GetContentLauncher 类。

class GetContentLauncher(caller: ActivityResultCaller) :
  BaseActivityResultLauncher<String, Uri>(caller, GetContent())
复制代码

只需这么简单的继承封装,后续使用就更加简洁易用了。

val getContentLauncher = GetContentLauncher(this)

override fun onCreate(savedInstanceState: Bundle?) {
  // ...
  selectButton.setOnClickListener {
    getContentLauncher.launch("image/*") { uri: Uri? ->
      // Handle the returned Uri
    }
  }
}
复制代码

再封装一个 Launcher 类的好处是,能更方便地重载 launch() 方法,比如在类里增加一个方法在获取图片之前会先授权读取权限。如果改用 Kotlin 拓展函数来实现,在 Java 会更加难用。Launcher 类能对 Java 用法进行兼顾。

最后总结一下,对比原本 Activity Result API 的用法,改善了什么问题:

  • 简化冗长的注册代码,改成简单地创建一个对象;
  • 改善对象的命名,比如官方示例命名为 getContent 对象就很奇怪,这通常是函数的命名。优化后很自然地用类名来命名为 getContentLauncher,使用一个启动器对象调用 launch() 方法会更加合理;
  • 改变回调的位置,使其更加符合使用习惯,逻辑更加连贯,代码阅读性更好;
  • 输入参数和输出参数不会限制为一个对象,可以重载方法简化用法;
  • 能更方便地整合多个启动器的功能,比如获取读取权限后再跳转相册选择图片;

最终用法

由于 Activity Result API 已有很多的协议类,如果每一个协议都去封装一个启动器类会有点麻烦,所以个人已经写好一个库 ActivityResultLauncher 方便大家使用。还新增和完善了一些功能,有以下特点:

  • 完美替代 startActivityForResult()
  • 支持 Kotlin 和 Java 用法
  • 支持请求权限
  • 支持拍照(已适配 Android 10)
  • 支持录像(已适配 Android 10)
  • 支持选择图片或视频
  • 支持裁剪图片(已适配 Android11)
  • 支持打开蓝牙
  • 支持打开定位
  • 支持使用存储访问框架 SAF
  • 支持选择联系人

个人写了个 Demo 给大家来演示有什么功能,完整的代码在 Github 里。

demo-qr-code.png

screenshot

下面来介绍 Kotlin 的用法,Java 的用法可以查看 Wiki 文档

在根目录的 build.gradle 添加:

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}
复制代码

添加依赖:

dependencies {
    implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.1'
}
复制代码

用法也只有简单的两步:

第一步,在 ComponentActivityFragment 创建对应的对象,需要注意创建对象的时机要在 onStart() 之前。例如创建通用的启动器:

private val startActivityLauncher = StartActivityLauncher(this)
复制代码

提供以下默认的启动器类:

启动器作用
StartActivityLauncher完美替代 startActivityForResult()
TakePicturePreviewLauncher拍照预览,返回 bitmap
TakePictureLauncher拍照,返回 uri,已适配 Android 10
TakeVideoLauncher录像,返回 uri,已适配 Android 10
PickContentLauncher, GetContentLauncher选择单个图片或视频
GetMultipleContentsLauncher选择多个图片或视频
CropPictureLauncher裁剪图片,已适配 Android 11
RequestPermissionLauncher请求单个权限
RequestMultiplePermissionsLauncher请求多个权限
AppDetailsSettingsLauncher打开系统设置的 App 详情页
EnableBluetoothLauncher打开蓝牙
EnableLocationLauncher打开定位
CreateDocumentLauncher创建文档
OpenDocumentLauncher打开单个文档
OpenMultipleDocumentsLauncher打开多个文档
OpenDocumentTreeLauncher访问目录内容
PickContactLauncher选择联系人
StartIntentSenderLauncher替代 startIntentSender()

第二步,调用启动器对象的 launch() 方法。

比如跳转一个输入文字的页面,点击保存按钮回调结果。

我们用 StartActivityLauncher 替换掉原来 startActivityForResult() 的写法。

val intent = Intent(this, InputTextActivity::class.java)
intent.putExtra(KEY_NAME, "nickname")
startActivityLauncher.launch(intent) { activityResult ->
  if (activityResult.resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
  }
}
复制代码

为了方便使用,有些启动器会增加一些更易用的 launch() 方法。比如这个例子能改成下面更简洁的写法。

startActivityLauncher.launch<InputTextActivity>(KEY_NAME to "nickname") { resultCode, data ->
  if (resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
  }
}
复制代码

由于输入文字页面可能有多个地方需要跳转复用,我们可以用前面的封装思路,自定义实现一个 InputTextLauncher 类,进一步简化调用的代码,只关心输入值和输出值,不用再处理跳转和解析过程。

inputTextLauncher.launch("nickname") { value ->
  if (value != null) {
    toast(value)
  }
}
复制代码

通常要对返回值进行判断,因为可能会有取消操作,要判断是不是被取消了。比如返回的 Boolean 要为 true,返回的 Uri 不为 null,返回的数组不为空数组等。

还有一些常用的功能,比如调用系统相机拍照,保存到外置存储的应用缓存目录。

takePictureLauncher.launch { uri ->
  if (uri != null) {
    // 拍照成功,上传或取消等操作后建议把缓存文件删除
  }
}
复制代码

或者拍照保存到系统相册,已适配 Android 10。

takePictureLauncher.launchForMediaImage { uri ->
  if (uri != null) {
    // 拍照成功
  }
}
复制代码

调用系统相册选择图片,增加了申请读取权限的操作。

pickContentLauncher.launchForImage(
  onActivityResult = { uri ->
    if (uri != null) {
      // 处理 uri
    }
  },
  onPermissionDenied = {
    // 拒绝了读取权限且不再询问,可引导用户到设置里授权该权限
  },
  onExplainRequestPermission = {
    // 拒绝了一次读取权限,可弹框解释为什么要获取该权限
  }
)
复制代码

能更方便地使用存储访问框架 SAF,在 Android 10 或以上访问共享储存空间的文档文件会用到。

个人也新增了些功能,比如裁剪图片,通常上传头像要裁剪成 1:1 比例,已适配 Android 11。

cropPictureLauncher.launch(inputUri) { uri, file ->
  if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
  }
}
复制代码

还有开启蓝牙功能,能更容易地开启蓝牙和确保蓝牙功能是可用的(需要授权定位权限和确保定位已打开)。

enableBluetoothLauncher.launchAndEnableLocation(
  "为保证蓝牙正常使用,请开启定位",  // 已授权权限但未开启定位,会跳转对应设置页面,并吐司该字符串
  onLocationEnabled= { enabled ->
    if (enabled) {
      // 已开启了蓝牙,并且授权了位置权限和打开了定位
    }
  },
  onPermissionDenied = {
    // 拒绝了位置权限且不再询问,可引导用户到设置里授权该权限
  },
  onExplainRequestPermission = {
    // 拒绝了一次位置权限,可弹框解释为什么要获取该权限
  }
)
复制代码

更多的用法请查看 Wiki 文档

原本 Activity Result API 已经有很多默认的协议类,都封装了对应的启动器类。大家可能不会用到所有类,不过开了混淆会自动移除没有使用的类。

后续还会支持用 Kotlin Flow 回调数据,能更方便地写流式编程的代码。用法上我还在斟酌,有兴趣的可以关注一下。如果有其它使用场景或者别的想法可以在 Github 提 issue,我会继续完善的。

彩蛋

个人之前封装过一个 startActivityForResult() 拓展函数,可以直接在后面写回调逻辑。

startActivityForResult(intent, requestCode) { resultCode, data ->
  // Handle result
}
复制代码

下面是实现的代码,使用一个 Fragment 来分发 onActivityResult 的结果。代码量不多,逻辑应该比较清晰,感兴趣的可以了解一下,Activity Result API 的实现原理应该也是类似的。

inline fun FragmentActivity.startActivityForResult(
  intent: Intent,
  requestCode: Int,
  noinline callback: (resultCode: Int, data: Intent?) -> Unit
) =
  DispatchResultFragment.getInstance(this).startActivityForResult(intent, requestCode, callback)

class DispatchResultFragment : Fragment() {
  private val callbacks = SparseArray<(resultCode: Int, data: Intent?) -> Unit>()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    retainInstance = true
  }

  fun startActivityForResult(
    intent: Intent,
    requestCode: Int,
    callback: (resultCode: Int, data: Intent?) -> Unit
  ) {
    callbacks.put(requestCode, callback)
    startActivityForResult(intent, requestCode)
  }

  override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    val callback = callbacks.get(requestCode)
    if (callback != null) {
      callback.invoke(resultCode, data)
      callbacks.remove(requestCode)
    }
  }

  companion object {
    private const val TAG = "dispatch_result"

    fun getInstance(activity: FragmentActivity): DispatchResultFragment =
      activity.run {
        val fragmentManager = supportFragmentManager
        var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
        if (fragment == null) {
          fragment = DispatchResultFragment()
          fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
          fragmentManager.executePendingTransactions()
        }
        fragment
      }
  }
}
复制代码

如果觉得 Activity Result API 比较复杂,也可以拷贝这个去用。不过 requestCode 处理得不够好,而且很多功能需要自己额外去实现,用起来可能没那么方便。

往期讲解封装的文章

总结

本文讲了 Activity Result API 的基础用法,虽然能替代 startActivityForResult()onActivityResult(),但没有做到足够地好用,有些人还宁愿继续使用 startActivityForResult()。之后分享了个人的封装思路,介绍了个人封装的库 ActivityResultLauncher,使 Activity Result API 更加简洁易用,能完美地替代 startActivityForResult()

如果您觉得有帮助的话,希望能点个 star 支持一下哟 ~ 我后面会分享更多封装相关的文章给大家。

文章分类
Android
文章标签