Skip to content

Commit bf97796

Browse files
committed
Refactor LifecycleOwner to ComposeLifecycleOwner for better lifecycle management
- Extracted anonymous LifecycleOwner and RememberObserver implementation into a reusable ComposeLifecycleOwner class. - ComposeLifecycleOwner synchronizes its lifecycle with the parent Lifecycle and integrates with Compose's memory model via RememberObserver. - Improves code readability, reusability, and aligns lifecycle management with Compose best practices.
1 parent b2e04e8 commit bf97796

File tree

3 files changed

+225
-44
lines changed

3 files changed

+225
-44
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,103 @@
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 lifecycleOwner = remember {
22+
ComposeLifecycleOwner.installOn(parentLifecycle)
23+
}
24+
return lifecycleOwner
25+
}
26+
27+
/**
28+
* A custom [LifecycleOwner] that synchronizes its lifecycle with a parent [Lifecycle] and
29+
* integrates with Jetpack Compose's lifecycle through [RememberObserver].
30+
*
31+
* ## Purpose
32+
*
33+
* - Ensures that any lifecycle-aware components within a composable function have a lifecycle that
34+
* accurately reflects both the parent lifecycle and the composable's own lifecycle.
35+
* - Manages lifecycle transitions and observer registration/removal to prevent memory leaks and
36+
* ensure proper cleanup when the composable leaves the composition.
37+
*
38+
* ## Key Features
39+
*
40+
* - Lifecycle Synchronization: Mirrors lifecycle events from the provided `parentLifecycle` to
41+
* its own [LifecycleRegistry], ensuring consistent state transitions.
42+
* - Compose Integration: Implements [RememberObserver] to align with the composable's lifecycle
43+
* in the Compose memory model.
44+
* - Automatic Observer Management: Adds and removes a [LifecycleEventObserver] to the parent
45+
* lifecycle, preventing leaks and ensuring proper disposal.
46+
* - **State Transition Safety:** Carefully manages lifecycle state changes to avoid illegal
47+
* transitions, especially during destruction.
48+
*
49+
* ## Usage Notes
50+
*
51+
* - Should be used in conjunction with `remember` and provided the `parentLifecycle` as a key to
52+
* ensure it updates correctly when the parent lifecycle changes.
53+
* - By integrating with Compose's lifecycle, it ensures that resources are properly released when
54+
* the composable leaves the composition.
55+
*
56+
* @param parentLifecycle The parent [Lifecycle] with which this lifecycle owner should synchronize.
57+
*/
58+
private class ComposeLifecycleOwner(
59+
private val parentLifecycle: Lifecycle
60+
) : LifecycleOwner, RememberObserver, LifecycleEventObserver {
61+
62+
private val registry = LifecycleRegistry(this)
63+
override val lifecycle: Lifecycle
64+
get() = registry
65+
66+
override fun onRemembered() {
67+
}
68+
69+
override fun onAbandoned() {
70+
onForgotten()
71+
}
72+
73+
override fun onForgotten() {
74+
parentLifecycle.removeObserver(this)
75+
76+
// If we're leaving the composition, ensure the lifecycle is cleaned up
77+
if (registry.currentState != Lifecycle.State.INITIALIZED) {
78+
registry.currentState = Lifecycle.State.DESTROYED
79+
}
80+
}
81+
82+
override fun onStateChanged(
83+
source: LifecycleOwner,
84+
event: Event
85+
) {
86+
registry.handleLifecycleEvent(event)
87+
}
88+
89+
companion object {
90+
fun installOn(parentLifecycle: Lifecycle): ComposeLifecycleOwner {
91+
return ComposeLifecycleOwner(parentLifecycle).also {
92+
// We need to synchronize the lifecycles before the child ever even sees the lifecycle
93+
// because composes contract tries to guarantee that the lifecycle is in at least the
94+
// CREATED state by the time composition is actually running. If we don't synchronize
95+
// the lifecycles right away, then we break that invariant. One concrete case of this is
96+
// that SavedStateRegistry requires its lifecycle to be CREATED before reading values
97+
// from it, and consuming values from an SSR is a valid thing to do from composition
98+
// directly, and in fact AndroidComposeView itself does this.
99+
parentLifecycle.addObserver(it)
100+
}
101+
}
102+
}
103+
}

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

-44
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,11 @@ 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
1510
import androidx.lifecycle.LifecycleOwner
16-
import androidx.lifecycle.LifecycleRegistry
1711
import com.squareup.workflow1.ui.Compatible
1812
import com.squareup.workflow1.ui.Screen
1913
import com.squareup.workflow1.ui.ScreenViewHolder
@@ -94,41 +88,3 @@ public fun WorkflowRendering(
9488
}
9589
}
9690
}
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)