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
相关产品推荐
相关产品推荐

