如何更好地進行 Android 組件化開發(五)路由原理篇

語言: CN / TW / HK

theme: devui-blue

本文為稀土掘金技術社區首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!

前言

組件化開發的會實現代碼隔離,在開發時訪問不到模塊的代碼,降低代碼耦合度。那麼如何跳轉組件的頁面、如何進行組件間的通信是個問題。這通常會使用到 ARouter、TheRouter、WMRouter 等路由框架。可能有不少人只知道怎麼去調用,並不知道其中的實現原理。其實瞭解路由原理後再進行組件化開發會更加得心應手。

網上很多講路由原理的文章都是直接分析 ARouter 源碼,這對於剛入門組件化開發的人來説比較晦澀難懂。所以個人嘗試用一種手寫迭代路由框架的方式,讓大家能從中學習到:

  • 如何從零開始設計一個路由框架;
  • 路由框架需要考慮怎麼樣的使用場景;
  • 瞭解路由框架的核心實現原理;

對這些有一定了解後,我們就能更容易地去閲讀其它路由框架的源碼了。

搭建簡易的路由框架

先了解下什麼是路由,維基百科的介紹是:路由(routing)就是通過互聯的網絡把信息從源地址傳輸到目的地址的活動。這是互聯網中的路由概念,其思想是通過一樣東西找到另一樣我們需要的東西。將路由的思想運用到 Android 的組件化中,就是希望能用某個東西能找到對應的類去實現對應的功能。

那我們就來實現一下,首先要有一個 String 和 Class 的映射表,可以用一個 Map 來保存。寫一個 SimpleRouter 提供一個設置映射關係和導航的方法。

```kotlin 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 字符串映射起來。

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

後面即使我們做了代碼隔離,不能直接訪問到對應的 Class,那麼我們能通過一個字符串去找到 Class 對象,有了 Class 對象就能跳轉頁面或實例化。

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

手動給路由表的 Map 添加類的信息看起來有點蠢,後面可以改成用註解配置:

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

前面的文章有介紹怎麼用 APT 解析註解生成文件,其實是最終做的事情是一樣的,都是往一個 Map 保存類的信息,只是 put 的代碼是自己手寫還是註解自動生成而已。本文主要還是瞭解路由的實現原理,就先用簡單的方式實現。

小結一下,第一版我們實現了路由框架的核心功能,建立映射表後就能通過一個字符串去找到類對象去實現想要的功能。

完善頁面導航跳轉

通過第一版的路由框架能得到所需的類對象了,想跳轉一個 Activity 也沒有問題,但是跳轉的代碼總是要判斷類對象非空後調用 startActivity(intent)。

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

每次跳轉頁面都要這麼寫挺繁瑣的,我們可以優化一下調用 SimpleRouter.navigation(path) 時會把 startActivity(intent) 給執行了,這樣就只需寫一行代碼就能跳轉頁面了。

kotlin SimpleRouter.navigation("/account/sign_in")

那麼問題又來了,怎麼傳參呢?僅僅只有一個 path 字符串是不夠的,需要有另外一個類來設置更多的信息。我們創建一個 Postcard 類,Postcard 是明信片的意思,類似我們能往明信片上寫東西,我們能給 Postcard 對象設置 path 和 Bundle 相關的數據。

```kotlin 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() 適配了。

```kotlin 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 對象去帶參跳轉頁面了。

kotlin 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 對象。

```kotlin object SimpleRouter {

// ...

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

然後再給 Postcard 類增加兩個 navigation() 函數 。

```kotlin 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) } }
```

這樣就能用一行鏈式代碼跳轉頁面。

kotlin SimpleRouter.build("/user/login") .withString("email","[email protected]") .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,要支持其它類型只需再添加。

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

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

我們把原來路由表的 HashMap<String, Class<*>> 緩存改成 HashMap,初始化路由表的代碼也要更改一下。

```kotlin object SimpleRouter { private val routes = HashMap()

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 參數給設置了。

```kotlin 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 的路由信息。

kotlin // 以後會改成用 @Router 註解來初始化路由表 SimpleRouter.putRoute("/account/me", "com.dylanc.simplerouter.user.MeFragment")

然後就能在沒有直接依賴其它模塊代碼的情況下,通過路由去創建 Fragment。

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

雖然跳轉 Activity 和創建 Fragment 都用了同一個 navigation() 函數,但是一個會用到返回值,一個沒有使用返回值,用法上會有點差異,因為根據不同的類型做了不同的事情。

小結,第三版代碼在第二版的基礎上,支持了 Fragment 的使用場景,如果路由表對應的 Class 是 Fragment,那麼會實例化該 Fragment。增加了 RouteType 類和 RouteMeta 類,用於區分類型和保存更多的路由信息。

支持模塊間通信

目前我們適配了跳轉 Activity 和創建 Fragment,但是組件化開發還有一個很常見的使用場景,就是模塊間的通信,獲取一個模塊的某些信息或者讓一個模塊做某些事。

這個接口的實現類在哪我們不用關心,因為我們可以用路由工具去得到實例對象。但是怎麼區分這是用於模塊間通信的類呢?

首先我們要區分出哪些類用於模塊間的通信的,定義一個 IProvider 接口,如果有類是實現了該接口,那麼該類是會用於模塊間通信。

kotlin interface IProvider { fun init(context: Context) }

給 RouteType 再加個 PROVIDER 類型,IProvider 子類對應的是 PROVIDER 類型。

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

判斷是 PROVIDER 類型,我們就用類似 Fragment 的處理方式反射無參的構造函數,但是又有些不一樣,沒必要每次都實例化 IProvider 對象,可以緩存起來。那麼現在緩存的東西開始變多了,我們可以創建一個 Warehouse 類專門來持有需要緩存的數據,把之前的路由表也放到該類中。Warehouse 和 Repository 一樣是倉庫的意思。

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

這樣我們就可以在 navigation() 函數增加 PROVIDER 類型的邏輯了,不過路由工具類的代碼就越來越多了,我們可以優化一下,將初始化緩存和讀取緩存的邏輯交給另一個類處理。

我們再創建一個 LogisticsCenter 類,把之前的初始化路由表的函數放到該類處理。還增加一個 completion() 函數去給 Postcard 補充信息,從倉庫的路由表查出對應的類信息設置到 Postcard 中。如果路由的類型是 PROVIDER 類型,要獲取倉庫的 IProvider 對象緩存,沒緩存就先實例化存到 Warehouse 中。

```kotlin 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 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。

```kotlin 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 接口。

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

然後在用户模塊寫一個 AccountService 接口的實現類,把功能實現出來。

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

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

override fun init(context: Context) = Unit } ```

在路由表註冊該類的路由信息。

kotlin // 以後會改成用 @Router 註解來初始化路由表 SimpleRouter.putRoute("/account/service", "com.dylanc.simplerouter.account.AccountServiceProvider")

之後就能通過路由工具得到該接口的實例。

kotlin 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 或者加我微信直接反饋。