fix: DashboardViewModel 竞态条件导致 Codex 用量闪回旧值

- init 从两个独立协程改为单协程串行 loadFromCache→refresh
- refresh() 加 Mutex.tryLock() 防并发调用交错覆盖 UI
- AGENTS.md 补充刷新竞态红线说明
This commit is contained in:
WaterRain 2026-07-10 22:54:13 +00:00
parent 1a3fdff0c1
commit fbae6701b8
No known key found for this signature in database
2 changed files with 50 additions and 32 deletions

View File

@ -52,6 +52,12 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
> >
> 原因:`init` 自动加载时 `workspaceIdOverride` 为 null,协程读到 OCGO 凭据,导致 CCGO 页面闪现 OCGO 数据。 > 原因:`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 红线**:
> ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。 > ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -43,48 +44,59 @@ class DashboardViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DashboardUiState()) private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow() val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
/** 防止并发 refresh() 调用交错覆盖 UI */
private val refreshMutex = Mutex()
init { init {
loadFromCache() viewModelScope.launch {
refresh() // 1) 先从缓存快速填充 UI(断网时也能看旧数据)
loadFromCache()
// 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
refresh()
}
} }
/** 从本地缓存快速填充一次(不阻塞) */ /** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
private fun loadFromCache() { private suspend fun loadFromCache() {
viewModelScope.launch { val cached = balanceCache.getAll()
val cached = balanceCache.getAll() val cards = ServiceType.entries.map { type ->
val cards = ServiceType.entries.map { type -> buildCard(type, cachedBalance = cached[type], lastFetchError = null)
buildCard(type, cachedBalance = cached[type], lastFetchError = null)
}
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
} }
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
} }
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */ /** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(refreshing = true) } // Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope { if (!refreshMutex.tryLock()) return@launch
ServiceType.entries.map { type -> try {
async { _uiState.update { it.copy(refreshing = true) }
val status = credentialRepository.statusFor(type) val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
if (status.state == CredentialStatus.State.NOT_CONFIGURED) { ServiceType.entries.map { type ->
type to null // 未配置的服务不拉 async {
} else { val status = credentialRepository.statusFor(type)
type to refreshBalanceUseCase(type) if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
type to null // 未配置的服务不拉
} else {
type to refreshBalanceUseCase(type)
}
} }
} }.awaitAll().toMap()
}.awaitAll().toMap() }
} val newCache = balanceCache.getAll()
val newCache = balanceCache.getAll() val cards = ServiceType.entries.map { type ->
val cards = ServiceType.entries.map { type -> val result = results[type]
val result = results[type] val errMsg = result?.exceptionOrNull()?.message
val errMsg = result?.exceptionOrNull()?.message buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg)
buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg) }
} _uiState.update { it.copy(refreshing = false, cards = cards) }
_uiState.update { it.copy(refreshing = false, cards = cards) } // 刷新成功后更新桌面小组件
// 刷新成功后更新桌面小组件 if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) { OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
OpenCodeGoWidgetProvider.notifyDataChanged(appContext) }
} finally {
refreshMutex.unlock()
} }
} }
} }