← 返回学习路线
🤖

第 7 章:Android UI(Jetpack Compose)

在 Android 端消费共享 ViewModel,用 Jetpack Compose 构建完整的 Material 3 界面

🤖 Android 模块配置

7.1 composeApp/build.gradle.kts

composeApp/build.gradle.kts plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { androidTarget() sourceSets { androidMain.dependencies { // Compose BOM(统一版本) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.material3) implementation(libs.compose.ui.tooling) implementation(libs.activity.compose) implementation(libs.navigation.compose) implementation(libs.lifecycle.viewmodel.compose) // Koin implementation(libs.koin.android) implementation(libs.koin.compose) // Coil3 implementation(libs.coil.compose) implementation(libs.coil.network.ktor) } } } android { namespace = "com.example.newsapp" compileSdk = 35 defaultConfig { applicationId = "com.example.newsapp" minSdk = 26 targetSdk = 35 versionCode = 1 versionName = "1.0" } buildFeatures { compose = true } }

💉 Koin 依赖注入

7.2 Application 初始化

androidMain — MyApplication.kt @HiltAndroidApp // 如使用 Koin,不需要这个注解 class MyApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidLogger(Level.DEBUG) androidContext(this@MyApplication) modules( sharedModule, // commonMain 中定义 databaseModule, // commonMain 中定义(expect/actual 驱动) androidModule // Android 特有模块 ) } } } // Android 特有模块(如需要 Context 的依赖) val androidModule = module { single<TokenStorage> { AndroidTokenStorage(androidContext()) } }

7.3 AndroidManifest.xml 注册 Application

AndroidManifest.xml <application android:name=".MyApplication" android:label="@string/app_name" android:theme="@style/Theme.App" ... />

🔗 消费共享 ViewModel

7.4 MainActivity + collectAsState

androidMain — MainActivity.kt class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { AppTheme { AppNavigation() } } } }
HomeScreen.kt — 消费共享 ViewModel @Composable fun HomeScreen( // koinViewModel() 从 Koin 容器获取 ViewModel viewModel: HomeViewModel = koinViewModel(), onArticleClick: (String) -> Unit = {} ) { // collectAsStateWithLifecycle:仅在 Resumed 生命周期时收集,节省资源 val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( topBar = { TopAppBar(title = { Text("每日资讯") }) } ) { paddingValues -> Box(Modifier.padding(paddingValues)) { when { uiState.isLoading -> LoadingView() uiState.error != null -> ErrorView( message = uiState.error!!, onRetry = viewModel::refresh ) else -> ArticleList( articles = uiState.articles, isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, onArticleClick = onArticleClick, onBookmarkClick = viewModel::toggleBookmark ) } } } }

📋 LazyColumn 文章列表

ArticleList.kt @Composable fun ArticleList( articles: List<Article>, isRefreshing: Boolean, onRefresh: () -> Unit, onArticleClick: (String) -> Unit, onBookmarkClick: (String) -> Unit ) { val pullRefreshState = rememberPullToRefreshState() PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = onRefresh, state = pullRefreshState ) { if (articles.isEmpty()) { EmptyState() } else { LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( items = articles, key = { it.id } // 唯一 key 提升滚动性能 ) { article -> ArticleCard( article = article, onClick = { onArticleClick(article.id) }, onBookmarkClick = { onBookmarkClick(article.id) } ) } } } } } @Composable fun ArticleCard( article: Article, onClick: () -> Unit, onBookmarkClick: () -> Unit ) { Card( onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // 封面图片(Coil3 异步加载) article.imageUrl?.let { url -> AsyncImage( model = url, contentDescription = null, modifier = Modifier .size(80.dp) .clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop ) } Column(Modifier.weight(1f)) { Text( text = article.title, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis ) Spacer(Modifier.height(4.dp)) Text( text = article.publishedAt.toDisplayString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = onBookmarkClick) { Icon( imageVector = if (article.isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, contentDescription = "收藏", tint = if (article.isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } } } }

🖼️ Coil3 图片加载

7.5 Coil3 配置

androidMain — Coil 单例配置 class MyApplication : Application(), SingletonImageLoader.Factory { override fun newImageLoader(context: PlatformContext): ImageLoader { return ImageLoader.Builder(context) .crossfade(true) .components { // 使用 Ktor 作为网络引擎(与 Ktor Client 共享连接池) add(KtorNetworkFetcherFactory()) } .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) .build() } }
Composable — AsyncImage 使用 import coil3.compose.* @Composable fun ArticleCoverImage(url: String?) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(url) .crossfade(true) .build(), contentDescription = null, placeholder = painterResource(R.drawable.ic_placeholder), error = painterResource(R.drawable.ic_error), modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), contentScale = ContentScale.Crop ) }

🎨 Material 3 主题

androidMain — AppTheme.kt private val DarkColorScheme = darkColorScheme( primary = Color(0xFF7F52FF), onPrimary = Color.White, primaryContainer = Color(0xFF4527A0), surface = Color(0xFF13101E), background = Color(0xFF0D0B14) ) private val LightColorScheme = lightColorScheme( primary = Color(0xFF7F52FF), onPrimary = Color.White, primaryContainer = Color(0xFFEDE7F6), surface = Color.White, background = Color(0xFFF5F5F5) ) @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, typography = Typography(), content = content ) }

📱 完整 Android 端实战示例

SearchBar + 列表整合 @Composable fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( topBar = { Column { TopAppBar( title = { Text("📰 每日资讯") }, actions = { IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "刷新") } } ) // 搜索框 SearchBar( query = uiState.searchQuery, onQueryChange = viewModel::onSearchQueryChange, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) ) } } ) { padding -> AnimatedContent( targetState = uiState.isLoading, modifier = Modifier.padding(padding) ) { isLoading -> if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else { ArticleList( articles = uiState.articles, isRefreshing = uiState.isRefreshing, onRefresh = viewModel::refresh, onArticleClick = {}, onBookmarkClick = viewModel::toggleBookmark ) } } } // 错误提示 Snackbar uiState.error?.let { error -> LaunchedEffect(error) { snackbarHostState.showSnackbar(error) viewModel.dismissError() } } }

🎯 实践任务

  1. 配置 composeApp 模块,添加 Compose BOM 和 Koin 依赖
  2. 在 Application 中初始化 Koin,注册共享模块和 Android 模块
  3. 实现 HomeScreen,用 koinViewModel() 获取共享 ViewModel
  4. 实现 ArticleCard,包含标题、封面图(Coil3)和收藏按钮