From a549184b8d70fe611f27f8581563123ef31c9c93 Mon Sep 17 00:00:00 2001 From: CherryPerry Date: Wed, 31 Aug 2022 18:13:00 +0100 Subject: [PATCH] Suspend nodes when required --- .../core/children/ChildNodeCreationManager.kt | 132 +++++++-- .../onscreen/OnScreenStateResolver.kt | 2 +- .../com/bumble/appyx/core/node/ParentNode.kt | 2 + .../appyx/core/children/ChildCreationTest.kt | 254 ++++++++++++++++++ .../NodeLifecycleAwareTest.kt | 2 +- 5 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 core/src/test/kotlin/com/bumble/appyx/core/children/ChildCreationTest.kt rename core/src/test/kotlin/com/bumble/appyx/core/{lifecycle => plugin}/NodeLifecycleAwareTest.kt (97%) diff --git a/core/src/main/kotlin/com/bumble/appyx/core/children/ChildNodeCreationManager.kt b/core/src/main/kotlin/com/bumble/appyx/core/children/ChildNodeCreationManager.kt index 052acdaeb..d7ba059ab 100644 --- a/core/src/main/kotlin/com/bumble/appyx/core/children/ChildNodeCreationManager.kt +++ b/core/src/main/kotlin/com/bumble/appyx/core/children/ChildNodeCreationManager.kt @@ -16,10 +16,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch +/** + * Initializes and removes nodes based on parent node routing source. + * + * Lifecycle of these nodes is managed in [com.bumble.appyx.core.lifecycle.ChildNodeLifecycleManager]. + */ internal class ChildNodeCreationManager( private var savedStateMap: SavedStateMap?, private val customisations: NodeCustomisationDirectory, private val childMode: ChildEntry.ChildMode, + private val keepMode: ChildEntry.KeepMode, ) { private val _children = MutableStateFlow, ChildEntry>>(emptyMap()) @@ -32,29 +38,80 @@ internal class ChildNodeCreationManager( _children.update { restoredMap } savedStateMap = null } + syncNavModelWithChildren(parentNode) + } + + private fun syncNavModelWithChildren(parentNode: ParentNode) { parentNode.lifecycle.coroutineScope.launch { - parentNode.navModel.elements.collect { elements -> - _children.update { map -> - val navModelKeys = elements - .mapTo(HashSet(elements.size, 1f)) { element -> element.key } - val localKeys = map.keys - val newKeys = navModelKeys - localKeys - val removedKeys = localKeys - navModelKeys - val mutableMap = map.toMutableMap() - newKeys.forEach { key -> - mutableMap[key] = - childEntry( - key = key, - savedState = null, - suspended = childMode == ChildEntry.ChildMode.LAZY, - ) + parentNode.navModel.screenState.collect { state -> + val navModelOnScreenKeys: Set> + val navModelOffScreenKeys: Set> + val navModelKeys: Set> + when (keepMode) { + ChildEntry.KeepMode.KEEP -> { + // Consider everything as on-screen for keep mode + navModelOnScreenKeys = + (state.onScreen + state.offScreen).mapNotNullToSet { element -> element.key } + navModelOffScreenKeys = emptySet() + navModelKeys = navModelOnScreenKeys } - removedKeys.forEach { key -> - mutableMap.remove(key) + ChildEntry.KeepMode.SUSPEND -> { + navModelOnScreenKeys = + state.onScreen.mapNotNullToSet { element -> element.key } + navModelOffScreenKeys = + state.offScreen.mapNotNullToSet { element -> element.key } + navModelKeys = navModelOnScreenKeys + navModelOffScreenKeys } - mutableMap } + updateChildren(navModelKeys, navModelOnScreenKeys, navModelOffScreenKeys) + } + } + } + + // TODO: Does not work with CHILD_MODE = LAZY + private fun updateChildren( + navModelKeys: Set>, + navModelOnScreenKeys: Set>, + navModelOffScreenKeys: Set>, + ) { + _children.update { map -> + val localKeys = map.keys + val localOnScreenKeys = map.entries.mapNotNullToSet { entry -> + entry.key.takeIf { entry.value is ChildEntry.Initialized } + } + val localOffScreenKeys = map.entries.mapNotNullToSet { entry -> + entry.key.takeIf { entry.value is ChildEntry.Suspended } + } + val newKeys = navModelKeys - localKeys + val removedKeys = localKeys - navModelKeys + val offToOnScreenKeys = localOffScreenKeys.intersect(navModelOnScreenKeys) + val onToOffScreenKeys = localOnScreenKeys.intersect(navModelOffScreenKeys) + val noKeysChanges = newKeys.isEmpty() && removedKeys.isEmpty() + val noScreenChanges = offToOnScreenKeys.isEmpty() && onToOffScreenKeys.isEmpty() + if (noKeysChanges && noScreenChanges) { + return@update map + } + val mutableMap = map.toMutableMap() + newKeys.forEach { key -> + val shouldSuspend = keepMode == ChildEntry.KeepMode.SUSPEND && + navModelOffScreenKeys.contains(key) + mutableMap[key] = + childEntry( + key = key, + savedState = null, + suspended = childMode == ChildEntry.ChildMode.LAZY || shouldSuspend, + ) + } + removedKeys.forEach { key -> + mutableMap.remove(key) } + offToOnScreenKeys.forEach { key -> + mutableMap[key] = requireNotNull(mutableMap[key]).initialize() + } + onToOffScreenKeys.forEach { key -> + mutableMap[key] = requireNotNull(mutableMap[key]).suspend() + } + mutableMap } } @@ -74,16 +131,8 @@ internal class ChildNodeCreationManager( ?: error("Requested child $routingKey disappeared") when (updateChild) { is ChildEntry.Initialized -> map - is ChildEntry.Suspended -> { - val initialized = ChildEntry.Initialized( - key = updateChild.key, - node = parentNode.resolve( - routing = updateChild.key.routing, - buildContext = childBuildContext(child.savedState), - ).build() - ) - map.plus(routingKey to initialized) - } + is ChildEntry.Suspended -> + map.plus(routingKey to updateChild.initialize()) } }[routingKey] as ChildEntry.Initialized } @@ -134,8 +183,35 @@ internal class ChildNodeCreationManager( ) } + private fun ChildEntry.initialize(): ChildEntry.Initialized = + when (this) { + is ChildEntry.Initialized -> this + is ChildEntry.Suspended -> + ChildEntry.Initialized( + key = key, + node = parentNode.resolve( + routing = key.routing, + buildContext = childBuildContext(savedState), + ).build() + ) + } + + private fun ChildEntry.suspend(): ChildEntry.Suspended = + when (this) { + is ChildEntry.Suspended -> this + is ChildEntry.Initialized -> + ChildEntry.Suspended( + key = key, + // TODO: Not able to get a scope from Compose here, providing fake one + savedState = node.saveInstanceState { true }, + ) + } + private companion object { const val KEY_CHILDREN_STATE = "ChildrenState" + + private fun Collection.mapNotNullToSet(mapper: (T) -> R?): Set = + mapNotNullTo(HashSet(size, 1f), mapper) } } diff --git a/core/src/main/kotlin/com/bumble/appyx/core/navigation/onscreen/OnScreenStateResolver.kt b/core/src/main/kotlin/com/bumble/appyx/core/navigation/onscreen/OnScreenStateResolver.kt index 0bf7d3efa..54a23ee3d 100644 --- a/core/src/main/kotlin/com/bumble/appyx/core/navigation/onscreen/OnScreenStateResolver.kt +++ b/core/src/main/kotlin/com/bumble/appyx/core/navigation/onscreen/OnScreenStateResolver.kt @@ -1,6 +1,6 @@ package com.bumble.appyx.core.navigation.onscreen -interface OnScreenStateResolver { +fun interface OnScreenStateResolver { fun isOnScreen(state: State): Boolean } diff --git a/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt b/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt index 368ccba6a..c3e85b335 100644 --- a/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt +++ b/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt @@ -48,6 +48,7 @@ abstract class ParentNode( buildContext: BuildContext, view: ParentNodeView = EmptyParentNodeView(), childMode: ChildEntry.ChildMode = ChildEntry.ChildMode.EAGER, + keepMode: ChildEntry.KeepMode = ChildEntry.KeepMode.KEEP, private val childAware: ChildAware> = ChildAwareImpl(), plugins: List = listOf(), ) : Node( @@ -66,6 +67,7 @@ abstract class ParentNode( savedStateMap = buildContext.savedStateMap, customisations = buildContext.customisations, childMode = childMode, + keepMode = keepMode, ) val children: StateFlow> get() = childNodeCreationManager.children diff --git a/core/src/test/kotlin/com/bumble/appyx/core/children/ChildCreationTest.kt b/core/src/test/kotlin/com/bumble/appyx/core/children/ChildCreationTest.kt new file mode 100644 index 000000000..4d92b2bc6 --- /dev/null +++ b/core/src/test/kotlin/com/bumble/appyx/core/children/ChildCreationTest.kt @@ -0,0 +1,254 @@ +package com.bumble.appyx.core.children + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.BaseNavModel +import com.bumble.appyx.core.navigation.Operation +import com.bumble.appyx.core.navigation.RoutingElement +import com.bumble.appyx.core.navigation.RoutingElements +import com.bumble.appyx.core.navigation.RoutingKey +import com.bumble.appyx.core.navigation.onscreen.OnScreenStateResolver +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.build +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class ChildCreationTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + // region Keep mode + + @Test + fun `parent node with keep mode creates initial child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + + assertEquals(1, parent.children.value.size) + assertNotNull(parent.child("initial")) + } + + @Test + fun `parent node with keep mode creates second child on add on screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + parent.routing.add("second", TestNavModel.State.ON_SCREEN) + + assertEquals(2, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + assertNotNull(parent.child("second")) + } + + @Test + fun `parent node with keep mode creates second child on add off screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + parent.routing.add("second", TestNavModel.State.OFF_SCREEN) + + assertEquals(2, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + assertNotNull(parent.child("second")) + } + + @Test + fun `parent node with keep mode removes second child on remove`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + parent.routing.add("second", TestNavModel.State.ON_SCREEN) + parent.routing.remove("second") + + assertEquals(1, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + } + + @Test + fun `parent node with keep mode keeps not on screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + parent.routing.suspend("initial") + + assertEquals(1, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + } + + @Test + fun `parent node with keep mode reuses same node when becomes on screen`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.KEEP) + parent.routing.suspend("initial") + val node = parent.child("initial") + parent.routing.unsuspend("initial") + + assertEquals(1, parent.children.value.values.size) + assertEquals(node, parent.child("initial")) + } + + // endregion + + // region Suspend mode + + @Test + fun `parent node with suspend mode creates initial child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + + assertEquals(1, parent.children.value.size) + assertNotNull(parent.child("initial")) + } + + @Test + fun `parent node with suspend mode creates second child on add on screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + parent.routing.add("second", TestNavModel.State.ON_SCREEN) + + assertEquals(2, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + assertNotNull(parent.child("second")) + } + + @Test + fun `parent node with suspend mode does not create second child on add off screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + parent.routing.add("second", TestNavModel.State.OFF_SCREEN) + + assertEquals(2, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + assertNull(parent.child("second")) + } + + @Test + fun `parent node with suspend mode removes second child on remove`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + parent.routing.add("second", TestNavModel.State.ON_SCREEN) + parent.routing.remove("second") + + assertEquals(1, parent.children.value.values.size) + assertNotNull(parent.child("initial")) + } + + @Test + fun `parent node with suspend mode suspends not on screen child`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + parent.routing.suspend("initial") + + assertEquals(1, parent.children.value.values.size) + assertNull(parent.child("initial")) + } + + @Test + fun `parent node with suspend mode restores child when becomes on screen`() { + val parent = Parent(keepMode = ChildEntry.KeepMode.SUSPEND) + parent.routing.suspend("initial") + parent.routing.unsuspend("initial") + + assertEquals(1, parent.children.value.values.size) + assertEquals(true, parent.child("initial")?.hasRestoredState) + } + + // endregion + + // region Setup + + private class TestNavModel : BaseNavModel( + screenResolver = OnScreenStateResolver { it == State.ON_SCREEN }, + finalState = State.DESTROYED, + savedStateMap = null, + ) { + enum class State { ON_SCREEN, OFF_SCREEN, DESTROYED } + + override val initialElements: RoutingElements = listOf( + RoutingElement( + key = RoutingKey("initial"), + fromState = State.ON_SCREEN, + targetState = State.ON_SCREEN, + operation = Operation.Noop(), + ) + ) + + fun add(routing: String, state: State) { + updateState { + it + RoutingElement( + key = RoutingKey(routing), + fromState = state, + targetState = state, + operation = Operation.Noop(), + ) + } + } + + fun remove(routing: String) { + updateState { + it.filterNot { it.key.routing == routing } + } + } + + fun suspend(routing: String) { + updateState { list -> + list.map { + if (it.key.routing == routing) { + it.transitionTo(State.OFF_SCREEN, Operation.Noop()) + } else { + it + } + } + } + } + + fun unsuspend(routing: String) { + updateState { list -> + list.map { + if (it.key.routing == routing) { + it.transitionTo(State.ON_SCREEN, Operation.Noop()) + } else { + it + } + } + } + } + + } + + private class Parent( + keepMode: ChildEntry.KeepMode = ChildEntry.KeepMode.KEEP, + buildContext: BuildContext = BuildContext.root(null), + val routing: TestNavModel = TestNavModel(), + ) : ParentNode( + buildContext = buildContext, + navModel = routing, + keepMode = keepMode, + ) { + init { + build() + manageTransitionsInTest() + } + + override fun resolve(routing: String, buildContext: BuildContext): Node = + Child(routing, buildContext) + + fun key(routing: String): RoutingKey? = + children.value.keys.find { it.routing == routing } + + fun child(routing: String): Child? = + children.value.values.find { it.key.routing == routing }?.nodeOrNull as Child? + + } + + private class Child( + val id: String, + buildContext: BuildContext + ) : Node(buildContext) { + val hasRestoredState: Boolean = + buildContext.savedStateMap?.contains("test") == true + + override fun onSaveInstanceState(state: MutableSavedStateMap) { + super.onSaveInstanceState(state) + state["test"] = true + } + } + + // endregion + +} diff --git a/core/src/test/kotlin/com/bumble/appyx/core/lifecycle/NodeLifecycleAwareTest.kt b/core/src/test/kotlin/com/bumble/appyx/core/plugin/NodeLifecycleAwareTest.kt similarity index 97% rename from core/src/test/kotlin/com/bumble/appyx/core/lifecycle/NodeLifecycleAwareTest.kt rename to core/src/test/kotlin/com/bumble/appyx/core/plugin/NodeLifecycleAwareTest.kt index a8d1c907c..4cdfb1377 100644 --- a/core/src/test/kotlin/com/bumble/appyx/core/lifecycle/NodeLifecycleAwareTest.kt +++ b/core/src/test/kotlin/com/bumble/appyx/core/plugin/NodeLifecycleAwareTest.kt @@ -1,4 +1,4 @@ -package com.bumble.appyx.core.lifecycle +package com.bumble.appyx.core.plugin import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.compose.runtime.Composable