如何更好地進行 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 了