如何更好地進行 Android 組件化開發(五)路由原理篇
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
修改一下 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 或者加我微信直接反饋。
- 掘金:juejin.cn/user/419539…
- GitHub:github.com/DylanCaiCod…
- 微信號:DylanCaiCoding
- 如何更好地進行 Android 組件化開發(三)ActivityResult 篇
- 如何更好地進行 Android 組件化開發(五)路由原理篇
- 如何更好地進行 Android 組件化開發(四)登錄攔截篇
- 如何更簡潔地實現富文本 Span
- 2022 年中總結|我的 GitHub 竟然被微軟大佬關注了?!
- 手把手帶你實現西瓜視頻的責任鏈埋點框架
- 優雅地結合 Kotlin 特性深度解耦標題欄
- 更多 ViewBinding 的封裝思路,適配 BRVAH 竟如此簡單
- 2021 年終總結,GitHub 1k stars 的目標終於達成!!
- 優雅地結合 Kotlin 特性封裝工具類
- 優雅地封裝和使用 ViewBinding,該替代 Kotlin synthetic 和 ButterKnife 了