Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce viewport updates in LazyList for improved perf with Zipline #1471

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,24 @@ internal fun LazyList(
content: LazyListScope.() -> Unit,
) {
val itemProvider = rememberLazyListItemProvider(content)
var lastVisibleItemIndex by remember { mutableStateOf(0) }
var lastPagedItemIndex by remember { mutableStateOf(0) }
val itemsBefore = remember(state.firstVisibleItemIndex) { (state.firstVisibleItemIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0) }
val itemsAfter = remember(lastVisibleItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastVisibleItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) }
val itemsAfter = remember(lastPagedItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastPagedItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) }
val scrollItemIndex = remember(state.scrollToItemTriggeredId) { ScrollItemIndex(state.scrollToItemTriggeredId, state.firstVisibleItemIndex) }
var placeholderPoolSize by remember { mutableStateOf(20) }
LazyList(
isVertical,
itemsBefore = itemsBefore,
itemsAfter = itemsAfter,
onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
onViewportChanged = { localFirstPagedItemIndex, localLastPagedItemIndex ->
val visibleItemCount = localLastPagedItemIndex - localFirstPagedItemIndex
val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
// We only ever want to increase the pool size.
if (placeholderPoolSize < proposedPlaceholderPoolSize) {
placeholderPoolSize = proposedPlaceholderPoolSize
}
state.firstVisibleItemIndex = localFirstVisibleItemIndex
lastVisibleItemIndex = localLastVisibleItemIndex
state.firstVisibleItemIndex = localFirstPagedItemIndex
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@swankjesse @JakeWharton I didn't notice it before, but such a paginated viewport introduces a bug here. This PR means we can no longer reliably expose to the caller of LazyList what the first visible item index is.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a third parameter to the lambda for the actual first index?

Copy link
Contributor Author

@veyndan veyndan Sep 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we did that and didn't update the frequency of the lambda invocation on every scroll, then the first visible item index would still be incorrect due to the new deduplication logic. If the new deduplication logic took into account the actual first index, then we're serializing and deserializing the viewport updates at the same frequency as before.

lastPagedItemIndex = localLastPagedItemIndex
},
width = width,
height = height,
Expand Down Expand Up @@ -97,24 +97,24 @@ internal fun RefreshableLazyList(
content: LazyListScope.() -> Unit,
) {
val itemProvider = rememberLazyListItemProvider(content)
var lastVisibleItemIndex by remember { mutableStateOf(0) }
var lastPagedItemIndex by remember { mutableStateOf(0) }
val itemsBefore = remember(state.firstVisibleItemIndex) { (state.firstVisibleItemIndex - OffscreenItemsBufferCount / 2).coerceAtLeast(0) }
val itemsAfter = remember(lastVisibleItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastVisibleItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) }
val itemsAfter = remember(lastPagedItemIndex, itemProvider.itemCount) { (itemProvider.itemCount - (lastPagedItemIndex + OffscreenItemsBufferCount / 2).coerceAtMost(itemProvider.itemCount)).coerceAtLeast(0) }
val scrollItemIndex = remember(state.scrollToItemTriggeredId) { ScrollItemIndex(state.scrollToItemTriggeredId, state.firstVisibleItemIndex) }
var placeholderPoolSize by remember { mutableStateOf(20) }
RefreshableLazyList(
isVertical,
itemsBefore = itemsBefore,
itemsAfter = itemsAfter,
onViewportChanged = { localFirstVisibleItemIndex, localLastVisibleItemIndex ->
val visibleItemCount = localLastVisibleItemIndex - localFirstVisibleItemIndex
onViewportChanged = { localFirstPagedItemIndex, localLastPagedItemIndex ->
val visibleItemCount = localLastPagedItemIndex - localFirstPagedItemIndex
val proposedPlaceholderPoolSize = visibleItemCount + visibleItemCount / 2
// We only ever want to increase the pool size.
if (placeholderPoolSize < proposedPlaceholderPoolSize) {
placeholderPoolSize = proposedPlaceholderPoolSize
}
state.firstVisibleItemIndex = localFirstVisibleItemIndex
lastVisibleItemIndex = localLastVisibleItemIndex
state.firstVisibleItemIndex = localFirstPagedItemIndex
lastPagedItemIndex = localLastPagedItemIndex
},
refreshing = refreshing,
onRefresh = onRefresh,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ internal class ComposeUiLazyList :
LazyList<@Composable () -> Unit>,
RefreshableLazyList<@Composable () -> Unit> {
private var isVertical by mutableStateOf(false)
private var onViewportChanged: ((firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit)? by mutableStateOf(null)
private var onViewportChanged: ((firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit)? by mutableStateOf(null)
private var itemsBefore by mutableStateOf(0)
private var itemsAfter by mutableStateOf(0)
private var isRefreshing by mutableStateOf(false)
Expand All @@ -78,7 +78,7 @@ internal class ComposeUiLazyList :
this.isVertical = isVertical
}

override fun onViewportChanged(onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit) {
override fun onViewportChanged(onViewportChanged: (firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit) {
this.onViewportChanged = onViewportChanged
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ internal open class HTMLLazyList(document: Document) : LazyList<HTMLElement> {
value.style.flexDirection = if (isVertical) "column" else "row"
}

override fun onViewportChanged(onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit) {
override fun onViewportChanged(onViewportChanged: (firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit) {
}

override fun itemsBefore(itemsBefore: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import app.cash.redwood.ui.Margin
@Widget(1)
public data class LazyList(
@Property(1) val isVertical: Boolean,
@Property(2) val onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit,
@Property(2) val onViewportChanged: (firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit,
@Property(3) val itemsBefore: Int,
@Property(4) val itemsAfter: Int,
@Property(5) val width: Constraint,
Expand All @@ -41,7 +41,7 @@ public data class LazyList(
@Widget(2)
public data class RefreshableLazyList(
@Property(1) val isVertical: Boolean,
@Property(2) val onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit,
@Property(2) val onViewportChanged: (firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit,
@Property(3) val itemsBefore: Int,
@Property(4) val itemsAfter: Int,
@Property(5) val refreshing: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ internal open class UIViewLazyList(
val visibleIndexPaths = collectionView.indexPathsForVisibleItems()

if (visibleIndexPaths.isNotEmpty()) {
// TODO: Optimize this for less operations
updateViewport(
visibleIndexPaths.minOf { (it as NSIndexPath).item.toInt() },
visibleIndexPaths.maxOf { (it as NSIndexPath).item.toInt() },
Expand Down
8 changes: 8 additions & 0 deletions redwood-lazylayout-widget/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ kotlin {
api projects.redwoodLazylayoutApi
}
}
commonTest {
dependencies {
implementation libs.assertk
implementation libs.kotlin.test
implementation libs.kotlinx.coroutines.test
implementation libs.turbine
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,27 @@ public abstract class WindowedLazyList<W : Any>(
private val listUpdateCallback: ListUpdateCallback,
) : LazyList<W> {

private var firstVisibleItemIndex = 0
private var lastVisibleItemIndex = 0
private var onViewportChanged: ((firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit)? = null
private var firstPagedItemIndex = 0
private var lastPagedItemIndex = 0
private var onViewportChanged: ((firstPagedItemIndex: Int, lastPagedItemIndex: Int) -> Unit)? = null

final override val items: WindowedChildren<W> = WindowedChildren(listUpdateCallback)

final override fun onViewportChanged(onViewportChanged: (Int, Int) -> Unit) {
this.onViewportChanged = onViewportChanged
}

protected fun updateViewport(firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) {
if (firstVisibleItemIndex != this.firstVisibleItemIndex || lastVisibleItemIndex != this.lastVisibleItemIndex) {
this.firstVisibleItemIndex = firstVisibleItemIndex
this.lastVisibleItemIndex = lastVisibleItemIndex
onViewportChanged?.invoke(firstVisibleItemIndex, lastVisibleItemIndex)
public fun updateViewport(firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) {
// Paginate the results so `onViewportChanged` isn't blasted with a bunch of updates. This is
// particularly important when using `LazyList` in Treehouse (compared to plain Redwood), as
// the serialization cost between the host-guest bridge can be expensive.
val viewportItemCount = lastVisibleItemIndex - firstVisibleItemIndex + 1
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you consider coerceAtLeast() instead of + 1 to make sure it's nonzero?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing + 1 with coerceAtLeast() would mean that we are no longer calculating the number of items in the viewport. The + 1 is to ensure that that is what we're calculating, and the fact that it produces a nonzero is happy happenstance.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the + 1 do? Inclusive to exclusive?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm kinda confused by what you're asking, as we're not dealing with a range here. As Jake pointed (#1471 (comment)), we need to do a + 1 to ensure we're calculating the number of items in the viewport.

val firstPagedItemIndex = (((firstVisibleItemIndex / viewportItemCount) * viewportItemCount) - viewportItemCount).coerceAtLeast(0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why subtract a page?

Suggested change
val firstPagedItemIndex = (((firstVisibleItemIndex / viewportItemCount) * viewportItemCount) - viewportItemCount).coerceAtLeast(0)
val firstPagedItemIndex = (firstVisibleItemIndex / viewportItemCount) * viewportItemCount

val lastPagedItemIndex = (((lastVisibleItemIndex / viewportItemCount) * viewportItemCount) + viewportItemCount * 2).coerceAtMost(items.items.size)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To round up, do this?

Suggested change
val lastPagedItemIndex = (((lastVisibleItemIndex / viewportItemCount) * viewportItemCount) + viewportItemCount * 2).coerceAtMost(items.items.size)
val lastPagedItemIndex = (lastVisibleItemIndex + viewportItemCount - 1) / viewportItemCount * viewportItemCount

if (firstPagedItemIndex != this.firstPagedItemIndex || lastPagedItemIndex != this.lastPagedItemIndex) {
this.firstPagedItemIndex = firstPagedItemIndex
this.lastPagedItemIndex = lastPagedItemIndex
onViewportChanged?.invoke(firstPagedItemIndex, lastPagedItemIndex)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.redwood.lazylayout.widget

import app.cash.redwood.Modifier
import app.cash.redwood.layout.api.Constraint
import app.cash.redwood.layout.api.CrossAxisAlignment
import app.cash.redwood.lazylayout.api.ScrollItemIndex
import app.cash.redwood.ui.Margin
import app.cash.redwood.widget.MutableListChildren
import app.cash.redwood.widget.Widget
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest

class WindowedLazyListTest {
@Test fun updateViewport_0() = updateViewport(40..49, 30..60)
@Test fun updateViewport_1() = updateViewport(41..50, 30..70)
@Test fun updateViewport_2() = updateViewport(42..51, 30..70)
@Test fun updateViewport_3() = updateViewport(49..58, 30..70)
@Test fun updateViewport_4() = updateViewport(59..68, 40..80)

private fun updateViewport(
visibleItemIndexRange: IntRange,
expectedViewportChange: IntRange,
) = runTest {
val windowedLazyList = FakeWindowedLazyList(this, "foo")
repeat(100) {
windowedLazyList.items.insert(
it,
object : Widget<String> {
override val value: String = it.toString()
override var modifier: Modifier = Modifier
},
)
}
windowedLazyList.updateViewport(visibleItemIndexRange.first, visibleItemIndexRange.last)
windowedLazyList.viewportChanges.test {
assertThat(awaitItem()).isEqualTo(expectedViewportChange)
}
}

@Test
fun viewportUpdatesAreDeduplicated() = runTest {
val windowedLazyList = FakeWindowedLazyList(this, "foo")
repeat(100) {
windowedLazyList.items.insert(
it,
object : Widget<String> {
override val value: String = it.toString()
override var modifier: Modifier = Modifier
},
)
}
windowedLazyList.updateViewport(41, 50)
windowedLazyList.updateViewport(41, 50)
windowedLazyList.updateViewport(49, 58)
windowedLazyList.updateViewport(59, 68)
windowedLazyList.viewportChanges.test {
assertThat(awaitItem()).isEqualTo(30..70)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanding it by one page on either side feels like you’re doing the guest code’s work on the host side. Would you consider changing these to 40..60 and 50..70 ?

Are these inclusive or exclusive? 40..60 seems like it’s 10 elements too large for 41..50 as input

assertThat(awaitItem()).isEqualTo(40..80)
}
}
}

private class FakeWindowedLazyList<W : Any>(
private val scope: CoroutineScope,
override val value: W,
) : WindowedLazyList<W>(NoOpListUpdateCallback) {
private val _viewportChanges = MutableSharedFlow<IntRange>()
val viewportChanges: SharedFlow<IntRange> = _viewportChanges

override var modifier: Modifier = Modifier
override val placeholder: Widget.Children<W> = MutableListChildren()

init {
onViewportChanged { firstPagedItemIndex, lastPagedItemIndex ->
scope.launch {
_viewportChanges.emit(firstPagedItemIndex..lastPagedItemIndex)
}
}
}

override fun isVertical(isVertical: Boolean) = error("unexpected call")
override fun width(width: Constraint) = error("unexpected call")
override fun height(height: Constraint) = error("unexpected call")
override fun margin(margin: Margin) = error("unexpected call")
override fun crossAxisAlignment(crossAxisAlignment: CrossAxisAlignment) = error("unexpected call")
override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) = error("unexpected call")
}

private object NoOpListUpdateCallback : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) = Unit
override fun onMoved(fromPosition: Int, toPosition: Int, count: Int) = Unit
override fun onRemoved(position: Int, count: Int) = Unit
}