diff --git a/AGENTS.md b/AGENTS.md index 341083d..9a67119 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,13 +68,23 @@ Compact(手机): → CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计) ↘ CCGO_USAGE_DATA(原始数据) - 返回用 guardedPop()(150ms 时间戳围栏)+ popExitTransition=None。 - 围栏防同一帧/连续帧的第二次 pop,popExitTransition 消旧 composable 残留窗口。 - 其余 transition 保留默认动画(enter/exit/popEnter)。 + 返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ popExit=fadeOut(1ms)+popEnter=None。 +围栏防同一帧/连续帧的第二次 pop;popExit=fadeOut(1ms) 确保旧 composable 正确从 layout 树移除 +(ExitTransition.None 是字面零帧,连续 pop 时退出 composable 可能未清除 → 幽灵残影卡在上层), +popEnter=None 消除进入动画与连点 pop 的竞态(根因:默认 popEnter fadeIn(700ms) 时, +第二次 pop 中断进入动画导致 AnimatedContent 状态不一致 → 空白页)。 +enter/exit 保留默认动画(仅影响前进导航)。 +PopGuard 额外检查 previousBackStackEntry != null,且 popBackStack() 返回 false 时 reset 围栏。 -⚠️ 已知未修复 Bug:快速连点左上角返回 → 仍然会返回到空页面。 - 根因尚未定位(已排除:动画残留、回调竞态、目标路由 no-op)。 - 待查方向:UsageDetailScreen 内部返回链路 + NavController backQueue 状态。 +✅ 已修复:快速连点左上角返回 → 空页面 / 旧页面残影卡在上层。 +根因:① mutableStateOf 写入触发 NavHost 重组,干扰 AnimatedContent 过渡状态机; + ② popExitTransition=None 字面零帧,连续 pop 时退出 composable 未从 layout 树清除; + ③ popExitTransition=None + 默认 popEnterTransition(700ms) 不匹配,第二次 pop + 中断进入动画导致 AnimatedContent 状态不一致。 +修复:① PopGuard 用普通对象替代 mutableStateOf,不触发重组; + ② popExitTransition=fadeOut(tween(1)),极短非零帧确保 composable 正确移除; + ③ popEnterTransition=EnterTransition.None,返回导航瞬时完成; + ④ previousBackStackEntry 空值检查 + popBackStack() 返回值检查。 Expanded(平板,≥840dp): ┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐ diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 39cbf32..cce505f 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -1,6 +1,10 @@ package com.rainy.token.ui +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,8 +53,8 @@ import com.rainy.token.ui.webview.WebViewLoginScreen * 应用导航图。 * * 自适应策略: - * - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(150ms)防连点栈损坏, - * popExitTransition=None 消除旧 composable 残留,其余 transition 保留默认动画。 + * - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏, + * popExit=fadeOut(1ms)+popEnter=None 消除过渡动画与连点 pop 的竞态,enter/exit 保留默认动画。 * - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换, * 用量子路由用局部 NavHost。 */ @@ -98,25 +102,61 @@ fun RainyTokenNavHost() { } // ═══════════════════════════════════════════════ -// Compact:单栈 NavHost —— guardedPop 围栏 + popExitTransition=None +// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None // ═══════════════════════════════════════════════ +/** + * 非 Compose State 的时间戳围栏。 + * + * 为什么不用 mutableStateOf: + * mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在 + * AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机, + * 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。 + * + * 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。 + */ +private class PopGuard( + private val cooldownMs: Long = 200 +) { + private var lastPopTime: Long = 0L + + fun tryAcquire(): Boolean { + val now = System.currentTimeMillis() + if (now - lastPopTime < cooldownMs) return false + lastPopTime = now + return true + } + + fun reset() { + lastPopTime = 0L + } +} + @Composable private fun CompactNavHost() { val navController = rememberNavController() - // 时间戳围栏:150ms 内只允许一次 pop,防止连点时旧 composable 残留接收触摸 - var lastPopTime by remember { mutableStateOf(0L) } + // 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组 + val popGuard = remember { PopGuard() } val guardedPop: () -> Unit = { - val now = System.currentTimeMillis() - if (now - lastPopTime >= 150) { - lastPopTime = now - navController.popBackStack() + // 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用 + if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) { + // popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却 + if (!navController.popBackStack()) { + popGuard.reset() + } } } NavHost( navController = navController, startDestination = Routes.DASHBOARD, - popExitTransition = { ExitTransition.None } + // popExit=fadeOut(1ms):极短但非零的退出动画,确保 AnimatedContent 正确执行 + // transition 结束帧来移除旧 composable。ExitTransition.None 是字面零帧, + // 连续 pop 时上一帧的退出 composable 可能未从 layout 树中清除 → 幽灵残影。 + // popEnter=None:新 composable 瞬时出现,消除进入动画与连点 pop 的竞态 + // (根因:默认 popEnter fadeIn(700ms) 时,第二次 pop 中断进入动画导致 + // AnimatedContent 状态不一致 → 空白页) + popExitTransition = { fadeOut(animationSpec = tween(1)) }, + popEnterTransition = { EnterTransition.None } ) { composable(Routes.DASHBOARD) { DashboardScreen(