注册

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

前言


Activity Result API。这是官方用于替代 startActivityForResult()onActivityResult() 的。虽然出了有大半年了,但是个人到现在没看到比较好用的封装。最初大多数人会用拓展函数进行封装,而在 activity-ktx:1.2.0-beta02 版本之后,调用注册方法的时机必须在 onStart() 之前,原来的拓展函数就不适用了,在这之后就没看到有人进行封装了。


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


基础用法


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


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


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

只是注册回调并不会启动另一个 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 提供了许多默认的协议类:


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


我们还可以自定义协议类,继承 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)
  }
}

自定义协议类实现后,就能调用注册方法和 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? = 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 launcher;
 private ActivityResultCallback callback;

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

 public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback callback) {
   this.callback = callback;
   launcher.launch(input);
}
}

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


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


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


class GetContentLauncher(caller: ActivityResultCaller) :
BaseActivityResultLauncher(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)
  • 支持裁剪图片(已适配 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.0'
}

用法也只有简单的两步:


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


private val startActivityLauncher = StartActivityLauncher(this)

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

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


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


比如跳转一个输入文字的页面,点击保存按钮回调结果。我们替换掉原来 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(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,返回的数组不为空数组等。


还有一些常用的功能,比如调用系统相机拍照和跳转系统相册选择图片,已适配 Android 10,可以直接得到 uri 来加载图片和用 file 进行上传等操作。


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

pickContentLauncher.launchForImage(
onActivityResult = { uri, file ->
if (uri != null && file != null) {
// 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
}
},
onPermissionDenied = {
// 拒绝了读取权限且不再询问,可引导用户到设置里授权该权限
},
onExplainRequestPermission = {
// 拒绝了一次读取权限,可弹框解释为什么要获取该权限
}
)

个人也新增了些功能,比如裁剪图片,通常上传头像要裁剪成 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 已经有很多默认的协议类,都封装了对应的启动器类。大家可能不会用到所有类,开了混淆会自动移除没使用到的类。


彩蛋


个人之前封装过一个 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 处理得不够好,而且很多功能需要自己额外去实现,用起来可能没那么方便。



0 个评论

要回复文章请先登录注册