Skip to content

Commit 5550805

Browse files
authored
Merge pull request #1234 from square/kabdulla/compose-lifecycle-owner
Refactor to ComposeLifecycleOwner for Better Lifecycle Synchronization
2 parents b2e04e8 + 00b3e2c commit 5550805

File tree

3 files changed

+240
-46
lines changed

3 files changed

+240
-46
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.squareup.workflow1.ui.compose
2+
3+
import androidx.compose.runtime.CompositionLocalProvider
4+
import androidx.compose.runtime.LaunchedEffect
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.platform.LocalLifecycleOwner
10+
import androidx.compose.ui.test.junit4.createComposeRule
11+
import androidx.lifecycle.Lifecycle
12+
import androidx.lifecycle.Lifecycle.State.CREATED
13+
import androidx.lifecycle.Lifecycle.State.RESUMED
14+
import androidx.lifecycle.LifecycleOwner
15+
import androidx.lifecycle.LifecycleRegistry
16+
import com.google.common.truth.Truth.assertThat
17+
import org.junit.Rule
18+
import org.junit.Test
19+
20+
class ComposeLifecycleOwnerTest {
21+
22+
@get:Rule
23+
val composeTestRule = createComposeRule()
24+
25+
private var mParentLifecycle: LifecycleRegistry? = null
26+
27+
@Test
28+
fun childLifecycleOwner_initialStateIsResumedWhenParentIsResumed() {
29+
val parentLifecycle = ensureParentLifecycle()
30+
31+
lateinit var childLifecycleOwner: LifecycleOwner
32+
composeTestRule.setContent {
33+
parentLifecycle.currentState = RESUMED
34+
childLifecycleOwner = rememberChildLifecycleOwner(parentLifecycle)
35+
CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) {
36+
// let's assert right away as things are composing, because we want to ensure that
37+
// the lifecycle is in the correct state as soon as possible & not just after composition
38+
// has finished
39+
assertThat(childLifecycleOwner.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED)
40+
}
41+
}
42+
43+
// Allow the composition to complete
44+
composeTestRule.waitForIdle()
45+
46+
// Outside the composition, assert the lifecycle state again
47+
assertThat(childLifecycleOwner.lifecycle.currentState)
48+
.isEqualTo(Lifecycle.State.RESUMED)
49+
}
50+
51+
@Test
52+
fun childLifecycleOwner_initialStateIsResumedAfterParentResumed() {
53+
val parentLifecycle = ensureParentLifecycle()
54+
55+
lateinit var childLifecycleOwner: LifecycleOwner
56+
composeTestRule.setContent {
57+
childLifecycleOwner = rememberChildLifecycleOwner(parentLifecycle)
58+
parentLifecycle.currentState = CREATED
59+
CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) {
60+
// let's assert right away as things are composing, because we want to ensure that
61+
// the lifecycle is in the correct state as soon as possible & not just after composition
62+
// has finished
63+
assertThat(childLifecycleOwner.lifecycle.currentState).isEqualTo(Lifecycle.State.CREATED)
64+
}
65+
}
66+
67+
// Allow the composition to complete
68+
composeTestRule.waitForIdle()
69+
70+
// Outside the composition, assert the lifecycle state again
71+
assertThat(childLifecycleOwner.lifecycle.currentState)
72+
.isEqualTo(Lifecycle.State.CREATED)
73+
}
74+
75+
@Test
76+
fun childLifecycleOwner_initialStateRemainsSameAfterParentLifecycleChange() {
77+
lateinit var updatedChildLifecycleOwner: LifecycleOwner
78+
lateinit var tempChildLifecycleOwner: LifecycleOwner
79+
80+
val customParentLifecycleOwner: LifecycleOwner = object : LifecycleOwner {
81+
private val registry = LifecycleRegistry(this)
82+
override val lifecycle: Lifecycle
83+
get() = registry
84+
}
85+
86+
composeTestRule.setContent {
87+
var seenRecomposition by remember { mutableStateOf(false) }
88+
// after initial composition, change the parent lifecycle owner
89+
LaunchedEffect(Unit) { seenRecomposition = true }
90+
CompositionLocalProvider(
91+
if (seenRecomposition) {
92+
LocalLifecycleOwner provides customParentLifecycleOwner
93+
} else {
94+
LocalLifecycleOwner provides LocalLifecycleOwner.current
95+
}
96+
) {
97+
98+
updatedChildLifecycleOwner = rememberChildLifecycleOwner()
99+
// let's save the original reference to lifecycle owner on first pass
100+
if (!seenRecomposition) {
101+
tempChildLifecycleOwner = updatedChildLifecycleOwner
102+
}
103+
}
104+
}
105+
106+
// Allow the composition to complete
107+
composeTestRule.waitForIdle()
108+
// assert that the [ComposeLifecycleOwner] is the same instance when the parent lifecycle owner
109+
// is changed.
110+
assertThat(updatedChildLifecycleOwner).isEqualTo(tempChildLifecycleOwner)
111+
}
112+
113+
private fun ensureParentLifecycle(): LifecycleRegistry {
114+
if (mParentLifecycle == null) {
115+
val owner = object : LifecycleOwner {
116+
override val lifecycle = LifecycleRegistry.createUnsafe(this)
117+
}
118+
mParentLifecycle = owner.lifecycle
119+
}
120+
return mParentLifecycle!!
121+
}
122+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.squareup.workflow1.ui.compose
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.RememberObserver
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.ui.platform.LocalLifecycleOwner
7+
import androidx.lifecycle.Lifecycle
8+
import androidx.lifecycle.Lifecycle.Event
9+
import androidx.lifecycle.LifecycleEventObserver
10+
import androidx.lifecycle.LifecycleOwner
11+
import androidx.lifecycle.LifecycleRegistry
12+
13+
/**
14+
* Returns a [LifecycleOwner] that is a mirror of the current [LocalLifecycleOwner] until this
15+
* function leaves the composition. Similar to [WorkflowLifecycleOwner] for views, but a
16+
* bit simpler since we don't need to worry about attachment state.
17+
*/
18+
@Composable internal fun rememberChildLifecycleOwner(
19+
parentLifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle
20+
): LifecycleOwner {
21+
val owner = remember {
22+
ComposeLifecycleOwner.installOn(
23+
initialParentLifecycle = parentLifecycle
24+
)
25+
}
26+
val lifecycleOwner = remember(parentLifecycle) {
27+
owner.apply { updateParentLifecycle(parentLifecycle) }
28+
}
29+
return lifecycleOwner
30+
}
31+
32+
/**
33+
* A custom [LifecycleOwner] that synchronizes its lifecycle with a parent [Lifecycle] and
34+
* integrates with Jetpack Compose's lifecycle through [RememberObserver].
35+
*
36+
* ## Purpose
37+
*
38+
* - Ensures that any lifecycle-aware components within a composable function have a lifecycle that
39+
* accurately reflects both the parent lifecycle and the composable's own lifecycle.
40+
* - Manages lifecycle transitions and observer registration/removal to prevent memory leaks and
41+
* ensure proper cleanup when the composable leaves the composition.
42+
*
43+
* ## Key Features
44+
*
45+
* - Lifecycle Synchronization: Mirrors lifecycle events from the provided `parentLifecycle` to
46+
* its own [LifecycleRegistry], ensuring consistent state transitions.
47+
* - Compose Integration: Implements [RememberObserver] to align with the composable's lifecycle
48+
* in the Compose memory model.
49+
* - Automatic Observer Management: Adds and removes a [LifecycleEventObserver] to the parent
50+
* lifecycle, preventing leaks and ensuring proper disposal.
51+
* - **State Transition Safety:** Carefully manages lifecycle state changes to avoid illegal
52+
* transitions, especially during destruction.
53+
*
54+
* ## Usage Notes
55+
*
56+
* - Should be used in conjunction with `remember` and provided the `parentLifecycle` as a key to
57+
* ensure it updates correctly when the parent lifecycle changes.
58+
* - By integrating with Compose's lifecycle, it ensures that resources are properly released when
59+
* the composable leaves the composition.
60+
*
61+
* @param initialParentLifecycle The parent [Lifecycle] with which this lifecycle owner should
62+
* synchronize with initially. If new parent lifecycles are provided, they should be passed to
63+
* [updateParentLifecycle].
64+
*/
65+
private class ComposeLifecycleOwner(
66+
initialParentLifecycle: Lifecycle
67+
) : LifecycleOwner, RememberObserver, LifecycleEventObserver {
68+
69+
private var parentLifecycle: Lifecycle = initialParentLifecycle
70+
71+
private val registry = LifecycleRegistry(this)
72+
override val lifecycle: Lifecycle
73+
get() = registry
74+
75+
override fun onRemembered() {
76+
}
77+
78+
override fun onAbandoned() {
79+
onForgotten()
80+
}
81+
82+
override fun onForgotten() {
83+
parentLifecycle.removeObserver(this)
84+
85+
// If we're leaving the composition, ensure the lifecycle is cleaned up
86+
if (registry.currentState != Lifecycle.State.INITIALIZED) {
87+
registry.currentState = Lifecycle.State.DESTROYED
88+
}
89+
}
90+
91+
fun updateParentLifecycle(lifecycle: Lifecycle) {
92+
parentLifecycle.removeObserver(this)
93+
parentLifecycle = lifecycle
94+
parentLifecycle.addObserver(this)
95+
}
96+
97+
override fun onStateChanged(
98+
source: LifecycleOwner,
99+
event: Event
100+
) {
101+
registry.handleLifecycleEvent(event)
102+
}
103+
104+
companion object {
105+
fun installOn(initialParentLifecycle: Lifecycle): ComposeLifecycleOwner {
106+
return ComposeLifecycleOwner(initialParentLifecycle).also {
107+
// We need to synchronize the lifecycles before the child ever even sees the lifecycle
108+
// because composes contract tries to guarantee that the lifecycle is in at least the
109+
// CREATED state by the time composition is actually running. If we don't synchronize
110+
// the lifecycles right away, then we break that invariant. One concrete case of this is
111+
// that SavedStateRegistry requires its lifecycle to be CREATED before reading values
112+
// from it, and consuming values from an SSR is a valid thing to do from composition
113+
// directly, and in fact AndroidComposeView itself does this.
114+
initialParentLifecycle.addObserver(it)
115+
}
116+
}
117+
}
118+
}

workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt

-46
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,16 @@ package com.squareup.workflow1.ui.compose
33
import androidx.compose.foundation.layout.Box
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.CompositionLocalProvider
6-
import androidx.compose.runtime.DisposableEffect
76
import androidx.compose.runtime.key
87
import androidx.compose.runtime.remember
98
import androidx.compose.ui.Modifier
109
import androidx.compose.ui.platform.LocalLifecycleOwner
11-
import androidx.lifecycle.Lifecycle
12-
import androidx.lifecycle.Lifecycle.State.DESTROYED
13-
import androidx.lifecycle.Lifecycle.State.INITIALIZED
14-
import androidx.lifecycle.LifecycleEventObserver
15-
import androidx.lifecycle.LifecycleOwner
16-
import androidx.lifecycle.LifecycleRegistry
1710
import com.squareup.workflow1.ui.Compatible
1811
import com.squareup.workflow1.ui.Screen
1912
import com.squareup.workflow1.ui.ScreenViewHolder
2013
import com.squareup.workflow1.ui.ViewEnvironment
2114
import com.squareup.workflow1.ui.WorkflowUiExperimentalApi
2215
import com.squareup.workflow1.ui.WorkflowViewStub
23-
import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner
2416

2517
/**
2618
* Renders [rendering] into the composition using the [ViewEnvironment] found in
@@ -94,41 +86,3 @@ public fun WorkflowRendering(
9486
}
9587
}
9688
}
97-
98-
/**
99-
* Returns a [LifecycleOwner] that is a mirror of the current [LocalLifecycleOwner] until this
100-
* function leaves the composition. Similar to [WorkflowLifecycleOwner] for views, but a
101-
* bit simpler since we don't need to worry about attachment state.
102-
*/
103-
@Composable private fun rememberChildLifecycleOwner(): LifecycleOwner {
104-
val lifecycleOwner = remember {
105-
object : LifecycleOwner {
106-
val registry = LifecycleRegistry(this)
107-
override val lifecycle: Lifecycle
108-
get() = registry
109-
}
110-
}
111-
val parentLifecycle = LocalLifecycleOwner.current.lifecycle
112-
113-
DisposableEffect(parentLifecycle) {
114-
val parentObserver = LifecycleEventObserver { _, event ->
115-
// Any time the parent lifecycle changes state, perform the same change on our lifecycle.
116-
lifecycleOwner.registry.handleLifecycleEvent(event)
117-
}
118-
119-
parentLifecycle.addObserver(parentObserver)
120-
onDispose {
121-
parentLifecycle.removeObserver(parentObserver)
122-
123-
// If we're leaving the composition it means the WorkflowRendering is either going away itself
124-
// or about to switch to an incompatible rendering – either way, this lifecycle is dead. Note
125-
// that we can't transition from INITIALIZED to DESTROYED – the LifecycleRegistry will throw.
126-
// WorkflowLifecycleOwner has this same check.
127-
if (lifecycleOwner.registry.currentState != INITIALIZED) {
128-
lifecycleOwner.registry.currentState = DESTROYED
129-
}
130-
}
131-
}
132-
133-
return lifecycleOwner
134-
}

0 commit comments

Comments
 (0)