From 5ad184ba584bf639523612654c30ccf32e6859eb Mon Sep 17 00:00:00 2001 From: OneWay Date: Sat, 10 Oct 2026 01:13:03 +0800 Subject: [PATCH] refactor(article): warm up the blocks that are actually rendered The screen merges small parser blocks into larger render blocks, while the warm-up measured the unmerged parser blocks. Merge once in the view model and keep the merged blocks in the article state, so the screen and the warm-up share exactly what will be drawn. --- .../feature/search/SearchViewModel.kt | 36 +++++------- .../research/ui/article/ArticleScreen.kt | 58 +------------------ .../research/ui/article/ArticleState.kt | 1 + .../research/ui/article/MergeDslBlocks.kt | 51 ++++++++++++++++ 4 files changed, 69 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/com/example/research/ui/article/MergeDslBlocks.kt diff --git a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt index 9b45c63..0621eca 100644 --- a/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/example/research/feature/search/SearchViewModel.kt @@ -14,8 +14,9 @@ import com.example.research.data.local.preferences.PreferencesManager import com.example.research.data.repository.LocalDictionaryRepository import com.example.research.ui.article.ArticleState import com.example.research.ui.article.ArticleTextWarmUp -import com.example.research.ui.article.DslBlock import com.example.research.ui.article.DslAnnotatedParser +import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT +import com.example.research.ui.article.mergeDslBlocks import com.example.research.R import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -63,7 +64,7 @@ class SearchViewModel( private var indexWarmupJob: Job? = null private var debounceSaveQueryJob: Job? = null - private val parseCache = java.util.LinkedHashMap>(PARSE_CACHE_SIZE, 0.75f, true) + private val parseCache = java.util.LinkedHashMap(PARSE_CACHE_SIZE, 0.75f, true) private val articleHistory = ArrayDeque() private val activeDictionaries: StateFlow> = @@ -211,12 +212,7 @@ class SearchViewModel( val cached = synchronized(parseCache) { parseCache[cacheKey] } if (cached != null) { mutableSelectedArticle.update { - ArticleState.Success( - title = entry.originalWord, - dslContent = cached, - dictionaryName = entry.dictionaryName, - dictionaryPath = entry.dictionaryPath, - ) + cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName) } return@launch } @@ -228,6 +224,13 @@ class SearchViewModel( colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME, ) } + val article = ArticleState.Success( + title = entry.originalWord, + dslContent = mergeDslBlocks(annotatedStringList), + dictionaryName = entry.dictionaryName, + dictionaryPath = entry.dictionaryPath, + isHeavy = annotatedStringList.size > HEAVY_ARTICLE_BLOCK_COUNT, + ) synchronized(parseCache) { if (parseCache.size >= PARSE_CACHE_SIZE) { val iterator = parseCache.entries.iterator() @@ -235,19 +238,12 @@ class SearchViewModel( iterator.next().run { iterator.remove() } } } - parseCache[cacheKey] = annotatedStringList + parseCache[cacheKey] = article } viewModelScope.launch(Dispatchers.Default) { - ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList) - } - mutableSelectedArticle.update { - ArticleState.Success( - title = entry.originalWord, - dslContent = annotatedStringList, - dictionaryName = entry.dictionaryName, - dictionaryPath = entry.dictionaryPath, - ) + ArticleTextWarmUp.warmUp(getApplication(), article.dslContent) } + mutableSelectedArticle.update { article } } .onError { message, exception -> Log.e(TAG, "loadArticle() - error: $message", exception) @@ -324,10 +320,6 @@ class SearchViewModel( companion object { private const val TAG = "SearchViewModel" - // Parse cache: stores parsed DslBlock lists to avoid re-parsing articles - // Size: 50 articles - typical user navigation depth in a session - // Memory estimate: ~50KB per article average (text + formatting) - // Total: ~2.5MB worst case private const val PARSE_CACHE_SIZE = 50 private const val KEY_SEARCH_QUERY = "search_query" diff --git a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt index b0d719d..b5ce37e 100644 --- a/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt +++ b/app/src/main/java/com/example/research/ui/article/ArticleScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize @@ -55,54 +54,6 @@ import com.example.research.common.ui.theme.dictionaryTitleLarge import com.example.research.ui.theme.AppWindowInsets import com.example.research.ui.theme.Spacing -private const val MERGED_BLOCK_MAX_CHARS = 1500 -private const val MERGED_BLOCK_MAX_ITEMS = 12 - -private fun mergeDslBlocks(blocks: List): List { - if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks - val merged = ArrayList(blocks.size / 4) - var i = 0 - while (i < blocks.size) { - val builder = AnnotatedString.Builder() - val refs = ArrayList() - var j = i - while (j < blocks.size) { - val block = blocks[j] - repeat(block.indent) { builder.append(' ') } - val paraStart = builder.length - builder.append(block.text) - block.lazyRefs.forEach { ref -> - refs.add( - ref.copy( - startIndex = ref.startIndex + paraStart, - endIndex = ref.endIndex + paraStart - ) - ) - } - val more = j + 1 < blocks.size && - (j + 1 - i) < MERGED_BLOCK_MAX_ITEMS && - builder.length < MERGED_BLOCK_MAX_CHARS - if (more) { - builder.append('\n') - } - j++ - if (!more) { - break - } - } - merged.add( - DslBlock( - text = builder.toAnnotatedString(), - indent = 0, - lazyRefs = refs, - continuesPrevious = blocks[i].continuesPrevious - ) - ) - i = j - } - return merged -} - @Composable fun ArticleContent( articleState: ArticleState?, @@ -183,8 +134,7 @@ fun ArticleContent( } } - val isHeavyArticle = - articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT + val isHeavyArticle = articleState.isHeavy val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f val displayBlocks = remember( @@ -192,14 +142,12 @@ fun ArticleContent( baseTextStyle, darkTheme ) { - val merged = - mergeDslBlocks(articleState.dslContent) if (darkTheme) { - merged.map { + articleState.dslContent.map { it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text)) } } else { - merged + articleState.dslContent } } val articleList: @Composable (Modifier) -> Unit = { listModifier -> diff --git a/app/src/main/java/com/example/research/ui/article/ArticleState.kt b/app/src/main/java/com/example/research/ui/article/ArticleState.kt index f18e901..c0a1a15 100644 --- a/app/src/main/java/com/example/research/ui/article/ArticleState.kt +++ b/app/src/main/java/com/example/research/ui/article/ArticleState.kt @@ -37,6 +37,7 @@ sealed class ArticleState { val dictionaryPath: String = "", val scrollIndex: Int = 0, val scrollOffset: Int = 0, + val isHeavy: Boolean = false, ) : ArticleState() @Immutable data class Error( diff --git a/app/src/main/java/com/example/research/ui/article/MergeDslBlocks.kt b/app/src/main/java/com/example/research/ui/article/MergeDslBlocks.kt new file mode 100644 index 0000000..7bbc310 --- /dev/null +++ b/app/src/main/java/com/example/research/ui/article/MergeDslBlocks.kt @@ -0,0 +1,51 @@ +package com.example.research.ui.article + +import androidx.compose.ui.text.AnnotatedString + +private const val MERGED_BLOCK_MAX_CHARS = 1500 +private const val MERGED_BLOCK_MAX_ITEMS = 12 + +internal fun mergeDslBlocks(blocks: List): List { + if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks + val merged = ArrayList(blocks.size / 4) + var i = 0 + while (i < blocks.size) { + val builder = AnnotatedString.Builder() + val refs = ArrayList() + var j = i + while (j < blocks.size) { + val block = blocks[j] + repeat(block.indent) { builder.append(' ') } + val paraStart = builder.length + builder.append(block.text) + block.lazyRefs.forEach { ref -> + refs.add( + ref.copy( + startIndex = ref.startIndex + paraStart, + endIndex = ref.endIndex + paraStart + ) + ) + } + val more = j + 1 < blocks.size && + (j + 1 - i) < MERGED_BLOCK_MAX_ITEMS && + builder.length < MERGED_BLOCK_MAX_CHARS + if (more) { + builder.append('\n') + } + j++ + if (!more) { + break + } + } + merged.add( + DslBlock( + text = builder.toAnnotatedString(), + indent = 0, + lazyRefs = refs, + continuesPrevious = blocks[i].continuesPrevious + ) + ) + i = j + } + return merged +}