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 数据。
**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 内全局唯一、不区分类型。

View File

@ -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,25 +44,33 @@ class DashboardViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
/** 防止并发 refresh() 调用交错覆盖 UI */
private val refreshMutex = Mutex()
init {
viewModelScope.launch {
// 1) 先从缓存快速填充 UI(断网时也能看旧数据)
loadFromCache()
// 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
refresh()
}
}
/** 从本地缓存快速填充一次(不阻塞) */
private fun loadFromCache() {
viewModelScope.launch {
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
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 {
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
if (!refreshMutex.tryLock()) return@launch
try {
_uiState.update { it.copy(refreshing = true) }
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
ServiceType.entries.map { type ->
@ -86,6 +95,9 @@ class DashboardViewModel @Inject constructor(
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
}
} finally {
refreshMutex.unlock()
}
}
}