fix: DashboardViewModel 竞态条件导致 Codex 用量闪回旧值
- init 从两个独立协程改为单协程串行 loadFromCache→refresh - refresh() 加 Mutex.tryLock() 防并发调用交错覆盖 UI - AGENTS.md 补充刷新竞态红线说明
This commit is contained in:
parent
1a3fdff0c1
commit
fbae6701b8
@ -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 内全局唯一、不区分类型。
|
||||
|
||||
@ -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<DashboardUiState> = _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<ServiceType, Result<ServiceBalance>?> = 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<ServiceType, Result<ServiceBalance>?> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user