-
Notifications
You must be signed in to change notification settings - Fork 74
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you consider There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replacing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does the + 1 do? Inclusive to exclusive? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
val firstPagedItemIndex = (((firstVisibleItemIndex / viewportItemCount) * viewportItemCount) - viewportItemCount).coerceAtLeast(0) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why subtract a page?
Suggested change
|
||||||
val lastPagedItemIndex = (((lastVisibleItemIndex / viewportItemCount) * viewportItemCount) + viewportItemCount * 2).coerceAtMost(items.items.size) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To round up, do this?
Suggested change
|
||||||
if (firstPagedItemIndex != this.firstPagedItemIndex || lastPagedItemIndex != this.lastPagedItemIndex) { | ||||||
this.firstPagedItemIndex = firstPagedItemIndex | ||||||
this.lastPagedItemIndex = lastPagedItemIndex | ||||||
onViewportChanged?.invoke(firstPagedItemIndex, lastPagedItemIndex) | ||||||
} | ||||||
} | ||||||
|
||||||
|
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.