Compose Multiplatform 与注解 - 共享 UI
本教程演示了一个 Compose Multiplatform 应用程序,该程序通过 The Metropolitan Museum of Art Collection API 显示博物馆艺术品。它使用 Koin 注解在具有共享 UI 的 Android 和 iOS 平台上进行依赖注入。 您大约需要 20 分钟 来完成本教程。
NOTE
更新 - 2024-11-12
获取代码
INFO
Gradle 设置
首先,添加 Koin 注解依赖项:
plugins {
id("com.google.devtools.ksp") version kspVersion
}
dependencies {
// Koin for Compose Multiplatform
implementation("io.insert-koin:koin-core:$koin_version")
implementation("io.insert-koin:koin-compose-viewmodel:$koin_version")
// Koin Annotations
implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
ksp("io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
}应用程序概览
该应用程序从远程 API 获取博物馆艺术品对象并将其显示在列表中。用户可以点击某一项来查看详细信息:
MuseumAPI -> MuseumStorage -> MuseumRepository -> ViewModels -> Compose UI
使用的技术:
- Compose Multiplatform 用于共享 UI (Android & iOS)
- Ktor 用于 HTTP 网络连接
- Koin 注解用于依赖注入
- Kotlin 协程与 Flow 用于异步操作
- Navigation Compose 用于路由
数据层
所有通用/共享代码都位于
composeAppGradle 项目中
MuseumObject 模型
博物馆艺术品对象数据类:
@Serializable
data class MuseumObject(
val objectID: Int,
val title: String,
val artistDisplayName: String,
val medium: String,
val dimensions: String,
val objectURL: String,
val objectDate: String,
val primaryImage: String,
val primaryImageSmall: String,
val repository: String,
val department: String,
val creditLine: String,
)MuseumApi - 网络层
我们创建一个 API 接口来获取数据:
interface MuseumApi {
suspend fun getData(): List<MuseumObject>
}
@Single
class KtorMuseumApi(private val client: HttpClient) : MuseumApi {
override suspend fun getData(): List<MuseumObject> {
return try {
client.get(API_URL).body()
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
emptyList()
}
}
}MuseumStorage - 本地缓存
interface MuseumStorage {
suspend fun saveObjects(newObjects: List<MuseumObject>)
fun getObjectById(objectId: Int): Flow<MuseumObject?>
fun getObjects(): Flow<List<MuseumObject>>
}
@Single
class InMemoryMuseumStorage : MuseumStorage {
private val storedObjects = MutableStateFlow(emptyList<MuseumObject>())
override suspend fun saveObjects(newObjects: List<MuseumObject>) {
storedObjects.value = newObjects
}
override fun getObjectById(objectId: Int): Flow<MuseumObject?> {
return storedObjects.map { objects ->
objects.find { it.objectID == objectId }
}
}
override fun getObjects(): Flow<List<MuseumObject>> = storedObjects
}MuseumRepository
该仓库在 API 和存储之间进行协调:
@Single(createdAtStart = true)
class MuseumRepository(
private val museumApi: MuseumApi,
private val museumStorage: MuseumStorage,
) {
private val scope = CoroutineScope(SupervisorJob())
init {
initialize()
}
fun initialize() {
scope.launch {
refresh()
}
}
suspend fun refresh() {
museumStorage.saveObjects(museumApi.getData())
}
fun getObjects(): Flow<List<MuseumObject>> = museumStorage.getObjects()
fun getObjectById(objectId: Int): Flow<MuseumObject?> = museumStorage.getObjectById(objectId)
}NOTE
@Single(createdAtStart = true) 注解确保在 Koin 启动时创建该仓库,从而立即触发数据获取。
Koin 模块
我们将依赖项组织到不同的模块中:
数据模块
@Module
@ComponentScan
class DataModule {
@Single
fun providesHttpClient(): HttpClient {
val json = Json { ignoreUnknownKeys = true }
return HttpClient {
install(ContentNegotiation) {
json(json, contentType = ContentType.Any)
}
}
}
}@ComponentScan 注解会自动发现该软件包中所有带有 @Single 注解的类 (MuseumApi, MuseumStorage, MuseumRepository)。
ViewModel 模块
让我们为这两个屏幕创建 ViewModel:
// 列表屏幕 ViewModel
@KoinViewModel
class ListViewModel(museumRepository: MuseumRepository) : ViewModel() {
val objects: StateFlow<List<MuseumObject>> =
museumRepository.getObjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
// 详情屏幕 ViewModel
@KoinViewModel
class DetailViewModel(private val museumRepository: MuseumRepository) : ViewModel() {
fun getObject(objectId: Int): StateFlow<MuseumObject?> {
return museumRepository.getObjectById(objectId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
}在一个模块中声明它们:
@ComponentScan
@Module
class ViewModelModule@KoinViewModel 注解会自动将这些类注册为 ViewModel 定义,并由 @ComponentScan 进行发现。
平台特定模块
针对平台特定组件 (Android 与 iOS):
@ComponentScan
@Module
class PlatformComponentModule主应用模块
合并所有模块:
@Configuration
@Module(includes = [DataModule::class, ViewModelModule::class, PlatformComponentModule::class])
class AppModule@Configuration- 启用通过@KoinApplication进行的自动模块发现@Module(includes = [...])- 声明要包含哪些模块
Koin 应用程序对象
创建一个 @KoinApplication 对象:
@KoinApplication
object KoinApp
fun initKoin(configuration: KoinAppDeclaration? = null) {
KoinApp.startKoin {
includes(configuration)
}
val platformInfo = KoinPlatform.getKoin().get<PlatformComponent>().getInfo()
println("Running on: $platformInfo")
}@KoinApplication 注解会生成一个 startKoin() 扩展函数,该函数会自动加载所有模块。
在 Compose 中注入 ViewModel
所有通用 Compose 应用程序都位于
composeAppGradle 模块的commonMain中
在 Compose 中使用 koinViewModel() 注入 ViewModel:
@Composable
fun App() {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
Surface {
val navController: NavHostController = rememberNavController()
NavHost(navController = navController, startDestination = ListDestination) {
composable<ListDestination> {
val vm = koinViewModel<ListViewModel>()
ListScreen(viewModel = vm, navigateToDetails = { objectId ->
navController.navigate(DetailDestination(objectId))
})
}
composable<DetailDestination> { backStackEntry ->
val vm = koinViewModel<DetailViewModel>()
DetailScreen(
objectId = backStackEntry.toRoute<DetailDestination>().objectId,
viewModel = vm,
navigateBack = { navController.popBackStack() }
)
}
}
}
}
}INFO
koinViewModel() 函数会自动检索通过 @KoinViewModel 注册的 ViewModel 实例。
启动 Koin
Android 设置
在 Android 中,Koin 从主入口点进行初始化:
// 从 Android 入口点调用
initKoin()iOS 设置
所有 iOS 应用程序都位于
iosApp文件夹中
在 iOS 中,从 SwiftUI App 入口点初始化 Koin:
@main
struct iOSApp: App {
init() {
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Compose UI 的启动方式如下:
fun MainViewController() = ComposeUIViewController { App() }注解与编译器插件 DSL 比较
以下是注解方法与编译器插件 DSL 的对比:
使用注解 (compose-annotations/):
@Configuration
@Module(includes = [DataModule::class, ViewModelModule::class])
class AppModule
@Single
class MuseumRepository(api: MuseumApi, storage: MuseumStorage)
@KoinViewModel
class ListViewModel(repository: MuseumRepository) : ViewModel()
// 启动 Koin
KoinApp.startKoin()编译器插件 DSL (compose/):
val appModule = module {
includes(dataModule, viewModelModule)
}
val dataModule = module {
single<MuseumRepository>() withOptions { createdAtStart() }
}
val viewModelModule = module {
viewModel<ListViewModel>()
}
// 启动 Koin
startKoin { modules(appModule) }两种方法都能达到相同的效果:
- 注解:通过 KSP 进行编译时验证,自动模块发现
- 编译器插件 DSL:编译时自动装配,更简洁的
single<T>()语法
