Skip to content

Commit

Permalink
Update docs, tests and clean up
Browse files Browse the repository at this point in the history
  • Loading branch information
sebaslogen committed Oct 8, 2023
1 parent 62d568f commit ca0082d
Show file tree
Hide file tree
Showing 11 changed files with 26 additions and 72 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ Here are a few suggestions of how to provide objects in combination with this li

- When using the Lazy* family of Composables it is recommended that you use `rememberScoped`/`viewModelScoped` outside the scope of Composables created by Lazy
constructors (e.g. LazyColumn) because there is a chance that a lazy initialized Composable will be disposed of when it is not visible anymore (e.g. scrolled
away) and that will also dispose of the `rememberScoped`/`viewModelScoped` object (after a few seconds), this might not be the intended behavior. For more
away) and that will also dispose of the `rememberScoped`/`viewModelScoped` object immediately, this might not be the intended behavior. For more
info see Compose's [State Hoisting](https://developer.android.com/jetpack/compose/state#state-hoisting).
- When a Composable is used more than once in the same screen with the same input, then the ViewModel (or business logic object) should be provided only once
with `viewModelScoped` at a higher level in the tree using Compose's [State Hoisting](https://developer.android.com/jetpack/compose/state#state-hoisting).
Expand Down Expand Up @@ -277,19 +277,19 @@ This project uses internally a ViewModel as a container to store all scoped View
<summary>What happens when a Composable is disposed?</summary>

When a Composable is disposed of, we don't know for sure if it will return again later. So at the moment of disposal, we mark in our container the associated
object to be disposed of after a small delay (currently 5 seconds). During the span of time of this delay, a few things can happen:
object to be disposed of after the next frame when the Activity is resumed. During the span of time of this next frame, a few things can happen:

- The Composable is not part of the composition anymore after the delay and then the associated object is disposed of. 🚮
- The Composable is not part of the composition anymore after the next frame and the associated object is disposed of. 🚮
- The LifecycleOwner of the disposed Composable (i.e. the navigation destination where the Composable lived) is paused (e.g. screen went to background) before
the delay finishes. Then the disposal of the scoped object is canceled, but the object is still marked for disposal at a later stage.
the next frame happened. Then the disposal of the scoped object is canceled, but the object is still marked for disposal at a later stage.
- This can happen when the application goes through a configuration change and the container Activity is recreated.
- Also when the Composable is part of a Fragment that has been pushed to the backstack.
- And also when the Composable is part of a Compose Navigation destination that has been pushed to the backstack.
- When the LifecycleOwner of the disposed Composable is resumed (e.g. Fragment comes back to foreground), then the disposal of the associated object is
scheduled again to happen after a small delay. At this point two things can happen:
scheduled again to happen after the next frame when the Activity is resumed. At this point two things can happen:
- The Composable becomes part of the composition again and the `rememberScoped`/`viewModelScoped` function restores the associated object while also
canceling any pending delayed disposal. 🎉
- The Composable is not part of the composition anymore after the delay and then the associated object is disposed of. 🚮
cancelling any pending disposal in the next frame when the Activity is resumed. 🎉
- The Composable is not part of the composition anymore after the next frame and then the associated object is disposed of. 🚮

> **Note**:
Expand Down
3 changes: 1 addition & 2 deletions resaca/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures { // Enables Jetpack Compose for this module
Expand All @@ -34,7 +33,7 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
packagingOptions {
packaging {
resources {
excludes += setOf(
// Exclude AndroidX version files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Choreographer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
Expand Down Expand Up @@ -85,7 +84,7 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {

/**
* Lock to wait for the first composition after Activity resumes.
* This is apparently only required in automated Espresso tests.
* This is apparently only required in automated tests.
*/
private var compositionResumedTimeout = CountDownLatch(1)

Expand Down Expand Up @@ -138,7 +137,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
): T {
@Composable
fun buildAndStoreObject() = builder.invoke().apply { scopedObjectsContainer[positionalMemoizationKey] = this }
Log.d("Sebas", "REQUEST CANCEL DISPOSAL OF Object $positionalMemoizationKey - it's being requested again - ${Thread.currentThread().name}")
cancelDisposal(positionalMemoizationKey)

val originalObject: Any? = scopedObjectsContainer[positionalMemoizationKey]
Expand Down Expand Up @@ -271,17 +269,12 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
if (disposingJobs.containsKey(key)) return // Already disposing, quit

val newDisposingJob = viewModelScope.launch {
Log.d("Sebas", "scheduleToDispose of $key - ${Thread.currentThread().name}")
if (!isInForeground) awaitChoreographerFramePostFrontOfQueue(key) // When in background, wait for the next frame when the Activity is resumed
if (!isInForeground) awaitChoreographerFramePostFrontOfQueue() // When in background, wait for the next frame when the Activity is resumed
withContext(NonCancellable) { // We treat the disposal/remove/clear block as an atomic transaction
Log.d("Sebas", "scheduleToDispose REMOVING $key - ${Thread.currentThread().name}")
if (isInForeground || isChangingConfiguration) {
Log.d("Sebas", "scheduleToDispose REMOVED $key - ${Thread.currentThread().name}")
markedForDisposal.remove(key)
scopedObjectKeys.remove(key)
scopedObjectsContainer.remove(key)?.also { clearLastDisposedObject(it) }
} else {
Log.d("Sebas", "scheduleToDispose NOT REMOVED $key because we are in the background - ${Thread.currentThread().name}")
}
disposingJobs.remove(key)
}
Expand All @@ -300,35 +293,27 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
* If the Activity never comes back, then the work will be cancelled and
* the FrameCallback will be removed thanks to the coroutine scope cancellation.
*/
private suspend fun awaitChoreographerFramePostFrontOfQueue(key: String) {
private suspend fun awaitChoreographerFramePostFrontOfQueue() {
val localCoroutineScope = CoroutineScope(coroutineContext)
suspendCancellableCoroutine { continuation ->
val frameCallback = Choreographer.FrameCallback {
Log.d("Sebas", "FrameCallback executed for $key - ${Thread.currentThread().name}")
handler.postAtFrontOfQueue { // This needs to be posted and run right after Activity resumes
Log.d("Sebas", "postAtFrontOfQueue for $key - ${Thread.currentThread().name}")
localCoroutineScope.launch {
withContext(Dispatchers.IO) { // This needs to be done in IO because it's a blocking call
// This extra wait is needed to make sure Composition happens after resume on Espresso tests
Log.d("Sebas", "waiting resume signal for $key - ${Thread.currentThread().name}")
compositionResumedTimeout.await(COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) // Wait for Compose lifecycle to resume and first composition to happen
// This extra wait is needed to make sure Composition happens after resume on automated tests
compositionResumedTimeout.await(COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS)
}
Log.d("Sebas", "finished waiting for resume for $key - ${Thread.currentThread().name}")
handler.post {
Log.d("Sebas", "post for $key - ${Thread.currentThread().name}")
if (!continuation.isCompleted) {
Log.d("Sebas", "post + resume + should remove NOW for $key - ${Thread.currentThread().name}")
continuation.resume(Unit)
}
}
}
}
}
Log.d("Sebas", "FrameCallback posted for $key - ${Thread.currentThread().name}")
Choreographer.getInstance().postFrameCallback(frameCallback)

continuation.invokeOnCancellation {
Log.d("Sebas", "FrameCallback cancelled for $key - ${Thread.currentThread().name}")
Choreographer.getInstance().removeFrameCallback(frameCallback)
}
}
Expand All @@ -342,7 +327,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
}

private fun cancelDisposal(key: String) {
Log.d("Sebas", "CANCELLING DISPOSAL OF $key - ${Thread.currentThread().name}")
disposingJobs.remove(key)?.cancel() // Cancel scheduled disposal
markedForDisposal.remove(key) // Un-mark for disposal in case it's not yet scheduled for disposal
}
Expand All @@ -353,7 +337,6 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
override fun onCleared() {
// Cancel disposal jobs, all those references will be garbage collected anyway with this ViewModel
disposingJobs.forEach { (_, job) -> job.cancel() }
Log.d("Sebas", "CLEARING ALL ${scopedObjectsContainer.size} OBJECTS - ${Thread.currentThread().name}")
// Cancel all coroutines, Closeables and ViewModels hosted in this object
val objectsToClear: MutableList<Any> = scopedObjectsContainer.values.toMutableList()
while (objectsToClear.isNotEmpty()) {
Expand All @@ -367,27 +350,23 @@ public class ScopedViewModelContainer : ViewModel(), LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_RESUME -> { // Note Fragment View creation happens before this onResume
Log.d("Sebas", "ON_RESUME")
isInForeground = true
isChangingConfiguration = false // Clear this flag when the scope is resumed
compositionResumedTimeout.countDown() // Signal that the first composition after resume is happening
scheduleToDisposeAfterReturningFromBackground()
}

Lifecycle.Event.ON_PAUSE -> {
Log.d("Sebas", "ON_PAUSE")
isInForeground = false
}

Lifecycle.Event.ON_DESTROY -> { // Remove ourselves so that this ViewModel can be garbage collected
Log.d("Sebas", "ON_DESTROY")
source.lifecycle.removeObserver(this)
compositionResumedTimeout.countDown() // Clear any pending waiting latch
compositionResumedTimeout = CountDownLatch(1) // Start a new latch for the next time this ViewModel is used after resume
}

else -> {
Log.d("Sebas", "onSomethingElse ${event.name}")
// No-Op: the other lifecycle event are irrelevant for this class
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.sebaslogen.resaca

import android.os.Bundle
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
Expand Down Expand Up @@ -40,7 +39,6 @@ internal object ScopedViewModelUtils {
scopedObjectKeys: MutableMap<String, ScopedViewModelContainer.ExternalKey>,
cancelDisposal: ((String) -> Unit)
): T {
Log.d("Sebas", "REQUEST CANCEL DISPOSAL OF ViewModel $positionalMemoizationKey - it's being requested again")
cancelDisposal(positionalMemoizationKey)

val originalScopedViewModelOwner: ScopedViewModelOwner<T>? =
Expand Down
3 changes: 1 addition & 2 deletions resacahilt/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
Expand All @@ -37,7 +36,7 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
packagingOptions {
packaging {
resources {
excludes += setOf(
// Exclude AndroidX version files
Expand Down
3 changes: 1 addition & 2 deletions resacakoin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
Expand All @@ -35,7 +34,7 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
packagingOptions {
packaging {
resources {
excludes += setOf(
// Exclude AndroidX version files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ package com.sebaslogen.resacaapp.sample.ui.main

import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
Expand Down Expand Up @@ -64,26 +61,8 @@ class ComposeActivity : ComponentActivity() {
const val START_DESTINATION = "START_DESTINATION"
}

override fun onPause() {
super.onPause()
Log.d("Sebas", "PAUSE")
}

override fun onResume() {
super.onResume()
Log.d("Sebas", "RESUME")
}

override fun onDestroy() {
super.onDestroy()
Log.d("Sebas", "DESTROY")
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("Sebas", "CREATE")
val handler = Handler(Looper.getMainLooper())
handler.post { Log.d("Sebas", "Posted ON_CREATE") }

setContent {
ResacaAppTheme {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.sebaslogen.resaca.COMPOSITION_RESUMED_TIMEOUT_IN_SECONDS
import com.sebaslogen.resacaapp.sample.ui.main.compose.examples.DemoScopedObjectComposable
import com.sebaslogen.resacaapp.sample.ui.main.data.FakeRepo
import com.sebaslogen.resacaapp.sample.utils.ComposeTestUtils
Expand Down Expand Up @@ -50,7 +51,7 @@ class ClearRememberScopedObjectTests : ComposeTestUtils {
val initialAmountOfCloseableClosed = closeableClosedGloballySharedCounter.get()
composablesShown = false // Trigger disposal
composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition
advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer
advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter
printComposeUiTreeToLog()
val finalAmountOfCloseableClosed = closeableClosedGloballySharedCounter.get()

Expand Down Expand Up @@ -84,7 +85,7 @@ class ClearRememberScopedObjectTests : ComposeTestUtils {
val initialAmountOfCloseablesCleared = closeableClosedGloballySharedCounter.get()
composablesShown = false // Trigger disposal
composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition
advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer
advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter
printComposeUiTreeToLog()
val finalAmountOfCloseablesCleared = closeableClosedGloballySharedCounter.get()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class ClearScopedViewModelTests : ComposeTestUtils {
val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()
composablesShown = false // Trigger disposal
composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition
advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer
advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter
printComposeUiTreeToLog()
val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()

Expand Down Expand Up @@ -126,7 +126,7 @@ class ClearScopedViewModelTests : ComposeTestUtils {
val initialAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()
composablesShown = false // Trigger disposal
composeTestRule.onNodeWithText(textTitle).assertExists() // Required to trigger recomposition
advanceTimeBy(6000) // Advance more than 5 seconds to pass the disposal delay on ScopedViewModelContainer
advanceTimeBy(100) // Advance time to allow clear call on ScopedViewModelContainer to be processed before querying the counter
printComposeUiTreeToLog()
val finalAmountOfViewModelsCleared = viewModelsClearedGloballySharedCounter.get()

Expand Down
Loading

0 comments on commit ca0082d

Please sign in to comment.