Skip to content

Commit

Permalink
Introduced RetainedInstanceStore to keep data during configuration ch…
Browse files Browse the repository at this point in the history
…ange
  • Loading branch information
LachlanMcKee committed Feb 18, 2023
1 parent 7145c54 commit dcf4973
Show file tree
Hide file tree
Showing 15 changed files with 341 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.bumble.appyx.core.node

import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.AppyxTestScenario
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.store.RetainedInstanceStore
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import kotlin.reflect.KClass

class RetainedInstanceStoreTest {
private val stubRetainedInstanceStore = StubRetainedInstanceStore()

@get:Rule
val rule = AppyxTestScenario { buildContext ->
TestParentNode(buildContext, stubRetainedInstanceStore)
}

@Test
fun WHEN_activity_finished_THEN_retained_instance_store_content_is_removed() {
rule.start()

rule.activityScenario.moveToState(Lifecycle.State.DESTROYED)

assertTrue(stubRetainedInstanceStore.removeAllCalled)
}

@Test
fun WHEN_activity_recreated_THEN_retained_instance_store_content_is_not_removed() {
rule.start()

rule.activityScenario.recreate()

assertFalse(stubRetainedInstanceStore.removeAllCalled)
}

class TestParentNode(
buildContext: BuildContext,
retainedInstanceStore: RetainedInstanceStore,
) : Node(
buildContext = buildContext,
retainedInstanceStore = retainedInstanceStore,
)

class StubRetainedInstanceStore : RetainedInstanceStore {
var removeAllCalled: Boolean = false

override fun <T : Any> get(
nodeId: String,
clazz: KClass<*>,
disposer: (T) -> Unit,
factory: () -> T
): T = RetainedInstanceStore.get(nodeId, clazz, disposer, factory)

override fun removeAll(nodeId: String) {
removeAllCalled = true
RetainedInstanceStore.removeAll(nodeId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ open class ActivityIntegrationPoint(
override val permissionRequester: PermissionRequester
get() = permissionRequestBoundary

override val isChangingConfigurations: Boolean
get() = activity.isChangingConfigurations

fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
activityBoundary.onActivityResult(requestCode, resultCode, data)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ abstract class IntegrationPoint(

abstract val permissionRequester: PermissionRequester

abstract val isChangingConfigurations: Boolean

fun onSaveInstanceState(outState: Bundle) {
requestCodeRegistry.onSaveInstanceState(outState)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ class IntegrationPointStub : IntegrationPoint(savedInstanceState = null) {
override val permissionRequester: PermissionRequester
get() = error(ERROR)

override val isChangingConfigurations: Boolean
get() = false

override fun handleUpNavigation() {
error(ERROR)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package com.bumble.appyx.core.modality

import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import com.bumble.appyx.utils.customisations.NodeCustomisation
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectory
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import com.bumble.appyx.core.state.SavedStateMap
import java.util.UUID

data class BuildContext(
val ancestryInfo: AncestryInfo,
val savedStateMap: SavedStateMap?,
val customisations: NodeCustomisationDirectory,
) {
companion object {
private const val IDENTIFIER_KEY = "build.context.identifier"

fun root(
savedStateMap: SavedStateMap?,
customisations: NodeCustomisationDirectory = NodeCustomisationDirectoryImpl()
Expand All @@ -22,6 +26,18 @@ data class BuildContext(
)
}

fun <T : NodeCustomisation> getOrDefault(defaultCustomisation: T) : T =
val identifier: String by lazy {
if (savedStateMap == null) {
UUID.randomUUID().toString()
} else {
savedStateMap[IDENTIFIER_KEY] as String? ?: error("onSaveInstanceState() was not called")
}
}

fun <T : NodeCustomisation> getOrDefault(defaultCustomisation: T): T =
customisations.getRecursivelyOrDefault(defaultCustomisation)

fun onSaveInstanceState(state: MutableSavedStateMap) {
state[IDENTIFIER_KEY] = identifier
}
}
32 changes: 17 additions & 15 deletions libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.bumble.appyx.core.node

import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
Expand Down Expand Up @@ -35,17 +36,24 @@ import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.bumble.appyx.core.state.SavedStateMap
import com.bumble.appyx.core.store.RetainedInstanceStore
import kotlinx.coroutines.withContext
import java.util.UUID

@Suppress("TooManyFunctions")
@Stable
open class Node(
buildContext: BuildContext,
open class Node @VisibleForTesting internal constructor(
val buildContext: BuildContext,
val view: NodeView = EmptyNodeView,
private val retainedInstanceStore: RetainedInstanceStore,
plugins: List<Plugin> = emptyList()
) : NodeLifecycle, NodeView by view, RequestCodeClient {

constructor(
buildContext: BuildContext,
view: NodeView = EmptyNodeView,
plugins: List<Plugin> = emptyList()
) : this(buildContext, view, RetainedInstanceStore, plugins)

@Suppress("LeakingThis") // Implemented in the same way as in androidx.Fragment
private val nodeLifecycle = NodeLifecycleImpl(this)

Expand Down Expand Up @@ -77,7 +85,8 @@ open class Node(

private var wasBuilt = false

val id = getNodeId(buildContext)
val id: String
get() = buildContext.identifier

override val requestCodeClientId: String = id

Expand All @@ -92,14 +101,6 @@ open class Node(
})
}

private fun getNodeId(buildContext: BuildContext): String {
val state = buildContext.savedStateMap ?: return UUID.randomUUID().toString()

return state[NODE_ID_KEY] as String? ?: error(
"super.onSaveInstanceState() was not called for the node: ${this::class.qualifiedName}"
)
}

@Deprecated(
replaceWith = ReplaceWith("executeAction(action)"),
message = "Will be removed in 1.1"
Expand Down Expand Up @@ -182,6 +183,9 @@ open class Node(
}
nodeLifecycle.updateLifecycleState(state)
if (state == Lifecycle.State.DESTROYED) {
if (!integrationPoint.isChangingConfigurations) {
retainedInstanceStore.removeAll(id)
}
plugins<Destroyable>().forEach { it.destroy() }
}
}
Expand All @@ -197,7 +201,7 @@ open class Node(

@CallSuper
protected open fun onSaveInstanceState(state: MutableSavedStateMap) {
state[NODE_ID_KEY] = id
buildContext.onSaveInstanceState(state)
}

fun finish() {
Expand Down Expand Up @@ -231,8 +235,6 @@ open class Node(
plugins<UpNavigationHandler>().any { it.handleUpNavigation() }

companion object {
private const val NODE_ID_KEY = "node.id"

// BackPressHandler is correct when only one of its properties is implemented.
private fun BackPressHandler.isCorrect(): Boolean {
val listIsOverriddenOrPluginIgnored = onBackPressedCallback == null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.bumble.appyx.core.store

import kotlin.reflect.KClass

/**
* A simple storage to preserve any objects during configuration change events.
* `factory` function will be invoked immediately on the same thread
* only if an object of the same class within the same Node does not exist.
*
* The framework will manage the lifecycle of provided objects
* and invoke `disposer` function to destroy objects properly.
*
* Sample usage:
* ```kotlin
* val feature = RetainedInstanceStore.get(
* owner = buildContext.identifier,
* factory = { FeatureImpl() },
* disposer = { feature.dispose() }
* }
* ```
* or
* * ```kotlin
* * val feature = buildContext.getRetainedInstance(
* * factory = { FeatureImpl() },
* * disposer = { feature.dispose() }
* * }
* * ```
*/
interface RetainedInstanceStore {

fun <T : Any> get(nodeId: String, clazz: KClass<*>, disposer: (T) -> Unit, factory: () -> T): T

fun removeAll(nodeId: String)

companion object : RetainedInstanceStore by RetainedInstanceStoreImpl()

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bumble.appyx.core.store

import com.bumble.appyx.core.modality.BuildContext

inline fun <reified T : Any> RetainedInstanceStore.get(
nodeId: String,
noinline disposer: (T) -> Unit = {},
noinline factory: () -> T
): T =
get(nodeId, T::class, disposer, factory)

inline fun <reified T : Any> BuildContext.getRetainedInstance(
noinline disposer: (T) -> Unit = {},
noinline factory: () -> T
) =
RetainedInstanceStore.get(identifier, disposer, factory)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.bumble.appyx.core.store

import kotlin.reflect.KClass

internal class RetainedInstanceStoreImpl : RetainedInstanceStore {

private val map: MutableMap<String, MutableMap<KClass<*>, ValueHolder<*>>> = HashMap()

@Suppress("UNCHECKED_CAST")
override fun <T : Any> get(nodeId: String, clazz: KClass<*>, disposer: (T) -> Unit, factory: () -> T): T =
map
.getOrPut(nodeId) { HashMap() }
.getOrPut(clazz) { ValueHolder(factory(), disposer) }
.value as T

override fun removeAll(nodeId: String) {
map.remove(nodeId)?.values?.forEach { it.dispose() }
}

private class ValueHolder<T : Any>(
val value: T,
private val disposer: (T) -> Unit
) {
fun dispose() {
disposer(value)
}
}
}
Loading

0 comments on commit dcf4973

Please sign in to comment.