From fbae6701b818e29d336b23f2f4714ac50d954809 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:54:13 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20DashboardViewModel=20=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=AF=BC=E8=87=B4=20Codex=20=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E9=97=AA=E5=9B=9E=E6=97=A7=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init 从两个独立协程改为单协程串行 loadFromCache→refresh - refresh() 加 Mutex.tryLock() 防并发调用交错覆盖 UI - AGENTS.md 补充刷新竞态红线说明 --- AGENTS.md | 6 ++ .../token/ui/dashboard/DashboardViewModel.kt | 76 +++++++++++-------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 077bc51..8fc1f6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,12 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut > > 原因:`init` 自动加载时 `workspaceIdOverride` 为 null,协程读到 OCGO 凭据,导致 CCGO 页面闪现 OCGO 数据。 +**DashboardViewModel 刷新竞态红线**: + +> ⚠️ `DashboardViewModel.init` 必须在**同一协程内串行**调用 `loadFromCache()` → `refresh()`,不能拆成两个独立协程。 +> 原因:并行时 `loadFromCache()` 读到的旧缓存快照可能在 `refresh()` 写入新数据后才 `_uiState.update`,导致用量数据闪回旧值(如 Codex 5h 61%→2%)。 +> `refresh()` 内部用 `Mutex.tryLock()` 防并发——下拉刷新与 init 的 refresh 并发时后者直接跳过,避免交错覆盖。 + **hiltViewModel key 红线**: > ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index 870ec21..bc0f157 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import javax.inject.Inject /** @@ -43,48 +44,59 @@ class DashboardViewModel @Inject constructor( private val _uiState = MutableStateFlow(DashboardUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** 防止并发 refresh() 调用交错覆盖 UI */ + private val refreshMutex = Mutex() + init { - loadFromCache() - refresh() + viewModelScope.launch { + // 1) 先从缓存快速填充 UI(断网时也能看旧数据) + loadFromCache() + // 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值 + refresh() + } } - /** 从本地缓存快速填充一次(不阻塞) */ - private fun loadFromCache() { - viewModelScope.launch { - val cached = balanceCache.getAll() - val cards = ServiceType.entries.map { type -> - buildCard(type, cachedBalance = cached[type], lastFetchError = null) - } - _uiState.update { it.copy(loading = false, refreshing = false, cards = cards) } + /** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */ + private suspend fun loadFromCache() { + val cached = balanceCache.getAll() + val cards = ServiceType.entries.map { type -> + buildCard(type, cachedBalance = cached[type], lastFetchError = null) } + _uiState.update { it.copy(loading = false, refreshing = false, cards = cards) } } /** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */ fun refresh() { viewModelScope.launch { - _uiState.update { it.copy(refreshing = true) } - val results: Map?> = coroutineScope { - ServiceType.entries.map { type -> - async { - val status = credentialRepository.statusFor(type) - if (status.state == CredentialStatus.State.NOT_CONFIGURED) { - type to null // 未配置的服务不拉 - } else { - type to refreshBalanceUseCase(type) + // Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过 + if (!refreshMutex.tryLock()) return@launch + try { + _uiState.update { it.copy(refreshing = true) } + val results: Map?> = coroutineScope { + ServiceType.entries.map { type -> + async { + val status = credentialRepository.statusFor(type) + if (status.state == CredentialStatus.State.NOT_CONFIGURED) { + type to null // 未配置的服务不拉 + } else { + type to refreshBalanceUseCase(type) + } } - } - }.awaitAll().toMap() - } - val newCache = balanceCache.getAll() - val cards = ServiceType.entries.map { type -> - val result = results[type] - val errMsg = result?.exceptionOrNull()?.message - buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg) - } - _uiState.update { it.copy(refreshing = false, cards = cards) } - // 刷新成功后更新桌面小组件 - if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) { - OpenCodeGoWidgetProvider.notifyDataChanged(appContext) + }.awaitAll().toMap() + } + val newCache = balanceCache.getAll() + val cards = ServiceType.entries.map { type -> + val result = results[type] + val errMsg = result?.exceptionOrNull()?.message + buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg) + } + _uiState.update { it.copy(refreshing = false, cards = cards) } + // 刷新成功后更新桌面小组件 + if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) { + OpenCodeGoWidgetProvider.notifyDataChanged(appContext) + } + } finally { + refreshMutex.unlock() } } }