A
第 53 章KOTLIN45 分钟

Compose 测试与 View 互操作

Compose UI 测试体系、AndroidView 互操作测试、ComposeView 在 Fragment/Activity 中的使用、渐进式迁移策略与性能最佳实践,并以全书结语给出下一步学习路径。

学习目标

  • 用 createComposeRule 编写 Compose UI 测试
  • 用 onNodeWithText/onNodeWithTag/performClick/assertIsDisplayed 完成交互断言
  • 测试 AndroidView 与 ComposeView 的互操作场景
  • 制定渐进式迁移策略并理解互操作边界
  • 掌握 Compose 性能最佳实践与不稳定重组的识别

学习目标

  • 用 createComposeRule 编写 Compose UI 测试;
  • 用 onNodeWithText/onNodeWithTag/performClick/assertIsDisplayed 完成交互断言;
  • 测试 AndroidView 与 ComposeView 的互操作场景;
  • 制定渐进式迁移策略并理解互操作边界;
  • 掌握 Compose 性能最佳实践与不稳定重组的识别。

Compose 测试与 View 互操作

测试是工程化的最后一公里。Compose 提供了独立的 UI 测试框架(compose-ui-test),不依赖 Espresso,API 更直观。本章先讲测试,再回到迁移与性能,最后给出全书的下一步学习路径。

依赖配置

android {
    // 测试时关闭多余的清单合并错误
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
    androidTestImplementation(composeBom)
    // Compose UI 测试
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")   // 提供测试所需的 Activity
    // Espresso 兼容(与 View 测试混用时)
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}

ui-test-manifest 必须放在 debugImplementation,它注册了测试用的 ComponentActivity,否则 createComposeRule 找不到承载 Activity。

Compose UI 测试基础:createComposeRule

createComposeRule() 返回一个 ComposeTestRule,它负责启动一个空 Activity 并设置 Compose 内容:

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test

class CounterScreenTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun `点击加一按钮后数字递增`() {
        rule.setContent {
            CounterScreen()
        }
        rule.onNodeWithText("0").assertExists()
        rule.onNodeWithText("加一").performClick()
        rule.onNodeWithText("1").assertExists()
    }
}

要点:

  • setContent { ... } 设置要测的 Composable;
  • onNodeWithText/onNodeWithTag 查找节点;
  • performClick/performTextInput/performScrollTo 模拟交互;
  • assertExists/assertIsDisplayed/assertText 做断言。

查找节点:onNodeWithText 与 onNodeWithTag

onNodeWithText 按可见文本查找,对本地化友好但脆弱(文案改动会挂测试)。更推荐用 testTag:

@Composable
fun LoginScreen() {
    var name by remember { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { name = it },
        modifier = Modifier.testTag("usernameField")
    )
    Button(onClick = { /* ... */ }, modifier = Modifier.testTag("loginButton")) {
        Text("登录")
    }
}

测试:

@Test
fun `输入用户名后点击登录`() {
    rule.setContent { LoginScreen() }
    rule.onNodeWithTag("usernameField").performTextInput("Tom")
    rule.onNodeWithTag("loginButton").performClick()
    // 断言后续 UI 状态
}

testTag 在 Release 版默认会被剥离(节省性能),如需在 Release 也保留,在 androidx.compose.ui.test 中通过 testTagAsResourceId(true) 启用,或在 Modifier.testTag 配合 SemanticsPropertyReceiver 保留。

常用断言与交互

// 断言显示
rule.onNodeWithTag("loginButton").assertIsDisplayed()
rule.onNodeWithTag("loginButton").assertIsEnabled()
// 断言文本
rule.onNodeWithText("欢迎").assertTextEquals("欢迎,Tom")
// 不存在
rule.onNodeWithText("加载中").assertDoesNotExist()
// 滚动到节点(LazyColumn 中常用)
rule.onNodeWithText("第50条").performScrollTo()
// 双击/长按
rule.onNodeWithTag("card").performTouchInput {
    longClick()
}

Compose 测试框架不依赖 Espresso,但 API 思路一致:查找-交互-断言。所有交互都是同步的,不需要写 Thread.sleep 等动画结束。

测试 ViewModel 与状态

测试中替换 ViewModel 是关键。可用 viewModel(mock)、provideViewModel 或直接传 mock 实例给 Composable(推荐):

class FakeCounterViewModel : CounterViewModel() {
    override val count = MutableStateFlow(5)
}

@Test
fun `默认值渲染`() {
    val vm = FakeCounterViewModel()
    rule.setContent {
        CounterScreen(vm = vm)
    }
    rule.onNodeWithText("5").assertExists()
}

通过依赖注入(构造参数或 viewModel(factory = ...)),让 UI 测试可以传任意 fake/real ViewModel。

测试 AndroidView 互操作

AndroidView 内嵌的传统 View 也能被测到,但要用 Espresso 的方式查找:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.withId

@Test
fun `AndroidView 内的 WebView 加载`() {
    rule.setContent {
        AndroidView(
            factory = { ctx -> WebView(ctx).apply { id = R.id.testWebView } },
            update = { it.loadUrl("about:blank") }
        )
    }
    // 用 Espresso 找到 WebView
    onView(withId(R.id.testWebView)).check(matches(isDisplayed()))
}

Compose 节点树与 View 节点树是独立的,Compose 查找 API 找不到 AndroidView 内部的 View。反之亦然:Espresso 找不到 Compose 节点。混合测试要分清用哪套 API。

ComposeView 在 Fragment/Activity 中

把 Compose 嵌入传统 Fragment/Activity 时,互操作边界要清晰:

class ComposeFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, s: Bundle?
    ): View = ComposeView(requireContext()).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
        setContent {
            MyAppTheme { DetailContent() }
        }
    }
}

在测试 ComposeView 时,用 launchFragmentInContainer:

import androidx.fragment.app.testing.launchFragmentInContainer

@Test
fun `Fragment 中 Compose 渲染`() {
    val scenario = launchFragmentInContainer<ComposeFragment>(themeResId = R.style.Theme_App)
    scenario.onFragment {
        Espresso
            .onView(withId(R.id.root))
            .check(matches(isDisplayed()))
    }
}

渐进式迁移策略

把传统 View 项目迁到 Compose,推荐“由表及里、分阶段推进”。

阶段 1:叶子组件试点

先迁“无依赖、改动频繁”的叶子组件,如某个列表项、按钮、头像。在传统布局里用 ComposeView 嵌入:

class ProfileItemView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AbstractComposeView(context, attrs) {

    var name by mutableStateOf("")
    var avatar by mutableStateOf<Any?>(null)

    @Composable
    override fun Content() {
        MyAppTheme {
            Row(verticalAlignment = Alignment.CenterVertically) {
                AsyncImage(avatar, null, Modifier.size(40.dp))
                Text(name)
            }
        }
    }
}

XML 中直接用 <com.example.ProfileItemView ... />。

阶段 2:整屏迁移

把整个 Fragment 的内容用 ComposeView 替换,Fragment 仅作“导航容器”。这阶段 ViewModel 仍是 View 体系的,UI 状态用 LiveData/StateFlow 接入(见第 49 章)。

阶段 3:导航与全 Compose

到这一步,把 Fragment + Navigation 完全换成 Compose 版 NavHost,MainActivity 里只剩一个 setContent { AppNavHost() }。整个 App 跑在 Compose 上,传统 View 仅剩 AndroidView 嵌入的地图、WebView、广告等。

互操作边界

场景 推荐做法
单个 Compose 组件嵌入 XML ComposeView 或 AbstractComposeView
单个传统 View 嵌入 Compose AndroidView
状态在 Compose 与 View 之间共享 经 ViewModel + StateFlow/LiveData
主题切换 Compose 用 MaterialTheme,View 仍用 MaterialComponents,两者颜色由同一组资源驱动
测试 Compose 部分 createComposeRule,View 部分 Espresso,混合互操作时分别测

边界原则:不要把 Compose 与 View “交错”在一个组件内部,应整块切换。一次切换一片叶子、一屏或一模块,互操作边界越少,调试与性能越好。

性能最佳实践

1. 让可组合函数稳定

Compose 通过“稳定性”决定是否跳过重组。data class + 不可变字段 + val 默认稳定;List/Map 不稳定,应包装:

@Immutable
data class UserList(val items: List<User>)   // 标记为稳定

@Composable
fun UserListRow(users: UserList) { /* ... */ }   // 引用不变即跳过重组

2. 用 remember 避免重复计算

@Composable
fun Greeting(names: List<String>) {
    val first = remember(names) { names.firstOrNull() }  // 只在 names 变化时重算
    Text(first ?: "无")
}

3. 用 derivedStateOf 减少冗余重组

val showClear by remember { derivedStateOf { keyword.isNotEmpty() } }

4. LazyColumn 的 key

LazyColumn {
    items(items, key = { it.id }) { item -> ItemRow(item) }
}

key 让框架在增删时复用节点与状态,避免“位置错位”与不必要的重组。

5. 避免在 lambda 里 new 大对象

// 差:每次重组都 new
LazyColumn {
    items(list) { Modifier.background(randomColor()) }
}

// 好:Modifier 用 remember

6. 检测不稳定重组

用 Layout Inspector 的 “Recomposition Counts” 或运行 composable.stability.knownParameters 检查。对怀疑的可组合加 Log.d("RECOMPOSE", "...") 看是否被频繁触发。

Compose 1.6+ 提供了 Modifier.composed 的替代 Modifier 工厂,避免每个 Modifier 链都重新构建。

7. 大列表用 LazyColumn 而非 Column

Column 一次性把所有子项组合,LazyColumn 只组合可见项。前者在 100+ 项时严重卡顿。

8. 重组外的状态修改

不要在 remember 块里修改 MutableState,也不要在 derivedStateOf 的计算块里改变外部状态。所有状态变化要由“事件 → 状态”流程触发。

实战:完整测试用例

下面给一个可点击的 CounterScreen 配套测试:

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    Column(Modifier.testTag("root")) {
        Text("$count", Modifier.testTag("count"))
        Button(onClick = { count++ }, Modifier.testTag("inc")) {
            Text("加一")
        }
    }
}

class CounterScreenTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun 初始值为0() {
        rule.setContent { CounterScreen() }
        rule.onNodeWithTag("count").assertTextEquals("0")
    }

    @Test
    fun 点击后递增() {
        rule.setContent { CounterScreen() }
        repeat(3) {
            rule.onNodeWithTag("inc").performClick()
        }
        rule.onNodeWithTag("count").assertTextEquals("3")
    }

    @Test
    fun root可见() {
        rule.setContent { CounterScreen() }
        rule.onNodeWithTag("root").assertIsDisplayed()
    }
}

常见坑与最佳实践

  1. 忘记 debugImplementation("ui-test-manifest"):测试启动时找不到默认 Activity 报错。
  2. onNodeWithText 匹配多个节点:会抛 AssertionError。用 onAllNodesWithText(...)[0] 或更精确的 testTag。
  3. testTag 在 Release 被剥离:如需在端上测试,启用 testTagAsResourceId(true) 让 tag 走资源 ID 通道。
  4. 把 ViewModel 写死在 Composable 内部:无法注入 fake。vm: Vm = viewModel() 是默认值,测试时可显式传 fake。
  5. 测试中依赖动画结束:performClick 后状态变化是同步的,不要 Thread.sleep 等待。但动画 AnimatedVisibility 的进入退出需要 waitForIdle() 让 recomposition 完成。
  6. Compose 节点与 View 节点混测:Compose onNodeWithTag 找不到 AndroidView 内部 View;反之亦然。互操作测试要分清边界。
  7. ComposeView 没设 setViewCompositionStrategy:默认 DisposeOnViewTreeLifecycleDestroyed,但 Fragment 中要明确确认,避免泄漏。
  8. 迁移过程中新旧两套主题并存:View 体系用 Theme.Material3.DayNight、Compose 用 MaterialTheme,颜色定义要同步,否则风格不统一。
  9. 大列表用 Column:应改用 LazyColumn + key。
  10. derivedStateOf 不包 remember:每次重组都 new 实例,缓存失效。

章节小结

本章覆盖了 Compose 的测试与互操作收尾:createComposeRule 是测试入口,setContent 设置内容;onNodeWithTag 比 onNodeWithText 更稳定;performClick/performTextInput/assertIsDisplayed/assertTextEquals 完成交互断言;AndroidView 内的 View 用 Espresso 测,ComposeView 在 Fragment 中用 launchFragmentInContainer 测;迁移遵循“叶子 → 整屏 → 全 Compose”三阶段,互操作边界尽量清晰;性能上要让 Composable 稳定、用 remember/derivedStateOf/LazyColumn 优化重组。

结语与下一步

恭喜你读完了这本《Android 开发指南》。从第 1 章的环境搭建到第 53 章的 Compose 测试,我们走过了完整的现代 Android 开发链路:

  • 基础篇:JDK、Android Studio、Gradle 与项目结构、Kotlin 与 Java 基础;
  • UI 基础篇:布局、常用控件、RecyclerView、自定义 View;
  • 核心组件篇:Activity 生命周期、Intent、Fragment、权限、通知、推送、Service、WorkManager、广播;
  • 数据与网络篇:Retrofit + OkHttp、Gson、SharedPreferences、Room、Glide、文件存储;
  • 架构篇:MVC/MVP/MVVM、ViewModel + LiveData、Dagger 2、RxJava、测试;
  • 体验篇:动画、深色模式与主题;
  • 发布篇:混淆、签名、CI/CD、崩溃监控;
  • 现代开发篇(Part 8-10):Kotlin + Compose 的全套现代 UI 体系。

下一步学习建议

  1. 深入 Kotlin:协程、Flow、ChannelFlow、SharedFlow 与背压、@JvmName/@JvmField 互操作;
  2. Compose 进阶:自定义 Layout、SubcomposeLayout、Modifier.composed、CompositionLocal 深入、Semantics 与无障碍;
  3. 架构演进:从 MVVM 走向 MVI(单向数据流 + 不可变状态)、模块化与多模块工程、Clean Architecture;
  4. 依赖注入现代化:从 Dagger 2 迁移到 Hilt,体验 Android 注入的开箱即用;
  5. 后端通信:WebSocket、gRPC、Protobuf;离线优先架构(Room + WorkManager + Retrofit 同步);
  6. 性能与稳定性:Macrobenchmark、Baseline Profile、ANR 监控、内存抖动与 LeakCanary;
  7. 跨平台探索:Kotlin Multiplatform(KMP)、Compose Multiplatform、Flutter、React Native——按需选择;
  8. 持续关注官方:developer.android.com 的 What’s New、androidx 版本说明、Google I/O 大会更新。

学习方法论

  • 边读边写:每个章节的示例都自己跑一遍,改一改参数感受行为;
  • 看官方 Sample:android/compose-samples、android/nowinandroid 是优质参考工程;
  • 读源码:从 androidx.compose.material3 入手,理解一个组件是如何用低层 API 组合出来的;
  • 写博客/笔记:把踩过的坑整理出来,是对自己最好的复盘。

愿你写出流畅、稳定、用户喜爱的 Android 应用。下一站,由你定义。