From 34d2f95c6ba444cc3c817ac9934ffce2c1d4fd66 Mon Sep 17 00:00:00 2001 From: Ray Ryan Date: Mon, 4 Nov 2024 16:47:27 -0800 Subject: [PATCH] Introduces `SavedStateHandle.removeWorkflowState()` If you're juggling multiple workflow runtimes per `Activity` (if you're the kind of person who needs to call `Job.cancel` on the `Job` returned from `WorkflowLayout.take`) then you might also need to throw away the state captured by an AndroidX `SavedStateHandler` passed to the Android flavor of `renderWorkflowIn()` to prevent memory leaks. --- workflow-ui/core-android/api/core-android.api | 1 + workflow-ui/core-android/build.gradle.kts | 3 + .../ui/AndroidRenderWorkflowInTest.kt | 82 +++++++++++++++++++ .../workflow1/ui/AndroidRenderWorkflow.kt | 14 +++- 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index dcd11bfbda..d0218530cf 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -1,4 +1,5 @@ public final class com/squareup/workflow1/ui/AndroidRenderWorkflowKt { + public static final fun removeWorkflowState (Landroidx/lifecycle/SavedStateHandle;)V public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 9f634d9f6b..89adc8b266 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -11,7 +11,10 @@ android { } dependencies { + androidTestImplementation(libs.androidx.activity.ktx) androidTestImplementation(libs.androidx.appcompat) + androidTestImplementation(libs.androidx.lifecycle.viewmodel.ktx) + androidTestImplementation(libs.androidx.lifecycle.viewmodel.savedstate) androidTestImplementation(libs.truth) api(libs.androidx.lifecycle.common) diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt new file mode 100644 index 0000000000..08376e93f7 --- /dev/null +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt @@ -0,0 +1,82 @@ +package com.squareup.workflow1.ui + +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.squareup.workflow1.StatelessWorkflow +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle.State.CREATED +import kotlinx.coroutines.flow.StateFlow +import androidx.lifecycle.viewModelScope +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Job + +@OptIn(WorkflowUiExperimentalApi::class) +internal class AndroidRenderWorkflowInTest { + @get:Rule val scenarioRule = ActivityScenarioRule(ComponentActivity::class.java) + private val scenario get() = scenarioRule.scenario + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(scenarioRule) + .around(IdlingDispatcherRule) + + @Test fun removeWorkflowStateDoesWhatItSaysOnTheTin() { + var job: Job? = null + scenario.onActivity { activity -> + val model: SomeViewModel by activity.viewModels() + val renderings: StateFlow = renderWorkflowIn( + workflow = SomeWorkflow, + scope = model.viewModelScope, + savedStateHandle = model.savedStateHandle + ) + + val layout = WorkflowLayout(activity) + activity.setContentView(layout) + + assertThat(model.savedStateHandle.contains(KEY)).isFalse() + + job = layout.take(activity.lifecycle, renderings) + assertThat(model.savedStateHandle.contains(KEY)).isFalse() + } + + scenario.moveToState(CREATED) + scenario.onActivity { activity -> + val model: SomeViewModel by activity.viewModels() + assertThat(model.savedStateHandle.contains(KEY)).isTrue() + + job?.cancel() + assertThat(model.savedStateHandle.contains(KEY)).isTrue() + + model.savedStateHandle.removeWorkflowState() + assertThat(model.savedStateHandle.contains(KEY)).isFalse() + } + } + + object SomeScreen : AndroidScreen { + override val viewFactory: ScreenViewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + ScreenViewHolder( + initialEnvironment, + FrameLayout(context) + ) { _, _ -> } + } + } + + object SomeWorkflow : StatelessWorkflow() { + override fun render( + renderProps: Unit, + context: RenderContext + ): Screen { + return SomeScreen + } + } + + class SomeViewModel(val savedStateHandle: SavedStateHandle) : ViewModel() +} diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt index feba253806..2ba4defea2 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.ui +import androidx.annotation.VisibleForTesting import androidx.lifecycle.SavedStateHandle import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions @@ -290,4 +291,15 @@ public fun renderWorkflowIn( .stateIn(scope, Eagerly, renderingsAndSnapshots.value.rendering) } -private const val KEY = "com.squareup.workflow1.ui.renderWorkflowIn-snapshot" +/** + * Removes state added to the `savedStateHandle` argument of the Android-specific + * overload of [renderWorkflowIn]. For use in obscure cases like swapping between + * different Workflow runtimes in an app. Most apps will not use this function. + */ +@WorkflowUiExperimentalApi +public fun SavedStateHandle.removeWorkflowState() { + remove(KEY) +} + +@VisibleForTesting +internal const val KEY = "com.squareup.workflow1.ui.renderWorkflowIn-snapshot"