/ Jetpack Compose  Android  Kotlin  MVI架构  性能优化  声明式UI  Navigation  Material3 

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 场景,按本文的结构设计走,不做任何特殊优化也能有很好的性能表现。

发布评论

热门评论区: