如何更好地进行 Android 组件化开发(三)ActivityResult 篇

·  阅读 866

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

如果用过 ARouter 做组件化开发,可能会遇到过在 Fragment 用路由跳转到带结果的页面后不回调 onActivityResult() 的问题。原因是 ARouter 路由跳转时调用的是 Activity 的 startActivityForResult(),网上的解决方案是想办法改成调用 Fragment 的 startActivityForResult()。

不过现在 startActivityForResult() 已经弃用了,官方推荐用 Activity Result API 来替代。而 ActivityResult API 和路由的用法都比较特别,尝试过会发现不好适配,并且很多路由框架也没适配。个人摸索了一下如何给路由框架适配 ActivityResult API,发现还是有些办法适的。只是基于路由常用的 API 不好实现,可能要了解下源码找下实现思路。

下面就给大家分享一些路由框架适配 ActivityResult API 的方案。

ActivityResult 基础用法

先了解一下 ActivityResult API 怎么使用,首先要添加依赖:

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

在 ComponentActivity 或 Fragment 中调用 ActivityResult API 提供的 registerForActivityResult() 函数注册结果回调 (要在 onStart() 之前),该函数返回一个可以跳转页面的 ActivityResultLauncher 对象。

private val launcher = registerForActivityResult(StartActivityForResult()) {
  if (it.resultCode == RESULT_OK) {
    val intent = it.data
    // 处理回调结果
  }
}
复制代码

调用 ActivityResultLauncher 的 launch() 函数就能跳转页面。

launcher.launch(Intent(this, SignInActivity::class.java))
复制代码

这就能替代 startActivityForResult() 和 onActivityResult() 了,但是官方并不是希望大家这么用。registerForActivityResult() 函数有个 ActivityResultContract 类型的参数,顾名思义这是个协议,能决定输入参数的类型和回调结果的类型。

前面的 StartActivityForResult 协议类是在 launch() 时是输入了一个 intent,后面输出 resultCode 和 data,这是一个通用的协议。官方还实现了很多协议类,比如 GetContent 获取手机的内容,输入图片、视频、音频等 mime 类型,会跳转系统的内容选择器,选择内容后回调一个 uri。

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

override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    
    selectButton.setOnClickListener {
        // Pass in the mime type you'd like to allow the user to select
        // as the input
        getContent.launch("image/*")
    }
}
复制代码

官方还提供了申请权限、拍照、录视频等常用的协议类,我们也能自定义所需的协议类。比如常见的跳转文字输入页面,我们可以在启动时输入一个 name 去更改标题,回调函数输出编辑框的内容。下面是协议的实现,要继承 ActivityResultContract:

class InputTextResultContract : ActivityResultContract<String, String>() {
  override fun createIntent(context: Context, input: String?) =
    Intent(context, InputTextActivity::class.java)
      .putExtra(KEY_NAME, input)
    
  override fun parseResult(resultCode: Int, intent: Intent?): String? =
    if (resultCode == Activity.RESULT_OK) intent?.getStringExtra(KEY_VALUE) else null
}
复制代码

ActivityResultContract 类有两个泛型需要声明,表示 launch() 参数的类型和回调结果的类型。还有两个函数需要重写,一个创建 intent,一个解析结果,都很容易理解就不多赘述了。

那么我们也可以写一个路由的 ActivityResultContract,输入一个 path 就能跳转页面。

自定义路由的 ActivityResultContract

这里有个难点是创建 intent 的时候用到 Activity 的 Class 对象,我们要怎么获取呢?查下路由框架的文档并不能找到相关的方法,那就只能从路由框架的源码找到答案了。

而路由框架的源码这么多,该怎么去阅读找到答案呢?只要记住一点,找关键线索

这里的关键线索是什么呢?我们想一下,路由框架能用一个 path 跳转到对应的 Activity,还能怎么跳转呢?页面跳转是不好脱离平台特性去另外实现的,就像 Retrofit 在 Android 切线程还是得用 Handler,所以路由最终要么显示 intent 跳转要么隐式 intent 跳转。隐式跳转要改 manifests 不太可能,那就大概率是用了显示 intent 跳转。

所以我们就有了关键线索:startActivity(intent) 代码。要从哪里开始找呢?执行了哪个函数能跳转页面就从哪个函数开始找,ARouter 是调用了 navigation() 函数后才跳转页面,我们就从该函数的源码开始找,也可以 debug 走一遍。

最终我们能在 _ARouter 类的 startActivity() 函数里找到跳转的代码。ActivityCompat 是在以前 v4 包引入的,内部还是调用了 startActivity(intent)。

image.png

第一个关键线索找到后,我们就能得到第二个关键线索:intent 对象。通过 intent 对象我们就能知道要跳转什么 Class,要么是 Intent 的构造函数设置,要么是 intent.setClass() 函数设置,我们往上找一找调用的代码。

image.png

看到这里的源码我们也能大概了解到路由的流程,通过 path 能知道对应的 Class 是什么类型,然后根据不同的类型去做不同的事。是 Activity 类型,就调用 startActivity(intent) 跳转页面。可以看到是 Intent 的构造函数设置了目标 Activity 的 Class,传入的是 postcard.getDestination()。

既然 Postcard#getDestination() 返回一个 Class 对象,那么第三个关键线索就是 Postcard#setDestination(destination) 函数,我们要找到 Class 的来源。点击去发现只有一处地方用到。

image.png

可以看到传入的是 routeMeta.getDestination(),同理得到第四个线索:RouteMeta#setDestination(destination) 函数。点击后会发现跳回上面同一行代码,因为 Postcard 是继承了 RouteMeta,仅有一处 postcard.setDestination(...) 调用了该函数,那肯定不是这里设置的。

这样貌似线索断了?其实不然,属性不一定的是 set() 函数设置的,还有可能是构造函数传进来的。所以第四个关键线索应该改成 routeMeta 对象,看下是怎么得到的。

image.png

可以看到是 Warehouse.routes 查出来的,所以这就是下一个线索,跳进去看下是什么。

image.png

到这里我们就能看到 routes 是一个 HashMap,key 的类型是 String,可以通过 path 去查出路由信息 routeMeta,之后能获取到对应类的 Class 对象。这其实就是一个路由表,保存着路由信息。

我们终于找到了一个通过路由表获取 Class 对象的方式:

val clazz = Warehouse.routes[path]?.destination
复制代码

可以自定义一个路由的协议类了,首先要考虑输入参数类型和输出参数类型,我们就用官方自带的 ActivityResult 作为输出参数类型,可以获取 resultCode 和 data。但是要用什么作为输入参数类型呢?一个 path 肯定不够,我们可能要传参或设置 flag 什么的,所以我们增加一个 RouteRequest 类作为输入参数的类型,包含 path 和 intent,intent 可以补充 Class 以外的信息。

data class RouteRequest(
  val path: String,
  val intent: Intent = Intent()
)
复制代码

创建一个类继承 ActivityResultContract,其中创建 Intent 的函数就用 RouteRequest 的 intent 对象去补充 Class 信息,判断一下不是 Activity 类型就抛异常,因为不是 Activity 的话也没法跳转。另外 Warehouse 的 routes 对象没有用 public 修饰,所以要访问该对象的话还需要反射一下。

class StartRouteActivityContract : ActivityResultContract<RouteRequest, ActivityResult>() {

  override fun createIntent(context: Context, input: RouteRequest) =
    input.intent.apply {
      val routeMeta = routes[input.path]
      if (routeMeta?.type != RouteType.ACTIVITY) {
        throw IllegalArgumentException("The routing class for the path is not an activity type.")
      }
      setClass(context, routeMeta.destination)
    }

  override fun parseResult(resultCode: Int, intent: Intent?) = ActivityResult(resultCode, intent)
  
  companion object{
    @Suppress("UNCHECKED_CAST")
    private val routes: Map<String, RouteMeta> by lazy {
      val clazz = Class.forName("com.alibaba.android.arouter.core.Warehouse")
      val field = clazz.getDeclaredField("routes")
      field.isAccessible = true
      field[null] as Map<String, RouteMeta>
    }
  }
}
复制代码

我们来调用看看,下面就是标准的 ActivityResult API 用法了。

private val launcher = registerForActivityResult(StartRouteActivityContract()) {
  if (it.resultCode == RESULT_OK) {
    // 处理回调结果
  }
}

launcher.launch(RouteRequest("/xxx/xxx"))
复制代码

以上的思路适用于绝大多数路由框架去适配 ActivityResult API,关键是要知道怎么才能用 path 去获取对应的 Class 对象。路由框架基本都会用一个路由表缓存着路由的映射关系,那就能以路由表为目标。又因为跳转页面离不开平台特性,路由工具跳转页面的函数最终还是会执行 startActivity(intent),以此为线索就大概率能一步步地找到路由表的位置。

方案改进

不用反射

通常路由框架是不会把路由表给暴露的,就比如 ARouter 的路由表 Warehouse.routes 没有对外开放。那么用前面阅读源码的思路找到路由表的位置后,往往还是要用到反射得到路由表对象的。虽然一次反射的性能可以忽略不计,但是有人看到反射就很十分反感。

那么有没什么办法不用反射就能查出所需的 Class 对象呢?可能会有的,但是估计要对整个路由框架的实现流程比较熟悉后,才更容易找到不用反射的实现方式。下面以 ARouter 为例抛砖引玉一下。

我们先用个寄信流程类比 ARouter 的路由流程,其中有两个类我们需要特别关注,LogisticsCenter 和 Postcard,代表物流中心和明信片。

一开始初始化,物流中心会让各个省份到仓库的一个表格填写每个详细地址 (xx 省 xx 市 xxxxxxx) 对应的目的地位置(经纬度),这样准备工作就完成了。然后就是寄信,我们从邮递员手中拿一张明信片填写地址:xx 省 xx 市 xxxxxxx,填好后交给邮递员。邮递员看到一个具体到门牌号的地址并不一定清楚确切的位置,就先拿明信片给物流中心去补充该地址对应的具体位置,知道具体位置后就能把信送出去了。

理解这个流程后,我们再来看路由的流程,首先是初始化:

ARouter.init(this)
复制代码

ARouter 初始化的时候会执行 LogisticsCenter.init(context) 初始化物流中心,物流中心会让各个模块到仓库的路由表 Warehouse.routes 设置每个 path 对应哪些 Class 信息。

之后就能调用路由的导航方法了,我们通常是直接链式调用,其实会经历了两个过程。首先是 build(path) 函数返回了一个 Postcard 明信片对象,而之后的 postcard.navigation() 函数在内部会调用 ARouter.getInstance().navigation(postcard)。所以我们链式调用的代码等效于:

val postcard = ARouter.getInstance().build("/app/main")
ARouter.getInstance().navigation(postcard)
复制代码

我们把 ARouter 当作邮递员,这就能对应前面说的,在邮递员手上拿一张明信片填地址,再交回给邮递员去邮寄。而快递员 ARouter 在 navigation(postcard) 的时候,并不知道 postcard.path 对应的 Class 是什么,就会调用:

LogisticsCenter.completion(postcard)
复制代码

让物流中心到仓库的路由表用 path 查出 Class,设置到 postcard.destination 中。这样 ARouter 就能用 postcard.destination 得到 Class 对象,再根据类型去跳转 Activity 或者实例化对象。

以上就是 ARouter 主要的路由流程了,我们能知道 ARouter 会拿 postcard 给到 LogisticsCenter 补充对应的 Class 信息。那么可以不用劳烦 ARouter,我们自己亲自把 postcard 交给 LogisticsCenter,得到路由的 Class 信息后想做什么都可以。

val postcard = ARouter.getInstance().build(input.path)
LogisticsCenter.completion(postcard)
val clazz = postcard.destination
复制代码

思路有了,优化一下前面的自定义 ActivityResultContract,把反射路由表的代码去掉,改成用 LogisticsCenter 补充 Class 信息。

class StartRouteActivityContract : ActivityResultContract<RouteRequest, ActivityResult>() {

  override fun createIntent(context: Context, input: RouteRequest) =
    input.intent.apply {
      val postcard = ARouter.getInstance().build(input.path)
      LogisticsCenter.completion(postcard)
      if (postcard?.type != RouteType.ACTIVITY) {
        throw IllegalArgumentException("The routing class for the path is not an activity type.")
      }
      setClass(context, postcard.destination)
    }

  override fun parseResult(resultCode: Int, intent: Intent?) = ActivityResult(resultCode, intent)
}
复制代码

这样就能不用反射实现 ActivityResult API 了,不过这个思路不一定适合其它路由框架,可能没有类似 LogisticsCenter 的角色或者相关函数只能在内部访问。如果还得用到反射,就不如用前面的思路反射路由表对象了。

完善路由拦截功能

大多数需求只是替代 startActivityForResult(intent),上述的自定义 ActivityResultContract 封装就能满足,但是如果还有路由拦截需求的话就不适用了。

怎么保留这部分逻辑个人思考了很久,最开始是尝试重写 ActivityResultContract 的 getSynchronousResult() 函数,如果返回值不为 null,则会中断跳转的操作。

比如封装开启蓝牙的功能,如果蓝牙已经开启了,就无需调用 startActivityForResult(intent) 开启蓝牙了。所以在 getSynchronousResult() 函数验证了蓝牙已开启就返回 SynchronousResult(true),这会直接走蓝牙开启成功的回调。

class EnableBluetoothContract : ActivityResultContract<Unit, Boolean>() {

  override fun createIntent(context: Context, input: Unit?) =
    Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)

  override fun parseResult(resultCode: Int, intent: Intent?) =
    resultCode == Activity.RESULT_OK

  override fun getSynchronousResult(context: Context, input: Unit?): SynchronousResult<Boolean>? =
    if (isBluetoothEnabled) SynchronousResult(true) else null
}
复制代码

那么同理判断到需要路由拦截的时候返回一个取消跳转的结果不就行了?稍微尝试后发现不行,因为路由拦截是异步操作,而 getSynchronousResult() 函数是需要返回一个同步结果。

这条路走不通后又思考了很久,想到了另一个思路。路由框架一般会传一个 requestCode 参数,如果是一个正数就会调用 startActivityForResult(intent)。那么可以改成支持传一个 launcher 对象,当要跳转页面的时候就执行 launcher.launch(intent),其它的逻辑都不变。最终改成下面的用法:

private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
  if (it.resultCode == RESULT_OK) {
    // ...
  }
}

ARouter.getInstance().build("/account/sign_in").navigation(this, launcher)
复制代码

先找到传入了 requestCode 参数并且具有路由逻辑的函数,我们读下源码发现是集中到了 _ARouter 类的一个 navigation() 函数进行处理:

image.png

把该函数涉及的所有源码全部拷贝出来,将 requestCode 参数改成 launcher 参数,再将 startActivityForResult(intent) 函数改为 launcher.launch(intent),其它的逻辑代码全部保留。并且把函数改成 Postcard 的扩展函数,移除 postcard 参数。

这里有个难点是保留所有逻辑,因为可能会调用到非 public 的属性或函数,这就要找替代方案了。而 ARouter 需要修改的并不多,找到以下的替代方式:

  • logger 改为 ARouter.logger
  • debuggable() 改成 ARouter.debuggable()

我们就能实现以下扩展,代码会有点多,但逻辑更完整。

fun Postcard.navigation(context: Context, launcher: ActivityResultLauncher<Intent>, callback: NavigationCallback? = null): Any? {
  val pretreatmentService = ARouter.getInstance().navigation(PretreatmentService::class.java)
  if (null != pretreatmentService && !pretreatmentService.onPretreatment(context, this)) {
    // Pretreatment failed, navigation canceled.
    return null
  }

  // Set context to postcard.
  this.context = context

  try {
    LogisticsCenter.completion(this)
  } catch (ex: NoRouteFoundException) {
    ARouter.logger.warning(Consts.TAG, ex.message)

    if (ARouter.debuggable()) {
      runInMainThread {
        val text = "There's no route matched!\nPath = [$path]\nGroup = [$group]"
        Toast.makeText(context, text, Toast.LENGTH_LONG).show()
      }
    }

    if (null != callback) {
      callback.onLost(this)
    } else {
      // No callback for this invoke, then we use the global degrade service.
      ARouter.getInstance().navigation(DegradeService::class.java)
        ?.onLost(context, this)
    }
    return null
  }

  callback?.onFound(this)

  if (!isGreenChannel) { // It must be run in async thread, maybe interceptor cost too mush time made ANR.
    interceptorService.doInterceptions(this, object : InterceptorCallback {

      override fun onContinue(postcard: Postcard) {
        postcard._navigation(launcher, callback)
      }

      override fun onInterrupt(exception: Throwable) {
        callback?.onInterrupt(this@navigation)
        ARouter.logger.info(Consts.TAG, "Navigation failed, termination by interceptor : " + exception.message)
      }
    })
  } else {
    return _navigation(launcher, callback)
  }
  return null
}

@Suppress("FunctionName", "DEPRECATION")
private fun Postcard._navigation(launcher: ActivityResultLauncher<Intent>, callback: NavigationCallback?): Any? {
  val currentContext = context

  when (type) {
    RouteType.ACTIVITY -> {
      // Build intent
      val intent = Intent(currentContext, destination).putExtras(extras)

      // Set flags.
      val flags = flags
      if (0 != flags) {
        intent.flags = flags
      }

      // Non activity, need FLAG_ACTIVITY_NEW_TASK
      val action = action
      if (!TextUtils.isEmpty(action)) {
        intent.action = action
      }

      // Navigation in main looper.
      runInMainThread {
        launcher.launch(intent)

        if (-1 != enterAnim && -1 != exitAnim && currentContext is Activity) {    // Old version.
          currentContext.overridePendingTransition(enterAnim, exitAnim)
        }

        callback?.onArrival(this)
      }
    }
    RouteType.PROVIDER -> return provider
    RouteType.BOARDCAST, RouteType.CONTENT_PROVIDER, RouteType.FRAGMENT -> {
      val fragmentMeta = destination
      try {
        val instance = fragmentMeta.getConstructor().newInstance()
        if (instance is android.app.Fragment) {
          instance.arguments = extras
        } else if (instance is Fragment) {
          instance.arguments = extras
        }
        return instance
      } catch (ex: java.lang.Exception) {
        ARouter.logger.error(Consts.TAG, "Fetch fragment instance error, " + TextUtils.formatStackTrace(ex.stackTrace))
      }
      return null
    }
    RouteType.METHOD, RouteType.SERVICE -> return null
    else -> return null
  }
  return null
}

private val interceptorService by lazy {
  ARouter.getInstance().build("/arouter/service/interceptor").navigation() as InterceptorService
}

private val handler by lazy { Handler(Looper.getMainLooper()) }

private fun runInMainThread(runnable: Runnable) {
  if (Looper.getMainLooper().thread !== Thread.currentThread()) {
    handler.post(runnable)
  } else {
    runnable.run()
  }
}
复制代码

这样就能把传 requestCode 改成传 launcher 对象,如果 path 对应是 Activity 类型,就会用 ActivityResult API 跳转页面,路由拦截功能也会保留。

ARouter.getInstance().build("/account/sign_in").navigation(this, launcher)
复制代码

还可以再优化一下用法,增加一个 ActivityResultLauncher 的扩展去调用该路由方法。

fun ActivityResultLauncher<Intent>.launchByRoute(
  context: Context, path: String, callback: NavigationCallback? = null, block: (Postcard.() -> Unit)? = null
) = ARouter.getInstance().build(path).apply { block?.invoke(this) }.navigation(context, this, callback)
复制代码

最终就更趋近于 ActivityResult API 原本的用法。

private val launcher = registerForActivityResult(StartActivityForResult()) {
  if (it.resultCode == RESULT_OK) {
    // ...
  }
}

launcher.launchByRoute(this, "/account/sign_in")
复制代码

以上就是 ARouter 结合 ActivityResult API 比较完美的方案了,预处理和路由拦截的逻辑都有保留。这个思路可用于其它路由框架,就是有个难点要保留所有的路由逻辑,如果有非 public 的属性或函数就要找替代方案,没替代方案就可能得用反射了。

总结

本文分享了 ActivityResult API 的基础用法,提供了两个适配路由框架的方案:

  • 自定义 ActivityResultContract,代码量很少,满足常见的跳转页面返回结果的需求。但是要阅读代码找到路由表的问题,想不用反射可能要对整体路由流程比较熟悉。
  • 用 launcher 参数替代 requestCode 参数,保留了路由拦截功能,逻辑更加完善。但是修改成本可能会比较高,并且代码量会很多。

关于我

一个兴趣使然的程序“工匠”  。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改