Skip to content

Commit

Permalink
Merge pull request #361 from LachlanMcKee/add-ability-to-retain-on-co…
Browse files Browse the repository at this point in the history
…nfiguration-change

Introduced RetainedInstanceStore to keep data during config change
  • Loading branch information
LachlanMcKee authored Feb 20, 2023
2 parents d97aef1 + fe8d961 commit 781d834
Show file tree
Hide file tree
Showing 20 changed files with 592 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Pending changes

- [#361](https://github.com/bumble-tech/appyx/pull/361)**Added**: Introduced `RetainedInstanceStore`. This provides developers the ability to retain objects between configuration changes.
- [#336](https://github.com/bumble-tech/appyx/pulls/336)**Updated**: ChildAware API does not enforce Node subtypes only anymore, making it possible to use interfaces as public contracts for child nodes.

---
Expand Down
49 changes: 49 additions & 0 deletions documentation/apps/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Configuration change

To retain objects during configuration change you can use the `RetainedInstanceStore` class.

## How does it work?

The `RetainedInstanceStore` stores the objects within a singleton. The node manages whether the content should be removed by checking whether the `Activity` is being recreated due to a configuration change or not.

These are the following scenarios:
- If the `Activity` is recreated: the retained instance is returned instead of a new instance.
- If the `Activity` is destroyed: the retained instance is removed and disposed.

## Example

Here is an example of how you can use the `RetainedInstanceStore`:

```kotlin
import com.bumble.appyx.core.builder.Builder
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.store.getRetainedInstance
import com.bumble.appyx.interop.rx2.store.getRetainedDisposable

class RetainedInstancesBuilder : Builder<String>() {

override fun build(buildContext: BuildContext, payload: String): Node {
val retainedNonDisposable = buildContext.getRetainedInstance(
factory = { NonDisposableClass(payload) },
disposer = { feature.cleanUp() }
)
val retainedFeature = buildContext.getRetainedDisposable {
RetainedInstancesFeature(payload)
}

val view = RetainedInstancesViewImpl()
val interactor = RetainedInstancesInteractor(
feature = retainedFeature,
nonDisposable = retainedNonDisposable,
view = view
)

return RetainedInstancesNode(
buildContext = buildContext,
view = view,
plugins = listOf(interactor)
)
}
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 com.bumble.appyx.core.store.getRetainedInstance
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class RetainedInstanceStoreTest {
private val stubRetainedInstanceStore = StubRetainedInstanceStore()
private var beforeNodeCreatedFunc: ((BuildContext) -> Unit)? = null

@get:Rule
val rule = AppyxTestScenario { buildContext ->
beforeNodeCreatedFunc?.invoke(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.clearStoreCalled)
}

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

rule.activityScenario.recreate()

assertFalse(stubRetainedInstanceStore.clearStoreCalled)
}

@Test
fun GIVEN_counter_stored_and_incremented_AND_activity_finished_WHEN_stored_counter_incremented_THEN_counter_value_is_1() {
var nodeBuildInvocationCount = 0
var factoryInvocationCount = 0
var disposerCalled = false
var buildContext: BuildContext? = null

val getRetainedCounterFunc = {
requireNotNull(buildContext) { "Build context not set" }
.getRetainedInstance(
disposer = {
disposerCalled = true
},
factory = {
factoryInvocationCount++
Counter()
}
)
}
beforeNodeCreatedFunc = {
buildContext = it
nodeBuildInvocationCount++
}
rule.start()
getRetainedCounterFunc().increment()
rule.activityScenario.moveToState(Lifecycle.State.DESTROYED)

getRetainedCounterFunc().increment()

assertEquals(1, getRetainedCounterFunc().value)
assertEquals(1, nodeBuildInvocationCount)
assertEquals(2, factoryInvocationCount)
assertTrue(disposerCalled)
}

@Test
fun GIVEN_counter_stored_and_incremented_AND_activity_recreated_WHEN_stored_counter_incremented_THEN_counter_value_is_2() {
var nodeBuildInvocationCount = 0
var factoryInvocationCount = 0
var disposerCalled = false
var buildContext: BuildContext? = null

val getRetainedCounterFunc = {
requireNotNull(buildContext) { "Build context not set" }
.getRetainedInstance(
disposer = {
disposerCalled = true
},
factory = {
factoryInvocationCount++
Counter()
}
)
}
beforeNodeCreatedFunc = {
buildContext = it
nodeBuildInvocationCount++
}
rule.start()
getRetainedCounterFunc().increment()
rule.activityScenario.recreate()

getRetainedCounterFunc().increment()

assertEquals(2, getRetainedCounterFunc().value)
assertEquals(2, nodeBuildInvocationCount)
assertEquals(1, factoryInvocationCount)
assertFalse(disposerCalled)
}

class Counter {
var value: Int = 0
private set

fun increment() {
value++
}
}

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

class StubRetainedInstanceStore : RetainedInstanceStore by RetainedInstanceStore {
var clearStoreCalled: Boolean = false

override fun clearStore(storeId: String) {
clearStoreCalled = true
RetainedInstanceStore.clearStore(storeId)
}
}
}
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(
private 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.clearStore(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,35 @@
package com.bumble.appyx.core.store

/**
* 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(
* storeId = buildContext.identifier,
* factory = { FeatureImpl() },
* disposer = { feature.dispose() }
* }
* ```
* or
* * ```kotlin
* * val feature = buildContext.getRetainedInstance(
* * factory = { FeatureImpl() },
* * disposer = { feature.dispose() }
* * }
* * ```
*/
interface RetainedInstanceStore {

fun <T : Any> get(storeId: String, key: String, disposer: (T) -> Unit = {}, factory: () -> T): T

fun clearStore(storeId: String)

companion object : RetainedInstanceStore by RetainedInstanceStoreImpl()

}
Loading

0 comments on commit 781d834

Please sign in to comment.