perf: optimize screen load times with progressive un-gating and SWR caching (#1192)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 23:25:30 +03:00
1 parent abce60ad7a
commit f5eaecbbef
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,