Skip to content

Commit

Permalink
Start using UiSavedStateRegistry to preserve screen "view" state.
Browse files Browse the repository at this point in the history
Currently screens' states are saved as long as the Backstack is in the composition
by being kept entirely alive. This is not efficient, it's equivalent to keeping
all views in the backstack attached at all times. Ideally we would only keep screens
in the composition while they are actually visible, and remove all hidden screens as
soon as possible. Unfortunately, until dev08, the only way to preserve view state
was to keep these screens composed.

Dev08 introduces `rememberSavedInstanceState` and `savedInstanceState` composables.
They mirror `remember` and `state`, but will restore their values from a
`UiSavedStateRegistry` when they are first composed. This is the standard API
for saving view state, much like `onSavedInstanceState` in the legacy framework,
and so the backstack should support it.

This change does a few things:
 - When a screen is not longer visible, remove it from the composition. Note that
   the `ScreenWrapper` remains in the composition as long as the screen's key is
   in the active backstack, but when a screen stops being visible, we stop calling
   the `drawScreen` function for it. Note that this means we no longer need to set
   the "hidden" semantics property on screens that aren't visible.
 - The `ScreenWrapper` holds a map of saved state values. This map, in turn, gets
   saved and restored to the parent's `UiSavedStateRegistry`, so it can survive activity
   recreations etc.
 - Each screen is wrapped with its own `UiSavedStateRegistry` that is scoped to just
   that screen. This registry is asked to save values to the above map when the screen
   is going to be hidden, and is re-created to restore from the cached values when it
   is shown.

The keys to making this work are:
 - Scoping `ScreenWrapper`s to the presence of a screen in the backstack, even though
   the screen's actual composables are only alive when the screen is visible.
 - The mechanism that the saved state machinery uses to key values is derived from the
   positional memoization keys. The only way this works is because those keys remain
   static even when a composable is removed and re-added later. Because that happens,
   and because those keys are globally unique (at least within the composition), the
   individual screen registries don't need to do any scoping of their keys, and we don't
   need to worry about somehow turning the screen keys (`T`) into state keys (`String`).
   The one thing I haven't verified is if these keys stay constant even if items in the
   hidden backstack are reordered, but I expect they will since `@Pivotal` makes use of
   this same keying infrastructure.
  • Loading branch information
zach-klippenstein committed Apr 19, 2020
1 parent 9b01032 commit 0604179
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class BackstackViewerTest {
.doClick()
.assertIsSelected()

findByText("Screen one").assertIsDisplayed()
findByText("Screen one").assertDoesNotExist()
findByText("Screen two").assertIsDisplayed()
}

Expand All @@ -72,8 +72,8 @@ class BackstackViewerTest {
.doClick()
.assertIsSelected()

findByText("Screen one").assertIsDisplayed()
findByText("Screen two").assertIsDisplayed()
findByText("Screen one").assertDoesNotExist()
findByText("Screen two").assertDoesNotExist()
findByText("Screen three").assertIsDisplayed()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.ui.material.icons.Icons
import androidx.ui.material.icons.filled.Add
import androidx.ui.material.icons.filled.ArrowBack
import androidx.ui.material.icons.filled.Menu
import androidx.ui.savedinstancestate.savedInstanceState
import androidx.ui.tooling.preview.Preview

internal fun addTestTag(screen: String) = "add screen to $screen"
Expand Down Expand Up @@ -68,7 +69,10 @@ internal fun AppScreen(
@Suppress("SameParameterValue")
@Composable
private fun Counter(@Pivotal periodMs: Long): Int {
var value by state { 0 }
// If the screen is temporarily removed from the composition, the counter will effectively
// be "paused": it will stop incrementing, but will resume from its last value when restored to
// the composition.
var value by savedInstanceState { 0 }
onActive {
val mainHandler = Handler()
var disposed = false
Expand Down
1 change: 1 addition & 0 deletions compose-backstack/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {

implementation(Dependencies.Kotlin.stdlib)
implementation(Dependencies.Compose.foundation)
implementation(Dependencies.Compose.savedstate)

testImplementation(Dependencies.Test.junit)
testImplementation(Dependencies.Test.truth)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import androidx.compose.Providers
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.ui.core.AnimationClockAmbient
import androidx.ui.foundation.Text
import androidx.ui.test.*
import androidx.ui.test.assertIsDisplayed
import androidx.ui.test.createComposeRule
import androidx.ui.test.findByText
import androidx.ui.test.runOnUiThread
import com.zachklipp.compose.backstack.BackstackTransition.Crossfade
import com.zachklipp.compose.backstack.BackstackTransition.Slide
import org.junit.Rule
Expand Down Expand Up @@ -74,13 +77,7 @@ class BackstackComposableTest {
}

findByText("two").assertIsDisplayed()

findByText("one")
.assertExists()
// Check explicit semantics flag. We can't use assertIsNotDisplayed because it checks
// layout bounds and the transition might hide the screen by just making it fully
// transparent, but leaving it positioned on the screen.
.assertIsHidden()
findByText("one").assertDoesNotExist()
}

private fun assertTransition(transition: BackstackTransition) {
Expand All @@ -97,36 +94,35 @@ class BackstackComposableTest {
}
}

findByText("one").assertIsNotHidden()
findByText("one").assertIsDisplayed()
findByText("two").assertDoesNotExist()

runOnUiThread {
state.backstack = destinationBackstack
}

findByText("one").assertIsNotHidden()
findByText("two").assertExists()
.assertIsHidden()
findByText("one").assertIsDisplayed()
findByText("two").assertDoesNotExist()

advanceTransition(.25f)
setTransitionTime(25)

findByText("one").assertIsNotHidden()
findByText("two").assertIsNotHidden()
findByText("one").assertIsDisplayed()
findByText("two").assertIsDisplayed()

advanceTransition(.75f)
setTransitionTime(75)

findByText("one").assertIsNotHidden()
findByText("two").assertIsNotHidden()
findByText("one").assertIsDisplayed()
findByText("two").assertIsDisplayed()

advanceTransition(1f)
setTransitionTime(100)

findByText("one").assertIsHidden()
findByText("two").assertIsNotHidden()
findByText("one").assertDoesNotExist()
findByText("two").assertIsDisplayed()
}

private fun advanceTransition(percentage: Float) {
private fun setTransitionTime(time: Long) {
runOnUiThread {
clock.clockTimeMillis = (100 * percentage).toLong()
clock.clockTimeMillis = time
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import androidx.ui.core.*
import androidx.ui.foundation.Box
import androidx.ui.foundation.shape.RectangleShape
import androidx.ui.layout.Stack
import androidx.ui.savedinstancestate.UiSavedStateRegistryAmbient
import androidx.ui.semantics.Semantics
import androidx.ui.semantics.hidden
import com.zachklipp.compose.backstack.TransitionDirection.Backward
import com.zachklipp.compose.backstack.TransitionDirection.Forward

Expand Down Expand Up @@ -68,7 +68,15 @@ private val DefaultBackstackAnimation: AnimationBuilder<Float>
*
* This composable does not actually provide any navigation functionality – it just renders
* transitions between stacks of screens. It can be plugged into your navigation library of choice,
* or just used on its own with a simple list of screens, like this:
* or just used on its own with a simple list of screens.
*
* ## Instance state caching
*
* Screens that contain persistable state using the (i.e. via
* [savedInstanceState][androidx.ui.savedinstancestate.savedInstanceState]) will automatically have
* that state saved when they are hidden, and restored the next time they're shown.
*
* ## Example
*
* ```
* sealed class Screen {
Expand Down Expand Up @@ -220,6 +228,9 @@ fun <T : Any> Backstack(
// state as soon as a different branch is taken. See @Pivotal for more information.
activeStackDrawers = remember(activeKeys, transition) {
activeKeys.mapIndexed { index, key ->
// This wrapper composable will remain in the composition as long as its key is
// in the backstack. So we can use remember here to hold state that should persist
// even when the screen is hidden.
ScreenWrapper(key) { progress, children ->
// Inspector and transition are mutually exclusive.
val screenProperties = if (inspector.isInspectionActive) {
Expand All @@ -228,12 +239,23 @@ fun <T : Any> Backstack(
calculateRegularModifier(transition, index, activeKeys.size, progress)
}

// This must be called even if the screen is not visible, so the screen's state gets
// cached before it's removed from the composition.
val savedStateRegistry = ChildSavedStateRegistry(screenProperties.isVisible)

if (!screenProperties.isVisible) {
// Remove the screen from the composition.
// This must be done after updating the savedState visibility so it has a chance
// to query providers before they're unregistered.
return@ScreenWrapper
}

// Without an explicit semantics container, all screens will be merged into a single
// semantics group.
Semantics(container = true, properties = {
hidden = !screenProperties.isVisible
}) {
Box(screenProperties.modifier, children = children)
Semantics(container = true) {
Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) {
Box(screenProperties.modifier, children = children)
}
}
}
}
Expand All @@ -247,14 +269,7 @@ fun <T : Any> Backstack(
// as they're invoked through the exact same sequence of source locations from within this
// key lambda, they will keep their state.
key(item) {
// Cache the composable that actually draws this item so it's not recomposed if the
// backstack doesn't change. This helps performance with long backstacks.
// We don't need to pass item to remember because key guarantees that it won't change
// within this part of the composition.
val drawItem: @Composable() () -> Unit = remember {
@Composable { drawScreen(item) }
}
transition(transitionProgress.value, drawItem)
transition(transitionProgress.value) { drawScreen(item) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.zachklipp.compose.backstack

import androidx.compose.Composable
import androidx.compose.remember
import androidx.ui.savedinstancestate.UiSavedStateRegistry
import androidx.ui.savedinstancestate.UiSavedStateRegistryAmbient
import androidx.ui.savedinstancestate.rememberSavedInstanceState

/**
* Returns a [UiSavedStateRegistry] that will automatically save values from all its registered
* providers whenever [childWillBeComposed] transitions from true to false, and make those values available
* to be restored when [childWillBeComposed] transitions from false to true.
*/
@Composable
@Suppress("RemoveExplicitTypeArguments")
fun ChildSavedStateRegistry(childWillBeComposed: Boolean): UiSavedStateRegistry {
val parentRegistry = UiSavedStateRegistryAmbient.current

// This map holds all the savedInstanceState for this screen as long as it exists
// in the backstack. When the screen is hidden, we will cache its state providers
// into this map before removing it from the composition. This cache will in turn
// be persisted into and restored from the parent UiSavedStateRegistry.
val values = rememberSavedInstanceState<MutableMap<String, Any>> { mutableMapOf() }
val holder = remember {
// If there's no registry available, then we won't be restored anyway so there are no
// serializability restrictions on saved values.
val canBeSaved: (Any) -> Boolean = parentRegistry?.let { it::canBeSaved } ?: { true }
SavedStateHolder(canBeSaved, values)
}
holder.setScreenVisibility(childWillBeComposed)
return holder.registry
}

internal class SavedStateHolder(
private val canBeSaved: (Any) -> Boolean,
private var values: Map<String, Any>
) {
var registry: UiSavedStateRegistry = createRegistry()
private set
private var isScreenVisible = false

/**
* Tracks the visibility of the screen this class holds the state for and returns either the
* [UiSavedStateRegistry] if visible, or null if not visible.
*
* When [isVisible] transitions from false to true, a new registry will be created that can will
* restore from previously-saved values.
*
* When [isVisible] transitions from true to false, the existing registry will be used to save
* all values.
*/
fun setScreenVisibility(isVisible: Boolean) {
if (isVisible == this.isScreenVisible) return
this.isScreenVisible = isVisible

if (!isVisible) {
// This will automatically preserve any values that were passed into the factory
// function but not consumed.
values = registry.performSave()
} else {
// Recreate the registry so the most recently-saved values will be used to restore.
// The UiSavedStateRegistry function makes a defensive copy of the passed-in map, so
// it needs to be recreated on every restoration.
registry = createRegistry()
}
}

private fun createRegistry() = UiSavedStateRegistry(values, canBeSaved)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.zachklipp.compose.backstack

import com.google.common.truth.Truth.assertThat
import org.junit.Test

class SavedStateHolderTest {

@Test
fun `saves and restores`() {
val holder = SavedStateHolder(canBeSaved = { true }, values = mutableMapOf())

holder.setScreenVisibility(true)
holder.registry.registerProvider("key") { "value" }
holder.setScreenVisibility(false)
holder.registry.unregisterProvider("key")
holder.setScreenVisibility(true)

assertThat(holder.registry.consumeRestored("key")).isEqualTo("value")
}

@Test
fun `restores from initial values`() {
val holder =
SavedStateHolder(canBeSaved = { true }, values = mutableMapOf("key" to "value"))

holder.setScreenVisibility(true)

assertThat(holder.registry.consumeRestored("key")).isEqualTo("value")
}

@Test
fun `doesn't save unregistered providers`() {
val holder = SavedStateHolder(canBeSaved = { true }, values = mutableMapOf())

holder.setScreenVisibility(true)
holder.registry.registerProvider("key") { "value" }
holder.registry.unregisterProvider("key")
holder.setScreenVisibility(false)
holder.setScreenVisibility(true)

assertThat(holder.registry.consumeRestored("key")).isNull()
}

@Test
fun `preserves unrestored values from previous save`() {
val holder =
SavedStateHolder(canBeSaved = { true }, values = mutableMapOf("old key" to "old value"))

holder.setScreenVisibility(true)
// Performs the save without having consumed "old key".
holder.setScreenVisibility(false)
holder.setScreenVisibility(true)

assertThat(holder.registry.consumeRestored("old key")).isEqualTo("old value")
}
}

0 comments on commit 0604179

Please sign in to comment.