Skip to content

Commit

Permalink
Add rememberRetainedSaveable (#1305)
Browse files Browse the repository at this point in the history
Adds a `rememberRetainedSaveable` variant that participates in both
`RetainedStateRegistry` and `SaveableStateRegistry` restoration.

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

The `RememberObserver` behavior is duplicated from the
`rememberRetained` behavior.

---------

Co-authored-by: Zac Sweers <[email protected]>
  • Loading branch information
alexvanyo and ZacSweers authored May 27, 2024
1 parent ae42097 commit b314c71
Show file tree
Hide file tree
Showing 8 changed files with 475 additions and 58 deletions.
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions circuit-retained/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ kotlin {
commonMain {
dependencies {
api(libs.compose.runtime)
api(libs.compose.runtime.saveable)
api(libs.coroutines)
}
}
Expand Down Expand Up @@ -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")
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions circuit-retained/dependencies/jvmRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

@get:Rule
val rule =
RuleChain.emptyRuleChain().detectLeaksAfterTestSuccessWrapping(tag = "ActivitiesDestroyed") {
around(composeTestRule)
}

private val scenario: ActivityScenario<ComponentActivity>
get() = composeTestRule.activityRule.scenario

private class RecordingContinuityVmFactory : ViewModelProvider.Factory {
var continuity: ContinuityViewModel? = null

override fun <T : ViewModel> create(modelClass: Class<T>): 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<CacheableData, *> = Saver({ it.id }, { CacheableData(it) })
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit b314c71

Please sign in to comment.