diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a09ce88..e5fe248e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,15 @@ Changelog - **New**: Add `FakeNavigator` functions to check for the lack of pop/resetRoot events. - **New**: Add `FakeNavigator` constructor param to add additional screens to the backstack. - **New**: Add support for static UIs. In some cases, a UI may not need a presenter to compute or manage its state. Examples of this include UIs that are stateless or can derive their state from a single static input or an input [Screen]'s properties. In these cases, make your _screen_ implement the `StaticScreen` interface. When a `StaticScreen` is used, Circuit will internally allow the UI to run on its own and won't connect it to a presenter if no presenter is provided. -- **New**: Add `RecordLifecycle` and `LocalRecordLifecycle` composition local, allowing UIs and presenters to observe when they are 'active'. Currently, a record is 'active' when it is the top record on the back stack. -- **Behaviour Change**: Presenters are now 'paused' and replay their last emitted `CircuitUiState` when they are not active. Presenters can opt-out of this behavior by implementing `NonPausablePresenter`. +- **New**: Add `RecordLifecycle` and `LocalRecordLifecycle` composition local, allowing UIs and presenters to observe when they are 'active'. Currently, a record is considered 'active' when it is the top record on the back stack. +- **New**: Add a `rememberRetainedSaveable` variant that participates in both `RetainedStateRegistry` and `SaveableStateRegistry` restoration, allowing layered state persistence. + - The logic is the following upon `rememberRetainedSaveable` entering composition: + - consume from both `RetainedStateRegistry` and `SaveableStateRegistry`, if available + - if the retained value is available, use that + - otherwise, if the saveable restored value is available, use that + - otherwise, re-initialize the value + - There is also an overload of `rememberRetained` that explicitly requires a `Saver` parameter. +- **Behaviour Change**: Presenters are now 'paused' when inactive and replay their last emitted `CircuitUiState` when they are not active. Presenters can opt-out of this behavior by implementing `NonPausablePresenter`. - **Behaviour Change**: `NavigatorImpl.goTo` no longer navigates if the `Screen` is equal to `Navigator.peek()`. - **Behaviour Change**: `Presenter.present` is now annotated with `@ComposableTarget("presenter")`. This helps prevent use of Compose UI in the presentation logic as the compiler will emit a warning if you do. Note this does not appear in the IDE, so it's recommended to use `allWarningsAsErrors` to fail the build on this event. - **Change**: `Navigator.goTo` now returns a Bool indicating navigation success. diff --git a/circuit-retained/build.gradle.kts b/circuit-retained/build.gradle.kts index e9055bd92..79bd2c8af 100644 --- a/circuit-retained/build.gradle.kts +++ b/circuit-retained/build.gradle.kts @@ -36,6 +36,7 @@ kotlin { commonMain { dependencies { api(libs.compose.runtime) + api(libs.compose.runtime.saveable) api(libs.coroutines) } } @@ -87,6 +88,11 @@ kotlin { targets.configureEach { compilations.configureEach { compilerOptions.configure { freeCompilerArgs.add("-Xexpect-actual-classes") } + if (compilationName == "releaseAndroidTest") { + compilerOptions.configure { + optIn.add("com.slack.circuit.retained.DelicateCircuitRetainedApi") + } + } } } } diff --git a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt index b949d058d..e4c335c07 100644 --- a/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt +++ b/circuit-retained/dependencies/androidReleaseRuntimeClasspath.txt @@ -52,6 +52,7 @@ androidx.startup:startup-runtime androidx.tracing:tracing androidx.versionedparcelable:versionedparcelable com.google.guava:listenablefuture +org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime org.jetbrains.kotlin:kotlin-bom org.jetbrains.kotlin:kotlin-stdlib diff --git a/circuit-retained/dependencies/jvmRuntimeClasspath.txt b/circuit-retained/dependencies/jvmRuntimeClasspath.txt index 945407de7..38c0b72ba 100644 --- a/circuit-retained/dependencies/jvmRuntimeClasspath.txt +++ b/circuit-retained/dependencies/jvmRuntimeClasspath.txt @@ -4,6 +4,8 @@ androidx.collection:collection-jvm androidx.collection:collection org.jetbrains.compose.collection-internal:collection org.jetbrains.compose.runtime:runtime-desktop +org.jetbrains.compose.runtime:runtime-saveable-desktop +org.jetbrains.compose.runtime:runtime-saveable org.jetbrains.compose.runtime:runtime org.jetbrains.kotlin:kotlin-bom org.jetbrains.kotlin:kotlin-stdlib diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedSaveableTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedSaveableTest.kt new file mode 100644 index 000000000..a301eaa81 --- /dev/null +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedSaveableTest.kt @@ -0,0 +1,152 @@ +// Copyright (C) 2022 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.retained.android + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ActivityScenario +import com.slack.circuit.retained.Continuity +import com.slack.circuit.retained.ContinuityViewModel +import com.slack.circuit.retained.LocalCanRetainChecker +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.continuityRetainedStateRegistry +import com.slack.circuit.retained.rememberCanRetainChecker +import com.slack.circuit.retained.rememberRetained +import leakcanary.DetectLeaksAfterTestSuccess.Companion.detectLeaksAfterTestSuccessWrapping +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +class RetainedSaveableTest { + private val composeTestRule = createAndroidComposeRule() + + @get:Rule + val rule = + RuleChain.emptyRuleChain().detectLeaksAfterTestSuccessWrapping(tag = "ActivitiesDestroyed") { + around(composeTestRule) + } + + private val scenario: ActivityScenario + get() = composeTestRule.activityRule.scenario + + private class RecordingContinuityVmFactory : ViewModelProvider.Factory { + var continuity: ContinuityViewModel? = null + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") return ContinuityViewModel().also { continuity = it } as T + } + } + + private val vmFactory = RecordingContinuityVmFactory() + + private var canRetainOverride: Boolean? = null + + @Test + fun retainedIsUsedWhenRecreating() { + var id = 0 + lateinit var data: CacheableData + + val content = + @Composable { + data = rememberRetained(saver = CacheableData.Saver) { CacheableData(id++) } + Text(modifier = Modifier.testTag("id"), text = "${data.id}") + Text(modifier = Modifier.testTag("superBigData"), text = "${data.superBigData}") + } + + setActivityContent(content) + + // Check initial state is correct + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("null") + + data.superBigData = "Super big data" + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("Super big data") + + scenario.recreate() + + setActivityContent(content) + + // Retained state is preserved + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("Super big data") + } + + @Test + fun saveableIsUsedWhenRecreatingWithProcessDeath() { + var id = 0 + lateinit var data: CacheableData + + val content = + @Composable { + data = rememberRetained(saver = CacheableData.Saver) { CacheableData(id++) } + Text(modifier = Modifier.testTag("id"), text = "${data.id}") + Text(modifier = Modifier.testTag("superBigData"), text = "${data.superBigData}") + } + + setActivityContent(content) + + // Check initial state is correct + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("null") + + data.superBigData = "Super big data" + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("Super big data") + + // Override can retain to drop retaining values, to simulate process death + canRetainOverride = false + scenario.recreate() + canRetainOverride = null + + setActivityContent(content) + + // Retained state is not preserved, but id is + composeTestRule.onNodeWithTag("id").assertTextEquals("0") + composeTestRule.onNodeWithTag("superBigData").assertTextEquals("null") + } + + private fun setActivityContent(content: @Composable () -> Unit) { + scenario.onActivity { activity -> + activity.setContent { + val defaultCanRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker() + CompositionLocalProvider( + LocalRetainedStateRegistry provides + continuityRetainedStateRegistry(Continuity.KEY, vmFactory) { + canRetainOverride ?: defaultCanRetainChecker.canRetain(it) + } + ) { + content() + } + } + } + } +} + +@Stable +private class CacheableData(val id: Int) { + var superBigData: String? by mutableStateOf(null) + + companion object { + val Saver: Saver = Saver({ it.id }, { CacheableData(it) }) + } +} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/DelicateCircuitRetainedApi.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/DelicateCircuitRetainedApi.kt new file mode 100644 index 000000000..92c440031 --- /dev/null +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/DelicateCircuitRetainedApi.kt @@ -0,0 +1,11 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.retained + +import kotlin.annotation.AnnotationTarget.FUNCTION + +/** Indicates that the annotated API is delicate and should be used carefully. */ +@RequiresOptIn +@Retention(AnnotationRetention.BINARY) +@Target(FUNCTION) +public annotation class DelicateCircuitRetainedApi diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt index 5e8fcc241..ccd44e96a 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RememberRetained.kt @@ -3,10 +3,22 @@ package com.slack.circuit.retained import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.autoSaver +import androidx.compose.runtime.snapshots.SnapshotMutableState +import androidx.compose.runtime.structuralEqualityPolicy /** * Remember the value produced by [init]. @@ -14,7 +26,7 @@ import androidx.compose.runtime.remember * It behaves similarly to [remember], but the stored value will survive configuration changes, such * as a screen rotation. * - * You can use it with a value stored inside [androidx.compose.runtime.mutableStateOf]. + * You can use it with a value stored inside [mutableStateOf]. * * This differs from `rememberSaveable` by not being tied to Android bundles or parcelable. You * should take care to ensure that the state computed by [init] does not capture anything that is @@ -24,8 +36,8 @@ import androidx.compose.runtime.remember * However, it does not participate in saved instance state either, so care should be taken to * choose the right retention mechanism for your use case. Consider the below two examples. * - * The first case will retain `state` across configuration changes but will _not_ survive process - * death. + * The first case will retain `state` across configuration changes and the back stack but will _not_ + * survive process death. * * ```kotlin * @Composable @@ -41,8 +53,9 @@ import androidx.compose.runtime.remember * } * ``` * - * This second case will retain `count` across configuration changes _and_ survive process death. - * However, it only works with primitives or `Parcelable` state types. + * This second case will retain `count` across configuration changes, the back stack, _and_ survive + * process death. However, it only works with primitives or implicitly Saveable (i.e. `Parcelable` + * on Android) state types. * * ```kotlin * @Composable @@ -58,6 +71,13 @@ import androidx.compose.runtime.remember * } * ``` * + * ## Layering + * + * There is also an overload of [rememberRetained] that takes a [Saver], which participates in both + * the saved state registry system and retaining. Alternatively, use [rememberRetainedSaveable] for + * one that uses [autoSaver]. These can be used to persist state across multiple layers, allowing + * for both the caching of [rememberRetained] while also the process-death-survival of saveable. + * * @param inputs A set of inputs such that, when any of them have changed, will cause the state to * reset and [init] to be rerun * @param key An optional key to be used as a key for the saved value. If not provided we use the @@ -65,19 +85,65 @@ import androidx.compose.runtime.remember * location in the composition tree * @param init A factory function to create the initial value of this state */ +@OptIn(DelicateCircuitRetainedApi::class) @Composable -public fun rememberRetained(vararg inputs: Any?, key: String? = null, init: () -> T): T { - val registry = LocalRetainedStateRegistry.current - // Short-circuit no-ops - if (registry === NoOpRetainedStateRegistry) { - return when (inputs.size) { - 0 -> remember(init) - 1 -> remember(inputs[0], init) - 2 -> remember(inputs[0], inputs[1], init) - 3 -> remember(inputs[0], inputs[1], inputs[2], init) - else -> remember(keys = inputs, init) - } - } +public fun rememberRetained(vararg inputs: Any?, key: String? = null, init: () -> T): T = + rememberRetained(inputs = inputs, saver = neverSave(), key = key, init = init) + +/** + * A simple proxy to [rememberRetained] that uses the default [autoSaver] for [saver] and a more + * explicit name. + * + * @see rememberRetained + */ +@OptIn(DelicateCircuitRetainedApi::class) +@Composable +public fun rememberRetainedSaveable( + vararg inputs: Any?, + saver: Saver = autoSaver(), + key: String? = null, + init: () -> T, +): T = rememberRetained(inputs = inputs, saver = saver, key = key, init = init) + +/** + * Remember the value produced by [init]. + * + * It behaves similarly to [rememberRetained] by retaining the value in memory, but with an + * additional [saver] parameter that will opportunistically save the value to saved instance state. + * + * The retained value will be the source of truth for the value, except in the case where the + * process was killed, and the value is being restored. In that situation only, the most recent + * value recovered from the saveable state registry will be used instead of the value provided by + * [init]. + * + * Importantly, the most recent value persisted in saveable state may lag behind the value that is + * saved in memory. For example, on Android, saved state will be collected when an Activity is + * backgrounded and onStop is called. The retained value might continue to be updated while in the + * background, but those updates will not be reflected in the saved state. If the process is killed + * before the app has returned to the foreground, these background updates will be lost, as the + * restored saved state will have been taken before the background updates occurred. + * + * Therefore, [saver] should only store and restore information that is logically safe to have an + * out-of-date view of. + * + * @param inputs A set of inputs such that, when any of them have changed, will cause the state to + * reset and [init] to be rerun + * @param saver The [Saver] object which defines how the state is saved and restored. + * @param key An optional key to be used as a key for the saved value. If not provided we use the + * automatically generated by the Compose runtime which is unique for the every exact code + * location in the composition tree + * @param init A factory function to create the initial value of this state + */ +@DelicateCircuitRetainedApi +@Composable +public fun rememberRetained( + vararg inputs: Any?, + saver: Saver, + key: String? = null, + init: () -> T, +): T { + val saveableStateRegistry = LocalSaveableStateRegistry.current + val retainedStateRegistry = LocalRetainedStateRegistry.current val compositeKey = currentCompositeKeyHash // key is the one provided by the user or the one generated by the compose runtime @@ -88,68 +154,164 @@ public fun rememberRetained(vararg inputs: Any?, key: String? = null, compositeKey.toString(MaxSupportedRadix) } + @Suppress("UNCHECKED_CAST") (saver as Saver) + val canRetainChecker = LocalCanRetainChecker.current ?: rememberCanRetainChecker() val holder = remember(canRetainChecker) { - // value is restored using the registry or created via [init] lambda - val restored = registry.consumeValue(finalKey) as? RetainableHolder.Value<*> - val finalValue = restored?.value ?: init() - val finalInputs = restored?.inputs ?: inputs - RetainableHolder( - registry = registry, + // value is restored using the retained registry first, the saveable registry second, or + // created via [init] lambda third + @Suppress("UNCHECKED_CAST") + val retainedRestored = + retainedStateRegistry.consumeValue(finalKey) as? RetainableSaveableHolder.Value + val saveableRestored = + saveableStateRegistry?.consumeRestored(finalKey)?.let { saver.restore(it) } + val finalValue = retainedRestored?.value ?: saveableRestored ?: init() + val finalInputs = retainedRestored?.inputs ?: inputs + RetainableSaveableHolder( + retainedStateRegistry = retainedStateRegistry, canRetainChecker = canRetainChecker, + saveableStateRegistry = saveableStateRegistry, + saver = saver, key = finalKey, value = finalValue, inputs = finalInputs, - hasBeenRestored = restored != null, + hasBeenRestoredFromRetained = retainedRestored != null, ) } val value = holder.getValueIfInputsAreEqual(inputs) ?: init() - SideEffect { holder.update(registry, finalKey, value, inputs) } - @Suppress("UNCHECKED_CAST") return value as T + SideEffect { + holder.update(retainedStateRegistry, saveableStateRegistry, saver, finalKey, value, inputs) + } + return value } +/** + * Remember the value produced by [init]. + * + * It behaves similarly to [remember], but the stored value will survive configuration changes, such + * as a screen rotation. + * + * Use this overload if you remember a mutable state to specify a [stateSaver] for the value + * contained inside the [MutableState]. + * + * @param inputs A set of inputs such that, when any of them have changed, will cause the state to + * reset and [init] to be rerun + * @param stateSaver The [Saver] object which defines how the value inside the MutableState is saved + * and restored. + * @param key An optional key to be used as a key for the saved value. If not provided we use the + * automatically generated by the Compose runtime which is unique for the every exact code + * location in the composition tree + * @param init A factory function to create the initial value of this state + */ +@DelicateCircuitRetainedApi +@Composable +public fun rememberRetained( + vararg inputs: Any?, + stateSaver: Saver, + key: String? = null, + init: () -> MutableState, +): MutableState = + rememberRetained(*inputs, saver = mutableStateSaver(stateSaver), key = key, init = init) + +/** + * A simple proxy to [rememberRetained] that uses the default [autoSaver] for [saver] and a more + * explicit name. + * + * @see rememberRetained + */ +@DelicateCircuitRetainedApi +@Composable +public fun rememberRetainedSaveable( + vararg inputs: Any?, + stateSaver: Saver, + key: String? = null, + init: () -> MutableState, +): MutableState = + rememberRetained(*inputs, saver = mutableStateSaver(stateSaver), key = key, init = init) + /** The maximum radix available for conversion to and from strings. */ private const val MaxSupportedRadix = 36 -private class RetainableHolder( - private var registry: RetainedStateRegistry?, +private val NoOpSaver = Saver({ null }, { null }) + +@Suppress("UNCHECKED_CAST") +private fun neverSave() = NoOpSaver as Saver + +private class RetainableSaveableHolder( + private var retainedStateRegistry: RetainedStateRegistry?, private var canRetainChecker: CanRetainChecker, + private var saveableStateRegistry: SaveableStateRegistry?, + private var saver: Saver, private var key: String, private var value: T, private var inputs: Array, - private var hasBeenRestored: Boolean = false, -) : RetainedValueProvider, RememberObserver { - private var entry: RetainedStateRegistry.Entry? = null - - fun update(registry: RetainedStateRegistry?, key: String, value: T, inputs: Array) { - var entryIsOutdated = false - if (this.registry !== registry) { - this.registry = registry - entryIsOutdated = true + private var hasBeenRestoredFromRetained: Boolean = false, +) : RetainedValueProvider, RememberObserver, SaverScope { + private var retainedStateEntry: RetainedStateRegistry.Entry? = null + private var saveableStateEntry: SaveableStateRegistry.Entry? = null + + /** Value provider called by the registry. */ + private val valueProvider = { + with(saver) { save(requireNotNull(value) { "Value should be initialized" }) } + } + + fun update( + retainedStateRegistry: RetainedStateRegistry?, + saveableStateRegistry: SaveableStateRegistry?, + saver: Saver, + key: String, + value: T, + inputs: Array, + ) { + var retainedEntryIsOutdated = false + var saveableEntryIsOutdated = false + if (this.retainedStateRegistry !== retainedStateRegistry) { + this.retainedStateRegistry = retainedStateRegistry + retainedEntryIsOutdated = true + } + if (this.saveableStateRegistry !== saveableStateRegistry) { + this.saveableStateRegistry = saveableStateRegistry + saveableEntryIsOutdated = true } if (this.key != key) { this.key = key - entryIsOutdated = true + retainedEntryIsOutdated = true + saveableEntryIsOutdated = true } if (this.value !== value) { // If the value changes, clear the hasBeenRestored flag - hasBeenRestored = false + hasBeenRestoredFromRetained = false } + this.saver = saver this.value = value this.inputs = inputs - if (entry != null && entryIsOutdated) { - entry?.unregister() - entry = null - register() + if (retainedStateEntry != null && retainedEntryIsOutdated) { + retainedStateEntry?.unregister() + retainedStateEntry = null + registerRetained() + } + if (saveableStateRegistry != null && saveableEntryIsOutdated) { + saveableStateEntry?.unregister() + saveableStateEntry = null + registerSaveable() } } - private fun register() { - val registry = registry - require(entry == null) { "entry($entry) is not null" } + private fun registerRetained() { + val registry = retainedStateRegistry + require(retainedStateEntry == null) { "entry($retainedStateEntry) is not null" } if (registry != null) { - entry = registry.registerValue(key, this) + retainedStateEntry = registry.registerValue(key, this) + } + } + + private fun registerSaveable() { + val registry = saveableStateRegistry + require(saveableStateEntry == null) { "entry($saveableStateEntry) is not null" } + if (registry != null) { + registry.requireCanBeSaved(valueProvider()) + saveableStateEntry = registry.registerProvider(key, valueProvider) } } @@ -157,12 +319,17 @@ private class RetainableHolder( override fun invoke(): Any = Value(value = requireNotNull(value) { "Value should be initialized" }, inputs = inputs) + override fun canBeSaved(value: Any): Boolean { + val registry = saveableStateRegistry + return registry == null || registry.canBeSaved(value) + } + fun saveIfRetainable() { val v = value ?: return - val reg = registry ?: return + val reg = retainedStateRegistry ?: return if (!canRetainChecker.canRetain(reg)) { - entry?.unregister() + retainedStateEntry?.unregister() when (v) { // If value is a RememberObserver, we notify that it has been forgotten. is RememberObserver -> v.onForgotten() @@ -185,10 +352,11 @@ private class RetainableHolder( } override fun onRemembered() { - register() + registerRetained() + registerSaveable() // If value is a RememberObserver, we notify that it has remembered - if (!hasBeenRestored) { + if (!hasBeenRestoredFromRetained) { val v = value if (v is RememberObserver) v.onRemembered() } @@ -196,10 +364,12 @@ private class RetainableHolder( override fun onForgotten() { saveIfRetainable() + saveableStateEntry?.unregister() } override fun onAbandoned() { saveIfRetainable() + saveableStateEntry?.unregister() } fun getValueIfInputsAreEqual(inputs: Array): T? { @@ -208,3 +378,59 @@ private class RetainableHolder( class Value(override val value: T, val inputs: Array) : RetainedValueHolder } + +private fun SaveableStateRegistry.requireCanBeSaved(value: Any?) { + require(value == null || canBeSaved(value)) { + if (value is SnapshotMutableState<*>) { + if ( + value.policy !== neverEqualPolicy() && + value.policy !== structuralEqualityPolicy() && + value.policy !== referentialEqualityPolicy() + ) { + "If you use a custom SnapshotMutationPolicy for your MutableState you have to" + + " write a custom Saver" + } else { + "MutableState containing ${value.value} cannot be saved using the current " + + "SaveableStateRegistry. The default implementation only supports types " + + "which can be stored inside the Bundle. Please consider implementing a " + + "custom Saver for this class and pass it as a stateSaver parameter to " + + "rememberRetainedSaveable()." + } + } else { + "$value cannot be saved using the current SaveableStateRegistry. The default " + + "implementation only supports types which can be stored inside the Bundle" + + ". Please consider implementing a custom Saver for this class and pass it" + + " to rememberRetainedSaveable()." + } + } +} + +@Suppress("UNCHECKED_CAST") +private fun mutableStateSaver(inner: Saver) = + with(inner as Saver) { + Saver, MutableState>( + save = { state -> + require(state is SnapshotMutableState) { + "If you use a custom MutableState implementation you have to write a custom " + + "Saver and pass it as a saver param to rememberRetainedSaveable()" + } + val saved = save(state.value) + if (saved != null) { + mutableStateOf(saved, state.policy as SnapshotMutationPolicy) + } else { + // if the inner saver returned null we need to return null as well so the + // user's init lambda will be used instead of restoring mutableStateOf(null) + null + } + }, + restore = + @Suppress("UNCHECKED_CAST", "ExceptionMessage") { + require(it is SnapshotMutableState) + mutableStateOf( + if (it.value != null) restore(it.value!!) else null, + it.policy as SnapshotMutationPolicy, + ) + as MutableState + }, + ) + } diff --git a/docs/presenter.md b/docs/presenter.md index cad9569e9..17d1ac3d2 100644 --- a/docs/presenter.md +++ b/docs/presenter.md @@ -85,12 +85,12 @@ Presenter logic should _not_ emit any Compose UI. They are purely for presentati There are three types of composable retention functions used in Circuit. 1. `remember` – from Compose, remembers a value across recompositions. Can be any type. -2. `rememberRetained` – custom, remembers a value across recompositions and configuration changes. Can be any type, but should not retain leak-able things like `Navigator` instances or `Context` instances. Backed by a hidden `ViewModel` on Android. Note that this is not necessary in most cases if handling configuration changes yourself via `android:configChanges`. -3. `rememberSaveable` – from Compose, remembers a value across recompositions, configuration changes, and process death. Must be `Parcelable` or implement a custom `Saver`, should not retain leakable things like `Navigator` instances or `Context` instances. Backed by the framework saved instance state system. +2. `rememberRetained` – custom, remembers a value across recompositions, the back stack, and configuration changes. Can be any type, but should not retain leak-able things like `Navigator` instances or `Context` instances. Backed by a hidden `ViewModel` on Android. +3. `rememberSaveable` – from Compose, remembers a value across recompositions, the back stack, configuration changes, _and_ process death. Must be a primitive, `Parcelable` (on Android), or implement a custom `Saver`. This should not retain leakable things like `Navigator` instances or `Context` instances and is backed by the framework saved instance state system. Developers should use the right tool accordingly depending on their use case. Consider these three examples. -The first one will preserve the `count` value across recompositions, but not configuration changes or process death. +The first one will preserve the `count` value across recompositions, but not the back stack, configuration changes, or process death. ```kotlin @Composable @@ -106,7 +106,7 @@ fun CounterPresenter(): CounterState { } ``` -The second one will preserve the state across recompositions and configuration changes, but not process death. +The second one will preserve the state across recompositions, the back stack, and configuration changes, but _not_ process death. ```kotlin @Composable @@ -122,7 +122,7 @@ fun CounterPresenter(): CounterState { } ``` -The third case will preserve the `count` state across recompositions, configuration changes, and process death. However, it only works with primitives or `Parcelable` state types. +The third case will preserve the `count` state across recompositions, the back stack, configuration changes, _and_ process death. ```kotlin @Composable @@ -137,3 +137,15 @@ fun CounterPresenter(): CounterState { } } ``` + +--- + +| | `remember` | `rememberRetained` | `rememberSaveable` | +|---------------------------------|------------|--------------------|--------------------| +| Recompositions | ✅ | ✅ | ✅ | +| Back stack | ❌ | ✅* | ✅* | +| Configuration changes (Android) | ❌ | ✅ | ✅ | +| Process death | ❌ | ❌ | ✅ | +| Can be non-Saveable types | ✅ | ✅ | ❌ | + +*If using `NavigableCircuitContent`'s default configuration. \ No newline at end of file