perf(#281): fix PERF-13/14/15/16 — main-thread settings, 100Hz polling, leaked scope, WS disconnect (#359)
* perf(#281): fix PERF-13/14/15/16 — main-thread settings load, 100Hz polling, leaked scope, WS disconnect * fix(#281): fix SettingsViewModelTest failures from async init dispatcher --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
9297413b2a
commit
3399880621
5 files changed
+81
-46
No files matched your search
@@ -797,10 +797,11 @@ private fun StreamingBubbleWithTypingEffect(
|
||||
|
||||
// Timer that ticks at the configured delay, incrementing the visible word
|
||||
// count each tick. Stops ticking when streaming ends, then shows all words.
|
||||
// Optimized: split is only called when waiting for new content, not per tick.
|
||||
LaunchedEffect(Unit) {
|
||||
var wordCount = 0
|
||||
while (true) {
|
||||
val words = currentContent.value.split(" ")
|
||||
if (visibleWordCount < words.size) {
|
||||
if (visibleWordCount < wordCount) {
|
||||
delay(currentDelayMs.value.toLong())
|
||||
visibleWordCount++
|
||||
} else {
|
||||
@@ -808,7 +809,11 @@ private fun StreamingBubbleWithTypingEffect(
|
||||
onAnimationComplete()
|
||||
break
|
||||
}
|
||||
delay(10)
|
||||
// Only split when we need to check for new content arriving
|
||||
val words = currentContent.value.split(" ")
|
||||
wordCount = words.size
|
||||
if (visibleWordCount < wordCount) continue
|
||||
delay(100) // was 10 — reduced from 100Hz to 10Hz
|
||||
}
|
||||
}
|
||||
visibleWordCount = Int.MAX_VALUE
|
||||
|
||||
@@ -1020,7 +1020,8 @@ class ChatViewModel(
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pendingCleanupJob?.cancel()
|
||||
wsClient.disconnect()
|
||||
// PERF-16: Don't disconnect the global HermesWsClient singleton when
|
||||
// leaving the Chat screen — it's used by background notification reply.
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.theme.BottomNavDisplayMode
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -38,47 +39,57 @@ data class SettingsUiState(
|
||||
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
|
||||
)
|
||||
|
||||
class SettingsViewModel : ViewModel() {
|
||||
class SettingsViewModel(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
private suspend fun loadSettings() {
|
||||
val selectedId = AuthManager.getSelectedProfileId()
|
||||
val host = AuthManager.getHost()
|
||||
val port = AuthManager.getPort().toString()
|
||||
val token = AuthManager.getToken() ?: ""
|
||||
val autoReconnect = AuthManager.isAutoReconnect()
|
||||
val themePreference = AuthManager.getThemePreference()
|
||||
val useDynamicColors = AuthManager.isUseDynamicColors()
|
||||
val themePreset = AuthManager.getThemePreset()
|
||||
val selectedNavItems = AuthManager.getBottomNavItems()
|
||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
|
||||
val renameProfileName =
|
||||
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = AuthManager.getHost(),
|
||||
port = AuthManager.getPort().toString(),
|
||||
token = AuthManager.getToken() ?: "",
|
||||
autoReconnect = AuthManager.isAutoReconnect(),
|
||||
// B6 (Jun 18 2026, kanban t_86e9be9b): restore user's theme
|
||||
// choice from persistent storage on init. Previously this slot
|
||||
// was missing — always defaulted to ThemePreference.SYSTEM.
|
||||
themePreference = AuthManager.getThemePreference(),
|
||||
useDynamicColors = AuthManager.isUseDynamicColors(),
|
||||
themePreset = AuthManager.getThemePreset(),
|
||||
selectedNavItems = AuthManager.getBottomNavItems(),
|
||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||
profiles = AuthManager.getConnectionProfiles(),
|
||||
host = host,
|
||||
port = port,
|
||||
token = token,
|
||||
autoReconnect = autoReconnect,
|
||||
themePreference = themePreference,
|
||||
useDynamicColors = useDynamicColors,
|
||||
themePreset = themePreset,
|
||||
selectedNavItems = selectedNavItems,
|
||||
typingEffectEnabled = typingEffectEnabled,
|
||||
typingEffectDelayMs = typingEffectDelayMs,
|
||||
profiles = profiles,
|
||||
selectedProfileId = selectedId,
|
||||
renameProfileName =
|
||||
AuthManager
|
||||
.getConnectionProfiles()
|
||||
.firstOrNull { p ->
|
||||
p.id == selectedId
|
||||
}?.name ?: "",
|
||||
bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode(),
|
||||
renameProfileName = renameProfileName,
|
||||
bottomNavDisplayMode = bottomNavDisplayMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectProfile(profileId: String?) {
|
||||
AuthManager.setSelectedProfileId(profileId)
|
||||
loadSettings()
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
ApiClient.rebuild()
|
||||
}
|
||||
|
||||
@@ -95,7 +106,7 @@ class SettingsViewModel : ViewModel() {
|
||||
if (it.id == currentId) it.copy(name = newName) else it
|
||||
}
|
||||
AuthManager.saveConnectionProfiles(updatedProfiles)
|
||||
loadSettings()
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: String) {
|
||||
@@ -105,7 +116,7 @@ class SettingsViewModel : ViewModel() {
|
||||
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||
AuthManager.setSelectedProfileId(null)
|
||||
}
|
||||
loadSettings()
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
ApiClient.rebuild()
|
||||
}
|
||||
|
||||
@@ -207,8 +218,10 @@ class SettingsViewModel : ViewModel() {
|
||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||
ApiClient.rebuild()
|
||||
|
||||
loadSettings()
|
||||
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
loadSettings()
|
||||
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
@@ -225,7 +238,7 @@ class SettingsViewModel : ViewModel() {
|
||||
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.getStatus() }
|
||||
}
|
||||
when (result) {
|
||||
|
||||
Reference in new issue
Block a user