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

优雅地封装 Activity Result API,完美地替代 startActivityForResult()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 用法进行兼顾。

我们还能支持 Kotlin 协程, 只需再写一个 BaseActivityResultLauncher 的拓展函数,在开头使用 suspend 修饰,使用 suspendCancellableCoroutinelaunch() 方法转为协程的挂起方法。

suspend fun <I, O> BaseActivityResultLauncher<I, O>.launchForResult(input: I?) =
  suspendCancellableCoroutine<O> { continuation ->
    launch(input) {
      if (it != null) {
        continuation.resume(it)
      } else {
        continuation.cancel()
      }
    }
  }

然后就能在协程作用域内,使用类似同步的方式写异步代码。

lifecycleScope.launch {
  val uri = getContentLauncher.launchForResult("image/*")
  // Handle the returned Uri
}

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

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

最终用法

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

  • 完美替代 startActivityForResult()
  • 支持 Kotlin 和 Java 用法
  • 支持协程用法
  • 支持拍照(已适配 Android 10)
  • 支持录像(已适配 Android 10)
  • 支持选择图片或视频
  • 支持裁剪图片(已适配 Android 11)
  • 支持请求权限
  • 支持打开蓝牙
  • 支持打开定位
  • 支持使用存储访问框架 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.1.0
}

用法也只有简单的两步:

第一步,在 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) {
    activityResult.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 ->
  if (uri != null) {
    // 裁剪成功
  }
}

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

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

对 Kotlin 协程熟悉的小伙伴还可以选择协程的用法,使用类似同步的方式写异步代码。在协程作用域内调用 launchForResult() 得到返回的结果,或者调用 launchForFlow() 得到数据流进行流式编程。

下面给出一段运用 Kotlin Flow 流式编程的示例。

// 第一步,请求读取 SD 卡权限
requestPermissionLauncher.launchForFlow(Manifest.permission.READ_EXTERNAL_STORAGE,
  onDenied = {
    showDialog(...)
  },
  onExplainRequest = {
    showDialog(...)
  })
  .transform { emit(pickContentLauncher.launchForImageResult()) } // 第二步,选择相册图片
  .transform { emit(cropPictureLauncher.launchForResult(it)) } // 第三步,裁剪图片
  .collect { uri ->
    // 第四步,上传头像
  }

多个操作会产生多个回调,使用流式编程能够很好地避免回调地狱,减少代码的层级,代码可读性会更好。

更多的用法请查看 Wiki 文档 。如果有其它使用场景或者别的想法可以在 Github 提 issue,我会继续完善的。

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

彩蛋

个人之前封装过一个 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 {
      val fragmentManager = activity.supportFragmentManager
      var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
      if (fragment == null) {
        fragment = DispatchResultFragment()
        fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
        fragmentManager.executePendingTransactions()
      }
      return fragment
    }
  }
}

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

往期讲解封装的文章

总结

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

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

今天的文章优雅地封装 Activity Result API,完美地替代 startActivityForResult()分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/13614.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注