From 79cba2f0efacb6f09a4c1aa65475b0795212f144 Mon Sep 17 00:00:00 2001 From: sebas Date: Tue, 21 Dec 2021 17:14:36 +0100 Subject: [PATCH] Add tracking key toc rememberScope, tests and fix inline class bugs --- .../resaca/ScopedViewModelContainer.kt | 56 ++++++++++++------ .../resaca/compose/ScopedMemoizers.kt | 14 ++--- .../ui/main/compose/DemoComposable.kt | 4 +- .../com/sebaslogen/resacaapp/ScopeKeysTest.kt | 57 +++++++++++++++++++ 4 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 sample/src/test/java/com/sebaslogen/resacaapp/ScopeKeysTest.kt diff --git a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt index 74fc03a1..23925f77 100644 --- a/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt +++ b/resaca/src/main/java/com/sebaslogen/resaca/ScopedViewModelContainer.kt @@ -45,22 +45,28 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { */ private var isInForeground = true + /** + * Container of object keys associated with their [ExternalKey], + * the [ExternalKey] will be used to track and store new versions of the object to be stored/restored + */ + private val scopedObjectKeys = mutableMapOf() + /** * Generic objects container */ - private val scopedObjectsContainer = mutableMapOf() + private val scopedObjectsContainer = mutableMapOf() /** - * List of [Key]s for the objects that will be disposed (forgotten from this class so they can be garbage collected) in the near future + * List of keys for the objects that will be disposed (forgotten from this class so they can be garbage collected) in the near future */ private val markedForDisposal = ConcurrentSkipListSet() /** - * List of [Job]s associated with an object [Key] that is scheduled to be disposed very soon, unless + * List of [Job]s associated with an object (through its key) that is scheduled to be disposed very soon, unless * the object is requested again (and [cancelDisposal] is triggered) or * the container of this [ScopedViewModelContainer] class goes to the background (making [isInForeground] false) */ - private val disposingJobs = mutableMapOf() + private val disposingJobs = mutableMapOf() /** * Time to wait until disposing an object from the [scopedObjectsContainer] after it has been scheduled for disposal @@ -69,12 +75,21 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { @Suppress("UNCHECKED_CAST") fun getOrBuildObject( - key: Key, + key: String, + externalKey: ExternalKey = ExternalKey(0), builder: () -> T ): T { + fun buildAndStoreObject() = builder.invoke().apply { scopedObjectsContainer[key] = this } + cancelDisposal(key) - return scopedObjectsContainer[key] as? T - ?: builder.invoke().apply { scopedObjectsContainer[key] = this } + + return if (scopedObjectKeys.containsKey(key) && (scopedObjectKeys[key] == externalKey)) { + // When the object is already present and the external key matches, then try to restore it + scopedObjectsContainer[key] as? T ?: buildAndStoreObject() + } else { + scopedObjectKeys[key] = externalKey // Set the external key used to track and store new versions of the object + buildAndStoreObject() + } } /** @@ -82,8 +97,8 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { * that the object might be also disposed from this container only when the stored object * is not going to be used anymore (e.g. after configuration change or container fragment returning from backstack) */ - fun onDisposedFromComposition(key: Key) { - markedForDisposal.add(key.value) // Marked to be disposed after onResume + fun onDisposedFromComposition(key: String) { + markedForDisposal.add(key) // Marked to be disposed after onResume scheduleToDisposeBeforeGoingToBackground(key) // Schedule to dispose this object before onPause } @@ -91,7 +106,7 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { * Schedules the object referenced by this [key] in the [scopedObjectsContainer] * to be removed (so it can be garbage collected) if the screen associated with this is still in foreground ([isInForeground]) */ - private fun scheduleToDisposeBeforeGoingToBackground(key: Key) { + private fun scheduleToDisposeBeforeGoingToBackground(key: String) { scheduleToDispose(key = key) } @@ -106,7 +121,7 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { */ private fun scheduleToDisposeAfterReturningFromBackground() { markedForDisposal.forEach { key -> - scheduleToDispose(key = Key(key)) + scheduleToDispose(key) } } @@ -118,13 +133,13 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { * @param key Key of the object stored in either [scopedObjectsContainer] to be de-referenced for GC * @param removalCondition Last check at disposal time to prevent disposal when this condition is not met */ - private fun scheduleToDispose(key: Key, removalCondition: () -> Boolean = { isInForeground }) { + private fun scheduleToDispose(key: String, removalCondition: () -> Boolean = { isInForeground }) { if (disposingJobs.containsKey(key)) return // Already disposing, quit val newDisposingJob = viewModelScope.launch { delay(disposeDelayTimeMillis) if (removalCondition()) { - markedForDisposal.remove(key.value) + markedForDisposal.remove(key) scopedObjectsContainer.remove(key) ?.also { when (it) { @@ -139,9 +154,9 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { disposingJobs[key] = newDisposingJob } - private fun cancelDisposal(key: Key) { + private fun cancelDisposal(key: String) { disposingJobs.remove(key)?.cancel() // Cancel scheduled disposal - markedForDisposal.remove(key.value) // Un-mark for disposal in case it's not yet scheduled for disposal + markedForDisposal.remove(key) // Un-mark for disposal in case it's not yet scheduled for disposal } /** @@ -177,8 +192,15 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver { } /** - * Unique Key to identify objects store in the [ScopedViewModelContainer] + * Unique Key to identify versions objects stored in the [ScopedViewModelContainer] + * When this external key does not match the one stored for an object's main key in [scopedObjectKeys], + * then the object is removed (just overwritten), the new instance is stored and + * the new external key is stored in [scopedObjectKeys] */ @JvmInline - value class Key(val value: String) + value class ExternalKey(val value: Int) { + companion object { + fun from(objectInstance: Any?): ExternalKey = ExternalKey(objectInstance.hashCode()) + } + } } \ No newline at end of file diff --git a/resaca/src/main/java/com/sebaslogen/resaca/compose/ScopedMemoizers.kt b/resaca/src/main/java/com/sebaslogen/resaca/compose/ScopedMemoizers.kt index 93daeb73..4a585950 100644 --- a/resaca/src/main/java/com/sebaslogen/resaca/compose/ScopedMemoizers.kt +++ b/resaca/src/main/java/com/sebaslogen/resaca/compose/ScopedMemoizers.kt @@ -8,12 +8,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.viewmodel.compose.viewModel import com.sebaslogen.resaca.ScopedViewModelContainer -import com.sebaslogen.resaca.ScopedViewModelContainer.Key import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* - /** * Return an object created with the provided [builder] function * and store this object in the [ScopedViewModelContainer] that will keep this @@ -22,7 +20,7 @@ import java.util.* * is present in [ScopedViewModelContainer] for this key it will be returned instead of calling [builder] */ @Composable -fun rememberScoped(builder: (() -> T)): T { +fun rememberScoped(key: Any? = null, builder: (() -> T)): T { val scopedViewModelContainer: ScopedViewModelContainer = viewModel() // Observe this destination's lifecycle to detect screen resumed/paused/destroyed @@ -31,14 +29,16 @@ fun rememberScoped(builder: (() -> T)): T { // This key will be used to identify, retrieve and remove the stored object in the ScopedViewModelContainer // across recompositions, configuration changes and even process death - val key = Key(rememberSaveable { UUID.randomUUID().toString() }) + val internalKey = rememberSaveable { UUID.randomUUID().toString() } + // The external key will be used to track and store new versions of the object + val externalKey = ScopedViewModelContainer.ExternalKey.from(key) // The object will be built the first time and retrieved in next calls or recompositions - val scopedObject: T = scopedViewModelContainer.getOrBuildObject(key, builder) + val scopedObject: T = scopedViewModelContainer.getOrBuildObject(key = internalKey, externalKey = externalKey, builder = builder) // Remove reference to object from ScopedViewModelContainer so it can be garbage collected when needed - DisposableEffect(key) { - onDispose { scopedViewModelContainer.onDisposedFromComposition(key) } + DisposableEffect(internalKey) { + onDispose { scopedViewModelContainer.onDisposedFromComposition(internalKey) } } return scopedObject } diff --git a/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/compose/DemoComposable.kt b/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/compose/DemoComposable.kt index 6541fb71..5964d329 100644 --- a/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/compose/DemoComposable.kt +++ b/sample/src/main/java/com/sebaslogen/resacaapp/ui/main/compose/DemoComposable.kt @@ -70,7 +70,7 @@ fun DemoComposable( Text(scopedBannerText, textAlign = TextAlign.Center, modifier = Modifier.rotate(-90f)) // Text representation of the input object - val objectAddressName = remember { objectToShortStringWithoutPackageName(inputObject) } + val objectAddressName = remember(inputObject) { objectToShortStringWithoutPackageName(inputObject) } Text( modifier = Modifier .testTag("$objectType $scopedBannerText") // Semantics used for automated tests to find this node @@ -82,7 +82,7 @@ fun DemoComposable( ) // Emoji representation of the input object - val objectAddressEmoji = remember { objectToEmoji(inputObject) } + val objectAddressEmoji = remember(inputObject) { objectToEmoji(inputObject) } Text( modifier = Modifier .padding(vertical = 18.dp, horizontal = 4.dp) diff --git a/sample/src/test/java/com/sebaslogen/resacaapp/ScopeKeysTest.kt b/sample/src/test/java/com/sebaslogen/resacaapp/ScopeKeysTest.kt new file mode 100644 index 00000000..d47be068 --- /dev/null +++ b/sample/src/test/java/com/sebaslogen/resacaapp/ScopeKeysTest.kt @@ -0,0 +1,57 @@ +package com.sebaslogen.resacaapp + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.sebaslogen.resaca.compose.rememberScoped +import com.sebaslogen.resacaapp.ui.main.compose.DemoComposable +import com.sebaslogen.resacaapp.ui.main.data.FakeRepo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(instrumentedPackages = ["androidx.loader.content"]) +class ScopeKeysTest : ComposeTestUtils { + + @get:Rule + override val composeTestRule = createComposeRule() + + @Test + fun `when the key used for rememberScoped changes, then the scoped object is different`() { + + // Given the starting screen with scoped object that uses a key + composeTestRule.setContent { + var myKey by remember { mutableStateOf(false) } + val fakeRepo: FakeRepo = rememberScoped(key = myKey) { FakeRepo() } + DemoComposable(inputObject = fakeRepo, objectType = "FakeRepo", scoped = true) + Button(modifier = Modifier.testTag("Button"), + onClick = { myKey = !myKey }) { + Text("Click to change") + } + } + printComposeUiTreeToLog() + // Find the scoped text field and grab its text + val initialFakeScopedRepoText = retrieveTextFromNodeWithTestTag("FakeRepo Scoped") + + // When I click a button to change the state and key of the rememberScoped object + onNodeWithTestTag("Button").performClick() + printComposeUiTreeToLog() + + // Then the text of the NOT scoped object is different from the original one because it's a new object + onNodeWithTestTag("FakeRepo Scoped").assertIsDisplayed() + .assert(hasTextExactly(initialFakeScopedRepoText).not()) + } +} \ No newline at end of file