如何更好地进行 Android 组件化开发(五)路由原理篇

1,861 阅读10分钟

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

前言

组件化开发的会实现代码隔离,在开发时访问不到模块的代码,降低代码耦合度。那么如何跳转组件的页面、如何进行组件间的通信是个问题。这通常会使用到 ARouter、TheRouter、WMRouter 等路由框架。可能有不少人只知道怎么去调用,并不知道其中的实现原理。其实了解路由原理后再进行组件化开发会更加得心应手。

网上很多讲路由原理的文章都是直接分析 ARouter 源码,这对于刚入门组件化开发的人来说比较晦涩难懂。所以个人尝试用一种手写迭代路由框架的方式,让大家能从中学习到:

  • 如何从零开始设计一个路由框架;
  • 路由框架需要考虑怎么样的使用场景;
  • 了解路由框架的核心实现原理;

对这些有一定了解后,我们就能更容易地去阅读其它路由框架的源码了。

搭建简易的路由框架

先了解下什么是路由,维基百科的介绍是:路由(routing)就是通过互联的网络把信息从源地址传输到目的地址的活动。这是互联网中的路由概念,其思想是通过一样东西找到另一样我们需要的东西。将路由的思想运用到 Android 的组件化中,就是希望能用某个东西能找到对应的类去实现对应的功能。

那我们就来实现一下,首先要有一个 String 和 Class 的映射表,可以用一个 Map 来保存。写一个 SimpleRouter 提供一个设置映射关系和导航的方法。

object SimpleRouter {

  private val routes = HashMap<String, Class<*>>()

  fun putRoute(path: String, clazzName: String) = routes.apply {
    put(path, Class.forName(clazzName))
  }

  fun navigation(path: String): Class<*>? {
    return routes[path]
  }
}

这就实现了一个简易的路由框架,有人可能会说:就这?一个完善好用的路由框架肯定不止这些代码,但是最核心的代码就是路由表。

我们先来用一下,比如将登录页面和 /account/sign_in 字符串映射起来。

SimpleRouter.putRoute("/account/sign_in", "com.dylanc.simplerouter.user.SignInActivity")

后面即使我们做了代码隔离,不能直接访问到对应的 Class,那么我们能通过一个字符串去找到 Class 对象,有了 Class 对象就能跳转页面或实例化。

val clazz = SimpleRouter.navigation("/account/sign_in")
if (clazz != null) {
  startActivity(Intent(this, clazz))
}

手动给路由表的 Map 添加类的信息看起来有点蠢,后面可以改成用注解配置:

@Route(path = "/account/sign_in")
class SignInActivity : AppCompatActivity() {
  // ...
}

前面的文章有介绍怎么用 APT 解析注解生成文件,其实是最终做的事情是一样的,都是往一个 Map 保存类的信息,只是 put 的代码是自己手写还是注解自动生成而已。本文主要还是了解路由的实现原理,就先用简单的方式实现。

小结一下,第一版我们实现了路由框架的核心功能,建立映射表后就能通过一个字符串去找到类对象去实现想要的功能。

完善页面导航跳转

通过第一版的路由框架能得到所需的类对象了,想跳转一个 Activity 也没有问题,但是跳转的代码总是要判断类对象非空后调用 startActivity(intent)。

val clazz = SimpleRouter.navigation("/account/sign_in")
if (clazz != null) {
  startActivity(Intent(this, clazz))
}

每次跳转页面都要这么写挺繁琐的,我们可以优化一下调用 SimpleRouter.navigation(path) 时会把 startActivity(intent) 给执行了,这样就只需写一行代码就能跳转页面了。

SimpleRouter.navigation("/account/sign_in")

那么问题又来了,怎么传参呢?仅仅只有一个 path 字符串是不够的,需要有另外一个类来设置更多的信息。我们创建一个 Postcard 类,Postcard 是明信片的意思,类似我们能往明信片上写东西,我们能给 Postcard 对象设置 path 和 Bundle 相关的数据。

class Postcard(
  val path: String,
  var bundle: Bundle = Bundle(),
) {

  fun with(bundle: Bundle): Postcard = apply {
    this.bundle = bundle
  }

  fun withString(key: String, value: String): Postcard = apply {
    bundle.putString(key, value)
  }

  fun withInt(key: String, value: Int): Postcard = apply {
    bundle.putInt(key, value)
  }

  fun withLong(key: String, value: Long): Postcard = apply {
    bundle.putLong(key, value)
  }

  fun withFloat(key: String, value: Float): Postcard = apply {
    bundle.putFloat(key, value)
  }

  fun withDouble(key: String, value: Double): Postcard = apply {
    bundle.putDouble(key, value)
  }

  fun withChar(key: String, value: Char): Postcard = apply {
    bundle.putChar(key, value)
  }

  fun withBoolean(key: String, value: Boolean): Postcard = apply {
    bundle.putBoolean(key, value)
  }

  fun withByte(key: String, value: Byte): Postcard = apply {
    bundle.putByte(key, value)
  }

  fun withCharSequence(key: String, value: CharSequence): Postcard = apply {
    bundle.putCharSequence(key, value)
  }
  
  // ...
}

把前面 navigation() 函数的 String 参数改成 Postcard 类型,可以顺便把 startActivityForResult() 适配了。

object SimpleRouter {

  // ...

  fun navigation(context: Context, postcard: Postcard, requestCode: Int = -1) {
    val destination = routes[postcard.path]
      ?: throw IllegalStateException("There is no route match the path [${postcard.path}]")
    val intent = Intent(context, destination).putExtras(postcard.bundle)
    if (context !is Activity) {
      intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
    }
    if (requestCode >= 0) {
      if (context is Activity) {
        context.startActivityForResult(intent, requestCode)
      }
    } else {
      context.startActivity(intent)
    }
  }
}

之后就可以创建 Postcard 对象去带参跳转页面了。

val postcard = Postcard("/uset/login").withString("email", email)
SimpleRouter.navigation(this, postcard)
// SimpleRouter.navigation(this, postcard, REQUEST_CODE_SIGN_IN)

不过总是要创建个 Postcard 对象也不是方便,最好是能改成链式调用。实现起来也挺简单,我们给 SimpleRouter 增加一个 build(path) 函数返回一个 Postcard 对象。

object SimpleRouter {

  // ...

  fun build(path: String): Postcard {
    return Postcard(path)
  }
}

然后再给 Postcard 类增加两个 navigation() 函数 。

class Postcard(
  val path: String,
  var bundle: Bundle = Bundle(),
) {

  // ...

  fun navigation(context: Context) {
    return SimpleRouter.navigation(context, this, -1)
  }

  fun navigation(activity: Activity, requestCode: Int) {
    return SimpleRouter.navigation(activity, this, requestCode)
  }
}  

这样就能用一行链式代码跳转页面。

SimpleRouter.build("/user/login")
  .withString("email","xxxxx@gmail.com")
  .navigation(this)
// .navigation(this, REQUEST_CODE_SIGN_IN)

至此我们就把路由工具核心的用法给实现了,ARouter 或其它路由框架都是类似的用法。

小结一下,第二版在第一版的基础上,增加了一个 Postcard 类,保存跳转页面时所需的信息。简化了路由工具跳转页面的用法,只需一行代码就能跳转,并且支持传参,支持调用 startActivityForResult(intent)。

支持创建 Fragment

虽然前面把路由工具的核心用法实现了,但是只处理了 Activity 一种情况,还可能会有其他的 Class,比如很常见的 Fragment,我们这就来适配一下。

通常我们是会创建 Fragment 对象来使用,那么可以在获得 Class 后判断一下是不是 Fragment 类型,是的话就实例化无参的构造函数。

目前只是简单地用 String 和 Class 做映射,有时候还是不太够用,最好能区分下类型。所以我们再增加一个 RouteMeta 类来保存更多的路由信息,并且添加一个 RouteType 的路由类型枚举,来区分一下是 Activity 或 Fragment,要支持其它类型只需再添加。

class RouteMeta(
  val destination: Class<*>,
  val type: RouteType,
)

enum class RouteType {
  ACTIVITY, FRAGMENT, UNKNOWN
}

我们把原来路由表的 HashMap<String, Class<*>> 缓存改成 HashMap<String, RouteMeta>,初始化路由表的代码也要更改一下。

object SimpleRouter {
  private val routes = HashMap<String, RouteMeta>()

  fun putRoute(path: String, clazzName: String) = routes.apply {
    val clazz = Class.forName(clazzName)
    val type = when {
      Activity::class.java.isAssignableFrom(clazz) -> RouteType.ACTIVITY
      Fragment::class.java.isAssignableFrom(clazz) -> RouteType.FRAGMENT
      else -> RouteType.UNKNOWN
    }
    put(path, RouteMeta(clazz, type))
  }
  
  // ...
}

这样我们就能从映射表中查出对应的类型,然后在 navigation() 函数根据类型做不同的事,是 Activity 就跳转页面,是 Fragment 就实例化 Fragment 对象,并且把 Bundle 参数给设置了。

object SimpleRouter {
  
  // ...

  private lateinit var application: Application
  
  fun init(application: Application) {
    this.application = application
  }

  fun navigation(ctx: Context? , postcard: Postcard, requestCode: Int = -1): Any? {
    val context = ctx ?: application
    val routeMeta = routes[postcard.path]
      ?: throw IllegalStateException("There is no route match the path [${postcard.path}]")
    val destination = routeMeta.destination
    return when (routeMeta.type) {
      RouteType.ACTIVITY -> {
        val intent = Intent(context, destination).putExtras(postcard.bundle)
        if (context !is Activity) {
          intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
        }
        if (requestCode >= 0) {
          if (context is Activity) {
            context.startActivityForResult(intent, requestCode)
          }
        } else {
          context.startActivity(intent)
        }
        null
      }
      RouteType.FRAGMENT -> {
        val fragmentMeta: Class<*> = destination
        try {
          val instance = fragmentMeta.getConstructor().newInstance()
          if (instance is Fragment) instance.arguments = postcard.bundle
          instance
        } catch (e: Exception) {
          null
        }
      }
      else -> null
    }
  }
}

我们来测试一下,配置个人页面 Fragment 的路由信息。

// 以后会改成用 @Router 注解来初始化路由表
SimpleRouter.putRoute("/account/me", "com.dylanc.simplerouter.user.MeFragment")

然后就能在没有直接依赖其它模块代码的情况下,通过路由去创建 Fragment。

val fragment = SimpleRouter.build("/account/me").navigation() as? Fragment

虽然跳转 Activity 和创建 Fragment 都用了同一个 navigation() 函数,但是一个会用到返回值,一个没有使用返回值,用法上会有点差异,因为根据不同的类型做了不同的事情。

小结,第三版代码在第二版的基础上,支持了 Fragment 的使用场景,如果路由表对应的 Class 是 Fragment,那么会实例化该 Fragment。增加了 RouteType 类和 RouteMeta 类,用于区分类型和保存更多的路由信息。

支持模块间通信

目前我们适配了跳转 Activity 和创建 Fragment,但是组件化开发还有一个很常见的使用场景,就是模块间的通信,获取一个模块的某些信息或者让一个模块做某些事。

这个接口的实现类在哪我们不用关心,因为我们可以用路由工具去得到实例对象。但是怎么区分这是用于模块间通信的类呢?

首先我们要区分出哪些类用于模块间的通信的,定义一个 IProvider 接口,如果有类是实现了该接口,那么该类是会用于模块间通信。

interface IProvider {
  fun init(context: Context)
}

给 RouteType 再加个 PROVIDER 类型,IProvider 子类对应的是 PROVIDER 类型。

enum class RouteType {
  ACTIVITY, FRAGMENT, PROVIDER, UNKNOWN
}

判断是 PROVIDER 类型,我们就用类似 Fragment 的处理方式反射无参的构造函数,但是又有些不一样,没必要每次都实例化 IProvider 对象,可以缓存起来。那么现在缓存的东西开始变多了,我们可以创建一个 Warehouse 类专门来持有需要缓存的数据,把之前的路由表也放到该类中。Warehouse 和 Repository 一样是仓库的意思。

object Warehouse {
  val routes = HashMap<String, RouteMeta>()
  val providers = HashMap<Class<*>, IProvider>()
}

这样我们就可以在 navigation() 函数增加 PROVIDER 类型的逻辑了,不过路由工具类的代码就越来越多了,我们可以优化一下,将初始化缓存和读取缓存的逻辑交给另一个类处理。

我们再创建一个 LogisticsCenter 类,把之前的初始化路由表的函数放到该类处理。还增加一个 completion() 函数去给 Postcard 补充信息,从仓库的路由表查出对应的类信息设置到 Postcard 中。如果路由的类型是 PROVIDER 类型,要获取仓库的 IProvider 对象缓存,没缓存就先实例化存到 Warehouse 中。

object LogisticsCenter {

  private lateinit var context: Application

  fun init(application: Application) {
    context = application
  }

  fun putRoute(path: String, clazzName: String) {
    val clazz = Class.forName(clazzName)
    val type = when {
      Activity::class.java.isAssignableFrom(clazz) -> RouteType.ACTIVITY
      Fragment::class.java.isAssignableFrom(clazz) -> RouteType.FRAGMENT
      IProvider::class.java.isAssignableFrom(clazz) -> RouteType.PROVIDER
      else -> RouteType.UNKNOWN
    }
    Warehouse.routes[path] = RouteMeta(clazz, type)
  }

  @Suppress("UNCHECKED_CAST")
  fun completion(postcard: Postcard) {
    val routeMeta = Warehouse.routes[postcard.path]
      ?: throw IllegalStateException("There is no route match the path [${postcard.path}]")
    postcard.destination = routeMeta.destination
    postcard.type = routeMeta.type
    if (routeMeta.type == RouteType.PROVIDER) {
      val providerClazz = routeMeta.destination as Class<IProvider>
      var instance = Warehouse.providers[providerClazz]
      if (instance == null) {
        try {
          val provider = providerClazz.getConstructor().newInstance()
          provider.init(context)
          Warehouse.providers[providerClazz] = provider
          instance = provider
        } catch (e: Exception) {
          throw IllegalStateException("Init provider failed!")
        }
      }
      postcard.provider = instance
    }
  }
}

修改一下 navigation() 函数,先执行 LogisticsCenter.completion(postcard) 补充信息,如果是 PROVIDER 类型就返回 postcard.provider。

object SimpleRouter {

  // ...

  fun putRoute(path: String, clazzName: String){
    LogisticsCenter.putRoute(path, clazzName)
  }
  
  fun navigation(context: Context, postcard: Postcard, requestCode: Int = -1): Any? {
    LogisticsCenter.completion(postcard)
    return when (postcard.type) {
      RouteType.ACTIVITY -> {
        // ...
      }
      RouteType.FRAGMENT -> {
        // ...
      }
      RouteType.PROVIDER -> postcard.provider
      else -> null
    }
  }
}

我们在一个公共模块新建一个 AccountService 接口提供登录组件的功能,需要继承 IProvider 接口。

interface AccountService : IProvider {
  val isSignIn: Boolean
  fun logout()
}

然后在用户模块写一个 AccountService 接口的实现类,把功能实现出来。

class AccountServiceProvider : UserService {
  override val isSignIn: Boolean
    get() = // ...

  override fun logout() {
    // ...
  }

  override fun init(context: Context) = Unit
}

在路由表注册该类的路由信息。

// 以后会改成用 @Router 注解来初始化路由表
SimpleRouter.putRoute("/account/service", "com.dylanc.simplerouter.account.AccountServiceProvider")

之后就能通过路由工具得到该接口的实例。

val accountService = SimpleRouter.build("/account/service").navigation() as? AccountService
if (accountService?.isSignIn == true) {
  // 已登录
} else {
  // 未登录
}

小结一下,第四版在第三版的基础上,支持了模块间通信,能获取一个模块的某些信息或者让一个模块做某些事。增加了 IProvider 接口来区分类型,增加了 Warehouse 类持有缓存信息,增加了 LogisticsCenter 类初始化和读取缓存。

路由原理分析

目前我们已经实现了一个简易的路由框架并实现了常用的路由功能,代码虽然比较简单,但其实都是路由框架的核心代码,我们可以从中了解到路由的实现原理。

首先路由框架一般会用个 Map 作为路由表,初始化的时候会给路由表添加每个 path 对应的路由信息,包含 Class 对象、类型等。目前的简易路由框架是手动 put 数据,改成使用注解也只是能优化了用法,实际上做的事是一样的。

然后就是通过 navigation() 函数进行路由导航,会用 path 到路由表查出对应的路由信息。虽然可以直接返回 Class 对象,但是开发起来不太方便,所以会根据不同的类型去做对应的事:

  • 如果 Class 对象是 Activity 类型,就给 intent 设置参数并调用 startActivity() 或者 startActivityForResult() 跳转页面;
  • 如果 Class 对象是 Fragment 类型,就反射无参的构造函数实例化 Fragment 对象,并且给 arguments 设置参数;
  • 如果 Class 对象是 IProvider 类型,也会反射无参的构造函数实例化对象,但是不需要多次实例化,所以会缓存起来;

大部分路由框架都是这样的路由流程,只是实现上会有些区别,大家可以将这个流程作为线索去阅读源码,看看完善的路由框架除了这些功能之外还会额外做些什么处理。

另外前面迭代的简易路由框架所实现的类和函数其实都尽量与 ARouter 的源码保持了一致,让大家顺便了解一下 ARouter 的整体结构可能是怎么样设计出来的,后续阅读源码也更加容易理解,ARouter 在面试中还是比较常会问到的。

总结

本文带着大家迭代了一个路由框架,从一个简单的工具类慢慢完善常见的路由场景,实现模块间跳转页面、创建 Fragment、通信。我们可以从中了解到路由的实现原理,其中最核心的是路由表,初始化的时候给路由表添加路由信息,然后路由导航的时候就能查出路由信息,再根据不同的类型去做不同的事情。大多数路由框架都是这样的流程,我们对此了解后就能更容易去阅读路由框架的源码。

关于我

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