perf: Phase 4 Android performance optimizations

- frequentEmojis: computed List → cached StateFlow (avoids parse+sort on every recomposition)
- SdkNodeRepositoryImpl: SharingStarted.Eagerly → WhileSubscribed(5_000) for ourNodeInfo, myId, localStats
- PacketRepositoryImpl: deduplicate flatMapLatest chains via shared combine(myNodeNumFlow, currentDb)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 14:02:03 -05:00
parent 52aaa4d926
commit fa6d625d90
5 changed files with 39 additions and 31 deletions

View File

@@ -21,6 +21,7 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -61,12 +62,15 @@ class PacketRepositoryImpl(
.map { it?.myNodeNum ?: 0 }
.distinctUntilChanged()
override fun getWaypoints(): Flow<List<DataPacket>> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow(num) } }
/** Cached upstream combining myNodeNum + currentDb — avoids creating duplicate flatMapLatest chains. */
private val numAndDb = combine(myNodeNumFlow, dbManager.currentDb) { num, db -> num to db }
override fun getWaypoints(): Flow<List<DataPacket>> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getAllWaypointsFlow(num) }
.map { list -> list.map { it.data } }
override fun getContacts(): Flow<Map<String, DataPacket>> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys(num) } }
override fun getContacts(): Flow<Map<String, DataPacket>> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getContactKeys(num) }
.map { map -> map.mapValues { it.value.data } }
override fun getContactsPaged(): Flow<PagingData<DataPacket>> = Pager(
@@ -87,17 +91,17 @@ class PacketRepositoryImpl(
override suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(currentMyNodeNum, contact) }
override fun getUnreadCountFlow(contact: String): Flow<Int> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(num, contact) } }
override fun getUnreadCountFlow(contact: String): Flow<Int> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getUnreadCountFlow(num, contact) }
override fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(num, contact) } }
override fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getFirstUnreadMessageUuid(num, contact) }
override fun hasUnreadMessages(contact: String): Flow<Boolean> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(num, contact) } }
override fun hasUnreadMessages(contact: String): Flow<Boolean> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().hasUnreadMessages(num, contact) }
override fun getUnreadCountTotal(): Flow<Int> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal(num) } }
override fun getUnreadCountTotal(): Flow<Int> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getUnreadCountTotal(num) }
override suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(currentMyNodeNum, contact, timestamp) }
@@ -463,8 +467,8 @@ class PacketRepositoryImpl(
suspend fun updateReaction(reaction: RoomReaction) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) }
override fun getFilteredCountFlow(contactKey: String): Flow<Int> = myNodeNumFlow
.flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(num, contactKey) } }
override fun getFilteredCountFlow(contactKey: String): Flow<Int> = numAndDb
.flatMapLatest { (num, db) -> db.packetDao().getFilteredCountFlow(num, contactKey) }
override suspend fun getFilteredCount(contactKey: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(currentMyNodeNum, contactKey) }

View File

@@ -99,15 +99,15 @@ class SdkNodeRepositoryImpl(
override val ourNodeInfo: StateFlow<Node?> =
combine(_nodeDBbyNum, _myNodeNum) { db, myNum -> myNum?.let { db[it] } }
.stateIn(scope, SharingStarted.Eagerly, null)
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
override val myId: StateFlow<String?> =
ourNodeInfo.map { it?.user?.id }
.stateIn(scope, SharingStarted.Eagerly, null)
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
override val localStats: StateFlow<LocalStats> =
localStatsDataSource.localStatsFlow
.stateIn(scope, SharingStarted.Eagerly, LocalStats())
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), LocalStats())
override fun updateLocalStats(stats: LocalStats) {
scope.launch { localStatsDataSource.setLocalStats(stats) }

View File

@@ -401,7 +401,7 @@ fun MessageScreen(
onSendMessage = { text, key -> viewModel.sendMessage(text, key) },
onReply = { message -> replyingToPacketId = message?.packetId },
),
quickEmojis = viewModel.frequentEmojis,
quickEmojis = viewModel.frequentEmojis.collectAsStateWithLifecycle().value,
)
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
@@ -105,17 +106,21 @@ class MessageViewModel(
}
.cachedIn(viewModelScope)
val frequentEmojis: List<String>
get() =
customEmojiPrefs.customEmojiFrequency.value
?.split(",")
?.associate { entry ->
entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0)
}
?.toList()
?.sortedByDescending { it.second }
?.map { it.first }
?.take(6) ?: listOf("👍", "👎", "😂", "🔥", "❤️", "😮")
private val defaultEmojis = listOf("👍", "👎", "😂", "🔥", "❤️", "😮")
val frequentEmojis: StateFlow<List<String>> =
customEmojiPrefs.customEmojiFrequency
.map { raw ->
raw?.split(",")
?.associate { entry ->
entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0)
}
?.toList()
?.sortedByDescending { it.second }
?.map { it.first }
?.take(6) ?: defaultEmojis
}
.stateInWhileSubscribed(defaultEmojis)
val homoglyphEncodingEnabled = homoglyphEncodingPrefs.homoglyphEncodingEnabled

View File

@@ -172,8 +172,7 @@ class MessageViewModelTest {
fun testFrequentEmojis() = runTest {
customEmojiFrequencyFlow.value = "👍=10,👎=5,😂=20"
// frequentEmojis is a property, not a flow.
val emojis = viewModel.frequentEmojis
val emojis = viewModel.frequentEmojis.value
assertEquals(listOf("😂", "👍", "👎"), emojis)
}