如何更好地進行 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 或者加我微信直接反饋。