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 数据。
|
> 原因:`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 内全局唯一、不区分类型。
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user