perf: optimize screen load times with progressive un-gating and SWR caching (#1192)
This commit is contained in:
55 files changed
+2494
-401
No files matched your search
@@ -99,7 +99,7 @@ fun ConfigScreen(
|
||||
title = { Text(stringResource(R.string.config_screen_title)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAll() },
|
||||
onRefresh = { viewModel.loadAll(forceRefresh = true) },
|
||||
actions = {
|
||||
if (!state.yamlMode && state.modifiedKeys.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.saveConfig() }) {
|
||||
|
||||
@@ -2,12 +2,16 @@ package com.m57.hermescontrol.ui.config
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.SwrCache
|
||||
import com.m57.hermescontrol.data.model.ConfigSchemaResponse
|
||||
import com.m57.hermescontrol.data.model.ConfigUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -50,35 +54,114 @@ class ConfigViewModel :
|
||||
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val pendingChanges = mutableMapOf<String, JsonElement>()
|
||||
private val schemaCache = SwrCache<String, ConfigSchemaResponse>(maxCapacity = 10)
|
||||
private val defaultsCache = SwrCache<String, Map<String, JsonElement>>(maxCapacity = 10)
|
||||
|
||||
init {
|
||||
// Scope transitions are driven by ProfileSwitchCoordinator (profile switch and
|
||||
// connection switch). An additional AuthManager.dataScopeFlow collector would
|
||||
// double-fire on the same transition and issue a duplicate loadAll().
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.switched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched.collect {
|
||||
onDataScopeChanged(AuthManager.currentDataScope())
|
||||
}
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll() {
|
||||
viewModelScope.launch {
|
||||
private fun onDataScopeChanged(newScope: DataScope?) {
|
||||
pendingChanges.clear()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
values = null,
|
||||
schema = null,
|
||||
defaults = null,
|
||||
uncoveredPaths = emptyList(),
|
||||
path = null,
|
||||
yamlText = null,
|
||||
modifiedKeys = emptySet(),
|
||||
isSaving = false,
|
||||
yamlIsSaving = false,
|
||||
yamlIsLoading = false,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
// A scope switch must load the new context. The scoped caches mean this paints the
|
||||
// new scope's own entry instantly when one exists, then revalidates in the background.
|
||||
loadAll()
|
||||
}
|
||||
|
||||
fun loadAll(forceRefresh: Boolean = false) {
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val scopeKey = requestScope?.inMemoryKey("schema") ?: ""
|
||||
if (forceRefresh && scopeKey.isNotBlank()) {
|
||||
schemaCache.remove(scopeKey)
|
||||
defaultsCache.remove(scopeKey)
|
||||
}
|
||||
val cachedSchema = if (scopeKey.isNotBlank()) schemaCache.get(scopeKey) else null
|
||||
val cachedDefaults = if (scopeKey.isNotBlank()) defaultsCache.get(scopeKey) else null
|
||||
val hasCachedData = _uiState.value.values != null
|
||||
if (!hasCachedData) {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
coroutineScope {
|
||||
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
|
||||
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
val schemaDeferred =
|
||||
if (cachedSchema == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val defaultsDeferred =
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
if (cachedDefaults == null) {
|
||||
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
|
||||
|
||||
val configResult = configDeferred.await()
|
||||
val schemaResult = schemaDeferred.await()
|
||||
val defaultsResult = defaultsDeferred.await()
|
||||
val schema: ConfigSchemaResponse? =
|
||||
(schemaDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) schemaCache.put(scopeKey, it)
|
||||
} ?: cachedSchema
|
||||
val defaults: Map<String, JsonElement>? =
|
||||
(defaultsDeferred?.await() as? NetworkResult.Success)?.data?.also {
|
||||
if (scopeKey.isNotBlank()) defaultsCache.put(scopeKey, it)
|
||||
} ?: cachedDefaults
|
||||
val rawResult = rawDeferred.await()
|
||||
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@coroutineScope
|
||||
|
||||
if (configResult is NetworkResult.Success) {
|
||||
val values = flattenConfig(configResult.data)
|
||||
val schema = (schemaResult as? NetworkResult.Success)?.data
|
||||
val defaults = (defaultsResult as? NetworkResult.Success)?.data
|
||||
val path = (rawResult as? NetworkResult.Success)?.data?.path
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
_uiState.update { state ->
|
||||
val categories = schema?.category_order ?: emptyList()
|
||||
val validCategory =
|
||||
when {
|
||||
state.activeCategory.isNotBlank() && state.activeCategory in categories -> {
|
||||
state.activeCategory
|
||||
}
|
||||
|
||||
categories.isNotEmpty() -> {
|
||||
categories.first()
|
||||
}
|
||||
|
||||
else -> {
|
||||
state.activeCategory
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
values = values,
|
||||
schema = schema,
|
||||
@@ -89,15 +172,11 @@ class ConfigViewModel :
|
||||
schema?.fields?.keys ?: emptySet(),
|
||||
),
|
||||
path = path,
|
||||
activeCategory =
|
||||
if (schema?.category_order?.isNotEmpty() == true) {
|
||||
schema.category_order.first()
|
||||
} else {
|
||||
""
|
||||
},
|
||||
activeCategory = validCategory,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (!hasCachedData) {
|
||||
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
|
||||
@@ -105,8 +184,11 @@ class ConfigViewModel :
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope == requestScope && !hasCachedData) {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +280,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveConfig() {
|
||||
if (pendingChanges.isEmpty()) return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(isSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val changeset = buildChangeset(pendingChanges)
|
||||
@@ -209,6 +292,8 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
pendingChanges.clear()
|
||||
@@ -216,6 +301,8 @@ class ConfigViewModel :
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSaving = false,
|
||||
@@ -242,6 +329,7 @@ class ConfigViewModel :
|
||||
|
||||
fun saveYamlConfig() {
|
||||
val yamlText = _uiState.value.yamlText ?: return
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
_uiState.update { it.copy(yamlIsSaving = true) }
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
@@ -252,12 +340,16 @@ class ConfigViewModel :
|
||||
)
|
||||
}
|
||||
}
|
||||
val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScope != requestScope) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val configResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.getConfig() }
|
||||
}
|
||||
val currentScopeAfterGet = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
if (requestScope != null && currentScopeAfterGet != requestScope) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
yamlIsSaving = false,
|
||||
|
||||
Reference in new issue
Block a user