Skip to content

Commit

Permalink
Add tracking key toc rememberScope, tests and fix inline class bugs
Browse files Browse the repository at this point in the history
  • Loading branch information
sebaslogen committed Dec 21, 2021
1 parent e5c09ed commit 79cba2f
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ExternalKey>()

/**
* Generic objects container
*/
private val scopedObjectsContainer = mutableMapOf<Key, Any>()
private val scopedObjectsContainer = mutableMapOf<String, Any>()

/**
* 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<String>()

/**
* 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<Key, Job>()
private val disposingJobs = mutableMapOf<String, Job>()

/**
* Time to wait until disposing an object from the [scopedObjectsContainer] after it has been scheduled for disposal
Expand All @@ -69,29 +75,38 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {

@Suppress("UNCHECKED_CAST")
fun <T : Any> 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()
}
}

/**
* Triggered when a Composable that stored an object in this class is disposed and signals this container
* 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
}

/**
* 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)
}

Expand All @@ -106,7 +121,7 @@ class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
*/
private fun scheduleToDisposeAfterReturningFromBackground() {
markedForDisposal.forEach { key ->
scheduleToDispose(key = Key(key))
scheduleToDispose(key)
}
}

Expand All @@ -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) {
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +20,7 @@ import java.util.*
* is present in [ScopedViewModelContainer] for this key it will be returned instead of calling [builder]
*/
@Composable
fun <T : Any> rememberScoped(builder: (() -> T)): T {
fun <T : Any> rememberScoped(key: Any? = null, builder: (() -> T)): T {
val scopedViewModelContainer: ScopedViewModelContainer = viewModel()

// Observe this destination's lifecycle to detect screen resumed/paused/destroyed
Expand All @@ -31,14 +29,16 @@ fun <T : Any> 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions sample/src/test/java/com/sebaslogen/resacaapp/ScopeKeysTest.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}

0 comments on commit 79cba2f

Please sign in to comment.