Compose Multiplatform 實戰:CMP中使用koin來依賴注入Dependency Injection
前言
Compose Multiplatform (簡稱CMP)
嗨,大家
今天繼續來介紹CMP的應用
我們將使用koin來依賴注入
來降低程式碼之間的耦合
讓其更易於維護
原本在寫Android的人可能會使用Dagger2 or Hilt
但現在CMP官方有支援的主要是koin
使用其他DI方案可能要需自己使用其他worked around
所以我們今天會先以koin導入到CMP為主
什麼是依賴注入 Dependency Injection ?
在軟體開發中
高耦合(Coupling)是指程式碼中的模組或元件之間存在過多的依賴關係
這會使得程式碼難以維護和測試
為了解決高耦合的情況
我們可以使用依賴注入(Dependency Injection, DI)來減少程式碼之間的耦合
依賴注入是一種設計模式
它允許我們在物件的生命週期中將其依賴項注入到物件中
而不是在物件內部創建instance
這樣可以使得程式碼更加靈活和可測試
我們來看一個前幾天建StateFlow時用到viewmodel的例子
這邊我們需要手動創建SettingViewModel實例  且 內部有多個class需init
fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {
    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = SettingViewModel()
        SettingScreen(navController, viewModel)
    }
}
class SettingViewModel() {
   lateinit var a :A
   lateinit var b :B
   lateinit var c :C
   lateinit var e :E
   lateinit var f :F
   
   init{
     a = A()
     b = B()
     ...
     ...
     ...
   }
}
如果透過DI來導入viewmodel
則你不需要自己去創建instance
fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {
    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = koinViewModel<SettingViewModel>()
        SettingScreen(navController, viewModel)
    }
}
因為只是把創建實例變成透過 koinViewModel 去注入而已
不過如果仔細看
如果你的 SettingViewModel 的構造函數突然變得很複雜
那麼你就需要一個一個建立每一個必需的實例
例如:
val a = A()
val b = B()
val c = C()
val d = D()
val e = E()
val f = F()
...
SettingViewModel(a,b,c,d,e,f,...)
這時候 DI 注入的優點就顯現出來了
可以讓你免去自己一個一個創建實例的步驟
也可以讓你的程式碼行數變更少、更簡潔
另一個優勢是
當你需要改動程式碼時
彈性會比較好
你只需要改動注入模組的程式碼即可
原本 ViewModel 的程式碼可以保持不變
只是修改模組實作那邊的邏輯而已
此外
如果你在多個地方需要使用某個 class
依賴注入也可以減少你自己創建實例的步驟。
class SettingViewModel(private val a: A, private val b: B,...) {
 ...
}
在使用時依舊是
val viewModel = koinViewModel<SettingViewModel>()
在CMP中實作koin
首先
我們需要在 lib.versions.toml 文件中加入 koin 的依賴和版本號
完成後
記得同步 gradle
[versions]
koin = "3.5.0"
koinCompose = "1.2.0-Beta4"
[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel= { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koinCompose" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinCompose" }
- 這次一樣是共用的 所以在commonMain中加入以下:
    sourceSets {
        commonMain.dependencies {
            implementation(libs.koin.core)
            implementation(libs.koin.compose.viewmodel)
            implementation(libs.koin.compose)
        }
    }
(記得我們前面各平台的進入點嗎?
忘記的話可以回去看看)
- 
我們可以先在 commonMain加入一個expectplatformModule因為目標平台可能會有不同的實作方式 若是CMP還沒支援時 可以透過 platformModule去分別實作與注入使得不同目標平台的內容 可以注入到 commonMain中例如:持久化儲存dataStore、本地話儲存RoomDatabase的Builder...等 
這邊先在 commonMain 中 expect 一個 platformModule
&
appModule()主要是用來方便你放入或擴充更多module
// in ../commonMain
expect val platformModule: Module
fun appModule() =
    listOf(platformModule,...)
接著實作androidMain & iosMain的platformModule
androidMain:
// in ../androidMain
actual val platformModule: Module = module {
    /** Add some target class that you would like to get it instance*/
    // for example : single { dataStore(get<Context>()) }
}
iosMain:
// in ../iosMain
actual val platformModule: Module = module {
    /** Add some target class that you would like to get it instance*/
    // for example : single { dataStore() }
}
- 
現在我們需要在 CMP目標平台中配置 koin首先我們在 androidMain加入koin
// in ../androidMain/../MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val androidModule = module {
            single<Context> { this@MainActivity.applicationContext }
        }
        startKoin {
            modules(appModule() + androidModule)
        }
        setContent {
            App()
        }
    }
}
關鍵程式碼解說:
- 
androidModule:因為不管Android平台或是iOS平台可能會有他們自定義的規則例如: Android有Context,但是iOS沒有可以先做一個 androidModule把Context實例配置進koin裡面
- 
startKoin:接著我們就可以把androidModule跟前面做的appModule()帶進來而根據 前面的程式碼我們前面autual 的 platformModule也會被帶進來
- 開始配置iosMain的koin
// in ../iosMain/../MainViewController.kt
fun MainViewController() = ComposeUIViewController {
    val uiViewController = LocalUIViewController.current
    val iosModule = module {
        single<UIViewControllerWrapper> { UIViewControllerWrapperImpl(uiViewController) }
    }
    KoinApplication(application = {
        modules(appModule() + iosModule)
    }) {
        App()
    }
}
interface UIViewControllerWrapper {
    fun getViewController(): UIViewController
}
class UIViewControllerWrapperImpl(private val viewController: UIViewController) : UIViewControllerWrapper {
    override fun getViewController() = viewController
}
關鍵程式碼解說:
- 
iosModule:跟android一樣,ios也有獨有的東西UIViewController,若剛好需要則可以把它做成一個 iosModule
- 
KoinApplication:接著我們就可以把iosModule跟前面做的appModule()帶進來而根據 前面的程式碼我們前面autual 的 platformModule也會被帶進來
前個區塊主要介紹了在多個目標平台上進行開發的方法
現在,我們終於可以開始開發共用模組
在 koin 中製作模組也相對直觀
首先,我們來看一下如何在 commonMain 中定義 koin 的模組
// in ../commonMain
expect val platformModule: Module
fun appModule() =
    listOf(platformModule, utilModule, viewModelModule ...)
val utilModule = module {
    single { A() }
    single { B() }
    single { C() }
}
val viewModelModule = module {
    single { SettingViewModel(get(), get(), get()) }
}
關鍵程式碼解說:
- 
single { A() }: 定義一個單例 A 的實例每次注入 A 時,koin 都會返回同一個實例 single { B() }和single { C() }: 同樣定義單例 B 和 C 的實例
- 
single { SettingViewModel(get(), get(), get()) }: 定義SettingViewModel的單例實例,並通過get()方法從koin容器中注入 A, B, C 的實例。
- 
appModule():前面我們就有先定義他了,現在把新的module加入即可。
- 
這個module主要使用koin library內提供的 module{}去創建核心概念就是把你想要的instance給創建進來而當配置好 startKoin時你就可以 透過koin幫你inject進來
現在我們能開心解放複雜的手動創instance了
fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {
    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = koinViewModel<SettingViewModel>()
        SettingScreen(navController, viewModel)
    }
}
總結
- Koin 能在 Compose Multiplatform 使用
- 通過適當的配置,可以在不同平台上靈活使用 Koin
- 使用 Koin 可以大大簡化跨平台項目的依賴管理
- 根據項目規模和複雜度,選擇合適的初始化方式