You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Compose多页面TopAppBar实现遇测试超时问题求助

问题描述

开发Jetpack Compose应用时,为每个页面配置不同的TopAppBar,采用页面传递TopAppBar状态设置器的方案,但传入onClick Lambda后测试抛出ComposeNotIdleException(空闲资源超时,Compose处于忙碌状态)。若移除ActionState中onClick的相等性检查,测试可通过,但从其他页面返回后TopAppBar操作会失效,推测是使用了过期状态数据。

可复现代码

// ActionState.kt
class ActionState(val title: String = "", val onClick: () -> Unit = {}) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as ActionState

        if (title != other.title) return false
        // 注释此行可修复测试但会破坏实际功能
        if (onClick != other.onClick) return false

        return true
    }

    override fun hashCode(): Int {
        var result = title.hashCode()
        result = 31 * result + onClick.hashCode()
        return result
    }
}

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            App()
        }
    }
}

@Composable
fun TopBar(actionState: ActionState) {
    TopAppBar(
        title = { Text("App") },
        actions = {
            val contentColor = contentColorFor(MaterialTheme.colors.primarySurface)
            val colors = ButtonDefaults.textButtonColors(contentColor = contentColor)
            TextButton(onClick = actionState.onClick, colors = colors) {
                Text(actionState.title)
            }
        },
    )
}

@Composable
fun ScreenA(setActionState: (ActionState) -> Unit, navigateToScreenB: () -> Unit) {
    setActionState(ActionState("To B", navigateToScreenB))
    Text("Screen A")
}

@Composable
fun ScreenB(setActionState: (ActionState) -> Unit, navigateToScreenA: () -> Unit) {
    setActionState(ActionState("To A", navigateToScreenA))
    Text("Screen B")
}

@Composable
fun App() {
    val navController = rememberNavController()
    val (actionState, setActionState) = remember { mutableStateOf(ActionState()) }
    Scaffold(topBar = { TopBar(actionState = actionState) }) {
        NavHost(navController = navController, startDestination = "a") {
            composable("a") {
                ScreenA(
                    setActionState = setActionState,
                    navigateToScreenB = { navController.navigate("b") },
                )
            }
            composable("b") {
                ScreenB(
                    setActionState = setActionState,
                    navigateToScreenA = { navController.navigate("a") },
                )
            }
        }
    }
}

// Test
class ExampleInstrumentedTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun test() {
        composeTestRule.onNodeWithText("Screen A").assertIsDisplayed()
        composeTestRule.onNodeWithText("To B").performClick()
        composeTestRule.onNodeWithText("Screen B").assertIsDisplayed()
    }
}

错误信息

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@408168
All registered idling resources: Compose-Espresso link
at androidx.compose.ui.test.junit4.android.EspressoLink_androidKt.rethrowWithMoreInfo(EspressoLink.android.kt:135)
at androidx.compose.ui.test.junit4.android.EspressoLink_androidKt.runEspressoOnIdle(EspressoLink.android.kt:109)
at androidx.compose.ui.test.junit4.android.EspressoLink.runUntilIdle(EspressoLink.android.kt:78)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule.waitForIdle(AndroidComposeTestRule.android.kt:289)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule.access$waitForIdle(AndroidComposeTestRule.android.kt:155)
at androidx.compose.ui.test.junit4.AndroidComposeTestRule$AndroidTestOwner.getRoots(AndroidComposeTestRule.android.kt:441)
at androidx.compose.ui.test.TestContext.getAllSemanticsNodes$ui_test_release(TestOwner.kt:95)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNodes$ui_test_release(SemanticsNodeInteraction.kt:79)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrDie(SemanticsNodeInteraction.kt:145)
at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNode(SemanticsNodeInteraction.kt:96)
at androidx.compose.ui.test.AndroidAssertions_androidKt.checkIsDisplayed(AndroidAssertions.android.kt:29)
at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:33)
at ogbe.eva.topbarbug.ExampleInstrumentedTest.test(ExampleInstrumentedTest.kt:16)
... 33 trimmed
Caused by: androidx.test.espresso.IdlingResourceTimeoutException: Wait for [Compose-Espresso link] to become idle timed out
at androidx.test.espresso.IdlingPolicy.handleTimeout(IdlingPolicy.java:4)
at androidx.test.espresso.base.UiControllerImpl$5.resourcesHaveTimedOut(UiControllerImpl.java:1)
at androidx.test.espresso.base.IdlingResourceRegistry$Dispatcher.handleTimeout(IdlingResourceRegistry.java:4)
at androidx.test.espresso.base.IdlingResourceRegistry$Dispatcher.handleMessage(IdlingResourceRegistry.java:6)
at android.os.Handler.dispatchMessage(Handler.java:102)
at androidx.test.espresso.base.Interrogator.loopAndInterrogate(Interrogator.java:14)
at androidx.test.espresso.base.UiControllerImpl.loopUntil(UiControllerImpl.java:8)
at androidx.test.espresso.base.UiControllerImpl.loopMainThreadUntilIdle(UiControllerImpl.java:17)
at androidx.test.espresso.Espresso$1.run(Espresso.java:1)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loopOnce(Looper.java:201)
at android.os.Looper.loop(Looper.java:288)
at android.app.ActivityThread.main(ActivityThread.java:7839)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)

解决方案

一、修复测试超时问题

核心原因是Lambda的引用相等性导致无限重组循环:每次页面重组都会生成新的onClick Lambda实例,ActionState的equals判断为不相等,触发状态更新,进而引发新一轮重组,导致Compose一直处于忙碌状态,测试超时。

1. 修改ActionState的相等性判断逻辑

放弃比较onClick Lambda,改用业务标识(如枚举)判断状态是否需要更新:

class ActionState(
    val title: String = "",
    val actionType: ActionType = ActionType.NONE,
    val onClick: () -> Unit = {}
) {
    enum class ActionType {
        NONE, GO_TO_SCREEN_A, GO_TO_SCREEN_B
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as ActionState

        if (title != other.title) return false
        if (actionType != other.actionType) return false

        return true
    }

    override fun hashCode(): Int {
        var result = title.hashCode()
        result = 31 * result + actionType.hashCode()
        return result
    }
}

页面调用时传递枚举标识:

@Composable
fun ScreenA(setActionState: (ActionState) -> Unit, navigateToScreenB: () -> Unit) {
    setActionState(ActionState(
        title = "To B",
        actionType = ActionState.ActionType.GO_TO_SCREEN_B,
        onClick = navigateToScreenB
    ))
    Text("Screen A")
}

2. 用LaunchedEffect限制状态更新时机

页面直接调用setActionState会在每次重组时执行,改用LaunchedEffect仅在页面首次进入时更新状态:

@Composable
fun ScreenA(setActionState: (ActionState) -> Unit, navigateToScreenB: () -> Unit) {
    LaunchedEffect(Unit) {
        setActionState(ActionState(
            title = "To B",
            actionType = ActionState.ActionType.GO_TO_SCREEN_B,
            onClick = navigateToScreenB
        ))
    }
    Text("Screen A")
}

二、更优的多页面TopAppBar实现方案

方案1:基于Navigation栈自动更新

监听导航栈变化,根据当前路由直接生成TopAppBar状态,无需页面手动传递:

@Composable
fun App() {
    val navController = rememberNavController()
    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = currentBackStackEntry?.destination?.route

    val topBarActionState = remember(currentRoute) {
        when(currentRoute) {
            "a" -> ActionState("To B", onClick = { navController.navigate("b") })
            "b" -> ActionState("To A", onClick = { navController.navigate("a") })
            else -> ActionState()
        }
    }

    Scaffold(topBar = { TopBar(actionState = topBarActionState) }) {
        NavHost(navController = navController, startDestination = "a") {
            composable("a") { Text("Screen A") }
            composable("b") { Text("Screen B") }
        }
    }
}

方案2:ViewModel集中管理状态

创建ViewModel统一管理TopAppBar状态,页面通过ViewModel更新状态,测试时可轻松模拟:

class TopBarViewModel : ViewModel() {
    private val _actionState = MutableStateFlow(ActionState())
    val actionState = _actionState.asStateFlow()

    fun updateActionState(newState: ActionState) {
        if (newState != _actionState.value) {
            _actionState.value = newState
        }
    }
}

@Composable
fun App() {
    val topBarViewModel = viewModel<TopBarViewModel>()
    val actionState by topBarViewModel.actionState.collectAsStateWithLifecycle()
    val navController = rememberNavController()

    Scaffold(topBar = { TopBar(actionState = actionState) }) {
        NavHost(navController = navController, startDestination = "a") {
            composable("a") {
                ScreenA(
                    updateActionState = topBarViewModel::updateActionState,
                    navigateToScreenB = { navController.navigate("b") }
                )
            }
            composable("b") {
                ScreenB(
                    updateActionState = topBarViewModel::updateActionState,
                    navigateToScreenA = { navController.navigate("a") }
                )
            }
        }
    }
}

@Composable
fun ScreenA(updateActionState: (ActionState) -> Unit, navigateToScreenB: () -> Unit) {
    LaunchedEffect(Unit) {
        updateActionState(ActionState("To B", onClick = navigateToScreenB))
    }
    Text("Screen A")
}

内容的提问来源于stack exchange,提问作者Eva

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.24 16:36:06