上一篇提過如何將 MetroRide 由傳統 view system 遷移到 Jetpack Compose。但一篇又太長,所以分拆成兩篇。

Dependency injection

按照官方的建議,composable function 要用到的 dependency 應該由 caller 經參數提供。然後就是由外層一直傳進去。至於那個外層最遠可以去到 Activity 或者 Fragment。由於 composable function 就是 top-level function,沒有 class 包住,所以平常用開的 Dagger 或者 Koin 之類的 DI library 都無辦法輕易地在 composable function 經 DI 拿到 dependency。如果以 Dagger Hilt 來計,目前是有這幾種方式:

  1. ViewModel 的 constructor injection 拿到 dependency,然後用 Hilt 的 viewModelhiltViewModel function 在 composable function 拿到 ViewModel instance,再從 ViewModel 拿到 dependency
  2. Activity 或者 Fragment@Inject 做 field injection,然後帶進去 setContent lambda
  3. 為 dependency 準備 entry pointContext 可以經 LocalContext.current 拿到

其實三個方法我覺得都是很不 Compose。但很多時都可以避免到在 composable function 拿 dependency。例如在 ViewModel 外露已經計算好結果的 StateFlow,composable function 就會接到 data class 形式的 state,不需接觸到其他 dependency。這樣做 preview function 都容易處理。Android platform 的 dependency 例如 system service 可以用 LocalContext.current 拿到 Context 之後就拿到。我目前是按照官方建議 screen level 的 composable function 可以拿到 ViewModel,但內裏用到的 composable function 部件就不會再接觸到 ViewModel。但即使限制 composable function 接觸到 ViewModel 的機會,但始終會有部分 composable function 因為 parameter 有 ViewModel 而不能寫 preview function。日後我會試試把 ViewModel 限制在 Navigation component 的 composable/dialog lambda 內使用,所有 StateFlow 全部在 navigation 那邊轉成 Compose 的 StateViewModel function call 變成 callback lambda。這樣就更容易為 screen level 的 composable function 寫 preview function。因為現在沒有 XML 版 Navigation component 這種的圖像化 navigation graph,如果連 screen level 的 composable function 都無預覽的話去到大 project 就很難維護。

除了那三種方法外,如果是比較通用的東西可以用 CompositionLocal 而不用逐層傳入,例如剛才提及的 LocalContext。可以理解 CompositionLocal 就是 service locator。其實 Compose 的 Material Design theming 都用了 CompositionLocal 來減少傳遞參數。例子有 LocalContentAlpha 用來改變某個範圍的顏色透明度。我在這次遷移應該算是用得克制,只加了兩個自訂 CompositionLocal:一個是用來提供 Java 的 Clock,另一個是用來拿用戶語言設定(因為 app 有自己的語言設定,不是按系統設定),方便顯示文字內容。用法大致上是這樣:

@Composable
fun MetroRideAppContainer(
    viewModel: MainViewModel,
    splashScreenVisibleCondition: (SplashScreen.KeepOnScreenCondition) -> Unit,
    quitApp: () -> Unit,
    // 略……
) {
    val appBlocking by viewModel.appBlocking.collectAsState()
    val uiLanguage by viewModel.uiLanguage.collectAsState()

    splashScreenVisibleCondition { appBlocking == AppBlocking.Checking }

    CompositionLocalProvider(
        LocalClock provides viewModel.clock,
        LocalUiLanguage provides uiLanguage,
    ) {
        val navController = rememberNavController()
        MetroRideTheme {
            AppNavHost(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f),
                navController = navController,
            )
        }
    }
}

Interop

MetroRide app 如果講跟傳統 view system 之間的 interop 的話就只有 AdMob 的 AdView 需要用到,Composable function 的 code 我已經放到 Gist。但 LeakCanary 有時會投訴 AdMob 的 WebView 會有 memory leak。暫時找不到解決方法。另外亦為 IDE preview 做了個專用 UI,方法是檢查 LocalInspectionModecurrent

AdMob composable function preview

至於其他非傳統 view system 之間的 interop,其實 Google 之前都出了一堆 library 讓你在用 Compose 時減少直接接觸 Android platform 的功能。例如 Jetpack Lifecycle-aware components 將 Activity lifecycle 抽離、Activity Result APIs 取代 startActivityForResultSavedStateHandle 取代 onSaveInstanceState 等等其實都會在 Compose 上用到。在 AdMob 那個 Gist 已經透過 LifecycleEventObserver 來得知當前的 lifecycle 狀態。

主題

Jetpack Compose 有提供 Material Design 2 和 3 的 artifact。目前我仍使用 2,因為 3 還在 alpha,組件未夠齊(即使 Compose 版 Material Design 2 都是比傳統 view system 版少組件)。但講自訂程度我覺得 Compose 比傳統 view system 更好。傳統 view system 有 style resource 但要花時間查有那些地方可以用 style 改動,但 Compose 可以自己查 Material Design 組件的 composable function 源碼,發覺有地方不能自訂就把它的 code 抄走再改。而且 Compose 部分組件有提供原始版 composable function,例如負責文字輸入的 BasicTextField,它就是單純提供文字輸入功能。你喜歡加標題可以自己另外加個 Text、框線等等。但如果是傳統 view system 你要抄走某個 class 的 code 都不容易,因為要兼顧 Java/Kotlin code 的 access modifier 和 XML styleable attribute。

MetroRide 的 UI 因為都是按照 Material Design 2 而造,所以沒有做太多自訂主題。最多都是按照 Material Design 2 做了 dialog(Compose 版 Material Design 2 做出來 dialog 跟 Material Design 網站講的式樣不同,所以我特別處理過)和搜尋欄。但在舊公司用 Compose 開發新功能時因為那個 app 的主題跟 Material Design 有出入所以試過自訂過 UI。大概是寫一個 composable function 包住 Compose 本身提供的組件 composable function,但已經把需要調整的參數都設定好,又或者是現有的組件被其他新組件包住。這個是 Compose 做自訂 UI 組件的方法:composition。

小結

Compose 遷移就寫到這裏。其實遷移工作斷斷續續地做了半年。因為是 side-project 沒有時間限制,做了一部分又放下來做其他東西,再加上重鐵和輕鐵抵站時間在很久之前做了出來(重鐵那個在上去年參加 iThome 鐵人賽時拿來做示範 app,輕鐵是 MetroRide app 之前已經有的功能),隔了一段時間後都忘了之前的 code 寫了甚麼。其實還想分享一下 Navigation component,不過我覺得可以再開多一篇文。所以這部分會在下一篇出現。