Skip to content

Commit 120cb47

Browse files
authored
Merge pull request #894 from square/ray/leaky-backhandler
`View.backPressHandler` memory leak fix.
2 parents ddf0c03 + 16de577 commit 120cb47

File tree

5 files changed

+205
-31
lines changed

5 files changed

+205
-31
lines changed

workflow-ui/core-android/src/androidTest/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
<activity
88
android:name="com.squareup.workflow1.ui.container.fixtures.BackStackContainerLifecycleActivity"
99
android:theme="@style/Theme.AppCompat.NoActionBar"/>
10-
<activity android:name="androidx.activity.ComponentActivity"/>
10+
<activity android:name="androidx.activity.ComponentActivity"/>
1111
</application>
1212
</manifest>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.squareup.workflow1.ui
2+
3+
import android.view.View
4+
import android.view.ViewGroup
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.OnBackPressedCallback
7+
import androidx.activity.OnBackPressedDispatcherSpy
8+
import androidx.lifecycle.Lifecycle.State.DESTROYED
9+
import androidx.lifecycle.Lifecycle.State.RESUMED
10+
import androidx.lifecycle.LifecycleRegistry
11+
import androidx.lifecycle.ViewTreeLifecycleOwner
12+
import androidx.test.ext.junit.rules.ActivityScenarioRule
13+
import com.google.common.truth.Truth.assertThat
14+
import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess
15+
import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule
16+
import org.junit.Rule
17+
import org.junit.Test
18+
import org.junit.rules.RuleChain
19+
20+
@OptIn(WorkflowUiExperimentalApi::class)
21+
internal class BackPressedHandlerTest {
22+
private val scenarioRule = ActivityScenarioRule(ComponentActivity::class.java)
23+
private val scenario get() = scenarioRule.scenario
24+
25+
@get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess())
26+
.around(scenarioRule)
27+
.around(IdlingDispatcherRule)
28+
29+
private var viewHandlerCount = 0
30+
private val viewBackHandler: BackPressHandler = {
31+
viewHandlerCount++
32+
}
33+
34+
@Test fun itWorksWhenHandlerIsAddedBeforeAttach() {
35+
scenario.onActivity { activity ->
36+
val view = View(activity)
37+
view.backPressedHandler = viewBackHandler
38+
39+
activity.setContentView(view)
40+
assertThat(viewHandlerCount).isEqualTo(0)
41+
42+
activity.onBackPressed()
43+
assertThat(viewHandlerCount).isEqualTo(1)
44+
}
45+
}
46+
47+
@Test fun itWorksWhenHandlerIsAddedAfterAttach() {
48+
scenario.onActivity { activity ->
49+
val view = View(activity)
50+
activity.setContentView(view)
51+
52+
view.backPressedHandler = viewBackHandler
53+
assertThat(viewHandlerCount).isEqualTo(0)
54+
55+
activity.onBackPressed()
56+
assertThat(viewHandlerCount).isEqualTo(1)
57+
}
58+
}
59+
60+
@Test fun onlyActiveWhileViewIsAttached() {
61+
var fallbackCallCount = 0
62+
val defaultBackHandler = object : OnBackPressedCallback(true) {
63+
override fun handleOnBackPressed() {
64+
fallbackCallCount++
65+
}
66+
}
67+
68+
scenario.onActivity { activity ->
69+
activity.onBackPressedDispatcher.addCallback(defaultBackHandler)
70+
71+
val view = View(activity)
72+
view.backPressedHandler = viewBackHandler
73+
74+
activity.onBackPressed()
75+
assertThat(fallbackCallCount).isEqualTo(1)
76+
assertThat(viewHandlerCount).isEqualTo(0)
77+
78+
activity.setContentView(view)
79+
activity.onBackPressed()
80+
assertThat(fallbackCallCount).isEqualTo(1)
81+
assertThat(viewHandlerCount).isEqualTo(1)
82+
83+
(view.parent as ViewGroup).removeView(view)
84+
activity.onBackPressed()
85+
assertThat(fallbackCallCount).isEqualTo(2)
86+
assertThat(viewHandlerCount).isEqualTo(1)
87+
88+
activity.setContentView(view)
89+
activity.onBackPressed()
90+
assertThat(fallbackCallCount).isEqualTo(2)
91+
assertThat(viewHandlerCount).isEqualTo(2)
92+
}
93+
}
94+
95+
@Test fun callbackIsRemoved() {
96+
scenario.onActivity { activity ->
97+
val spy = OnBackPressedDispatcherSpy(activity.onBackPressedDispatcher)
98+
assertThat(spy.callbacks()).isEmpty()
99+
100+
val lifecycle = LifecycleRegistry(activity)
101+
lifecycle.currentState = RESUMED
102+
103+
val view = View(activity)
104+
view.backPressedHandler = viewBackHandler
105+
assertThat(spy.callbacks()).hasSize(1)
106+
107+
ViewTreeLifecycleOwner.set(view) { lifecycle }
108+
activity.setContentView(view)
109+
110+
(view.parent as ViewGroup).removeView(view)
111+
assertThat(spy.callbacks()).hasSize(1)
112+
113+
lifecycle.currentState = DESTROYED
114+
assertThat(spy.callbacks()).isEmpty()
115+
}
116+
}
117+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package androidx.activity;
2+
3+
import java.util.ArrayDeque;
4+
5+
public class OnBackPressedDispatcherSpy {
6+
private final OnBackPressedDispatcher dispatcher;
7+
8+
public OnBackPressedDispatcherSpy(OnBackPressedDispatcher dispatcher) {
9+
this.dispatcher = dispatcher;
10+
}
11+
12+
public ArrayDeque<OnBackPressedCallback> callbacks() {
13+
return dispatcher.mOnBackPressedCallbacks;
14+
}
15+
}

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/BackPressHandler.kt

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.view.View.OnAttachStateChangeListener
77
import androidx.activity.OnBackPressedCallback
88
import androidx.activity.OnBackPressedDispatcherOwner
99
import androidx.lifecycle.DefaultLifecycleObserver
10+
import androidx.lifecycle.Lifecycle
1011
import androidx.lifecycle.LifecycleOwner
1112
import androidx.lifecycle.ViewTreeLifecycleOwner
1213

@@ -21,79 +22,123 @@ public typealias BackPressHandler = () -> Unit
2122
* A function to be called if the device back button is pressed while this
2223
* view is attached to a window.
2324
*
24-
* Implemented via [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher],
25-
* making this a last-registered-first-served mechanism.
25+
* Implemented via [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher].
26+
* That means that this is a last-registered-first-served mechanism, and that it is
27+
* compatible with Compose back button handling.
2628
*/
2729
@WorkflowUiExperimentalApi
2830
public var View.backPressedHandler: BackPressHandler?
29-
get() = handlerWrapperOrNull?.handler
31+
get() = observerOrNull?.handler
3032
set(value) {
31-
handlerWrapperOrNull?.stop()
33+
observerOrNull?.stop()
3234

33-
val wrapper = value?.let {
34-
HandleBackPressWhenAttached(this, it).apply { start() }
35+
observerOrNull = value?.let {
36+
AttachStateAndLifecycleObserver(this, it).apply { start() }
3537
}
36-
setTag(R.id.view_back_handler, wrapper)
3738
}
3839

3940
@WorkflowUiExperimentalApi
40-
private val View.handlerWrapperOrNull
41-
get() = getTag(R.id.view_back_handler) as HandleBackPressWhenAttached?
41+
private var View.observerOrNull: AttachStateAndLifecycleObserver?
42+
get() = getTag(R.id.view_back_handler) as AttachStateAndLifecycleObserver?
43+
set(value) {
44+
setTag(R.id.view_back_handler, value)
45+
}
4246

4347
/**
44-
* Uses the [androidx.activity.OnBackPressedDispatcher] to associate a [BackPressHandler]
45-
* with a [View].
48+
* This is more complicated than one would hope because [Lifecycle] and memory leaks.
49+
*
50+
* - We need to claim our spot in the
51+
* [OnBackPressedDispatcher][androidx.activity.OnBackPressedDispatcher] immediately,
52+
* to ensure our [onBackPressedCallback] shadows upstream ones, and can be shadowed
53+
* appropriately itself
54+
* - The whole point of this mechanism is to be active only while the view is active
55+
* - That's what [ViewTreeLifecycleOwner] is for, but we can't really find that until
56+
* we're attached -- which often does not happen in the order required for registering
57+
* with the dispatcher
58+
*
59+
* So, our [start] is called immediately, to get [onBackPressedCallback] listed at the right
60+
* spot in the dispatcher's stack. But the [onBackPressedCallback]'s enabled / disabled state
61+
* is tied to whether the [view] is attached or not.
4662
*
47-
* - Registers [handler] on [start]
48-
* - Enables and disables it as [view] is attached to or detached from its window
49-
* - De-registers it on [stop], or when its [lifecycle][ViewTreeLifecycleOwner] is destroyed
63+
* Also note that we expect to find a [ViewTreeLifecycleOwner] at attach time,
64+
* so that we can know when it's time to remove the [onBackPressedCallback] from
65+
* the dispatch stack
66+
* ([no memory leaks please](https://github.com/square/workflow-kotlin/issues/889)).
67+
*
68+
* Why is it okay to wait for the [ViewTreeLifecycleOwner] to be destroyed before we
69+
* remove [onBackPressedCallback] from the dispatcher? In normal apps that's
70+
* the `Activity` or a `Fragment`, which will live a very long time, but Workflow UI
71+
* is more controlling than that. `WorkflowViewStub` and the rest of the stock container
72+
* classes use `WorkflowLifecycleOwner` to provide a short lived [ViewTreeLifecycleOwner]
73+
* for each [View] they create, and tear it down before moving to the next one.
74+
*
75+
* None the less, as a belt-and-suspenders guard against leaking,
76+
* we also take care to null out the pointer from the [onBackPressedCallback] to the
77+
* actual [handler] while the [view] is detached. We can't be confident that the
78+
* [ViewTreeLifecycleOwner] we find will be a well behaved one that was put in place
79+
* by `WorkflowLifecycleOwner`. Who knows what adventures our clients will get up to.
5080
*/
5181
@WorkflowUiExperimentalApi
52-
private class HandleBackPressWhenAttached(
82+
private class AttachStateAndLifecycleObserver(
5383
private val view: View,
5484
val handler: BackPressHandler
5585
) : OnAttachStateChangeListener, DefaultLifecycleObserver {
56-
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
57-
override fun handleOnBackPressed() {
58-
handler.invoke()
59-
}
60-
}
86+
private val onBackPressedCallback = NullableOnBackPressedCallback()
87+
private var lifecycleOrNull: Lifecycle? = null
6188

6289
fun start() {
6390
view.context.onBackPressedDispatcherOwnerOrNull()
6491
?.let { owner ->
6592
owner.onBackPressedDispatcher.addCallback(owner, onBackPressedCallback)
6693
view.addOnAttachStateChangeListener(this)
6794
if (view.isAttachedToWindow) onViewAttachedToWindow(view)
68-
69-
// We enable the handler only while its view is attached to a window.
70-
// This ensures that a temporarily removed view (e.g. for caching)
71-
// does not participate in back button handling.
72-
ViewTreeLifecycleOwner.get(view)?.lifecycle?.addObserver(this)
7395
}
7496
}
7597

7698
fun stop() {
7799
onBackPressedCallback.remove()
78100
view.removeOnAttachStateChangeListener(this)
79-
ViewTreeLifecycleOwner.get(view)?.lifecycle?.removeObserver(this)
101+
lifecycleOrNull?.removeObserver(this)
80102
}
81103

82104
override fun onViewAttachedToWindow(attachedView: View) {
83105
require(view === attachedView)
84-
onBackPressedCallback.isEnabled = true
106+
lifecycleOrNull?.let { lifecycle ->
107+
lifecycle.removeObserver(this)
108+
lifecycleOrNull = null
109+
}
110+
ViewTreeLifecycleOwner.get(view)?.lifecycle?.let { lifecycle ->
111+
lifecycleOrNull = lifecycle
112+
onBackPressedCallback.handlerOrNull = handler
113+
onBackPressedCallback.isEnabled = true
114+
lifecycle.addObserver(this)
115+
}
116+
?: error(
117+
"Expected to find a ViewTreeLifecycleOwner to manage the " +
118+
"backPressedHandler ($handler) for $view"
119+
)
85120
}
86121

87122
override fun onViewDetachedFromWindow(detachedView: View) {
88123
require(view === detachedView)
89124
onBackPressedCallback.isEnabled = false
125+
onBackPressedCallback.handlerOrNull = null
90126
}
91127

92128
override fun onDestroy(owner: LifecycleOwner) {
93129
stop()
94130
}
95131
}
96132

133+
@WorkflowUiExperimentalApi
134+
internal class NullableOnBackPressedCallback : OnBackPressedCallback(false) {
135+
var handlerOrNull: BackPressHandler? = null
136+
137+
override fun handleOnBackPressed() {
138+
handlerOrNull?.invoke()
139+
}
140+
}
141+
97142
@WorkflowUiExperimentalApi
98143
public tailrec fun Context.onBackPressedDispatcherOwnerOrNull(): OnBackPressedDispatcherOwner? =
99144
when (this) {

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowLifecycleOwner.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,6 @@ internal class RealWorkflowLifecycleOwner(
112112
OnAttachStateChangeListener,
113113
LifecycleEventObserver {
114114

115-
/**
116-
* Weak reference ensures that we don't leak the view.
117-
*/
118115
private var view: View? = null
119116

120117
private val localLifecycle =

0 commit comments

Comments
 (0)