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.
This commit is contained in:
1 parent
381ce33017
commit
5ad184ba58
4 files changed
+69
-77
No files matched your search
@@ -14,8 +14,9 @@ import com.example.research.data.local.preferences.PreferencesManager
|
|||||||
import com.example.research.data.repository.LocalDictionaryRepository
|
import com.example.research.data.repository.LocalDictionaryRepository
|
||||||
import com.example.research.ui.article.ArticleState
|
import com.example.research.ui.article.ArticleState
|
||||||
import com.example.research.ui.article.ArticleTextWarmUp
|
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.DslAnnotatedParser
|
||||||
|
import com.example.research.ui.article.HEAVY_ARTICLE_BLOCK_COUNT
|
||||||
|
import com.example.research.ui.article.mergeDslBlocks
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -63,7 +64,7 @@ class SearchViewModel(
|
|||||||
private var indexWarmupJob: Job? = null
|
private var indexWarmupJob: Job? = null
|
||||||
private var debounceSaveQueryJob: Job? = null
|
private var debounceSaveQueryJob: Job? = null
|
||||||
|
|
||||||
private val parseCache = java.util.LinkedHashMap<String, List<DslBlock>>(PARSE_CACHE_SIZE, 0.75f, true)
|
private val parseCache = java.util.LinkedHashMap<String, ArticleState.Success>(PARSE_CACHE_SIZE, 0.75f, true)
|
||||||
private val articleHistory = ArrayDeque<ArticleState.Success>()
|
private val articleHistory = ArrayDeque<ArticleState.Success>()
|
||||||
|
|
||||||
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
private val activeDictionaries: StateFlow<List<com.example.research.core.domain.model.Dictionary>> =
|
||||||
@@ -211,12 +212,7 @@ class SearchViewModel(
|
|||||||
val cached = synchronized(parseCache) { parseCache[cacheKey] }
|
val cached = synchronized(parseCache) { parseCache[cacheKey] }
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
mutableSelectedArticle.update {
|
mutableSelectedArticle.update {
|
||||||
ArticleState.Success(
|
cached.copy(title = entry.originalWord, dictionaryName = entry.dictionaryName)
|
||||||
title = entry.originalWord,
|
|
||||||
dslContent = cached,
|
|
||||||
dictionaryName = entry.dictionaryName,
|
|
||||||
dictionaryPath = entry.dictionaryPath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -228,6 +224,13 @@ class SearchViewModel(
|
|||||||
colorScheme = DslAnnotatedParser.ARTICLE_COLOR_SCHEME,
|
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) {
|
synchronized(parseCache) {
|
||||||
if (parseCache.size >= PARSE_CACHE_SIZE) {
|
if (parseCache.size >= PARSE_CACHE_SIZE) {
|
||||||
val iterator = parseCache.entries.iterator()
|
val iterator = parseCache.entries.iterator()
|
||||||
@@ -235,19 +238,12 @@ class SearchViewModel(
|
|||||||
iterator.next().run { iterator.remove() }
|
iterator.next().run { iterator.remove() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parseCache[cacheKey] = annotatedStringList
|
parseCache[cacheKey] = article
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
ArticleTextWarmUp.warmUp(getApplication(), annotatedStringList)
|
ArticleTextWarmUp.warmUp(getApplication(), article.dslContent)
|
||||||
}
|
|
||||||
mutableSelectedArticle.update {
|
|
||||||
ArticleState.Success(
|
|
||||||
title = entry.originalWord,
|
|
||||||
dslContent = annotatedStringList,
|
|
||||||
dictionaryName = entry.dictionaryName,
|
|
||||||
dictionaryPath = entry.dictionaryPath,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
mutableSelectedArticle.update { article }
|
||||||
}
|
}
|
||||||
.onError { message, exception ->
|
.onError { message, exception ->
|
||||||
Log.e(TAG, "loadArticle() - error: $message", exception)
|
Log.e(TAG, "loadArticle() - error: $message", exception)
|
||||||
@@ -324,10 +320,6 @@ class SearchViewModel(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SearchViewModel"
|
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 PARSE_CACHE_SIZE = 50
|
||||||
|
|
||||||
private const val KEY_SEARCH_QUERY = "search_query"
|
private const val KEY_SEARCH_QUERY = "search_query"
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
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.LineHeightStyle
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntSize
|
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.AppWindowInsets
|
||||||
import com.example.research.ui.theme.Spacing
|
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<DslBlock>): List<DslBlock> {
|
|
||||||
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
|
|
||||||
val merged = ArrayList<DslBlock>(blocks.size / 4)
|
|
||||||
var i = 0
|
|
||||||
while (i < blocks.size) {
|
|
||||||
val builder = AnnotatedString.Builder()
|
|
||||||
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
|
|
||||||
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
|
@Composable
|
||||||
fun ArticleContent(
|
fun ArticleContent(
|
||||||
articleState: ArticleState?,
|
articleState: ArticleState?,
|
||||||
@@ -183,8 +134,7 @@ fun ArticleContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isHeavyArticle =
|
val isHeavyArticle = articleState.isHeavy
|
||||||
articleState.dslContent.size > HEAVY_ARTICLE_BLOCK_COUNT
|
|
||||||
|
|
||||||
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
val darkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
val displayBlocks = remember(
|
val displayBlocks = remember(
|
||||||
@@ -192,14 +142,12 @@ fun ArticleContent(
|
|||||||
baseTextStyle,
|
baseTextStyle,
|
||||||
darkTheme
|
darkTheme
|
||||||
) {
|
) {
|
||||||
val merged =
|
|
||||||
mergeDslBlocks(articleState.dslContent)
|
|
||||||
if (darkTheme) {
|
if (darkTheme) {
|
||||||
merged.map {
|
articleState.dslContent.map {
|
||||||
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
|
it.copy(text = DslAnnotatedParser.remapForDarkTheme(it.text))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
merged
|
articleState.dslContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
|
val articleList: @Composable (Modifier) -> Unit = { listModifier ->
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ sealed class ArticleState {
|
|||||||
val dictionaryPath: String = "",
|
val dictionaryPath: String = "",
|
||||||
val scrollIndex: Int = 0,
|
val scrollIndex: Int = 0,
|
||||||
val scrollOffset: Int = 0,
|
val scrollOffset: Int = 0,
|
||||||
|
val isHeavy: Boolean = false,
|
||||||
) : ArticleState()
|
) : ArticleState()
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Error(
|
data class Error(
|
||||||
|
|||||||
@@ -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<DslBlock>): List<DslBlock> {
|
||||||
|
if (blocks.size <= MERGED_BLOCK_MAX_ITEMS) return blocks
|
||||||
|
val merged = ArrayList<DslBlock>(blocks.size / 4)
|
||||||
|
var i = 0
|
||||||
|
while (i < blocks.size) {
|
||||||
|
val builder = AnnotatedString.Builder()
|
||||||
|
val refs = ArrayList<DslAnnotatedParser.LazyRef>()
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user