git.iko.soy/opencode-android
on master help sign in
Reduce recomposition and hot-path allocations across chat, session list, and file viewer

Follow-up performance pass found by a codebase audit: - SessionCard: memoize per-row callbacks on session id so background preview/unread updates only recompose the changed card, not all visible cards. - buildMessageListItems: cache dayKey per epoch-millis (bounded LRU) and reuse a cached DateFormat so streaming re-emissions stop re-allocating Instant/ ZonedDateTime/LocalDate per message. Keeps the full-list memo key so the live message's fresh parts still render. - File find-in-file: debounce the query, run the scan off the main thread via produceState, and reuse the already-split lines instead of re-splitting. - Tool output moreLines: memoize the lines() split; DiffView: memoize row colors. - Sort comparators (models, session titles): use CASE_INSENSITIVE_ORDER instead of allocating a lowercased string per comparison. - CodeHighlighter: resolve file language once per file (FileSyntax) rather than re-parsing the extension for every line. - AppContainer: skip the redundant _anyRunActive flag write once set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commitbc10148dfeec01557644c843e5e895a51d55bbff authoriko <mail@iko.soy> dateThu 2 Jul 2026, 05:11 (3 months ago) parent0881fcd
9 files changed+135−36
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+6−1 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+5−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−6 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−7 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+23−3 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+8−4 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+30−10 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−3 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+6−1
app/src/main/java/soy/iko/opencode/data/model/Provider.kt+6−1
@@ -61,7 +61,12 @@ fun ProvidersResponse.toOptions(): List<ModelOption> =
6161 modelLabel = info.displayName(modelKey),
6262 )
6363 }
64− }.sortedWith(compareBy({ it.providerLabel.lowercase() }, { it.modelLabel.lowercase() }))
64+ }.sortedWith(
65+ // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
66+ // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
67+ compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
68+ .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
69+ )
6570
6671/** The default option to preselect: the server's default for some provider, else the first model. */
6772fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+5−1
@@ -302,13 +302,17 @@ open class AppContainer private constructor(
302302 // Track sessions actively streaming so we know which idle events
303303 // represent a finished run worth notifying about.
304304 if (isLiveRunActivity(event)) {
305+ // The add-under-lock is required both for thread-safety and for the
306+ // LRU access-order refresh (keeps a continuously-streaming session
307+ // from being evicted), so it runs per event. Only the flag write is
308+ // skippable once it's already set.
305309 synchronized(activeRuns) {
306310 if (activeRuns.size >= activeRunsLimit) {
307311 activeRuns.remove(activeRuns.iterator().next())
308312 }
309313 activeRuns.add(sid)
310314 }
311− _anyRunActive.value = true
315+ if (!_anyRunActive.value) _anyRunActive.value = true
312316 }
313317 } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
314318 if (!isActive) break
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−6
@@ -1100,7 +1100,7 @@ private fun buildMessageListItems(
11001100): List<MessageListItem> {
11011101 if (messages.isEmpty()) return emptyList()
11021102 val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1103− val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1103+ val dateFmt = mediumDateFormat()
11041104 val result = ArrayList<MessageListItem>(messages.size + 4)
11051105 var lastDayKey: String? = null
11061106 var sepOrdinal = 0
@@ -1116,11 +1116,42 @@ private fun buildMessageListItems(
11161116 return result
11171117}
11181118
1119−/** A stable bucket key for a timestamp's calendar day (epoch-days as a string). */
1120−private fun dayKey(epochMillis: Long): String {
1121− val instant = java.time.Instant.ofEpochMilli(epochMillis)
1122− val date = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1123− return date.toEpochDay().toString()
1119+/**
1120+ * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
1121+ *
1122+ * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
1123+ * whole `messages` list, which must stay so the live message's fresh parts render), so a
1124+ * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
1125+ * every message on every token. Message timestamps are immutable once assigned, so we
1126+ * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
1127+ * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
1128+ * thread during composition, so the coarse lock is uncontended.
1129+ */
1130+private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
1131+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
1132+}
1133+
1134+private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
1135+ dayKeyCache.getOrPut(epochMillis) {
1136+ java.time.Instant.ofEpochMilli(epochMillis)
1137+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
1138+ }
1139+}
1140+
1141+// The medium-date formatter is an ICU locale lookup; cache one instance instead of
1142+// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
1143+// locale changes at runtime. Main-thread-only, so no synchronization is needed.
1144+private var cachedDateFmt: java.text.DateFormat? = null
1145+private var cachedDateFmtLocale: java.util.Locale? = null
1146+
1147+private fun mediumDateFormat(): java.text.DateFormat {
1148+ val locale = java.util.Locale.getDefault()
1149+ val existing = cachedDateFmt
1150+ if (existing != null && cachedDateFmtLocale == locale) return existing
1151+ return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
1152+ cachedDateFmt = it
1153+ cachedDateFmtLocale = locale
1154+ }
11241155}
11251156
11261157/**
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+9−7
@@ -284,6 +284,14 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
284284 // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
285285 // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
286286 val isDiff = remember(display) { looksLikeDiff(display) }
287+ // How many lines are hidden while collapsed. detail.lines() splits the whole
288+ // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
289+ // every recomposition, including each streaming update of a running tool.
290+ // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
291+ // 0 (a single long line cut mid-way) falls back to the generic label.
292+ val moreLines = remember(detail, collapsed) {
293+ if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
294+ }
287295 CollapsibleDetail(
288296 label = null,
289297 detail = display,
@@ -293,13 +301,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
293301 onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
294302 { expanded = !expanded }
295303 } else null,
296− // How many lines are hidden while collapsed, so the expand affordance can
297− // tell the user the scale of what's truncated instead of a bare "Show more".
298− // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
299− // 0 (a single long line cut mid-way) falls back to the generic label.
300− moreLines = if (detail.length > COLLAPSED_LIMIT) {
301− detail.lines().size - collapsed.lines().size
302− } else 0,
304+ moreLines = moreLines,
303305 expandedState = expandedState,
304306 collapsedState = collapsedState,
305307 onCopy = { copyToClipboard(context, "output", detail) },
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+23−3
@@ -28,7 +28,7 @@ import androidx.compose.ui.text.withStyle
2828/** Language families recognized by [highlightLine]. The set of keywords is shared
2929 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
3030 * common keywords overlap heavily; python has its own set; shell has its own. */
31−private enum class Language {
31+internal enum class Language {
3232 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
3333 PYTHON,
3434 SHELL, // sh, bash, zsh
@@ -117,10 +117,30 @@ fun rememberHighlightPalette(): HighlightPalette {
117117 * which matters because the file viewer renders lazily but can scroll through
118118 * thousands of lines — a heavy highlighter would jank on fast scrolls.
119119 */
120−fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString {
120+fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
121+ highlightLine(line, syntaxFor(filename), palette)
122+
123+/**
124+ * A file's language classification, resolved once from its name. Resolving this per file
125+ * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
126+ * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
127+ */
128+class FileSyntax internal constructor(
129+ internal val lang: Language,
130+ internal val keywords: Set<String>,
131+)
132+
133+/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
134+fun syntaxFor(filename: String): FileSyntax {
121135 val lang = languageFor(filename)
136+ return FileSyntax(lang, keywordsFor(lang))
137+}
138+
139+/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140+fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141+ val lang = syntax.lang
122142 if (lang == Language.NONE) return AnnotatedString(line)
123− val keywords = keywordsFor(lang)
143+ val keywords = syntax.keywords
124144 val builder = AnnotatedString.Builder(line.length + 8)
125145 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
126146 val mono = FontFamily.Monospace
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+8−4
@@ -125,10 +125,14 @@ private const val COLLAPSED_DIFF_LINES = 200
125125@Composable
126126fun DiffView(diff: String, modifier: Modifier = Modifier) {
127127 val lines = remember(diff) { parseDiff(diff) }
128− val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
129− val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
130− val addText = MaterialTheme.colorScheme.primary
131− val removeText = MaterialTheme.colorScheme.error
128+ // Derive the four row colors once per color scheme instead of allocating fresh Color
129+ // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
130+ // per token while its output streams under the collapse limit.
131+ val scheme = MaterialTheme.colorScheme
132+ val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
133+ val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
134+ val addText = scheme.primary
135+ val removeText = scheme.error
132136 val context = LocalContext.current
133137 val hScrollState = rememberScrollState()
134138 var expanded by rememberSaveable(diff) { mutableStateOf(false) }
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+30−10
@@ -54,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect
5454import androidx.compose.runtime.getValue
5555import androidx.compose.runtime.mutableIntStateOf
5656import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.produceState
5758import androidx.compose.runtime.remember
5859import androidx.compose.runtime.saveable.rememberSaveable
5960import androidx.compose.runtime.setValue
@@ -77,9 +78,13 @@ import soy.iko.opencode.R
7778import soy.iko.opencode.ui.components.DiffView
7879import soy.iko.opencode.ui.components.copyToClipboard
7980import soy.iko.opencode.ui.components.highlightLine
81+import soy.iko.opencode.ui.components.syntaxFor
8082import soy.iko.opencode.ui.components.rememberHighlightPalette
8183import soy.iko.opencode.ui.vmFactory
8284import soy.iko.opencode.util.runCatchingCancellable
85+import kotlinx.coroutines.Dispatchers
86+import kotlinx.coroutines.delay
87+import kotlinx.coroutines.withContext
8388
8489@OptIn(ExperimentalMaterial3Api::class)
8590@Composable
@@ -110,11 +115,19 @@ fun FileViewScreen(
110115 val listState = rememberLazyListState()
111116 val rawText = state.content?.content.orEmpty()
112117 val lines = remember(rawText) { rawText.split("\n") }
113− val matchIndices = remember(lines, findQuery) {
118+ // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
119+ // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
120+ var debouncedFind by remember { mutableStateOf("") }
121+ LaunchedEffect(findQuery) {
114122 val q = findQuery.trim()
115− if (q.isEmpty()) emptyList()
116− else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
117− .filter { it >= 0 }
123+ if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
124+ }
125+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
126+ val q = debouncedFind
127+ value = if (q.isEmpty()) emptyList()
128+ else withContext(Dispatchers.Default) {
129+ lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
130+ }
118131 }
119132 // Keep the current match index in range when the match set shrinks (e.g. the file
120133 // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
@@ -163,12 +176,17 @@ fun FileViewScreen(
163176 // state. During the crossfade the exiting layer still renders the old
164177 // content; passing the outer matchIndices (computed from the new content)
165178 // would highlight and scroll the wrong lines until the animation settles.
166− val sMatchIndices = remember(s.content?.content, findQuery) {
167− val q = findQuery.trim()
179+ val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
180+ val q = debouncedFind
168181 if (q.isEmpty()) emptyList()
169− else (s.content?.content.orEmpty()).split("\n")
170− .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
171− .filter { it >= 0 }
182+ else {
183+ // Reuse the already-split `lines` when this layer shows the current
184+ // content (the common case); only re-split for the exiting layer's
185+ // stale content during the crossfade.
186+ val sContent = s.content?.content
187+ val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
188+ sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
189+ }
172190 }
173191 FileViewStateContent(
174192 state = s,
@@ -498,6 +516,8 @@ private fun FileTextContent(
498516 }
499517 val hScrollState = rememberScrollState()
500518 val palette = rememberHighlightPalette()
519+ // Resolve the file's language once instead of re-parsing the extension for every line.
520+ val syntax = remember(filename) { syntaxFor(filename) }
501521 val q = findQuery.trim()
502522 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
503523 LazyColumn(
@@ -527,7 +547,7 @@ private fun FileTextContent(
527547 // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
528548 // file, or palette actually changes — not on every recomposition (e.g. every
529549 // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
530− val highlighted = remember(line, filename, palette) { highlightLine(line, filename, palette) }
550+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
531551 Text(
532552 highlighted,
533553 modifier = Modifier.padding(start = 8.dp),
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+11−3
@@ -442,6 +442,14 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
442442 false
443443 },
444444 )
445+ // Per-item callbacks memoized on the session id so SessionCard
446+ // stays skippable: the list emits a fresh SessionListState on
447+ // every background preview/unread update, and freshly-allocated
448+ // lambdas would otherwise force every visible card to recompose
449+ // on each of those emissions even when its own data is unchanged.
450+ val onCardClick = remember(session.id) { { onOpenSession(session.id) } }
451+ val onCardRename = remember(session.id) { { onRename(session.id) } }
452+ val onCardDelete = remember(session.id) { { onDelete(session.id) } }
445453 SwipeToDismissBox(
446454 state = swipeState,
447455 enableDismissFromStartToEnd = false,
@@ -468,9 +476,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
468476 preview = state.previews[session.id],
469477 unreadCount = unread[session.id] ?: 0,
470478 isSelected = session.id == selectedSessionId,
471− onClick = { onOpenSession(session.id) },
472− onRename = { onRename(session.id) },
473− onDelete = { onDelete(session.id) },
479+ onClick = onCardClick,
480+ onRename = onCardRename,
481+ onDelete = onCardDelete,
474482 modifier = Modifier.testTag("session_card"),
475483 )
476484 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+6−1
@@ -70,7 +70,12 @@ private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session>
7070 SessionSortMode.RECENT ->
7171 sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
7272 SessionSortMode.TITLE ->
73− sortedWith(compareBy<Session> { it.displayTitle.lowercase() }.thenByDescending { it.time?.updated ?: it.time?.created ?: 0L })
73+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
74+ // sort re-runs on every debounced list flush during streaming.
75+ sortedWith(
76+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
77+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
78+ )
7479}
7580
7681class SessionListViewModel(private val container: AppContainer) : ViewModel() {