Merge "Introduce slots reuse logic in SubcomposeLayout" into androidx-main
diff --git a/compose/ui/ui/api/1.0.0-beta07.txt b/compose/ui/ui/api/1.0.0-beta07.txt
index bafada9..5bd98c8 100644
--- a/compose/ui/ui/api/1.0.0-beta07.txt
+++ b/compose/ui/ui/api/1.0.0-beta07.txt
@@ -1760,6 +1760,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index bafada9..5bd98c8 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -1760,6 +1760,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt b/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
index b952190..7ccf817 100644
--- a/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
+++ b/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
@@ -1864,6 +1864,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/api/public_plus_experimental_current.txt b/compose/ui/ui/api/public_plus_experimental_current.txt
index b952190..7ccf817 100644
--- a/compose/ui/ui/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui/api/public_plus_experimental_current.txt
@@ -1864,6 +1864,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/api/restricted_1.0.0-beta07.txt b/compose/ui/ui/api/restricted_1.0.0-beta07.txt
index b504acd..bd71677 100644
--- a/compose/ui/ui/api/restricted_1.0.0-beta07.txt
+++ b/compose/ui/ui/api/restricted_1.0.0-beta07.txt
@@ -1761,6 +1761,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index b504acd..bd71677 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -1761,6 +1761,7 @@
}
public final class SubcomposeLayoutState {
+ ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
ctor public SubcomposeLayoutState();
method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
index e7e9b98..88cd537 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
@@ -25,13 +25,17 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.background
import androidx.compose.ui.draw.assertColor
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.layout.RootMeasurePolicy.measure
import androidx.compose.ui.platform.AndroidOwnerExtraAssertionsRule
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
@@ -822,6 +826,185 @@
assertThat(layoutCount).isEqualTo(1)
}
}
+
+ @Test
+ fun slotsKeptForReuse() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(2)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3)
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(2, 3) + /*reusable*/ listOf(1, 4),
+ doesNotExist = /*disposed*/ listOf(0)
+ )
+ }
+
+ @Test
+ fun newSlotIsUsingReusedSlot() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(2)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3)
+ // 1 and 4 are now in reusable buffer
+ }
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3, 5)
+ // the last reusable slot (4) will be used for composing 5
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(2, 3, 5) + /*reusable*/ listOf(1),
+ doesNotExist = /*disposed*/ listOf(0, 4)
+ )
+ }
+
+ @Test
+ fun theSameSlotIsUsedWhileItIsInReusableList() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(2)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3)
+ // 1 and 4 are now in reusable buffer
+ }
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3, 1)
+ // slot 1 should be taken back from reusable
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(2, 3, 1) + /*reusable*/ listOf(4)
+ )
+ }
+
+ @Test
+ fun prefetchIsUsingReusableNodes() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(2)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2, 3)
+ // 1 and 4 are now in reusable buffer
+ }
+
+ rule.runOnIdle {
+ state.precompose(5) {
+ ItemContent(5)
+ }
+ // prefetch should take slot 4 from reuse
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(2, 3) + /*prefetch*/ listOf(5) + /*reusable*/ listOf(1)
+ )
+ }
+
+ @Test
+ fun prefetchSlotWhichIsInReusableList() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(3)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2)
+ // 1, 3 and 4 are now in reusable buffer
+ }
+
+ rule.runOnIdle {
+ state.precompose(3) {
+ ItemContent(3)
+ }
+ // prefetch should take slot 3 from reuse
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(2) + /*prefetch*/ listOf(3) + /*reusable*/ listOf(1, 4),
+ doesNotExist = listOf(0)
+ )
+ }
+
+ @Test
+ fun nothingIsReusedWhenMaxSlotsAre0() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+ val state = SubcomposeLayoutState(0)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(2, 4)
+ }
+
+ assertNodes(
+ exists = listOf(2, 4),
+ doesNotExist = listOf(0, 1, 3)
+ )
+ }
+
+ @Test
+ fun reuse1Node() {
+ val items = mutableStateOf(listOf(0, 1, 2, 3))
+ val state = SubcomposeLayoutState(1)
+
+ composeItems(state, items)
+
+ rule.runOnIdle {
+ items.value = listOf(0, 1)
+ }
+
+ assertNodes(
+ exists = /*active*/ listOf(0, 1) + /*reusable*/ listOf(3),
+ doesNotExist = /*disposed*/ listOf(2)
+ )
+ }
+
+ private fun composeItems(
+ state: SubcomposeLayoutState,
+ items: MutableState<List<Int>>
+ ) {
+ rule.setContent {
+ SubcomposeLayout(state) { constraints ->
+ items.value.forEach {
+ subcompose(it) {
+ ItemContent(it)
+ }.forEach {
+ it.measure(constraints)
+ }
+ }
+ layout(10, 10) {}
+ }
+ }
+ }
+
+ @Composable
+ private fun ItemContent(index: Int) {
+ Box(Modifier.fillMaxSize().testTag("$index"))
+ }
+
+ private fun assertNodes(exists: List<Int>, doesNotExist: List<Int> = emptyList()) {
+ exists.forEach {
+ rule.onNodeWithTag("$it")
+ .assertExists()
+ }
+ doesNotExist.forEach {
+ rule.onNodeWithTag("$it")
+ .assertDoesNotExist()
+ }
+ }
}
fun ImageBitmap.assertCenterPixelColor(expectedColor: Color) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
index 2977afe..92a69d7 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
@@ -135,9 +135,21 @@
}
/**
- * Contains the state used by [SubcomposeLayout].
+ * State used by [SubcomposeLayout].
+ *
+ * @param maxSlotsToRetainForReuse when non-zero the layout will keep active up to this count
+ * slots which we were used but not used anymore instead of disposing them. Later when you try to
+ * compose a new slot instead of creating a completely new slot the layout would reuse the
+ * previous slot which allows to do less work especially if the slot contents are similar.
*/
-class SubcomposeLayoutState {
+class SubcomposeLayoutState(
+ private val maxSlotsToRetainForReuse: Int
+) {
+ /**
+ * State used by [SubcomposeLayout].
+ */
+ constructor() : this(0)
+
internal var compositionContext: CompositionContext? = null
// Pre-allocated lambdas to update LayoutNode
@@ -151,15 +163,21 @@
private val root: LayoutNode get() = requireNotNull(_root)
private var currentIndex = 0
private val nodeToNodeState = mutableMapOf<LayoutNode, NodeState>()
- private val slodIdToNode = mutableMapOf<Any?, LayoutNode>()
+ // this map contains active slotIds (without precomposed or reusable nodes)
+ private val slotIdToNode = mutableMapOf<Any?, LayoutNode>()
private val scope = Scope()
private val precomposeMap = mutableMapOf<Any?, LayoutNode>()
/**
- * `root.foldedChildren` list contains all the active children (used during the last measure
- * pass) plus n = `precomposedCount` nodes in the end of the list which were precomposed and
+ * `root.foldedChildren` list consist of:
+ * 1) all the active children (used during the last measure pass)
+ * 2) `reusableCount` nodes in the middle of the list which were active and stopped being
+ * used. now we keep them (up to `maxCountOfSlotsToReuse`) in order to reuse next time we
+ * will need to compose a new item
+ * 4) `precomposedCount` nodes in the end of the list which were precomposed and
* are waiting to be used during the next measure passes.
*/
+ private var reusableCount = 0
private var precomposedCount = 0
internal fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> {
@@ -168,12 +186,14 @@
"subcompose can only be used inside the measure or layout blocks"
}
- val node = slodIdToNode.getOrPut(slotId) {
+ val node = slotIdToNode.getOrPut(slotId) {
val precomposed = precomposeMap.remove(slotId)
if (precomposed != null) {
check(precomposedCount > 0)
precomposedCount--
precomposed
+ } else if (reusableCount > 0) {
+ takeNodeFromReusables(slotId)
} else {
createNodeAt(currentIndex)
}
@@ -239,20 +259,64 @@
}
private fun disposeAfterIndex(currentIndex: Int) {
- // this size is not including precomposed items in the end of the list
- val activeChildrenSize = root.foldedChildren.size - precomposedCount
- ignoreRemeasureRequests {
- for (i in currentIndex until activeChildrenSize) {
- disposeNode(root.foldedChildren[i])
- }
- root.removeAt(currentIndex, activeChildrenSize - currentIndex)
+ val precomposedNodesSectionStart = root.foldedChildren.size - precomposedCount
+ val reusableNodesSectionStart = maxOf(
+ currentIndex,
+ precomposedNodesSectionStart - maxSlotsToRetainForReuse
+ )
+
+ // keep up to maxCountOfSlotsToReuse last nodes to be reused later
+ reusableCount = precomposedNodesSectionStart - reusableNodesSectionStart
+ for (i in reusableNodesSectionStart until reusableNodesSectionStart + reusableCount) {
+ val node = root.foldedChildren[i]
+ val state = nodeToNodeState[node]!!
+ // remove them from slotIdToNode so they are not considered active
+ slotIdToNode.remove(state.slotId)
}
+
+ // dispose the rest of the nodes
+ val nodesToDispose = reusableNodesSectionStart - currentIndex
+ if (nodesToDispose > 0) {
+ ignoreRemeasureRequests {
+ for (i in currentIndex until currentIndex + nodesToDispose) {
+ disposeNode(root.foldedChildren[i])
+ }
+ root.removeAt(currentIndex, nodesToDispose)
+ }
+ }
+ }
+
+ private fun takeNodeFromReusables(slotId: Any?): LayoutNode {
+ check(reusableCount > 0)
+ val reusableNodesSectionEnd = root.foldedChildren.size - precomposedCount
+ val reusableNodesSectionStart = reusableNodesSectionEnd - reusableCount
+ var index = reusableNodesSectionStart
+ while (true) {
+ val node = root.foldedChildren[index]
+ val nodeState = nodeToNodeState.getValue(node)
+ if (nodeState.slotId == slotId) {
+ // we have a node with the same slotId
+ break
+ } else if (index == reusableNodesSectionEnd - 1) {
+ // it is the last available reusable node
+ nodeState.slotId = slotId
+ break
+ } else {
+ index++
+ }
+ }
+ if (index != reusableNodesSectionStart) {
+ // we need to rearrange the items
+ move(index, reusableNodesSectionStart, 1)
+ }
+ reusableCount--
+ return root.foldedChildren[reusableNodesSectionStart]
}
private fun disposeNode(node: LayoutNode) {
val nodeState = nodeToNodeState.remove(node)!!
nodeState.composition!!.dispose()
- slodIdToNode.remove(nodeState.slotId)
+ slotIdToNode.remove(nodeState.slotId)
}
private fun createMeasurePolicy(
@@ -292,7 +356,7 @@
it.composition!!.dispose()
}
nodeToNodeState.clear()
- slodIdToNode.clear()
+ slotIdToNode.clear()
}
/**
@@ -309,10 +373,19 @@
* @return [PrecomposedSlotHandle] instance which allows you to dispose the content.
*/
fun precompose(slotId: Any?, content: @Composable () -> Unit): PrecomposedSlotHandle {
- if (!slodIdToNode.containsKey(slotId)) {
+ if (!slotIdToNode.containsKey(slotId)) {
val node = precomposeMap.getOrPut(slotId) {
- createNodeAt(root.foldedChildren.size).also {
+ if (reusableCount > 0) {
+ val node = takeNodeFromReusables(slotId)
+ // now move this node to the end where we keep precomposed items
+ val nodeIndex = root.foldedChildren.indexOf(node)
+ move(nodeIndex, root.foldedChildren.size, 1)
precomposedCount++
+ node
+ } else {
+ createNodeAt(root.foldedChildren.size).also {
+ precomposedCount++
+ }
}
}
subcompose(node, slotId, content)
@@ -321,14 +394,21 @@
override fun dispose() {
val node = precomposeMap.remove(slotId)
if (node != null) {
- ignoreRemeasureRequests {
- disposeNode(node)
- val itemIndex = root.foldedChildren.indexOf(node)
- check(itemIndex != -1)
- root.removeAt(itemIndex, 1)
- check(precomposedCount > 0)
- precomposedCount--
+ val itemIndex = root.foldedChildren.indexOf(node)
+ check(itemIndex != -1)
+ if (reusableCount < maxSlotsToRetainForReuse) {
+ val reusableNodesSectionStart =
+ root.foldedChildren.size - precomposedCount - reusableCount
+ move(itemIndex, reusableNodesSectionStart, 1)
+ reusableCount++
+ } else {
+ ignoreRemeasureRequests {
+ disposeNode(node)
+ root.removeAt(itemIndex, 1)
+ }
}
+ check(precomposedCount > 0)
+ precomposedCount--
}
}
}
@@ -350,7 +430,7 @@
root.ignoreRemeasureRequests(block)
private class NodeState(
- val slotId: Any?,
+ var slotId: Any?,
var content: @Composable () -> Unit,
var composition: Composition? = null
)
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 5d9bd20..3fe9a9d 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -633,6 +633,8 @@
Transforming pages\: *[0-9]+
Rendering\: *[0-9]+
WARNING\: unable to find what is referred to by
+@param maxSlotsToRetainForReuse
+in DClass SubcomposeLayoutState
@param initVal
in DClass AnimationVector[0-9]+D
@param a,
@@ -966,4 +968,4 @@
Execution optimizations have been disabled for task ':benchmark:benchmark\-macro:.*' to ensure correctness due to the following reasons:
\- Gradle detected a problem with the following location: '\$OUT_DIR/androidx/benchmark/benchmark\-macro/build/generated/source/wire'\. Reason: Task ':benchmark:benchmark\-macro:.*' uses this output of task ':benchmark:benchmark\-macro:.*' without declaring an explicit or implicit dependency\. This can lead to incorrect results being produced, depending on what order the tasks are executed\. Please refer to https://docs\.gradle\.org/[0-9]+\.[0-9]+/userguide/validation_problems\.html\#implicit_dependency for more details about this problem\.
# > Task :support-emoji-demos:lintDebug
-Error processing .* broken class file\? \(This feature requires ASM[0-9]+\)
+Error processing .* broken class file\? \(This feature requires ASM[0-9]+\)
\ No newline at end of file