From 9b20a7c5a860104bdc6f2b61f26cf8f18657baed Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 26 Nov 2024 00:43:56 +0800 Subject: [PATCH] optimize CodeEditorView and BigMonospaceText to reduce the number of text layout, so that text layout in the first rendering is already stable, and no need to wait for 200ms to render the first layout --- .../hellohttp/util/ComposeStates.kt | 52 ++++++++++++------- .../hellohttp/ux/CodeEditorView.kt | 28 ++++++++-- .../hellohttp/ux/ResponseViewerView.kt | 2 +- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 10 +++- .../hellohttp/ux/bigtext/BigTextViewState.kt | 12 +++++ .../ux/bigtext/ConcurrentBigTextFieldState.kt | 4 +- 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeStates.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeStates.kt index 7f4c3024..719b68fa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeStates.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeStates.kt @@ -1,39 +1,51 @@ package com.sunnychung.application.multiplatform.hellohttp.util import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.sunnychung.lib.multiplatform.kdatetime.KDuration import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.distinctUntilChanged +/** + * @return pair of debounced state and "is the state latest" + */ @OptIn(FlowPreview::class) @Composable -fun debouncedStateOf(interval: KDuration, stateProducer: () -> T): T { +fun debouncedStateOf(interval: KDuration, tolerateCount: Int = 0, vararg cacheKeys: Any?, stateProducer: () -> T): Pair { val currentState = stateProducer() - val coroutineScope = rememberCoroutineScope() - val flow = remember { MutableSharedFlow( - replay = 1, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) } - var stateRecord by remember { mutableStateOf(currentState) } + val flow = remember(*cacheKeys) { MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) } + var stateRecord by remember(*cacheKeys) { mutableStateOf(currentState) } + var stateCount by remember(*cacheKeys) { mutableStateOf(0) } if (stateRecord != currentState) { - flow.tryEmit(currentState) - } - flow.debounce(interval.toMilliseconds()) - .onEach { - stateRecord = it + ++stateCount + flow.tryEmit(currentState).also { + log.v { "ds tryEmit($currentState) = $it. old = $stateRecord" } + } + if (stateCount <= tolerateCount) { + stateRecord = currentState } - .launchIn(scope = coroutineScope) - return stateRecord + } + LaunchedEffect(flow) { + log.v { "ds launch flow" } + flow.distinctUntilChanged() + .debounce(interval.toMilliseconds()) + .collect { + log.v { "ds collect state $it" } + stateRecord = it + } + } + return (stateRecord to (stateRecord == currentState)).also { + log.v { "ds state = $it, count = $stateCount" } + } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2302639d..4089a7c8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -101,6 +101,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.regex.Pattern +import kotlin.math.abs import kotlin.random.Random val MAX_TEXT_FIELD_LENGTH = 4 * 1024 * 1024 // 4 MB @@ -137,7 +138,10 @@ fun CodeEditorView( var layoutResult by remember { mutableStateOf(null) } - val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey) + val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey) { + log.d { "init BigText disable layout. lines = ${it.text.numOfLines}" } + it.viewState.isLayoutDisabled = true + } val bigTextValue: BigText = bigTextFieldState.text var bigTextValueId by remember(bigTextFieldState) { mutableStateOf(Random.nextLong()) } @@ -481,7 +485,11 @@ fun CodeEditorView( collapsedLines = collapsedLines.values.toList(), onCollapseLine = onCollapseLine, onExpandLine = onExpandLine, - modifier = Modifier.fillMaxHeight(), + onCorrectMeasured = { + log.v { "change isLayoutDisabled from ${bigTextFieldState.viewState.isLayoutDisabled} to false" } + bigTextFieldState.viewState.isLayoutDisabled = false + }, + modifier = Modifier.fillMaxHeight() ) if (isReadOnly) { @@ -733,6 +741,7 @@ fun BigTextLineNumbersView( collapsedLines: List, onCollapseLine: (Int) -> Unit, onExpandLine: (Int) -> Unit, + onCorrectMeasured: () -> Unit, ) = with(LocalDensity.current) { val colours = LocalColor.current val fonts = LocalFont.current @@ -761,7 +770,7 @@ fun BigTextLineNumbersView( firstRow = visibleRows.first, lastRow = visibleRows.endInclusive + 1, rowToLineIndex = { layoutText?.findOriginalLineIndexByRowIndex(it) ?: 0 }, - totalLines = layoutText?.numOfOriginalLines ?: bigText.numOfLines, + totalLines = bigText.numOfLines, lineHeight = (rowHeight).toDp(), getRowOffset = { (it * rowHeight - viewportTop).toDp() @@ -770,6 +779,7 @@ fun BigTextLineNumbersView( collapsedLinesState = collapsedLinesState, onCollapseLine = onCollapseLine, onExpandLine = onExpandLine, + onCorrectMeasured = onCorrectMeasured, modifier = modifier ) } @@ -787,6 +797,7 @@ private fun CoreLineNumbersView( collapsedLinesState: CollapsedLinesState, onCollapseLine: (Int) -> Unit, onExpandLine: (Int) -> Unit, + onCorrectMeasured: () -> Unit, ) = with(LocalDensity.current) { val colours = LocalColor.current val fonts = LocalFont.current @@ -801,6 +812,7 @@ private fun CoreLineNumbersView( maxOf(textMeasurer.measure("8".repeat(lineNumDigits), textStyle, maxLines = 1).size.width.toDp(), 20.dp) + 4.dp + (if (collapsableLines.isNotEmpty()) 24.dp else 0.dp) + 4.dp } + log.v { "totalLines = $totalLines, width = ${width.toPx()}" } Box( modifier = modifier @@ -808,7 +820,15 @@ private fun CoreLineNumbersView( .fillMaxHeight() .clipToBounds() .background(colours.backgroundLight) - .padding(top = 6.dp, start = 4.dp, end = 4.dp), // see AppTextField + .onGloballyPositioned { // need to be put before padding modifiers so that measured size includes padding + if (abs(it.size.width - width.toPx()) < 0.1 /* equivalent to it.size.width == width.toPx() */) { + log.v { "correct width ${it.size.width}. expected = ${width.toPx()}" } + onCorrectMeasured() + } else { + log.v { "reject width ${it.size.width}. expected = ${width.toPx()}" } + } + } + .padding(top = 6.dp, start = 4.dp, end = 4.dp) // see AppTextField ) { var ii: Int = firstRow var lastLineIndex = -1 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 851d1c1c..3d8830c9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -486,7 +486,7 @@ fun BodyViewerView( val isEnableJsonPath = selectedView.name.contains("json", ignoreCase = true) var jsonPathExpression by rememberLast(key) { mutableStateOf("") } var isJsonPathError by rememberLast(key) { mutableStateOf(false) } - val debouncedJsonPathExpression = debouncedStateOf(400.milliseconds()) { jsonPathExpression } + val (debouncedJsonPathExpression, _) = debouncedStateOf(400.milliseconds()) { jsonPathExpression } Column(modifier = modifier) { Box(modifier = Modifier.fillMaxWidth()) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2498278c..b332b243 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -105,6 +106,7 @@ import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.math.abs import kotlin.math.roundToInt import kotlin.random.Random import kotlin.reflect.KMutableProperty @@ -275,11 +277,14 @@ private fun CoreBigMonospaceText( ), textStyle ) } + // if the value of `viewState.isLayoutDisabled` is changed, trigger a recomposition + viewState.isLayoutDisabledFlow.collectAsState(initial = false).value + val isLayoutEnabled = !viewState.isLayoutDisabled // not using the value from flow because it is not instantly updated var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } var layoutCoordinates by remember { mutableStateOf(null) } - val contentWidth = debouncedStateOf(200.milliseconds()) { + val (contentWidth, isContentWidthLatest) = debouncedStateOf(200.milliseconds(), tolerateCount = 1, text) { /* handle the first non-zero width instantly */ width - with(density) { (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() } @@ -335,7 +340,7 @@ private fun CoreBigMonospaceText( forceRecompose = Random.nextLong() } - if (contentWidth > 0) { + if (isLayoutEnabled && contentWidth > 0 && isContentWidthLatest) { remember(transformedText, textLayouter, contentWidth) { log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" } @@ -1099,6 +1104,7 @@ private fun CoreBigMonospaceText( height = it.size.height layoutCoordinates = it viewState.visibleSize = Size(width = width, height = height) + log.v { "BigMonospaceText set width = $width" } } .clipToBounds() .padding(padding) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index 8affcdab..3a21b78e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TransformedText +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow val EMPTY_SELECTION_RANGE = 0 .. -1 @@ -158,6 +160,16 @@ class BigTextViewState { var transformedText: BigTextTransformed? = null internal set + private val isLayoutDisabledMutableStateFlow = MutableStateFlow(false) + + val isLayoutDisabledFlow: Flow = isLayoutDisabledMutableStateFlow + + var isLayoutDisabled: Boolean + get() = isLayoutDisabledMutableStateFlow.value + set(value) { + isLayoutDisabledMutableStateFlow.value = value + } + var layoutResult: BigTextSimpleLayoutResult? = null var visibleSize: Size = Size(0, 0) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt index 2caf37ae..cb5df704 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt @@ -14,14 +14,14 @@ import androidx.compose.ui.text.AnnotatedString * The argument `initialValue` is only used when there is a cache miss using the cache key `cacheKey`. */ @Composable -fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?): MutableState { +fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?, initialize: (BigTextFieldState) -> Unit = {}): MutableState { return rememberSaveable(*cacheKeys) { log.i { "cache miss concurrent 1" } mutableStateOf( BigTextFieldState( ConcurrentBigText(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue))), BigTextViewState() - ) + ).apply(initialize) ) } }