Jetpack Compose 实战:从零到高性能 Android UI 的完整指南

Jetpack Compose 已经发布正式版超过两年,但很多 Android 开发者在实际项目中落地时仍然踩了不少坑:性能抖动、状态管理混乱、与旧 View 体系混用问题频发。本文基于真实项目经验,从「能跑」到「跑得好」,系统梳理 Compose 实战中的核心技巧与常见陷阱。
一、为什么要用 Jetpack Compose?先看清楚再下手
很多团队是"追风"式迁移到 Compose,结果踩坑后悔。在动手之前,先明确几点:
优势:声明式 UI、更少的样板代码、与 Kotlin 协程天然配合、热重载(Live Edit)支持
当前局限:部分动画复杂场景性能弱于 View、与部分第三方 View 库兼容性差、编译速度较慢
适合场景:新项目、纯 Kotlin 栈、以列表/表单/详情页为主的 UI
如果你的项目大量依赖 Google Maps、WebView、自定义 SurfaceView,短期内不建议强行全迁移。
二、项目结构设计:从 MVI 架构角度组织 Compose 代码
Compose 最适合配合 MVI(Model-View-Intent)架构使用。推荐目录结构如下:
app/ ├── ui/ │ ├── screen/ │ │ ├── home/ │ │ │ ├── HomeScreen.kt # UI 入口,只负责组合组件 │ │ │ ├── HomeViewModel.kt # 持有 UiState,处理事件 │ │ │ └── HomeUiState.kt # 密封类定义页面状态 │ │ └── detail/ │ ├── component/ # 可复用的原子组件 │ └── theme/ # 主题、颜色、字体 ├── domain/ # 业务逻辑层(纯 Kotlin) └── data/ # 数据层
定义 UiState 时用 sealed class 或 data class:
// HomeUiState.kt
sealed class HomeUiState {
object Loading : HomeUiState()
data class Success(val items: List<ArticleItem>) : HomeUiState()
data class Error(val message: String) : HomeUiState()
}
// HomeViewModel.kt
class HomeViewModel(private val repo: ArticleRepository) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadArticles()
}
fun loadArticles() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
try {
val items = repo.getArticles()
_uiState.value = HomeUiState.Success(items)
} catch (e: Exception) {
_uiState.value = HomeUiState.Error(e.message ?: "未知错误")
}
}
}
}Screen 层只负责消费状态,不写任何业务逻辑:
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Success -> ArticleList(items = (uiState as HomeUiState.Success).items)
is HomeUiState.Error -> ErrorView(message = (uiState as HomeUiState.Error).message) {
viewModel.loadArticles()
}
}
}三、性能优化:避免不必要的重组(Recomposition)
这是 Compose 实战中最容易踩的坑。每次状态变化都可能触发重组,如果组件设计不当,会导致严重的性能问题。
3.1 用 remember 和 derivedStateOf 缓存计算
// ❌ 错误写法:每次重组都重新计算
@Composable
fun ArticleList(items: List<ArticleItem>) {
val sortedItems = items.sortedByDescending { it.publishTime } // 每次重组都排序!
LazyColumn {
items(sortedItems) { ArticleCard(it) }
}
}
// ✅ 正确写法:用 remember 缓存,derivedStateOf 监听依赖变化
@Composable
fun ArticleList(items: List<ArticleItem>) {
val sortedItems by remember(items) {
derivedStateOf { items.sortedByDescending { it.publishTime } }
}
LazyColumn {
items(sortedItems, key = { it.id }) { ArticleCard(it) }
}
}3.2 stable 和 immutable 注解减少重组范围
// ❌ List 是不稳定类型,会导致父组件重组时子组件一起重组 data class UserProfile(val name: String, val tags: List<String>) // ✅ 使用 @Immutable 标注数据类为稳定类型 @Immutable data class UserProfile(val name: String, val tags: List<String>)
或者使用 Kotlin 不可变集合库 `kotlinx.collections.immutable`:
// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
// 使用
data class UserProfile(
val name: String,
val tags: ImmutableList<String> // 稳定类型,Compose 可感知
)3.3 用 Layout Inspector 实际测量重组次数
Android Studio 的 Layout Inspector 可以实时显示每个 Composable 的重组次数。步骤:
运行 Debug 包,连接设备
菜单:View → Tool Windows → Layout Inspector
勾选「Show recomposition counts」
操作 UI,观察红色高亮(重组频繁)的组件
四、与旧 View 体系混用:AndroidView 和 ComposeView 的正确姿势
实际项目中完全不用 View 几乎不可能,以下是混用的标准写法:
4.1 在 Compose 中嵌入 View:AndroidView
@Composable
fun WebViewComposable(url: String) {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = WebViewClient()
settings.javaScriptEnabled = true
}
},
update = { webView ->
// update 在每次重组时调用,注意避免重复加载
webView.loadUrl(url)
},
modifier = Modifier.fillMaxSize()
)
}⚠️ 注意:`update` lambda 会在每次 Composable 重组时执行,如果 `url` 没变化却重新 loadUrl 会导致页面闪烁。应该用 `LaunchedEffect` 或在 update 中判断:
update = { webView ->
if (webView.url != url) { // 只有 URL 变化才加载
webView.loadUrl(url)
}
}4.2 在 XML 布局中嵌入 Compose:ComposeView
<!-- fragment_profile.xml --> <LinearLayout ...> <TextView android:id="@+id/title" ... /> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout>
// ProfileFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
ProfileCard(viewModel = viewModel)
}
}
}
}⚠️ 必须设置 `setViewCompositionStrategy`,否则在 Fragment 中会有内存泄漏风险。
五、导航:Navigation Compose 实战与深链接配置
推荐用 Navigation Compose 统一管理路由,定义路由常量避免字符串硬编码:
// NavRoutes.kt
object NavRoutes {
const val HOME = "home"
const val ARTICLE_DETAIL = "article/{articleId}"
const val PROFILE = "profile"
fun articleDetail(id: String) = "article/$id"
}
// AppNavGraph.kt
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = NavRoutes.HOME) {
composable(NavRoutes.HOME) {
HomeScreen(
onArticleClick = { id ->
navController.navigate(NavRoutes.articleDetail(id))
}
)
}
composable(
route = NavRoutes.ARTICLE_DETAIL,
arguments = listOf(navArgument("articleId") { type = NavType.StringType }),
deepLinks = listOf(navDeepLink { uriPattern = "myapp://article/{articleId}" })
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: return@composable
ArticleDetailScreen(articleId = articleId)
}
}
}六、主题系统:Material3 动态配色的正确接入方式
// Theme.kt
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, // Android 12+ 动态配色
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 = AppTypography,
content = content
)
}七、实战踩坑总结与最佳实践清单
以下是从真实项目中整理的高频坑点:
❌ 不要在 Composable 函数中直接创建 ViewModel(用 hiltViewModel() 或 viewModel())
❌ 不要在 Composable 中直接读写 SharedPreferences(应该通过 ViewModel/Repository 层)
❌ 不要用 GlobalScope 启动协程(Compose 侧用 LaunchedEffect,ViewModel 用 viewModelScope)
✅ LazyColumn 的 items 必须提供 key 参数,避免列表更新时的全量重组
✅ 图片加载推荐 Coil(完整 Compose 支持):
AsyncImage(model = url, contentDescription = null)✅ 需要访问 Context 时用
LocalContext.current,不要把 Context 传入 ViewModel✅ 列表分页用 Paging 3 +
collectAsLazyPagingItems(),官方 Compose 扩展很成熟
// 分页列表标准写法
@Composable
fun ArticlePagingList(viewModel: HomeViewModel = hiltViewModel()) {
val pagingItems = viewModel.articlePager.collectAsLazyPagingItems()
LazyColumn {
items(
count = pagingItems.itemCount,
key = pagingItems.itemKey { it.id }
) { index ->
val item = pagingItems[index]
if (item != null) ArticleCard(item)
else ArticleCardPlaceholder()
}
when (val loadState = pagingItems.loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error -> item {
RetryButton(onClick = { pagingItems.retry() })
}
else -> {}
}
}
}Jetpack Compose 的学习曲线主要在于思维转换,从「如何操作 View」变为「如何描述状态」。遇到性能问题不要慌,先用 Layout Inspector 定位重组热点,再针对性优化。实际上大多数日常 UI 场景,按本文的结构设计走,不做任何特殊优化也能有很好的性能表现。
发布评论
热门评论区: