diff --git a/benchmarks/compose-workflow/benchmark-proguard-rules.pro b/benchmarks/compose-workflow/benchmark-proguard-rules.pro new file mode 100644 index 000000000..e4061d222 --- /dev/null +++ b/benchmarks/compose-workflow/benchmark-proguard-rules.pro @@ -0,0 +1,37 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontobfuscate + +-ignorewarnings + +-keepattributes *Annotation* + +-dontnote junit.framework.** +-dontnote junit.runner.** + +-dontwarn androidx.test.** +-dontwarn org.junit.** +-dontwarn org.hamcrest.** +-dontwarn com.squareup.javawriter.JavaWriter + +-keepclasseswithmembers @org.junit.runner.RunWith public class * \ No newline at end of file diff --git a/benchmarks/compose-workflow/build.gradle.kts b/benchmarks/compose-workflow/build.gradle.kts new file mode 100644 index 000000000..2d1ffd466 --- /dev/null +++ b/benchmarks/compose-workflow/build.gradle.kts @@ -0,0 +1,77 @@ +import com.rickbusarow.kgx.libsCatalog +import com.rickbusarow.kgx.version +import com.squareup.workflow1.buildsrc.internal.javaTarget +import com.squareup.workflow1.buildsrc.internal.javaTargetVersion + +plugins { + id("com.android.library") + id("kotlin-android") + // id("android-defaults") + alias(libs.plugins.androidx.benchmark) + alias(libs.plugins.compose.compiler) +} + +// Note: We are not including our defaults from .buildscript as we do not need the base Workflow +// dependencies that those include. + +android { + compileSdk = libsCatalog.version("compileSdk").toInt() + + compileOptions { + sourceCompatibility = javaTargetVersion + targetCompatibility = javaTargetVersion + } + + kotlinOptions { + jvmTarget = javaTarget + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + } + + defaultConfig { + minSdk = 28 + targetSdk = libsCatalog.version("targetSdk").toInt() + + // TODO why isn't this taking? + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + + // must be one of: 'None', 'StackSampling', or 'MethodTracing' + testInstrumentationRunnerArguments["androidx.benchmark.profiling.mode"] = "MethodTracing" + testInstrumentationRunnerArguments["androidx.benchmark.output.enable"] = "true" + } + + // buildTypes { + // debug { + // isDebuggable = false + // isProfileable = true + // } + // } + + testBuildType = "release" + // testBuildType = "debug" + buildTypes { + debug { + // Since isDebuggable can"t be modified by gradle for library modules, + // it must be done in a manifest - see src/androidTest/AndroidManifest.xml + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro" + ) + } + release { + isDefault = true + } + } + + namespace = "com.squareup.benchmark.composeworkflow.benchmark" + testNamespace = "$namespace.test" +} + +dependencies { + androidTestImplementation(project(":workflow-runtime")) + androidTestImplementation(libs.androidx.benchmark) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.uiautomator) + androidTestImplementation(libs.kotlin.test.jdk) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/benchmarks/compose-workflow/src/androidTest/AndroidManifest.xml b/benchmarks/compose-workflow/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..6acd09fa3 --- /dev/null +++ b/benchmarks/compose-workflow/src/androidTest/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/benchmarks/compose-workflow/src/androidTest/java/com/squareup/benchmark/composeworkflow/benchmark/ComposeWorkflowMicroBenchmark.kt b/benchmarks/compose-workflow/src/androidTest/java/com/squareup/benchmark/composeworkflow/benchmark/ComposeWorkflowMicroBenchmark.kt new file mode 100644 index 000000000..d7ade2bb2 --- /dev/null +++ b/benchmarks/compose-workflow/src/androidTest/java/com/squareup/benchmark/composeworkflow/benchmark/ComposeWorkflowMicroBenchmark.kt @@ -0,0 +1,274 @@ +@file:OptIn(WorkflowExperimentalApi::class) + +package com.squareup.benchmark.composeworkflow.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.compose.composable +import com.squareup.workflow1.compose.renderChild +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.renderWorkflowIn +import com.squareup.workflow1.stateless +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +private const val MaxChildCount = 100 + +@OptIn(WorkflowExperimentalRuntime::class) +@RunWith(AndroidJUnit4::class) +class ComposeWorkflowMicroBenchmark { + + @get:Rule val benchmarkRule = BenchmarkRule() + + @Test fun tradRoot_tradChildren_initialRender() { + benchmarkSimpleTreeInitialRender( + composeRoot = false, + composeChildren = false + ) + } + + @Test fun tradRoot_composeChildren_initialRender() { + benchmarkSimpleTreeInitialRender( + composeRoot = false, + composeChildren = true + ) + } + + @Test fun composeRoot_tradChildren_initialRender() { + benchmarkSimpleTreeInitialRender( + composeRoot = true, + composeChildren = false + ) + } + + @Test fun composeRoot_composeChildren_initialRender() { + benchmarkSimpleTreeInitialRender( + composeRoot = true, + composeChildren = true + ) + } + + @Test fun tradRoot_tradChildren_tearDown() { + benchmarkSimpleTreeTearDown( + composeRoot = false, + composeChildren = false + ) + } + + @Test fun tradRoot_composeChildren_tearDown() { + benchmarkSimpleTreeTearDown( + composeRoot = false, + composeChildren = true + ) + } + + @Test fun composeRoot_tradChildren_tearDown() { + benchmarkSimpleTreeTearDown( + composeRoot = true, + composeChildren = false + ) + } + + @Test fun composeRoot_composeChildren_tearDown() { + benchmarkSimpleTreeTearDown( + composeRoot = true, + composeChildren = true + ) + } + + @Test fun tradRoot_tradChildren_subsequentRender() { + benchmarkSimpleTreeSubsequentRender( + composeRoot = false, + composeChildren = false + ) + } + + @Test fun tradRoot_composeChildren_subsequentRender() { + benchmarkSimpleTreeSubsequentRender( + composeRoot = false, + composeChildren = true + ) + } + + @Test fun composeRoot_tradChildren_subsequentRender() { + benchmarkSimpleTreeSubsequentRender( + composeRoot = true, + composeChildren = false + ) + } + + @Test fun composeRoot_composeChildren_subsequentRender() { + benchmarkSimpleTreeSubsequentRender( + composeRoot = true, + composeChildren = true + ) + } + + private fun benchmarkSimpleTreeInitialRender( + composeRoot: Boolean, + composeChildren: Boolean + ) = runTest { + val props = + MutableStateFlow(RootWorkflowProps(childCount = 0, composeChildren = composeChildren)) + val workflowJob = Job(parent = coroutineContext.job) + val renderings = renderWorkflowIn( + workflow = if (composeRoot) { + composeSimpleRoot + } else { + traditionalSimpleRoot + }, + props = props, + scope = this + workflowJob, + runtimeConfig = RuntimeConfigOptions.ALL, + onOutput = {} + ) + + benchmarkRule.measureRepeated { + runWithTimingDisabled { + props.value = RootWorkflowProps(childCount = 0, composeChildren = composeChildren) + testScheduler.runCurrent() + assertEquals(0, renderings.value.rendering) + } + + props.value = RootWorkflowProps(childCount = MaxChildCount, composeChildren = composeChildren) + testScheduler.runCurrent() + assertEquals(MaxChildCount, renderings.value.rendering) + } + + workflowJob.cancel() + } + + private fun benchmarkSimpleTreeTearDown( + composeRoot: Boolean, + composeChildren: Boolean + ) = runTest { + val props = + MutableStateFlow(RootWorkflowProps(childCount = 0, composeChildren = composeChildren)) + val workflowJob = Job(parent = coroutineContext.job) + val renderings = renderWorkflowIn( + workflow = if (composeRoot) { + composeSimpleRoot + } else { + traditionalSimpleRoot + }, + props = props, + scope = this + workflowJob, + runtimeConfig = RuntimeConfigOptions.ALL, + onOutput = {} + ) + + benchmarkRule.measureRepeated { + runWithTimingDisabled { + props.value = + RootWorkflowProps(childCount = MaxChildCount, composeChildren = composeChildren) + testScheduler.runCurrent() + assertEquals(MaxChildCount, renderings.value.rendering) + } + + props.value = RootWorkflowProps(childCount = 0, composeChildren = composeChildren) + testScheduler.runCurrent() + assertEquals(0, renderings.value.rendering) + } + + workflowJob.cancel() + } + + private fun benchmarkSimpleTreeSubsequentRender( + composeRoot: Boolean, + composeChildren: Boolean + ) = runTest { + val props = MutableStateFlow( + RootWorkflowProps( + childCount = MaxChildCount, + composeChildren = composeChildren + ) + ) + val workflowJob = Job(parent = coroutineContext.job) + val renderings = renderWorkflowIn( + workflow = if (composeRoot) { + composeSimpleRoot + } else { + traditionalSimpleRoot + }, + props = props, + scope = this + workflowJob, + runtimeConfig = RuntimeConfigOptions.ALL, + onOutput = {} + ) + + benchmarkRule.measureRepeated { + runWithTimingDisabled { + props.value = + RootWorkflowProps( + childCount = MaxChildCount, + composeChildren = composeChildren, + childProps = 1 + ) + testScheduler.runCurrent() + assertEquals(MaxChildCount, renderings.value.rendering) + } + + props.value = RootWorkflowProps( + childCount = MaxChildCount, + composeChildren = composeChildren, + childProps = 2 + ) + testScheduler.runCurrent() + assertEquals(MaxChildCount * 2, renderings.value.rendering) + } + + workflowJob.cancel() + } +} + +private data class RootWorkflowProps( + val childCount: Int, + val composeChildren: Boolean, + val childProps: Int = 1, +) + +private val traditionalSimpleRoot = Workflow.stateless { props -> + var rendering = 0 + repeat(props.childCount) { child -> + rendering += renderChild( + key = child.toString(), + props = props.childProps, + child = if (props.composeChildren) { + composeSimpleLeaf + } else { + traditionalSimpleLeaf + } + ) + } + rendering +} + +private val composeSimpleRoot = Workflow.composable { props, _ -> + var rendering = 0 + repeat(props.childCount) { + rendering += renderChild( + props = props.childProps, + workflow = if (props.composeChildren) { + composeSimpleLeaf + } else { + traditionalSimpleLeaf + }, + ) + } + rendering +} + +private val traditionalSimpleLeaf = Workflow.stateless { it } +private val composeSimpleLeaf = Workflow.composable { props, _ -> props } diff --git a/benchmarks/compose-workflow/src/main/AndroidManifest.xml b/benchmarks/compose-workflow/src/main/AndroidManifest.xml new file mode 100644 index 000000000..699668bcf --- /dev/null +++ b/benchmarks/compose-workflow/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/benchmarks/cw2/.gitignore b/benchmarks/cw2/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/benchmarks/cw2/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/benchmarks/cw2/benchmark-proguard-rules.pro b/benchmarks/cw2/benchmark-proguard-rules.pro new file mode 100644 index 000000000..e4061d222 --- /dev/null +++ b/benchmarks/cw2/benchmark-proguard-rules.pro @@ -0,0 +1,37 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontobfuscate + +-ignorewarnings + +-keepattributes *Annotation* + +-dontnote junit.framework.** +-dontnote junit.runner.** + +-dontwarn androidx.test.** +-dontwarn org.junit.** +-dontwarn org.hamcrest.** +-dontwarn com.squareup.javawriter.JavaWriter + +-keepclasseswithmembers @org.junit.runner.RunWith public class * \ No newline at end of file diff --git a/benchmarks/cw2/build.gradle.kts b/benchmarks/cw2/build.gradle.kts new file mode 100644 index 000000000..383ad3f59 --- /dev/null +++ b/benchmarks/cw2/build.gradle.kts @@ -0,0 +1,56 @@ +import com.rickbusarow.kgx.libsCatalog +import com.rickbusarow.kgx.version +import com.squareup.workflow1.buildsrc.internal.javaTarget +import com.squareup.workflow1.buildsrc.internal.javaTargetVersion + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.androidx.benchmark) +} + +android { + namespace = "com.example.cw2" + compileSdk = libsCatalog.version("compileSdk").toInt() + + defaultConfig { + minSdk = 28 + targetSdk = libsCatalog.version("targetSdk").toInt() + + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + } + + testBuildType = "release" + buildTypes { + debug { + // Since isDebuggable can"t be modified by gradle for library modules, + // it must be done in a manifest - see src/androidTest/AndroidManifest.xml + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro" + ) + } + release { + isDefault = true + } + } + compileOptions { + sourceCompatibility = javaTargetVersion + targetCompatibility = javaTargetVersion + } + kotlinOptions { + jvmTarget = javaTarget + } +} + +dependencies { + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.benchmark) + // Add your dependencies here. Note that you cannot benchmark code + // in an app module this way - you will need to move any code you + // want to benchmark to a library module: + // https://developer.android.com/studio/projects/android-library#Convert + +} diff --git a/benchmarks/cw2/src/androidTest/AndroidManifest.xml b/benchmarks/cw2/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..cbddc9060 --- /dev/null +++ b/benchmarks/cw2/src/androidTest/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/benchmarks/cw2/src/androidTest/java/com/example/cw2/ExampleBenchmark.kt b/benchmarks/cw2/src/androidTest/java/com/example/cw2/ExampleBenchmark.kt new file mode 100644 index 000000000..e45095034 --- /dev/null +++ b/benchmarks/cw2/src/androidTest/java/com/example/cw2/ExampleBenchmark.kt @@ -0,0 +1,29 @@ +package com.example.cw2 + +import android.util.Log +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Benchmark, which will execute on an Android device. + * + * The body of [BenchmarkRule.measureRepeated] is measured in a loop, and Studio will + * output the result. Modify your code to see how it affects performance. + */ +@RunWith(AndroidJUnit4::class) +class ExampleBenchmark { + + @get:Rule + val benchmarkRule = BenchmarkRule() + + @Test + fun log() { + benchmarkRule.measureRepeated { + Log.d("LogBenchmark", "the cost of writing this log method will be measured") + } + } +} diff --git a/benchmarks/cw2/src/main/AndroidManifest.xml b/benchmarks/cw2/src/main/AndroidManifest.xml new file mode 100644 index 000000000..568741e54 --- /dev/null +++ b/benchmarks/cw2/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/benchmarks/dungeon-benchmark/build.gradle.kts b/benchmarks/dungeon-benchmark/build.gradle.kts index 00f21d752..92ffc48b9 100644 --- a/benchmarks/dungeon-benchmark/build.gradle.kts +++ b/benchmarks/dungeon-benchmark/build.gradle.kts @@ -45,7 +45,7 @@ android { } dependencies { - implementation(libs.androidx.macro.benchmark) + implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.junit) implementation(libs.androidx.test.uiautomator) diff --git a/benchmarks/performance-poetry/complex-benchmark/build.gradle.kts b/benchmarks/performance-poetry/complex-benchmark/build.gradle.kts index b0916a387..b715fad24 100644 --- a/benchmarks/performance-poetry/complex-benchmark/build.gradle.kts +++ b/benchmarks/performance-poetry/complex-benchmark/build.gradle.kts @@ -54,7 +54,7 @@ android { } dependencies { - implementation(libs.androidx.macro.benchmark) + implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.junit) implementation(libs.androidx.test.uiautomator) diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt index 4b0756915..391c03de2 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/instrumentation/PerformanceTracingInterceptor.kt @@ -1,9 +1,12 @@ package com.squareup.benchmarks.performance.complex.poetry.instrumentation +import androidx.compose.runtime.Composable import androidx.tracing.Trace import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemWorkflow import com.squareup.benchmarks.performance.complex.poetry.PerformancePoemsBrowserWorkflow +import com.squareup.benchmarks.performance.complex.poetry.instrumentation.PerformanceTracingInterceptor.Companion.NODES_TO_TRACE import com.squareup.workflow1.BaseRenderContext +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession @@ -27,6 +30,24 @@ class PerformanceTracingInterceptor( context: BaseRenderContext, proceed: (P, S, RenderContextInterceptor?) -> R, session: WorkflowSession + ): R = traceRender(session) { + proceed(renderProps, renderState, null) + } + + @OptIn(WorkflowExperimentalApi::class) + @Composable + override fun onRenderComposeWorkflow( + renderProps: P, + emitOutput: (O) -> Unit, + proceed: @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R = traceRender(session) { + proceed(renderProps, emitOutput) + } + + private inline fun traceRender( + session: WorkflowSession, + render: () -> R ): R { val isRoot = session.parent == null val traceIdIndex = NODES_TO_TRACE.indexOfFirst { it.second == session.identifier } @@ -45,7 +66,7 @@ class PerformanceTracingInterceptor( Trace.beginSection(sectionName) } - return proceed(renderProps, renderState, null).also { + return render().also { if (traceIdIndex > -1 && !sample) { Trace.endSection() } diff --git a/build.gradle.kts b/build.gradle.kts index 63ecf3ea7..8315b5f66 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,7 @@ plugins { id("dependency-guard") alias(libs.plugins.ktlint) alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.androidx.benchmark) apply false } shardConnectedCheckTasks(project) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03264b2c8..787f55026 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ jdk-toolchain = "17" androidx-activity = "1.8.2" androidx-appcompat = "1.7.0" -androidx-benchmark = "1.3.3" +androidx-benchmark = "1.3.4" androidx-cardview = "1.0.0" # see https://developer.android.com/jetpack/compose/bom/bom-mapping androidx-compose-bom = "2024.09.02" @@ -95,12 +95,14 @@ timber = "5.0.1" truth = "1.4.4" turbine = "1.0.0" vanniktech-publish = "0.32.0" +agp = "8.8.0" [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +androidx-benchmark = { id = "androidx.benchmark", version.ref = "androidx-benchmark" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } @@ -114,6 +116,8 @@ kotlinx-apiBinaryCompatibility = { id = "org.jetbrains.kotlinx.binary-compatibil mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose-plugin" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } [libraries] @@ -163,7 +167,8 @@ androidx-lifecycle-viewmodel-core = { module = "androidx.lifecycle:lifecycle-vie androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } -androidx-macro-benchmark = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } +androidx-benchmark = { module = "androidx.benchmark:benchmark-junit4", version.ref = "androidx-benchmark" } +androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidx-profileinstaller" } @@ -264,7 +269,7 @@ squareup-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.re squareup-okio = { module = "com.squareup.okio:okio", version.ref = "squareup-okio" } -squareup-papa = { module = "com.squareup.papa:papa", version.ref = "squareup-papa"} +squareup-papa = { module = "com.squareup.papa:papa", version.ref = "squareup-papa" } squareup-radiography = { module = "com.squareup.radiography:radiography", version.ref = "squareup-radiography" } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt index 5d527d1f8..3b4298ecc 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt @@ -14,36 +14,32 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.compose.ComposeWorkflow import com.squareup.workflow1.config.AndroidRuntimeConfigTools -import com.squareup.workflow1.parse import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.compose.ComposeScreen import com.squareup.workflow1.ui.compose.WorkflowRendering import com.squareup.workflow1.ui.compose.renderAsState -object InlineRenderingWorkflow : StatefulWorkflow() { +@OptIn(WorkflowExperimentalApi::class) +object InlineRenderingWorkflow : ComposeWorkflow() { - override fun initialState( + @Composable + override fun produceRendering( props: Unit, - snapshot: Snapshot? - ): Int = snapshot?.bytes?.parse { it.readInt() } ?: 0 - - override fun render( - renderProps: Unit, - renderState: Int, - context: RenderContext - ): ComposeScreen { - val onClick = context.eventHandler("increment") { state += 1 } + emitOutput: (Nothing) -> Unit + ): Screen { + var state by rememberSaveable { mutableIntStateOf(0) } return ComposeScreen { - Content(renderState, onClick) + Content(state, onClick = { state++ }) } } - - override fun snapshotState(state: Int): Snapshot = Snapshot.of(state) } @Composable diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt index c39c42c78..316663348 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -47,7 +48,8 @@ class NestedRenderingsActivity : AppCompatActivity() { workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) }, scope = viewModelScope, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), + interceptors = listOf(SimpleLoggingWorkflowInterceptor()) ) } } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt index 4691e988e..e7d300e4d 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt @@ -2,6 +2,14 @@ package com.squareup.sample.compose.nestedrenderings +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement.SpaceEvenly import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,12 +21,17 @@ import androidx.compose.material.Card import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.tooling.preview.Preview import com.squareup.sample.compose.R @@ -27,6 +40,7 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.WorkflowRendering import com.squareup.workflow1.ui.compose.tooling.Preview +import kotlin.time.DurationUnit.MILLISECONDS /** * Composition local of [Color] to use as the background color for a [RecursiveComposableFactory]. @@ -44,7 +58,26 @@ val RecursiveComposableFactory = ScreenComposableFactory { rendering .compositeOver(Color.Black) } - Card(backgroundColor = color) { + var lastFlashedTrigger by remember { mutableIntStateOf(rendering.flashTrigger) } + val flashAlpha = remember { Animatable(Color(0x00FFFFFF)) } + + // Flash the card white when asked. + LaunchedEffect(rendering.flashTrigger) { + if (rendering.flashTrigger != 0) { + lastFlashedTrigger = rendering.flashTrigger + flashAlpha.animateTo(Color(0x00FFFFFF), animationSpec = keyframes { + Color.White at (rendering.flashTime / 7).toInt(MILLISECONDS) using FastOutLinearInEasing + Color(0x00FFFFFF) at rendering.flashTime.toInt(MILLISECONDS) using LinearOutSlowInEasing + }) + } + } + + Card( + backgroundColor = flashAlpha.value.compositeOver(color), + modifier = Modifier.pointerInput(rendering) { + detectTapGestures(onPress = { rendering.onSelfClicked() }) + } + ) { Column( Modifier .padding(dimensionResource(R.dimen.recursive_padding)) @@ -76,10 +109,14 @@ fun RecursiveViewFactoryPreview() { StringRendering("foo"), Rendering( children = listOf(StringRendering("bar")), + flashTrigger = 0, + onSelfClicked = {}, onAddChildClicked = {}, onResetClicked = {} ) ), + flashTrigger = 0, + onSelfClicked = {}, onAddChildClicked = {}, onResetClicked = {} ), diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt index 8e5fb1eee..6fdcb022e 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt @@ -1,16 +1,30 @@ package com.squareup.sample.compose.nestedrenderings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.squareup.sample.compose.databinding.LegacyViewBinding import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.LegacyRendering import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.Rendering -import com.squareup.sample.compose.nestedrenderings.RecursiveWorkflow.State -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.renderChild import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds /** * A simple workflow that produces [Rendering]s of zero or more children. @@ -20,9 +34,8 @@ import com.squareup.workflow1.ui.ScreenViewFactory * to force it to go through the legacy view layer. This way this sample both demonstrates pass- * through Composable renderings as well as adapting in both directions. */ -object RecursiveWorkflow : StatefulWorkflow() { - - data class State(val children: Int = 0) +@OptIn(WorkflowExperimentalApi::class) +object RecursiveWorkflow : ComposeWorkflow() { /** * A rendering from a [RecursiveWorkflow]. @@ -33,8 +46,11 @@ object RecursiveWorkflow : StatefulWorkflow() { */ data class Rendering( val children: List, - val onAddChildClicked: () -> Unit, - val onResetClicked: () -> Unit + val flashTrigger: Int = 0, + val flashTime: Duration = ZERO, + val onSelfClicked: () -> Unit = {}, + val onAddChildClicked: () -> Unit = {}, + val onResetClicked: () -> Unit = {} ) : Screen /** @@ -49,33 +65,45 @@ object RecursiveWorkflow : StatefulWorkflow() { ) } - override fun initialState( + @OptIn(ExperimentalStdlibApi::class) + @Composable override fun produceRendering( props: Unit, - snapshot: Snapshot? - ): State = State() + emitOutput: (Unit) -> Unit + ): Screen { + var children by rememberSaveable { mutableStateOf(0) } + var flashTrigger by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + DisposableEffect(Unit) { + println("OMG coroutineScope dispatcher: ${coroutineScope.coroutineContext[CoroutineDispatcher]}") + onDispose {} + } + + LaunchedEffect(Unit) { + println("OMG LaunchedEffect dispatcher: ${coroutineScope.coroutineContext[CoroutineDispatcher]}") + } - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext - ): Rendering { return Rendering( - children = List(renderState.children) { i -> - val child = context.renderChild(RecursiveWorkflow, key = i.toString()) + children = List(children) { i -> + val child = renderChild(RecursiveWorkflow, onOutput = { + // When a child is clicked, cascade the flash up. + coroutineScope.launch { + delay(0.1.seconds) + flashTrigger++ + emitOutput(Unit) + } + }) if (i % 2 == 0) child else LegacyRendering(child) }, - onAddChildClicked = { context.actionSink.send(addChild()) }, - onResetClicked = { context.actionSink.send(reset()) } + flashTrigger = flashTrigger, + flashTime = 0.5.seconds, + // Trigger a cascade of flashes when clicked. + onSelfClicked = { + flashTrigger++ + emitOutput(Unit) + }, + onAddChildClicked = { children++ }, + onResetClicked = { children = 0 } ) } - - override fun snapshotState(state: State): Snapshot? = null - - private fun addChild() = action("addChild") { - state = state.copy(children = state.children + 1) - } - - private fun reset() = action("reset") { - state = State() - } } diff --git a/samples/dungeon/common/build.gradle.kts b/samples/dungeon/common/build.gradle.kts index c635fbdd4..b63dbbd45 100644 --- a/samples/dungeon/common/build.gradle.kts +++ b/samples/dungeon/common/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("kotlin-jvm") id("kotlinx-serialization") + alias(libs.plugins.compose.compiler) } dependencies { diff --git a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt index 5655f27be..4048fe26b 100644 --- a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt +++ b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt @@ -1,5 +1,13 @@ package com.squareup.sample.dungeon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import com.squareup.sample.dungeon.ActorWorkflow.ActorProps import com.squareup.sample.dungeon.ActorWorkflow.ActorRendering import com.squareup.sample.dungeon.Direction.DOWN @@ -11,16 +19,13 @@ import com.squareup.sample.dungeon.GameWorkflow.Output import com.squareup.sample.dungeon.GameWorkflow.Output.PlayerWasEaten import com.squareup.sample.dungeon.GameWorkflow.Output.Vibrate import com.squareup.sample.dungeon.GameWorkflow.Props -import com.squareup.sample.dungeon.GameWorkflow.State import com.squareup.sample.dungeon.PlayerWorkflow.Rendering import com.squareup.sample.dungeon.board.Board import com.squareup.sample.dungeon.board.Board.Location -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Worker -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.runningWorker +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.renderChild import com.squareup.workflow1.ui.Screen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -30,11 +35,12 @@ import kotlin.random.Random private val ignoreInput: (Direction) -> Unit = {} +@OptIn(WorkflowExperimentalApi::class) class GameWorkflow( private val playerWorkflow: PlayerWorkflow, private val aiWorkflows: List, private val random: Random -) : StatefulWorkflow() { +) : ComposeWorkflow() { /** * @param board Should not change while the game is running. @@ -56,9 +62,9 @@ class GameWorkflow( /** * Emitted by [GameWorkflow] if the controller should be vibrated. */ - object Vibrate : Output() + data object Vibrate : Output() - object PlayerWasEaten : Output() + data object PlayerWasEaten : Output() } data class GameRendering( @@ -68,67 +74,68 @@ class GameWorkflow( val onStopMoving: (Direction) -> Unit ) : Screen - override fun initialState( + @Composable + override fun produceRendering( props: Props, - snapshot: Snapshot? - ): State { - val board = props.board - return State( - game = Game( - playerLocation = random.nextEmptyLocation(board), - aiLocations = aiWorkflows.map { random.nextEmptyLocation(board) } + emitOutput: (Output) -> Unit + ): GameRendering { + var state by remember { + mutableStateOf( + State( + game = Game( + playerLocation = random.nextEmptyLocation(props.board), + aiLocations = aiWorkflows.map { random.nextEmptyLocation(props.board) } + ) + ) ) - ) - } - - override fun onPropsChanged( - old: Props, - new: Props, - state: State - ): State { - check(old.board == new.board) { "Expected board to not change during the game." } - return state - } + } - override fun render( - renderProps: Props, - renderState: State, - context: RenderContext - ): GameRendering { - val running = !renderProps.paused && !renderState.game.isPlayerEaten + val running = !props.paused && !state.game.isPlayerEaten // Stop actors from ticking if the game is paused or finished. - val ticker: Worker = - if (running) TickerWorker(renderProps.ticksPerSecond) else Worker.finished() - val game = renderState.game - val board = renderProps.board + val ticker: Worker = if (running) { + remember { TickerWorker(props.ticksPerSecond) } + } else { + Worker.finished() + } + val game = state.game + val board = props.board // Render the player. val playerInput = ActorProps(board, game.playerLocation, ticker) - val playerRendering = context.renderChild(playerWorkflow, playerInput) + val playerRendering = renderChild(playerWorkflow, playerInput) + val updatedPR by rememberUpdatedState(playerRendering) // Render all the other actors. val aiRenderings = aiWorkflows.zip(game.aiLocations) .mapIndexed { index, (aiWorkflow, aiLocation) -> - val aiInput = ActorProps(board, aiLocation, ticker) - aiLocation to context.renderChild(aiWorkflow, aiInput, key = index.toString()) + key(index) { + val aiInput = ActorProps(board, aiLocation, ticker) + aiLocation to renderChild(aiWorkflow, aiInput) + } } + val updatedAIR by rememberUpdatedState(aiRenderings) // If the game is paused or finished, just render the board without ticking. if (running) { - context.runningWorker(ticker) { tick -> - return@runningWorker updateGame( - renderProps.ticksPerSecond, - tick, - playerRendering, - aiRenderings - ) + LaunchedEffect(ticker) { + ticker.run().collect { tick -> + state = updateGame( + props, + state, + props.ticksPerSecond, + tick, + updatedPR, + updatedAIR, + emitOutput + ) + } } } val aiOverlay = aiRenderings.map { (a, b) -> a to b.avatar } .toMap() val renderedBoard = board.withOverlay( - aiOverlay + (game.playerLocation to playerRendering.actorRendering.avatar) + aiOverlay + mapOf(game.playerLocation to playerRendering.actorRendering.avatar) ) return GameRendering( board = renderedBoard, @@ -137,17 +144,18 @@ class GameWorkflow( ) } - override fun snapshotState(state: State): Snapshot? = null - /** * Calculate new locations for player and other actors. */ private fun updateGame( + props: Props, + state: State, ticksPerSecond: Int, tick: Long, playerRendering: Rendering, - aiRenderings: List> - ) = action("updateGame") { + aiRenderings: List>, + emitOutput: (Output) -> Unit + ): State { // Calculate if this tick should result in movement based on the movement's speed. fun Movement.isTimeToMove(): Boolean { val ticksPerCell = (ticksPerSecond / cellsPerSecond).roundToLong() @@ -184,12 +192,11 @@ class GameWorkflow( // Check if AI captured player. if (newGame.isPlayerEaten) { - state = state.copy(game = newGame) - setOutput(PlayerWasEaten) + emitOutput(PlayerWasEaten) } else { - state = state.copy(game = newGame) - output?.let { setOutput(it) } + output?.let { emitOutput(it) } } + return state.copy(game = newGame) } } diff --git a/samples/hello-compose-workflow/build.gradle.kts b/samples/hello-compose-workflow/build.gradle.kts new file mode 100644 index 000000000..03bdedcd2 --- /dev/null +++ b/samples/hello-compose-workflow/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("android-sample-app") + id("android-ui-tests") + alias(libs.plugins.compose.compiler) +} + +android { + defaultConfig { + applicationId = "com.squareup.sample.hellocomposeworkflow" + } + namespace = "com.squareup.sample.hellocomposeworkflow" +} + +dependencies { + debugImplementation(libs.squareup.leakcanary.android) + + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.viewbinding) + + implementation(project(":workflow-ui:core-android")) + implementation(project(":workflow-ui:core-common")) + + testImplementation(libs.kotlin.test.jdk) + testImplementation(project(":workflow-testing")) +} diff --git a/samples/hello-compose-workflow/lint-baseline.xml b/samples/hello-compose-workflow/lint-baseline.xml new file mode 100644 index 000000000..ed333fbf8 --- /dev/null +++ b/samples/hello-compose-workflow/lint-baseline.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/samples/hello-compose-workflow/src/androidTest/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowAppTest.kt b/samples/hello-compose-workflow/src/androidTest/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowAppTest.kt new file mode 100644 index 000000000..1d558ed79 --- /dev/null +++ b/samples/hello-compose-workflow/src/androidTest/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowAppTest.kt @@ -0,0 +1,38 @@ +package com.squareup.sample.hellocomposeworkflow + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HelloComposeWorkflowAppTest { + + private val scenarioRule = ActivityScenarioRule(HelloComposeWorkflowActivity::class.java) + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(scenarioRule) + .around(IdlingDispatcherRule) + + @Test fun togglesHelloAndGoodbye() { + onView(withText("Hello")) + .check(matches(isDisplayed())) + .perform(click()) + + onView(withText("Goodbye")) + .check(matches(isDisplayed())) + .perform(click()) + + onView(withText("Hello")) + .check(matches(isDisplayed())) + } +} diff --git a/samples/hello-compose-workflow/src/main/AndroidManifest.xml b/samples/hello-compose-workflow/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1c7e81bd8 --- /dev/null +++ b/samples/hello-compose-workflow/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflow.kt b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflow.kt new file mode 100644 index 000000000..a9fd9c635 --- /dev/null +++ b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflow.kt @@ -0,0 +1,49 @@ +package com.squareup.sample.hellocomposeworkflow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.squareup.sample.hellocomposeworkflow.HelloComposeWorkflow.State.Goodbye +import com.squareup.sample.hellocomposeworkflow.HelloComposeWorkflow.State.Hello +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(WorkflowExperimentalApi::class) +object HelloComposeWorkflow : ComposeWorkflow() { + enum class State { + Hello, + Goodbye + } + + object StateSaver : Saver { + override fun restore(value: Int) = State.entries[value] + override fun SaverScope.save(value: State) = value.ordinal + } + + @Composable + override fun produceRendering( + props: Unit, + emitOutput: (Nothing) -> Unit + ): HelloRendering { + var state by rememberSaveable(stateSaver = StateSaver) { mutableStateOf(Hello) } + val compositions = remember { AtomicInteger(0) } + println("OMG recomposing state=$state (count=${compositions.incrementAndGet()})") + + return HelloRendering( + message = state.name, + onClick = { + println("OMG onClick! state=$state") + state = when (state) { + Hello -> Goodbye + Goodbye -> Hello + } + } + ) + } +} diff --git a/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowActivity.kt b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowActivity.kt new file mode 100644 index 000000000..04ca5f46e --- /dev/null +++ b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowActivity.kt @@ -0,0 +1,40 @@ +@file:OptIn(WorkflowExperimentalRuntime::class) + +package com.squareup.sample.hellocomposeworkflow + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.squareup.workflow1.SimpleLoggingWorkflowInterceptor +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn +import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.workflow1.ui.workflowContentView +import kotlinx.coroutines.flow.StateFlow + +class HelloComposeWorkflowActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // This ViewModel will survive configuration changes. It's instantiated + // by the first call to viewModels(), and that original instance is returned by + // succeeding calls. + val model: HelloViewModel by viewModels() + workflowContentView.take(lifecycle, model.renderings) + } +} + +class HelloViewModel(savedState: SavedStateHandle) : ViewModel() { + val renderings: StateFlow by lazy { + renderWorkflowIn( + workflow = HelloComposeWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), + interceptors = listOf(SimpleLoggingWorkflowInterceptor()) + ) + } +} diff --git a/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloRendering.kt b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloRendering.kt new file mode 100644 index 000000000..dbb7606f6 --- /dev/null +++ b/samples/hello-compose-workflow/src/main/java/com/squareup/sample/hellocomposeworkflow/HelloRendering.kt @@ -0,0 +1,17 @@ +package com.squareup.sample.hellocomposeworkflow + +import com.squareup.sample.hellocomposeworkflow.databinding.HelloGoodbyeLayoutBinding +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewFactory.Companion.fromViewBinding + +data class HelloRendering( + val message: String, + val onClick: () -> Unit +) : AndroidScreen { + override val viewFactory: ScreenViewFactory = + fromViewBinding(HelloGoodbyeLayoutBinding::inflate) { r, _ -> + helloMessage.text = r.message + helloMessage.setOnClickListener { r.onClick() } + } +} diff --git a/samples/hello-compose-workflow/src/main/res/layout/hello_goodbye_layout.xml b/samples/hello-compose-workflow/src/main/res/layout/hello_goodbye_layout.xml new file mode 100644 index 000000000..dcd6f7c0b --- /dev/null +++ b/samples/hello-compose-workflow/src/main/res/layout/hello_goodbye_layout.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/samples/hello-compose-workflow/src/main/res/values/strings.xml b/samples/hello-compose-workflow/src/main/res/values/strings.xml new file mode 100644 index 000000000..7589cc72d --- /dev/null +++ b/samples/hello-compose-workflow/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Hello Compose Workflow + diff --git a/samples/hello-compose-workflow/src/main/res/values/styles.xml b/samples/hello-compose-workflow/src/main/res/values/styles.xml new file mode 100644 index 000000000..e2331afcc --- /dev/null +++ b/samples/hello-compose-workflow/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/samples/hello-compose-workflow/src/test/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowTest.kt b/samples/hello-compose-workflow/src/test/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowTest.kt new file mode 100644 index 000000000..b5ed18bce --- /dev/null +++ b/samples/hello-compose-workflow/src/test/java/com/squareup/sample/hellocomposeworkflow/HelloComposeWorkflowTest.kt @@ -0,0 +1,52 @@ +package com.squareup.sample.hellocomposeworkflow + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.renderChild +import com.squareup.workflow1.identifier +import com.squareup.workflow1.testing.expectWorkflow +import com.squareup.workflow1.testing.testRender +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(WorkflowExperimentalApi::class) +class HelloComposeWorkflowTest { + + @Test fun foo() { + val child1 = Workflow.compose { _, _ -> "child1" } + val child2 = Workflow.compose { _, _ -> "child2" } + val workflow = Workflow.compose { _, _ -> + renderChild(child1) + renderChild(child2) + } + + workflow.testRender(Unit) + .expectWorkflow(child1.identifier, "fakechild1") + .expectWorkflow(child2.identifier, "fakechild2") + .render { rendering -> + assertEquals("fakechild1fakechild2", rendering) + } + } +} + +/** + * Returns a stateless [Workflow] via the given [render] function. + * + * Note that while the returned workflow doesn't have any _internal_ state of its own, it may use + * [props][PropsT] received from its parent, and it may render child workflows that do have + * their own internal state. + */ +@WorkflowExperimentalApi +inline fun Workflow.Companion.compose( + crossinline produceRendering: @Composable ( + props: PropsT, + emitOutput: (OutputT) -> Unit + ) -> RenderingT +): Workflow = + object : ComposeWorkflow() { + @Composable override fun produceRendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT = produceRendering(props, emitOutput) + } diff --git a/samples/tictactoe/common/build.gradle.kts b/samples/tictactoe/common/build.gradle.kts index ca3b3ad1c..9f425b060 100644 --- a/samples/tictactoe/common/build.gradle.kts +++ b/samples/tictactoe/common/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("kotlin-jvm") + alias(libs.plugins.compose.compiler) } dependencies { diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/MainState.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/MainState.kt index 01e9b0fe9..cc03fd7d6 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/MainState.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/MainState.kt @@ -1,5 +1,6 @@ package com.squareup.sample.mainworkflow +import androidx.compose.runtime.saveable.SaverScope import com.squareup.workflow1.Snapshot import com.squareup.workflow1.parse import com.squareup.workflow1.readUtf8WithLength @@ -12,9 +13,9 @@ import okio.ByteString */ sealed class MainState { - internal object Authenticating : MainState() + internal data object Authenticating : MainState() - internal object RunningGame : MainState() + internal data object RunningGame : MainState() fun toSnapshot(): Snapshot { return Snapshot.write { sink -> sink.writeUtf8WithLength(this::class.java.name) } @@ -29,4 +30,9 @@ sealed class MainState { } } } + + object Saver : androidx.compose.runtime.saveable.Saver { + override fun SaverScope.save(value: MainState) = value.toSnapshot().bytes + override fun restore(value: ByteString) = fromSnapshot(value) + } } diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt index 13a466347..f10ffa7be 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/mainworkflow/TicTacToeWorkflow.kt @@ -1,6 +1,10 @@ package com.squareup.sample.mainworkflow -import com.squareup.sample.authworkflow.AuthResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.squareup.sample.authworkflow.AuthResult.Authorized import com.squareup.sample.authworkflow.AuthResult.Canceled import com.squareup.sample.authworkflow.AuthWorkflow @@ -10,12 +14,10 @@ import com.squareup.sample.gameworkflow.GamePlayScreen import com.squareup.sample.gameworkflow.RunGameWorkflow import com.squareup.sample.mainworkflow.MainState.Authenticating import com.squareup.sample.mainworkflow.MainState.RunningGame -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowAction.Companion.noAction -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.renderChild import com.squareup.workflow1.ui.navigation.BackStackScreen import com.squareup.workflow1.ui.navigation.BodyAndOverlaysScreen import com.squareup.workflow1.ui.navigation.plus @@ -38,25 +40,29 @@ import com.squareup.workflow1.ui.navigation.plus * A [Unit] output event is emitted to signal that the workflow has ended, and the host * activity should be finished. */ +@OptIn(WorkflowExperimentalApi::class) class TicTacToeWorkflow( private val authWorkflow: AuthWorkflow, private val runGameWorkflow: RunGameWorkflow -) : StatefulWorkflow, *>>() { +) : ComposeWorkflow, *>>() { - override fun initialState( + @Composable + override fun produceRendering( props: Unit, - snapshot: Snapshot? - ): MainState = snapshot?.let { MainState.fromSnapshot(snapshot.bytes) } - ?: Authenticating - - override fun render( - renderProps: Unit, - renderState: MainState, - context: RenderContext + emitOutput: (Unit) -> Unit ): BodyAndOverlaysScreen, *> { - val bodyAndOverlays: BodyAndOverlaysScreen<*, *> = when (renderState) { + var state: MainState by rememberSaveable(stateSaver = MainState.Saver) { + mutableStateOf(Authenticating) + } + + val bodyAndOverlays: BodyAndOverlaysScreen<*, *> = when (state) { is Authenticating -> { - val authBackStack = context.renderChild(authWorkflow) { handleAuthResult(it) } + val authBackStack = renderChild(authWorkflow, onOutput = { + when (it) { + is Canceled -> emitOutput(Unit) + is Authorized -> state = RunningGame + } + }) // We always show an empty GameScreen behind the PanelOverlay that // hosts the authWorkflow's renderings because that's how the // award winning design team wanted it to look. Yes, it's a cheat @@ -68,7 +74,7 @@ class TicTacToeWorkflow( } is RunningGame -> { - val gameRendering = context.renderChild(runGameWorkflow) { startAuth } + val gameRendering = renderChild(runGameWorkflow, onOutput = { state = Authenticating }) if (gameRendering.namePrompt == null) { BodyAndOverlaysScreen(gameRendering.gameScreen, gameRendering.alerts) @@ -86,7 +92,7 @@ class TicTacToeWorkflow( // We use the "fake" uniquing name to make sure authWorkflow session from the // Authenticating state was allowed to die, so that this one will start fresh // in its logged out state. - val stubAuthBackStack = context.renderChild(authWorkflow, "fake") { noAction() } + val stubAuthBackStack = renderChild(authWorkflow, onOutput = null) val fullBackStack = stubAuthBackStack + BackStackScreen(gameRendering.namePrompt) val allModals = listOf(PanelOverlay(fullBackStack)) + gameRendering.alerts @@ -99,15 +105,4 @@ class TicTacToeWorkflow( val dim = bodyAndOverlays.overlays.any { modal -> modal is PanelOverlay<*> } return bodyAndOverlays.mapBody { body -> ScrimScreen(body, dimmed = dim) } } - - override fun snapshotState(state: MainState): Snapshot = state.toSnapshot() - - private val startAuth = action("startAuth") { state = Authenticating } - - private fun handleAuthResult(result: AuthResult) = action("handleAuthResult") { - when (result) { - is Canceled -> setOutput(Unit) - is Authorized -> state = RunningGame - } - } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 00f951b06..a73080585 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,7 @@ dependencyResolutionManagement { } include( + ":benchmarks:compose-workflow", ":benchmarks:dungeon-benchmark", ":benchmarks:performance-poetry:complex-benchmark", ":benchmarks:performance-poetry:complex-poetry", @@ -49,6 +50,7 @@ include( ":samples:dungeon:common", ":samples:dungeon:timemachine", ":samples:dungeon:timemachine-shakeable", + ":samples:hello-compose-workflow", ":samples:hello-terminal:hello-terminal-app", ":samples:hello-terminal:terminal-workflow", ":samples:hello-terminal:todo-terminal-app", @@ -75,7 +77,8 @@ include( ":workflow-ui:core-android", ":workflow-ui:internal-testing-android", ":workflow-ui:internal-testing-compose", - ":workflow-ui:radiography" + ":workflow-ui:radiography", + ":benchmarks:cw2" ) // Include the tutorial build so the IDE sees it when syncing the main project. diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index c3c841f41..2a27b1325 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 plugins { id("kotlin-multiplatform") id("published") + alias(libs.plugins.compose.compiler) } kotlin { @@ -23,6 +24,8 @@ dependencies { commonMainApi(libs.kotlinx.coroutines.core) // For Snapshot. commonMainApi(libs.squareup.okio) + commonMainApi("org.jetbrains.compose.runtime:runtime:1.7.3") + commonMainApi("org.jetbrains.compose.runtime:runtime-saveable:1.7.3") commonTestImplementation(libs.kotlinx.atomicfu) commonTestImplementation(libs.kotlinx.coroutines.test.common) diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Snapshot.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Snapshot.kt index 0aa2bbba7..642f5e0e7 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Snapshot.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/Snapshot.kt @@ -97,6 +97,10 @@ public fun BufferedSink.writeFloat(float: Float): BufferedSink = writeInt(float. public fun BufferedSource.readFloat(): Float = Float.fromBits(readInt()) +public fun BufferedSink.writeDouble(double: Double): BufferedSink = writeLong(double.toRawBits()) + +public fun BufferedSource.readDouble(): Double = Double.fromBits(readLong()) + public fun BufferedSink.writeUtf8WithLength(str: String): BufferedSink { return writeByteStringWithLength(str.encodeUtf8()) } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt new file mode 100644 index 000000000..ba6003d1b --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/ComposeWorkflow.kt @@ -0,0 +1,320 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.squareup.workflow1.IdCacheable +import com.squareup.workflow1.ImpostorWorkflow +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowIdentifier + +/** + * TODO + */ +@WorkflowExperimentalApi +public abstract class ComposeWorkflow : + Workflow, + IdCacheable { + + /** + * Use a lazy delegate so that any [ImpostorWorkflow.realIdentifier] will have been computed + * before this is initialized and cached. + */ + override var cachedIdentifier: WorkflowIdentifier? = null + + /** + * Override this method to produce the rendering value from this workflow. + * + * When a parent workflow renders this one, it must provide a [props] value that is passed to this + * method. This workflow can emit outputs to its parent by calling [emitOutput]. Calling + * [emitOutput] from most callbacks will either synchronously or asynchronously (depending on the + * workflow dispatcher) send the output to the parent, which may in turn emit its own output, etc + * bubbling all the way up to the root workflow. + * + * This method can render other workflows (of any [Workflow] type, not just other + * [ComposeWorkflow]s) by calling [renderChild] or [renderChildAsState]. + * + * For unit testability, it's recommended to factor out your logic into stateless composables and + * call them from here. This allows your tests to pass in whatever state they wish to test with. + * + * Example: + * ```kotlin + * data class Greeting( + * val message: String, + * val onClick: () -> Unit + * ) + * + * class GreetingWorkflow: ComposeWorkflow() { + * @Composable + * override fun produceRendering( + * props: String, + * emitOutput: (Unit) -> Unit + * ): Greeting { + * var welcome by remember { mutableStateOf(true) } + * // return Greeting( + * // message = if (welcome) "Hello $props" else "Goodbye $props", + * // onClick = { + * // welcome = !welcome + * // emitOutput(Unit) + * // } + * // ) + * return produceGreeting( + * name = props, + * welcome = welcome, + * onClick = { + * welcome = !welcome + * emitOutput(Unit) + * } + * ) + * } + * } + * + * @Composable + * internal fun produceGreeting( + * name: String, + * welcome: Boolean, + * onClick: () -> Unit + * ): Greeting = Greeting( + * message = if (welcome) "Hello $props" else "Goodbye $props", + * onClick = onClick + * ) + * ``` + * + * @param emitOutput Function to emit output to the parent workflow. This will be the same + * instance for the entire lifetime of this composable, so it's safe to capture in state that + * lives beyond a single recomposition. + */ + @WorkflowComposable + @Composable + public abstract fun produceRendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT + + final override fun asStatefulWorkflow(): StatefulWorkflow { + throw UnsupportedOperationException( + "This version of the Compose runtime does not support ComposeWorkflow. " + + "Please upgrade your workflow-runtime." + ) + } + + /** Helper to expose [produceRendering] to code outside this class. Do not call directly! */ + @Composable + internal inline fun invokeProduceRendering( + props: PropsT, + noinline emitOutput: (OutputT) -> Unit + ): RenderingT = produceRendering(props, emitOutput) +} + +/** + * Renders a child [Workflow] with the given [props] and returns its rendering. + * + * Example: + * ```kotlin + * data class CounterRendering(val count: Int) + * + * @Composable + * override fun produceRendering( + * props: Unit, + * emitOutput: (Nothing) -> Unit + * ): CounterRendering { + * val count = renderChild(counterWorkflow) + * return CounterRendering(count) + * } + * ``` + * + * ## Output handling + * + * When the child emits an output, the [onOutput] callback will be invoked. [onOutput] may change + * snapshot state and/or emit output to this workflow's parent by calling the `emitOutput` function + * passed to its [produceRendering][ComposeWorkflow.produceRendering] method. When [onOutput] calls + * `emitOutput` once, the output will be propagated to the parent synchronously. It is not + * recommended to call `emitOutput` more than once from the same [onOutput] call, but it is allowed: + * subsequent calls will queue up the outputs to be handled asynchronously. + * + * ## Recomposition + * + * This function will always render the child synchronously and return the rendering produced. It is + * appropriate to call if your [produceRendering][ComposeWorkflow.produceRendering] method needs to + * access the child's rendering directly in composition, e.g. to place (parts of) it in its own + * rendering or make decisions about what other rendering code to run. + * + * However, this means that the composable that calls this function will recompose any time the + * child needs to be re-rendered, even if only for internal state changes that end up returning the + * same rendering value (i.e. this function is not independently "restartable"). It also means that + * any time the composable that calls this function recomposes, this function will be called again + * (i.e. it is not "skippable"). For these reasons, if you do not need to access the child's + * rendering in composition, it's better to use [renderChildAsState], which returns a [State] of the + * rendering and is both skippable and restartable. + * + * @param workflow The child [Workflow] to render. This can be any [Workflow] type, it does not need + * to be a [ComposeWorkflow]. + */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +public fun renderChild( + workflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)? +): RenderingT { + val renderer = LocalWorkflowComposableRenderer.current + return renderer.renderChild(workflow, props, onOutput) +} + +/** @see renderChild */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, + noinline onOutput: ((OutputT) -> Unit)? +): RenderingT = renderChild(workflow, props = Unit, onOutput) + +/** @see renderChild */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, + props: PropsT, +): RenderingT = renderChild(workflow, props, onOutput = null) + +/** @see renderChild */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChild( + workflow: Workflow, +): RenderingT = renderChild(workflow, props = Unit, onOutput = null) + +/** + * Renders a child [Workflow] with the given [props] and returns a [State] that holds the rendering. + * + * The same [State] instance will be returned for every recomposition of this function, even if the + * lifecycle of the [workflow] itself restarts. Thus, it is safe to capture the returned [State] + * object and reference it for as long as this function is in the composition. + * + * Example: + * ```kotlin + * data class CounterRendering(val getCount: () -> Int) + * + * @Composable + * override fun produceRendering( + * props: Unit, + * emitOutput: (Nothing) -> Unit + * ): CounterRendering { + * val count by renderChildAsState(counterWorkflow) + * return CounterRendering(getCount = { count }) + * } + * ``` + * + * ## Output handling + * + * When the child emits an output, the [onOutput] callback will be invoked. [onOutput] may change + * snapshot state and/or emit output to this workflow's parent by calling the `emitOutput` function + * passed to its [produceRendering][ComposeWorkflow.produceRendering] method. When [onOutput] calls + * `emitOutput` once, the output will be propagated to the parent synchronously. It is not + * recommended to call `emitOutput` more than once from the same [onOutput] call, but it is allowed: + * subsequent calls will queue up the outputs to be handled asynchronously. + * + * ## Recomposition + * + * This function will initially render the child synchronously and return the rendering produced. It + * is appropriate to call if your [produceRendering][ComposeWorkflow.produceRendering] method does + * not need to access the child's rendering directly in composition, e.g. it only reads the + * rendering value inside an effect, or from a Composable function returned in your own rendering. + * + * This function makes the child both restartable and skippable: + * + * - If [workflow] needs to be rerendered but the calling composable does not, the child will + * rerender without recomposing the caller. + * - If the calling composable recomposes but passes the same arguments to this function, then the + * child will not rerender and its previous rendering will be returned. + * + * If you need to access the rendering value directly in composition, it's more efficient to call + * [renderChild] instead. + * + * @param workflow The child [Workflow] to render. This can be any [Workflow] type, it does not need + * to be a [ComposeWorkflow]. + */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +public fun renderChildAsState( + workflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)? +): State { + val renderingHolder: MutableState = remember { mutableStateOf(null) } + + ChildWorkflowRecomposeIsolator(workflow, props, onOutput, renderingHolder) + + // This cast is safe since above will always set the state to a value of RenderingT on the initial + // composition. + @Suppress("UNCHECKED_CAST") + return renderingHolder as State +} + +/** @see renderChildAsState */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChildAsState( + workflow: Workflow, + noinline onOutput: ((OutputT) -> Unit)? +): State = renderChildAsState(workflow, props = Unit, onOutput) + +/** @see renderChildAsState */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChildAsState( + workflow: Workflow, + props: PropsT, +): State = renderChildAsState(workflow, props, onOutput = null) + +/** @see renderChildAsState */ +@WorkflowExperimentalApi +@WorkflowComposable +@Composable +inline fun renderChildAsState( + workflow: Workflow, +): State = renderChildAsState(workflow, props = Unit, onOutput = null) + +/** + * This is a function that only exists to create an isolated restartable, skippable recompose scope + * from its caller for rendering [workflow] from [renderChildAsState]. + * + * In order to do this, it MUST return Unit and not be inline — never change this! + */ +@OptIn(WorkflowExperimentalApi::class) +@Composable +private fun ChildWorkflowRecomposeIsolator( + workflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)?, + renderingHolder: MutableState +) { + renderingHolder.value = renderChild(workflow, props, onOutput) +} + +@WorkflowExperimentalApi +public inline fun Workflow.Companion.composable( + crossinline produceRendering: @Composable ( + props: PropsT, + emitOutput: (OutputT) -> Unit + ) -> RenderingT +): Workflow = object : ComposeWorkflow() { + @Composable + override fun produceRendering( + props: PropsT, + emitOutput: (OutputT) -> Unit + ): RenderingT = produceRendering(props, emitOutput) +} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt new file mode 100644 index 000000000..77aef796e --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposable.kt @@ -0,0 +1,25 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.ComposableTargetMarker +import com.squareup.workflow1.WorkflowExperimentalApi +import kotlin.annotation.AnnotationRetention.BINARY +import kotlin.annotation.AnnotationTarget.FILE +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER +import kotlin.annotation.AnnotationTarget.TYPE +import kotlin.annotation.AnnotationTarget.TYPE_PARAMETER + +/** + * An annotation that can be used to mark a composable function as being expected to be use in a + * composable function that is also marked or inferred to be marked as a [WorkflowComposable], i.e. + * that can be called from [BaseRenderContext.renderComposable]. + * + * Using this annotation explicitly is rarely necessary as the Compose compiler plugin will infer + * the necessary equivalent annotations automatically. See + * [androidx.compose.runtime.ComposableTarget] for details. + */ +@WorkflowExperimentalApi +@ComposableTargetMarker(description = "Workflow Composable") +@Target(FILE, FUNCTION, PROPERTY_GETTER, TYPE, TYPE_PARAMETER) +@Retention(BINARY) +public annotation class WorkflowComposable diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposableRenderer.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposableRenderer.kt new file mode 100644 index 000000000..9d82606b5 --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/WorkflowComposableRenderer.kt @@ -0,0 +1,23 @@ +package com.squareup.workflow1.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi + +// TODO mark these with a separate InternalWorkflow annotation + +@WorkflowExperimentalApi +public val LocalWorkflowComposableRenderer = + staticCompositionLocalOf { error("No renderer") } + +@WorkflowExperimentalApi +public interface WorkflowComposableRenderer { + + @Composable + fun renderChild( + childWorkflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)? + ): RenderingT +} diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/internal/ComposeWorkflowRenderHelper.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/internal/ComposeWorkflowRenderHelper.kt new file mode 100644 index 000000000..ae7c8ea2e --- /dev/null +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/compose/internal/ComposeWorkflowRenderHelper.kt @@ -0,0 +1,26 @@ +package com.squareup.workflow1.compose.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.NonSkippableComposable +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.renderChild + +/** + * Exposes [ComposeWorkflow.produceRendering] to code outside this module. + * + * DO NOT CALL directly, call [renderChild] instead! + * + * @suppress + */ +// @InternalWorkflowApi +@OptIn(WorkflowExperimentalApi::class) +@NonRestartableComposable +@NonSkippableComposable +@Composable +fun _DO_NOT_USE_invokeComposeWorkflowProduceRendering( + workflow: ComposeWorkflow, + props: PropsT, + emitOutput: (OutputT) -> Unit +): RenderingT = workflow.invokeProduceRendering(props, emitOutput) diff --git a/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/BundleSaveableStateRegistry.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/BundleSaveableStateRegistry.kt new file mode 100644 index 000000000..ca3ffd67a --- /dev/null +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/BundleSaveableStateRegistry.kt @@ -0,0 +1,110 @@ +package com.squareup.workflow1.android + +import android.os.Binder +import android.os.Bundle +import android.os.Parcelable +import android.util.Size +import android.util.SizeF +import android.util.SparseArray +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.referentialEqualityPolicy +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.snapshots.SnapshotMutableState +import androidx.compose.runtime.structuralEqualityPolicy +import com.squareup.workflow1.Snapshot +import java.io.Serializable + +/** + * A [SaveableStateRegistry] that can save and restore anything that can be saved in a [Bundle]. + * + * Similar to Compose Android's `DisposableSaveableStateRegistry`. + */ +internal class BundleSaveableStateRegistry private constructor( + saveableStateRegistry: SaveableStateRegistry +) : SaveableStateRegistry by saveableStateRegistry { + constructor(restoredValues: Map>?) : this( + SaveableStateRegistry(restoredValues, ::canBeSavedToBundle) + ) + + // TODO move the functions from SnapshotParcels.kt into runtime-android. + // constructor(snapshot: Snapshot) : this(snapshot.toParcelable().toMap()) + // + // fun toSnapshot(): Snapshot = performSave().toBundle().toSnapshot() +} + +/** + * Checks that [value] can be stored inside [Bundle]. + */ +private fun canBeSavedToBundle(value: Any): Boolean { + // SnapshotMutableStateImpl is Parcelable, but we do extra checks + if (value is SnapshotMutableState<*>) { + if (value.policy === neverEqualPolicy() || + value.policy === structuralEqualityPolicy() || + value.policy === referentialEqualityPolicy() + ) { + val stateValue = value.value + return if (stateValue == null) true else canBeSavedToBundle(stateValue) + } else { + return false + } + } + // lambdas in Kotlin implement Serializable, but will crash if you really try to save them. + // we check for both Function and Serializable (see kotlin.jvm.internal.Lambda) to support + // custom user defined classes implementing Function interface. + if (value is Function<*> && value is Serializable) { + return false + } + for (cl in AcceptableClasses) { + if (cl.isInstance(value)) { + return true + } + } + return false +} + +/** + * Contains Classes which can be stored inside [Bundle]. + * + * Some of the classes are not added separately because: + * + * - These classes implement Serializable: + * - Arrays (DoubleArray, BooleanArray, IntArray, LongArray, ByteArray, FloatArray, ShortArray, + * CharArray, Array, Array) + * - ArrayList + * - Primitives (Boolean, Int, Long, Double, Float, Byte, Short, Char) will be boxed when casted + * to Any, and all the boxed classes implements Serializable. + * - This class implements Parcelable: + * - Bundle + * + * Note: it is simplified copy of the array from SavedStateHandle (lifecycle-viewmodel-savedstate). + */ +private val AcceptableClasses = arrayOf( + Serializable::class.java, + Parcelable::class.java, + String::class.java, + SparseArray::class.java, + Binder::class.java, + Size::class.java, + SizeF::class.java +) + +@Suppress("DEPRECATION") +private fun Bundle.toMap(): Map>? { + val map = mutableMapOf>() + this.keySet().forEach { key -> + @Suppress("UNCHECKED_CAST") + val list = getParcelableArrayList(key) as ArrayList + map[key] = list + } + return map +} + +private fun Map>.toBundle(): Bundle { + val bundle = Bundle() + forEach { (key, list) -> + val arrayList = if (list is ArrayList) list else ArrayList(list) + @Suppress("UNCHECKED_CAST") + bundle.putParcelableArrayList(key, arrayList as ArrayList) + } + return bundle +} diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index 10993c7ab..a3bb3b7d9 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("kotlin-multiplatform") id("published") id("app.cash.burst") + alias(libs.plugins.compose.compiler) } kotlin { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt index 58bb7af70..716df7aff 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/SimpleLoggingWorkflowInterceptor.kt @@ -1,7 +1,11 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.compose.LocalWorkflowComposableRenderer +import com.squareup.workflow1.compose.WorkflowComposableRenderer +import com.squareup.workflow1.internal.compose.withCompositionLocals import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -49,6 +53,48 @@ public open class SimpleLoggingWorkflowInterceptor : WorkflowInterceptor { proceed(renderProps, renderState, SimpleLoggingContextInterceptor(session)) } + @OptIn(WorkflowExperimentalApi::class) + @Composable + override fun onRenderComposeWorkflow( + renderProps: P, + emitOutput: (O) -> Unit, + proceed: @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R = logMethod("onRenderComposeWorkflow", session, "renderProps" to renderProps) { + val childRenderer = LocalWorkflowComposableRenderer.current + val loggingRenderer = androidx.compose.runtime.remember(childRenderer) { + SimpleLoggingWorkflowComposableRenderer(session, childRenderer) + } + withCompositionLocals(LocalWorkflowComposableRenderer provides loggingRenderer) { + proceed(renderProps, /* emitOutput= */{ output -> + logMethod("onEmitOutput", session, "output" to output) { + emitOutput(output) + } + }) + } + } + + @OptIn(WorkflowExperimentalApi::class) + private inner class SimpleLoggingWorkflowComposableRenderer( + val session: WorkflowSession, + val childRenderer: WorkflowComposableRenderer + ) : WorkflowComposableRenderer { + @Composable + override fun renderChild( + childWorkflow: Workflow, + props: PropsT, + onOutput: ((OutputT) -> Unit)? + ): RenderingT { + return logMethod("onRenderChild", session, "workflow" to childWorkflow, "props" to props) { + childRenderer.renderChild(childWorkflow, props, onOutput = { output -> + logMethod("onOutput", session, "output" to output) { + onOutput?.invoke(output) + } + }) + } + } + } + override fun onSnapshotState( state: S, proceed: (S) -> Snapshot?, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 903599871..4f65447e6 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -1,8 +1,10 @@ package com.squareup.workflow1 +import androidx.compose.runtime.Composable import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.compose.WorkflowComposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext @@ -121,6 +123,16 @@ public interface WorkflowInterceptor { session: WorkflowSession ): R = proceed(renderProps, renderState, null) + @WorkflowExperimentalApi + @WorkflowComposable + @Composable + public fun onRenderComposeWorkflow( + renderProps: P, + emitOutput: (O) -> Unit, + proceed: @WorkflowComposable @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R = proceed(renderProps, emitOutput) + /** * Intercept calls to [StatefulWorkflow.snapshotState] including the children calls. * This is useful to intercept a rendering + snapshot traversal for tracing purposes. @@ -403,8 +415,9 @@ internal fun WorkflowInterceptor.intercept( if (cachedInterceptedRenderContext == null || canonicalRenderContext !== context || canonicalRenderContextInterceptor != interceptor ) { - val interceptedRenderContext = interceptor?.let { InterceptedRenderContext(context, it) } - ?: context + val interceptedRenderContext = + interceptor?.let { InterceptedRenderContext(context, it) } + ?: context cachedInterceptedRenderContext = RenderContext(interceptedRenderContext, this) } canonicalRenderContext = context diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt index f5c405227..1f4167b0a 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.internal +import androidx.compose.runtime.Composable import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RenderingAndSnapshot @@ -7,6 +8,7 @@ import com.squareup.workflow1.Snapshot import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate @@ -99,6 +101,56 @@ internal class ChainedWorkflowInterceptor( return chainedProceed(renderProps, renderState, null) } + @OptIn(WorkflowExperimentalApi::class) + @Composable + override fun onRenderComposeWorkflow( + renderProps: P, + emitOutput: (O) -> Unit, + proceed: @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R = onRenderComposeWorkflowStep( + index = 0, + stepProps = renderProps, + stepEmitOutput = emitOutput, + stepProceed = proceed, + session = session + ) + + /** + * Recursive function for nesting chained interceptors' [onRenderComposeWorkflow] calls. We use + * recursion for the compose call since it avoids creating a new list in the composition on every + * render call. + */ + @OptIn(WorkflowExperimentalApi::class) + @Composable + private fun onRenderComposeWorkflowStep( + index: Int, + stepProps: P, + stepEmitOutput: (O) -> Unit, + stepProceed: @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R { + if (index >= interceptors.size) { + return stepProceed(stepProps, stepEmitOutput) + } + + val interceptor = interceptors[index] + return interceptor.onRenderComposeWorkflow( + renderProps = stepProps, + emitOutput = stepEmitOutput, + proceed = { innerProps, innerEmitOutput -> + onRenderComposeWorkflowStep( + index = index + 1, + stepProps = innerProps, + stepEmitOutput = innerEmitOutput, + stepProceed = stepProceed, + session = session + ) + }, + session = session + ) + } + override fun onSnapshotStateWithChildren( proceed: () -> TreeSnapshot, session: WorkflowSession diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt new file mode 100644 index 000000000..ce32c8966 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Channels.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.internal + +import kotlinx.coroutines.channels.SendChannel + +/** + * Tries to send [element] to this channel and throws an [IllegalStateException] if the channel is + * full or closed. + */ +internal fun SendChannel.requireSend(element: T) { + val result = trySend(element) + if (result.isClosed) { + throw IllegalStateException( + "Tried emitting output to workflow whose output channel was closed.", + result.exceptionOrNull() + ) + } + if (result.isFailure) { + error("Tried emitting output to workflow whose output channel was full.") + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index 125349287..56d45ecb2 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -8,9 +8,12 @@ import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.internal.compose.ComposeWorkflowNodeAdapter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -19,6 +22,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext +@OptIn(WorkflowExperimentalApi::class) internal fun createWorkflowNode( id: WorkflowNodeId, workflow: Workflow, @@ -32,19 +36,34 @@ internal fun createWorkflowNode( parent: WorkflowSession? = null, interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, idCounter: IdCounter? = null -): WorkflowNode = StatefulWorkflowNode( - id = id, - workflow = workflow.asStatefulWorkflow(), - initialProps = initialProps, - snapshot = snapshot, - baseContext = baseContext, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - emitAppliedActionToParent = emitAppliedActionToParent, - parent = parent, - interceptor = interceptor, - idCounter = idCounter, -) +): WorkflowNode = when (workflow) { + is ComposeWorkflow<*, *, *> -> ComposeWorkflowNodeAdapter( + id = id, + initialProps = initialProps, + snapshot = snapshot, + baseContext = baseContext, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + emitAppliedActionToParent = emitAppliedActionToParent, + parent = parent, + interceptor = interceptor, + idCounter = idCounter, + ) + + else -> StatefulWorkflowNode( + id = id, + workflow = workflow.asStatefulWorkflow(), + initialProps = initialProps, + snapshot = snapshot, + baseContext = baseContext, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + emitAppliedActionToParent = emitAppliedActionToParent, + parent = parent, + interceptor = interceptor, + idCounter = idCounter, + ) +} internal abstract class WorkflowNode( val id: WorkflowNodeId, diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 7761aacb3..77985e7f3 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -29,7 +29,7 @@ internal class WorkflowRunner( private val runtimeConfig: RuntimeConfig, private val workflowTracer: WorkflowTracer? ) { - private val workflow = protoWorkflow.asStatefulWorkflow() + private val workflow = protoWorkflow private val idCounter = IdCounter() private var currentProps: PropsT = props.value diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeChildNode.kt new file mode 100644 index 000000000..889e6b9da --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeChildNode.kt @@ -0,0 +1,52 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentCompositeKeyHash +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowIdentifier +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.internal.WorkflowNode +import com.squareup.workflow1.internal.WorkflowNodeId +import kotlinx.coroutines.selects.SelectBuilder + +/** + * Represents a workflow inside a [ComposeWorkflowNodeAdapter], either another + * [ComposeWorkflow]/[ComposeWorkflowChildNode], or a + * [Workflow]/[TraditionalWorkflowAdapterChildNode]. + */ +internal interface ComposeChildNode { + + /** See [WorkflowNode.id]. */ + val id: WorkflowNodeId + + /** + * Called during workflow render passes to produce the rendering for this workflow. + * + * The compose analog to [WorkflowNode.render]. + */ + @Composable fun produceRendering( + workflow: Workflow, + props: PropsT + ): RenderingT + + /** See [WorkflowNode.snapshot]. */ + fun snapshot(): TreeSnapshot + + /** See [WorkflowNode.registerTreeActionSelectors]. */ + fun registerTreeActionSelectors(selector: SelectBuilder) + + /** See [WorkflowNode.applyNextAvailableTreeAction]. */ + fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean = false): ActionProcessingResult +} + +/** + * Returns a stable key identifying a call to [ComposeWorkflowChildNode.renderChild] in the + * composition, suitable for use with [WorkflowIdentifier]. + */ +@OptIn(ExperimentalStdlibApi::class) +@Composable +internal fun rememberChildRenderKey(): String { + return currentCompositeKeyHash.toHexString(HexFormat.Default) +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowChildNode.kt new file mode 100644 index 000000000..f14c30152 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowChildNode.kt @@ -0,0 +1,424 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collection.MutableVector +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted +import com.squareup.workflow1.NoopWorkflowInterceptor +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowIdentifier +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.LocalWorkflowComposableRenderer +import com.squareup.workflow1.compose.WorkflowComposableRenderer +import com.squareup.workflow1.compose.internal._DO_NOT_USE_invokeComposeWorkflowProduceRendering +import com.squareup.workflow1.identifier +import com.squareup.workflow1.internal.IdCounter +import com.squareup.workflow1.internal.WorkflowNodeId +import com.squareup.workflow1.internal.createId +import com.squareup.workflow1.internal.requireSend +import com.squareup.workflow1.workflowSessionToString +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.selects.SelectBuilder +import kotlin.coroutines.CoroutineContext + +private const val OUTPUT_QUEUE_LIMIT = 1_000 + +/** + * Representation and implementation of a single [ComposeWorkflow] inside a + * [ComposeWorkflowNodeAdapter]. + */ +@OptIn(WorkflowExperimentalApi::class) +internal class ComposeWorkflowChildNode( + override val id: WorkflowNodeId, + initialProps: PropsT, + snapshot: TreeSnapshot?, + baseContext: CoroutineContext, + override val parent: WorkflowSession?, + override val workflowTracer: WorkflowTracer?, + override val runtimeConfig: RuntimeConfig, + private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, + private val idCounter: IdCounter? = null, + private val emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult, +) : + ComposeChildNode, + WorkflowSession, + WorkflowComposableRenderer, + CoroutineScope { + + // We don't need to create our own job because unlike for WorkflowNode, the baseContext already + // has a dedicated job: either from the adapter (for root compose workflow), or from + // rememberCoroutineScope(). + override val coroutineContext: CoroutineContext = baseContext + + CoroutineName(id.toString()) + + // WorkflowSession properties + override val identifier: WorkflowIdentifier get() = id.identifier + override val renderKey: String get() = id.name + override val sessionId: Long = idCounter.createId() + + /** This does not need to be a snapshot state object, it's only set again by [snapshot]. */ + private var snapshotCache = snapshot?.childTreeSnapshots + private val saveableStateRegistry: SaveableStateRegistry + + // Don't allocate childNodes list until a child is rendered, leaf node optimization. + private var childNodes: MutableVector>? = null + + private val outputsChannel = Channel(capacity = OUTPUT_QUEUE_LIMIT) + + // TODO this should be a ThreadLocal in case emitOutput is called from a different thread during + // an action cascade. + private var onEmitOutputOverride: ((OutputT) -> Unit)? = null + private val onEmitOutput: (OutputT) -> Unit = { output -> + val override = onEmitOutputOverride + if (override != null) { + override(output) + } else { + sendOutputToChannel(output) + } + } + + private var lastProps by mutableStateOf(initialProps) + + /** + * Function invoked when [onNextAction] receives an output from [outputsChannel]. + */ + private val processOutputFromChannel: (OutputT) -> ActionProcessingResult = { output -> + log("got output from channel: $output") + + val applied = ActionApplied( + output = WorkflowOutput(output), + // We can't know if any state read by this workflow composable specifically changed, but the + // ComposeWorkflowNodeAdapter hosting the composition will modify this as necessary based on + // whether _any_ state in the composition changed. + stateChanged = false + ) + + // Invoke the parent's handler to propagate the output up the workflow tree. + log("sending output to parent: $applied") + emitAppliedActionToParent(applied).also { + log("finished sending output to parent, result was: $it") + } + } + + init { + interceptor.onSessionStarted(workflowScope = this, session = this) + + val workflowSnapshot = snapshot?.workflowSnapshot + var restoredRegistry: SaveableStateRegistry? = null + // Don't care if the interceptor returns a state value, our state is stored in the composition. + interceptor.onInitialState( + props = initialProps, + snapshot = workflowSnapshot, + workflowScope = this, + session = this, + proceed = { _, innerSnapshot, _ -> + restoredRegistry = restoreSaveableStateRegistryFromSnapshot(innerSnapshot) + ComposeWorkflowState + } + ) + // Can't assign directly in proceed because the compiler can't guarantee it's ran during the + // initialization. + saveableStateRegistry = restoredRegistry ?: restoreSaveableStateRegistryFromSnapshot(null) + } + + override fun toString(): String = workflowSessionToString() + + @Composable + override fun produceRendering( + workflow: Workflow, + props: PropsT + ): RenderingT { + // No need to key anything on `this`, since either this is at the root of the composition, or + // inside a renderChild call and renderChild does the keying. + log("rendering workflow: props=$props") + workflow as ComposeWorkflow + + notifyInterceptorWhenPropsChanged(props) + + return withCompositionLocals( + LocalSaveableStateRegistry provides saveableStateRegistry, + LocalWorkflowComposableRenderer provides this + ) { + workflow.produceRendering(props, onEmitOutput) + // interceptor.onRenderComposeWorkflow( + // renderProps = props, + // emitOutput = onEmitOutput, + // session = this, + // proceed = { innerProps, innerEmitOutput -> + // _DO_NOT_USE_invokeComposeWorkflowProduceRendering(workflow, innerProps, innerEmitOutput) + // } + // ) + } + } + + @ReadOnlyComposable + @NonRestartableComposable + @Composable + private fun notifyInterceptorWhenPropsChanged(newProps: PropsT) { + // Don't both asking the composition to track reads of lastProps since this is the only function + // that will every write to it. + Snapshot.withoutReadObservation { + if (lastProps != newProps) { + interceptor.onPropsChanged( + old = lastProps, + new = newProps, + state = ComposeWorkflowState, + session = this, + proceed = { _, _, _ -> ComposeWorkflowState }, + ) + lastProps = newProps + } + } + } + + @Composable + override fun renderChild( + childWorkflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT { + // All child state should be preserved across renders if the Workflow instance changes but has + // the same identifier. + val childIdentifier = childWorkflow.identifier + return key(childIdentifier) { + val childNode = rememberComposeChildNode( + childWorkflow = childWorkflow, + childIdentifier = childIdentifier, + initialProps = props, + onOutput = onOutput + ) + + // Track child nodes for snapshotting. + // NOTE: While the effect will run after composition, it will run as part of the compose + // frame, so the child will be registered before ComposeWorkflowNodeAdapter's render method + // returns. + DisposableEffect(Unit) { + addChildNode(childNode) + onDispose { + removeChildNode(childNode) + } + } + + return@key childNode.produceRendering(childWorkflow, props) + } + } + + override fun snapshot(): TreeSnapshot { + // Get rid of any snapshots that weren't applied on the first render pass. + // They belong to children that were saved but not restarted. + snapshotCache = null + + return interceptor.onSnapshotStateWithChildren( + session = this, + proceed = { + val workflowSnapshot = interceptor.onSnapshotState( + state = ComposeWorkflowState, + session = this, + proceed = { + saveSaveableStateRegistryToSnapshot(saveableStateRegistry) + } + ) + + TreeSnapshot( + workflowSnapshot = workflowSnapshot, + childTreeSnapshots = ::createChildSnapshots + ) + } + ) + } + + override fun registerTreeActionSelectors(selector: SelectBuilder) { + childNodes?.forEach { child -> + child.registerTreeActionSelectors(selector) + } + + with(selector) { + outputsChannel.onReceive(processOutputFromChannel) + } + } + + override fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean): ActionProcessingResult { + // First let any children with pending actions process them. + childNodes?.forEach { child -> + val result = child.applyNextAvailableTreeAction(skipDirtyNodes) + if (result != ActionsExhausted) { + return result + } + } + + // If none of our children had any actions to process, then we can process any outputs of our + // own. + val pendingOutput = outputsChannel.tryReceive().getOrNull() + if (pendingOutput != null) { + return processOutputFromChannel(pendingOutput) + } + + return ActionsExhausted + } + + @Composable + private fun rememberComposeChildNode( + childWorkflow: Workflow, + childIdentifier: WorkflowIdentifier, + initialProps: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ComposeChildNode { + val childRenderKey = rememberChildRenderKey() + val childId = WorkflowNodeId(childIdentifier, name = childRenderKey) + val childSnapshot = snapshotCache?.get(childId) + val childCoroutineScope = rememberCoroutineScope() + val updatedOnOutput by rememberUpdatedState(onOutput) + + // Don't need to key the remember on workflow since we're already keyed on its identifier, which + // also implies the workflow's type. + return if (childWorkflow is ComposeWorkflow) { + remember { + ComposeWorkflowChildNode( + id = childId, + initialProps = initialProps, + snapshot = childSnapshot, + baseContext = childCoroutineScope.coroutineContext, + parent = this, + workflowTracer = workflowTracer, + runtimeConfig = runtimeConfig, + interceptor = interceptor, + idCounter = idCounter, + emitAppliedActionToParent = { actionApplied -> + handleChildOutput(actionApplied, updatedOnOutput) + } + ) + } + } else { + // We need to be able to explicitly request recomposition of this composable when an action + // cascade results in this child changing state because traditional workflows don't know + // about Compose. See comment in acceptChildActionResult for more info. + val recomposeScope = currentRecomposeScope + remember { + TraditionalWorkflowAdapterChildNode( + id = childId, + workflow = childWorkflow, + initialProps = initialProps, + contextForChildren = childCoroutineScope.coroutineContext, + parent = this, + snapshot = childSnapshot, + workflowTracer = workflowTracer, + runtimeConfig = runtimeConfig, + interceptor = interceptor, + idCounter = idCounter, + acceptChildActionResult = { actionApplied -> + // If this child needs to be re-rendered on the next render pass and there are no other + // state changes in the compose runtime during this action cascade, if we don't + // explicitly invalidate the recompose scope then the recomposer will think it doesn't + // have anything to do and not recompose us, which means we wouldn't have a chance to + // re-render the traditional workflow. + if (actionApplied.stateChanged) { + recomposeScope.invalidate() + } + handleChildOutput(actionApplied, updatedOnOutput) + } + ) + } + } + } + + private fun addChildNode(childNode: ComposeChildNode<*, *, *>) { + (childNodes ?: MutableVector>().also { childNodes = it }) += childNode + } + + private fun removeChildNode(childNode: ComposeChildNode<*, *, *>) { + val childNodes = childNodes + ?: throw AssertionError("removeChildNode called before addChildNode") + childNodes.remove(childNode) + } + + private fun createChildSnapshots(): Map = buildMap { + childNodes?.forEach { child -> + put(child.id, child.snapshot()) + } + } + + /** + * This is the lambda passed to every invocation of the [ComposeWorkflow.produceRendering] method. + * It merely enqueues the output in the channel. The actual processing happens when the receiver + * registered by [onNextAction] calls [processOutputFromChannel]. + */ + private fun sendOutputToChannel(output: OutputT) { + // TODO defer this work until some time very soon in the future, but after the immediate caller + // has returned. E.g. launch a coroutine, but make sure it runs before the next frame (compose + // or choreographer). This will ensure that if the caller sets state _after_ calling this + // method the state changes are consumed by the resulting recomposition. + + // If dispatcher is Main.immediate this will synchronously perform re-render. + println("sending output to channel: $output") + outputsChannel.requireSend(output) + } + + private fun handleChildOutput( + appliedActionFromChild: ActionApplied, + onOutput: ((ChildOutputT) -> Unit)? + ): ActionProcessingResult { + log("handling child output: $appliedActionFromChild") + val outputFromChild = appliedActionFromChild.output + // The child emitted an output, so we need to call our handler, which will zero or + // more of two things: (1) change our state, (2) emit an output. + + // If this workflow calls emitOutput while running child output handler, we don't want + // to send it to the channel, but rather capture it and propagate to our parent + // directly. But only for the first call to emitOutput – subsequent calls will need to + // be handled as usual. + var maybeParentResult: ActionProcessingResult? = null + + if (outputFromChild != null && onOutput != null) { + onEmitOutputOverride = { output -> + // We can't know if our own state changed, so just propagate from the child. + val applied = appliedActionFromChild.withOutput(WorkflowOutput(output)) + log("handler emitted output, propagating to parent…") + maybeParentResult = emitAppliedActionToParent(applied) + + // Immediately allow any future emissions in the same onOutput call to pass through. + onEmitOutputOverride = null + } + // Ask this workflow to handle the child's output. It may write snapshot state or call + // emitOutput. + onOutput(outputFromChild.value) + onEmitOutputOverride = null + } + + if (maybeParentResult == null) { + // onOutput did not call emitOutput, but we need to propagate the action cascade anyway to + // check if state changed. + log("handler did not emitOutput, propagating to parent anyway…") + maybeParentResult = emitAppliedActionToParent(appliedActionFromChild.withOutput(null)) + } + + // If maybeParentResult is not null then onOutput called emitOutput. + return maybeParentResult ?: appliedActionFromChild.withOutput(null) + } + + private fun ActionApplied<*>.withOutput(output: WorkflowOutput?) = + ActionApplied(output = output, stateChanged = stateChanged) +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowNodeAdapter.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowNodeAdapter.kt new file mode 100644 index 000000000..7b42b4892 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowNodeAdapter.kt @@ -0,0 +1,196 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted +import com.squareup.workflow1.NoopWorkflowInterceptor +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.internal.IdCounter +import com.squareup.workflow1.internal.WorkflowNode +import com.squareup.workflow1.internal.WorkflowNodeId +import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.selects.SelectBuilder +import kotlin.coroutines.CoroutineContext + +internal fun log(message: String) = message.lines().forEach { + // println("WorkflowComposableNode $it") +} + +/** + * Entry point into the Compose runtime from the Workflow runtime. + * + * This node hosts a compose runtime and synchronizes its recompositions to workflow render passes. + * Most of the workflow work (interception, action propagation) is delegated to a + * [ComposeWorkflowChildNode]. [ComposeWorkflow]s nested directly under this one do not have their + * own composition, they share this one. + */ +@OptIn(WorkflowExperimentalApi::class) +internal class ComposeWorkflowNodeAdapter( + id: WorkflowNodeId, + initialProps: PropsT, + snapshot: TreeSnapshot?, + baseContext: CoroutineContext, + // Providing default value so we don't need to specify in test. + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + workflowTracer: WorkflowTracer? = null, + emitAppliedActionToParent: (ActionApplied) -> ActionProcessingResult = { it }, + parent: WorkflowSession? = null, + interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, + idCounter: IdCounter? = null + // TODO AbstractWorkflowNode should not implement WorkflowSession, since only StatefulWorkflowNode + // needs that. The composable session is implemented by ComposeWorkflowChildNode. +) : WorkflowNode( + id = id, + baseContext = baseContext, + interceptor = interceptor, + emitAppliedActionToParent = emitAppliedActionToParent, +) { + + private val recomposeChannel = Channel(capacity = 1) + private val molecule = scope.launchSynchronizedMolecule( + onNeedsRecomposition = { recomposeChannel.trySend(Unit) } + ) + + private val childNode = ComposeWorkflowChildNode( + id = id, + initialProps = initialProps, + snapshot = snapshot, + baseContext = scope.coroutineContext, + parent = parent, + workflowTracer = workflowTracer, + runtimeConfig = runtimeConfig, + interceptor = interceptor, + idCounter = idCounter, + emitAppliedActionToParent = { actionApplied -> + // Ensure any state updates performed by the output sender gets to invalidate any + // compositions that read them, so we can check needsRecompose below. + Snapshot.sendApplyNotifications() + log( + "adapter node sent apply notifications from action cascade (" + + "actionApplied=$actionApplied, needsRecompose=${molecule.needsRecomposition})" + ) + + // ComposeWorkflowChildNode can't tell if its own state changed since that information about + // specific composables/recompose scopes is only visible inside the compose runtime, so + // individual ComposeWorkflow nodes always report no state changes (unless they have a + // traditional child that reported a state change). + // However, we *can* check if any state changed that was read by anything in the + // composition, so when an action bubbles up to here, the top of the composition, we use + // that information to set the state changed flag if necessary. + val aggregateAction = if (molecule.needsRecomposition && !actionApplied.stateChanged) { + actionApplied.copy(stateChanged = true) + } else { + actionApplied + } + + // Don't bubble up if no state changed and there was no output. + if (aggregateAction.stateChanged || aggregateAction.output != null) { + log("adapter node propagating action cascade up (aggregateAction=$aggregateAction)") + emitAppliedActionToParent(aggregateAction) + } else { + log( + "adapter node not propagating action cascade since nothing happened (aggregateAction=$aggregateAction)" + ) + aggregateAction + } + } + ) + + /** + * Function invoked when [onNextAction] receives a recompose request. + * This handles the case where some state read by the composition is changed but emitOutput is + * not called. + */ + private val processRecompositionRequestFromChannel: (Unit) -> ActionProcessingResult = { + // A pure frame request means compose state was updated that the composition read, but + // emitOutput was not called, so we don't have any outputs to report. + val applied = ActionApplied( + output = null, + // needsRecomposition should always be true now since the runtime explicitly requested + // recomposition, but check anyway. + stateChanged = molecule.needsRecomposition + ) + + // Propagate the action up the workflow tree. + log("frame request received from channel, sending no output to parent: $applied") + emitAppliedActionToParent(applied) + } + + override val session: WorkflowSession + get() = childNode + + override fun render( + workflow: Workflow, + input: PropsT + ): RenderingT { + // Ensure that recomposer has a chance to process any state changes from the action cascade that + // triggered this render before we check for a frame. + log("render sending apply notifications again needsRecompose=${molecule.needsRecomposition}") + // TODO Consider pulling this up into the workflow runtime loop, since we only need to run it + // once before the entire tree renders, not at every level. In fact, if this is only here to + // ensure cachedComposeWorkflow and lastProps are seen, that will only work if this + // ComposeWorkflow is not nested below another traditional and compose workflow, since anything + // rendering under the first CW will be in a snapshot. + Snapshot.sendApplyNotifications() + log("sent apply notifications, needsRecompose=${molecule.needsRecomposition}") + + // If this re-render was not triggered by the channel handler, then clear it so we don't + // immediately trigger another redundant render pass after this. + recomposeChannel.tryReceive() + + // It is very likely that this will be a noop: any time the workflow runtime is doing a + // render pass and no state read by our composition changed, there shouldn't be a frame request. + return molecule.recomposeWithContent { + childNode.produceRendering( + workflow = workflow, + props = input + ) + } + } + + override fun snapshot(): TreeSnapshot = childNode.snapshot() + + override fun registerTreeActionSelectors(selector: SelectBuilder) { + // We must register for child actions before frame requests, because selection is + // strongly-ordered: If multiple subjects become available simultaneously, then the one whose + // receiver was registered first will fire first. We always want to handle outputs first because + // the output handler will implicitly also handle frame requests. If a frame request happens at + // the same time or the output handler enqueues a frame request, then the subsequent render pass + // will dequeue the frame request itself before the next call to register. + childNode.registerTreeActionSelectors(selector) + + // If there's a frame request, then some state changed, which is equivalent to the traditional + // case of a WorkflowAction being enqueued that just modifies state. + with(selector) { + recomposeChannel.onReceive(processRecompositionRequestFromChannel) + } + } + + override fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean): ActionProcessingResult { + if (skipDirtyNodes && molecule.needsRecomposition) return ActionsExhausted + + val result = childNode.applyNextAvailableTreeAction(skipDirtyNodes) + + // If no child nodes had any actions to process, then we can check if we need to recompose, + // which means some composition state changed and is equivalent to the traditional case of a + // WorkflowAction being enqueued that just modifies state. + if (result == ActionsExhausted && molecule.needsRecomposition) { + // Consume the request since we're going to process it directly. The channel just contains + // Unit though so we don't actually care what the result of the receive is. + recomposeChannel.tryReceive() + return processRecompositionRequestFromChannel(Unit) + } + + return ActionsExhausted + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowSnapshots.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowSnapshots.kt new file mode 100644 index 000000000..2955bfaf5 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowSnapshots.kt @@ -0,0 +1,207 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.SaveableStateRegistry +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.readByteStringWithLength +import com.squareup.workflow1.readDouble +import com.squareup.workflow1.readFloat +import com.squareup.workflow1.readList +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.writeByteStringWithLength +import com.squareup.workflow1.writeDouble +import com.squareup.workflow1.writeFloat +import com.squareup.workflow1.writeList +import com.squareup.workflow1.writeUtf8WithLength +import okio.Buffer +import okio.BufferedSink +import okio.BufferedSource +import okio.ByteString + +internal fun saveSaveableStateRegistryToSnapshot(registry: SaveableStateRegistry): Snapshot = + Snapshot.write { sink -> + val values = registry.performSave() + sink.writeInt(values.size) + values.forEach { (key, valueList) -> + sink.writeUtf8WithLength(key) + sink.writeList(valueList) { value -> + writeTaggedValue(value, sink) + } + } + } + +internal fun restoreSaveableStateRegistryFromSnapshot(snapshot: Snapshot?): SaveableStateRegistry { + val restoredValues: Map>? = snapshot?.bytes?.let { snapshotBytes -> + val buffer = Buffer() + buffer.write(snapshotBytes) + val size = buffer.readInt() + buildMap { + repeat(size) { + val key = buffer.readUtf8WithLength() + val values = buffer.readList { + readTaggedValue(buffer) + } + put(key, values) + } + } + } + + return SaveableStateRegistry( + restoredValues = restoredValues, + canBeSaved = ::canBeSavedToBuffer + ) +} + +private fun writeTaggedValue( + value: Any?, + sink: BufferedSink +) { + // Write type tag followed by value. + when (value) { + null -> sink.writeByte(0) + is Byte -> { + sink.writeByte(1) + sink.writeByte(value.toInt()) + } + + is Short -> { + sink.writeByte(2) + sink.writeShort(value.toInt()) + } + + is Int -> { + sink.writeByte(3) + sink.writeInt(value) + } + + is Long -> { + sink.writeByte(4) + sink.writeLong(value) + } + + is Float -> { + sink.writeByte(5) + sink.writeFloat(value) + } + + is Double -> { + sink.writeByte(6) + sink.writeDouble(value) + } + + is String -> { + sink.writeByte(7) + sink.writeUtf8WithLength(value) + } + + is ByteString -> { + sink.writeByte(8) + sink.writeByteStringWithLength(value) + } + + is List<*> -> { + sink.writeByte(9) + sink.writeList(value) { + writeTaggedValue(it, this) + } + } + + is Map<*, *> -> { + sink.writeByte(10) + sink.writeInt(value.size) + value.entries.forEach { (mapKey, mapValue) -> + writeTaggedValue(mapKey, sink) + writeTaggedValue(mapValue, sink) + } + } + + is Set<*> -> { + sink.writeByte(11) + sink.writeList(value.toList()) { + writeTaggedValue(it, this) + } + } + + is Snapshot -> { + sink.writeByte(12) + sink.writeByteStringWithLength(value.bytes) + } + + is TreeSnapshot -> { + sink.writeByte(13) + sink.writeByteStringWithLength(value.toByteString()) + } + + is MutableState<*> -> { + sink.writeByte(14) + writeTaggedValue(value.value, sink) + } + } +} + +private fun readTaggedValue( + source: BufferedSource +): Any? { + // Read the type tag, then read the value. + return when (val typeTag = source.readByte().toInt()) { + 0 -> null + 1 -> source.readByte() + 2 -> source.readShort() + 3 -> source.readInt() + 4 -> source.readLong() + 5 -> source.readFloat() + 6 -> source.readDouble() + 7 -> source.readUtf8WithLength() + 8 -> source.readByteStringWithLength() + 9 -> source.readList { readTaggedValue(this) } + 10 -> { + val size = source.readInt() + buildMap { + repeat(size) { + val key = readTaggedValue(source) + val value = readTaggedValue(source) + put(key, value) + } + } + } + + 11 -> source.readList { readTaggedValue(this) }.toSet() + 12 -> Snapshot.of(source.readByteStringWithLength()) + 13 -> TreeSnapshot.parse(source.readByteStringWithLength()) + 14 -> mutableStateOf(readTaggedValue(source)) + else -> error("Unknown type tag encountered while parsing snapshot: $typeTag") + } +} + +private fun canBeSavedToBuffer(value: Any?): Boolean { + if (value == null) return true + + val isPrimitive = value is Byte || + value is Short || + value is Int || + value is Long || + value is Float || + value is Double || + value is String || + value is ByteString + if (isPrimitive) return true + + val isCollectionOfCompatibleElements = when (value) { + is List<*> -> value.all(::canBeSavedToBuffer) + is Set<*> -> value.all(::canBeSavedToBuffer) + is Map<*, *> -> value.all { (key, value) -> + canBeSavedToBuffer(key) && canBeSavedToBuffer(value) + } + + else -> false + } + if (isCollectionOfCompatibleElements) return true + + val isComposeState = value is MutableState<*> + if (isComposeState) return true + + val isSnapshot = value is Snapshot || value is TreeSnapshot + return isSnapshot +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowState.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowState.kt new file mode 100644 index 000000000..f20d05f7f --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/ComposeWorkflowState.kt @@ -0,0 +1,11 @@ +package com.squareup.workflow1.internal.compose + +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.compose.ComposeWorkflow + +/** + * Fake state object passed to [WorkflowInterceptor]s as the state for [ComposeWorkflow]s. + * + * If we need interceptors to be able to identify compose workflows, we can just make this public. + */ +public object ComposeWorkflowState diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt new file mode 100644 index 000000000..221b40218 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/CompositionLocals.kt @@ -0,0 +1,28 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.currentComposer + +/** + * Like [CompositionLocalProvider] but allows returning a value. + * + * Cash App's Molecule, [Amazon's app-platform](https://github.com/amzn/app-platform/blob/main/presenter-molecule/public/src/commonMain/kotlin/software/amazon/app/platform/presenter/molecule/ReturningCompositionLocalProvider.kt), + * and [Circuit](https://github.com/slackhq/circuit/blob/main/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/internal/WithCompositionLocalProviders.kt) + * all have the same workaround, see https://issuetracker.google.com/issues/271871288. + */ +@OptIn(InternalComposeApi::class) +@Composable +// TODO annotate internal, or pull out +public fun withCompositionLocals( + vararg values: ProvidedValue<*>, + content: @Composable () -> T, +): T { + currentComposer.startProviders(values) + val result = content() + currentComposer.endProviders() + + return result +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraditionalWorkflowAdapterChildNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraditionalWorkflowAdapterChildNode.kt new file mode 100644 index 000000000..53e035eff --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/TraditionalWorkflowAdapterChildNode.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.internal.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.NoopWorkflowInterceptor +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.WorkflowTracer +import com.squareup.workflow1.internal.IdCounter +import com.squareup.workflow1.internal.WorkflowNode +import com.squareup.workflow1.internal.WorkflowNodeId +import com.squareup.workflow1.internal.createWorkflowNode +import kotlinx.coroutines.selects.SelectBuilder +import kotlin.coroutines.CoroutineContext + +/** + * Entry point back into the Workflow runtime from a Compose runtime (i.e. a + * [ComposeWorkflowNodeAdapter]). + */ +internal class TraditionalWorkflowAdapterChildNode( + id: WorkflowNodeId, + workflow: Workflow, + initialProps: PropsT, + contextForChildren: CoroutineContext, + parent: WorkflowSession?, + snapshot: TreeSnapshot?, + workflowTracer: WorkflowTracer?, + runtimeConfig: RuntimeConfig, + interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, + idCounter: IdCounter?, + acceptChildActionResult: (ActionApplied) -> ActionProcessingResult, +) : ComposeChildNode { + + private val workflowNode: WorkflowNode = createWorkflowNode( + id = id, + workflow = workflow, + initialProps = initialProps, + snapshot = snapshot, + baseContext = contextForChildren, + runtimeConfig = runtimeConfig, + workflowTracer = workflowTracer, + emitAppliedActionToParent = acceptChildActionResult, + parent = parent, + interceptor = interceptor, + idCounter = idCounter + ) + + override val id: WorkflowNodeId + get() = workflowNode.id + + @Composable + override fun produceRendering( + workflow: Workflow, + props: PropsT + ): RenderingT = workflowNode.render( + workflow = workflow, + input = props + ) + + override fun snapshot(): TreeSnapshot = workflowNode.snapshot() + + override fun registerTreeActionSelectors(selector: SelectBuilder) { + workflowNode.registerTreeActionSelectors(selector) + } + + override fun applyNextAvailableTreeAction(skipDirtyNodes: Boolean): ActionProcessingResult = + workflowNode.applyNextAvailableTreeAction(skipDirtyNodes) +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt new file mode 100644 index 000000000..b1251eb55 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.kt @@ -0,0 +1,28 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +// TODO make this thread safe +internal object GlobalSnapshotManager { + private var started = false + private var applyScheduled = false + + fun ensureStarted() { + if (started) return + started = true + Snapshot.registerGlobalWriteObserver { + if (!applyScheduled) { + applyScheduled = true + CoroutineScope(GlobalSnapshotCoroutineDispatcher).launch { + applyScheduled = false + Snapshot.sendApplyNotifications() + } + } + } + } +} + +internal expect val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt new file mode 100644 index 000000000..e9d2be865 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SynchronizedMolecule.kt @@ -0,0 +1,301 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.squareup.workflow1.NullableInitBox +import com.squareup.workflow1.internal.Lock +import com.squareup.workflow1.internal.WorkStealingDispatcher +import com.squareup.workflow1.internal.withLock +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.concurrent.Volatile + +/** + * Creates a [launchSynchronizedMolecule] that will run its recomposition loop and effects in + * this [CoroutineScope]. [onNeedsRecomposition] must ensure that + * [SynchronizedMolecule.recomposeWithContent] is eventually called. + * + * See [SynchronizedMolecule] for more information. + */ +// TODO annotate internal, or pull out +public fun CoroutineScope.launchSynchronizedMolecule( + onNeedsRecomposition: () -> Unit +): SynchronizedMolecule = RealSynchronizedMolecule( + scope = this, + onNeedsRecomposition = onNeedsRecomposition, +) + +/** + * A self-contained Compose runtime (like Molecule) that is driven by explicitly telling it when to + * recompose. + * + * ## Usage + * + * Create an instance of this interface by calling [launchSynchronizedMolecule] and passing the + * [CoroutineScope] used to run recomposition and effects, as well as a function to schedule + * a call to [recomposeWithContent] when composition state is changed. When you're ready to + * compose the initial content, call [recomposeWithContent]: this will compose the composable passed + * to it and the compose runtime will start observing state changes. When state is changed, the + * scheduling function passed to [launchSynchronizedMolecule] will be called, but the composition + * will not be recomposed until [recomposeWithContent] is called again. The scheduling function will + * never be called more than once before the next call to [recomposeWithContent]. + * + * To check if composition state has changed imperatively, check [needsRecomposition]. + * + * To stop observing state changes, either cancel the [CoroutineScope] or call + * [SynchronizedMolecule.close]. + * + * ## Implementation and runtime behavior + * + * The compose runtime is driven by an instance of [Recomposer] that runs the recomposition loop + * in a coroutine, scheduling recompositions via a [MonotonicFrameClock]. When compose snapshot + * state is changed, the recomposer eventually gets notified about the changes, which resumes an + * internal suspending loop, and eventually requests a frame from its frame clock. + * + * This class helps out by collapsing some of those steps: As soon as snapshot apply notifications + * have been sent this class will immediately make the frame request available, even if the + * underlying dispatcher wouldn't have normally resumed the recompose loop in time to make the frame + * request. It does this by running the recompose loop on a special dispatcher that wraps the + * underlying dispatcher but allows us to explicitly advance for the recompose loop, and by + * advancing it any time the recomposer reports pending work but hasn't + * requested a frame yet. + */ +// TODO annotate internal, or pull out +public interface SynchronizedMolecule { + + /** + * Returns true if the last composable passed to [recomposeWithContent] needs to be recomposed + * due to some state that it read being changed. + * + * May start returning true before `onNeedsRecomposition` is called if the underlying dispatcher + * hasn't had a chance to request a frame yet. + */ + val needsRecomposition: Boolean + + /** + * Performs a recomposition with the given [content] and returns its result. + */ + fun recomposeWithContent(content: @Composable () -> R): R + + /** + * Stop observing composition state (calling `onNeedsRecomposition`). After calling this, it is + * an error to call [recomposeWithContent] again, and [needsRecomposition] will always return + * false. + * + * You don't need to call this if the coroutine scope is cancelled. + */ + fun close() +} + +private class RealSynchronizedMolecule( + private val scope: CoroutineScope, + private val onNeedsRecomposition: () -> Unit, +) : SynchronizedMolecule, MonotonicFrameClock { + + init { + GlobalSnapshotManager.ensureStarted() + } + + /** + * It's fine to run the recompose loop on Unconfined since it's thread-safe internally, and + * the only threading guarantee we care about is that the frame callback is executed during + * the render pass, which we already control and doesn't depend on what dispatcher is used. + */ + private val dispatcher = WorkStealingDispatcher(Dispatchers.Unconfined) + private val recomposer: Recomposer = Recomposer( + effectCoroutineContext = scope.coroutineContext + ) + private val composition: Composition = Composition(UnitApplier, recomposer) + private var content: (@Composable () -> Any?)? by mutableStateOf(null) + private var lastResult: NullableInitBox = NullableInitBox() + + /** Used to synchronize access to [frameRequest]. */ + private val lock = Lock() + + @Volatile + private var frameRequest: FrameRequest<*>? = null + + /** + * Used to stop [withFrameNanos] from calling [onNeedsRecomposition] when the frame is being + * requested inside of [recomposeWithContent]. + */ + // TODO this should be a ThreadLocal since withFrameNanos can be called from any thread but + // it should only be true from the thread calling recomposeWithContent. + @Volatile + private var recomposing = false + + override val needsRecomposition: Boolean + get() { + if (frameRequest == null && recomposer.hasPendingWork) { + // Allow the recompose loop to run and maybe request a frame. + dispatcher.advanceUntilIdle() + } + return frameRequest != null + } + + init { + // setContent will synchronously perform the first recomposition before returning, which is why + // we leave contentAfterInitial null for now: we don't want it to be called until we're actually + // inside tryPerformRecompose. + // We also need to set the composition content before calling startComposition so it doesn't + // need to suspend to wait for it. + // contentAfterInitial isn't snapshot state but that's fine, since when the recomposer is + // started it will always recompose, childNode will be non-null by then, and it will never + // change again. + composition.setContent { + content?.let { content -> + val result = content() + SideEffect { + this.lastResult = NullableInitBox(result) + } + } + } + } + + override fun recomposeWithContent(content: @Composable () -> R): R { + // Update content in a snapshot to ensure it is applied before we ask for a frame. + Snapshot.withMutableSnapshot { + this.content = content + } + Snapshot.sendApplyNotifications() + + if (!lastResult.isInitialized) { + // Initial request kicks off the recompose loop. This should synchronously request a frame. + launchComposition() + } + + // Synchronously recompose any invalidated composables, if any, and update lastResult. + val frameRequest = tryGetFrameRequest() + if (frameRequest == null) { + if (!lastResult.isInitialized) { + error("Expected initial composition to synchronously request initial frame.") + } + } else { + // Hard-code unchanging frame time since there's no actual frame time code shouldn't rely on + // this value. + val frameResult = frameRequest.execute(0L) + + // If the composition threw an exception, re-throw it ourselves now instead of waiting for the + // scope to get it, since lastResult may have not been initialized in this case and we'd throw + // below and get supppressed. + frameResult.exceptionOrNull()?.let { + throw RuntimeException("ComposeWorkflow composition threw an exception", it) + } + + // If the composition threw an exception, we want it to cancel the coroutine scope before + // getOrThrow below does so. + dispatcher.advanceUntilIdle() + } + + @Suppress("UNCHECKED_CAST") + return lastResult.getOrThrow() as R + } + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + // println("compose workflow withFrameNanos (dispatcher=${currentCoroutineContext()[CoroutineDispatcher]})") + // println(RuntimeException().stackTraceToString()) + + return suspendCancellableCoroutine { continuation -> + lock.withLock { + if (frameRequest != null) error("Concurrent frame request") + frameRequest = FrameRequest( + onFrame = onFrame, + continuation = continuation + ) + } + + if (!recomposing) { + onNeedsRecomposition() + } + } + } + + override fun close() { + recomposer.close() + } + + private fun launchComposition() { + val frameClock: MonotonicFrameClock = this + + // Launch as undispatched to ensure the composition has a chance to start before this method + // returns, and so that the composition is always disposed even if our job is cancelled + // immediately. + scope.launch( + // Note: This context is _only_ used for the actual recompose loop. Everything inside the + // composition (rememberCoroutineScope, LaunchedEffects, etc) will NOT see these, and will see + // only whatever context was passed into the Recomposer's constructor (plus the stuff it adds + // to that context itself, like the BroadcastFrameClock). + context = dispatcher + frameClock, + start = UNDISPATCHED + ) { + try { + recomposer.runRecomposeAndApplyChanges() + } finally { + composition.dispose() + } + } + } + + /** + * Returns a [FrameRequest] representing the next frame request if the recomposer has work to do, + * otherwise returns null. This method is best-effort: It tries to poke the recomposer to request + * a frame even if it hasn't had a chance to resume its recompose loop yet. + * + * The returned continuation should be resumed with the frame time in nanoseconds. + */ + private fun tryGetFrameRequest(): FrameRequest<*>? { + // Fast paths: A request was already enqueued, or… + // frameRequestChannel.tryReceive().getOrNull()?.let { return it } + consumeFrameRequest()?.let { + return it + } + + // …no request was enqueued, and the recomposer doesn't need one. + if (!recomposer.hasPendingWork) { + // The recomposer is waiting for work, so there won't be a frame request even if we advance + // the dispatcher. + return null + } + + // Slow path: The recomposer is waiting for its recompose loop to be resumed so it can request + // a frame, so let it do that. + // Set recomposing to avoid calling onRecomposeNeeded if this advancing triggers withFrameNanos. + recomposing = true + dispatcher.advanceUntilIdle() + recomposing = false + + // If there's still no request then the recomposer either didn't actually need to resume or it + // did but decided not to request a frame, either way we've done all we can. + // return frameRequestChannel.tryReceive().getOrNull() + return consumeFrameRequest() + } + + private fun consumeFrameRequest(): FrameRequest<*>? = lock.withLock { + frameRequest?.also { frameRequest = null } + } + + private class FrameRequest( + private val onFrame: (frameTimeNanos: Long) -> R, + private val continuation: CancellableContinuation + ) { + fun execute(frameTimeNanos: Long): Result { + val frameResult = runCatching { onFrame(frameTimeNanos) } + continuation.resumeWith(frameResult) + return frameResult + } + } +} diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt new file mode 100644 index 000000000..8299e31b1 --- /dev/null +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/compose/runtime/UnitApplier.kt @@ -0,0 +1,42 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.Applier + +internal object UnitApplier : Applier { + override val current: Unit + get() = Unit + + override fun clear() { + } + + override fun down(node: Unit) { + } + + override fun insertBottomUp( + index: Int, + instance: Unit + ) { + } + + override fun insertTopDown( + index: Int, + instance: Unit + ) { + } + + override fun move( + from: Int, + to: Int, + count: Int + ) { + } + + override fun remove( + index: Int, + count: Int + ) { + } + + override fun up() { + } +} diff --git a/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt b/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt new file mode 100644 index 000000000..a70ebfb71 --- /dev/null +++ b/workflow-runtime/src/iosMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.ios.kt @@ -0,0 +1,7 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = Dispatchers.Main diff --git a/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt b/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt new file mode 100644 index 000000000..aa8bedb8a --- /dev/null +++ b/workflow-runtime/src/jsMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.js.kt @@ -0,0 +1,6 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher + +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = TODO("Not yet implemented") diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt new file mode 100644 index 000000000..46c6fb7c3 --- /dev/null +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/GlobalSnapshotManager.jvm.kt @@ -0,0 +1,8 @@ +package com.squareup.workflow1.internal.compose.runtime + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +// TODO use AndroidUiDispatcher on android. +internal actual val GlobalSnapshotCoroutineDispatcher: CoroutineDispatcher + get() = Dispatchers.Main diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt new file mode 100644 index 000000000..d5df1d38c --- /dev/null +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/compose/runtime/SerializableSaveableStateRegistry.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.internal.compose.runtime + +import androidx.compose.runtime.saveable.SaveableStateRegistry +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.parse +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.writeUtf8WithLength +import okio.BufferedSink +import okio.ByteString +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * A [SaveableStateRegistry] that can save and restore anything that is [Serializable]. + */ +internal class SerializableSaveableStateRegistry private constructor( + saveableStateRegistry: SaveableStateRegistry +) : SaveableStateRegistry by saveableStateRegistry { + constructor(restoredValues: Map>?) : this( + SaveableStateRegistry(restoredValues, ::canBeSavedAsSerializable) + ) + + constructor(snapshot: Snapshot) : this(snapshot.bytes.toMap()) + + fun toSnapshot(): Snapshot = Snapshot.write { sink -> + performSave().writeTo(sink) + } +} + +/** + * Checks that [value] can be stored as a [Serializable]. + */ +private fun canBeSavedAsSerializable(value: Any): Boolean { + if (value !is Serializable) return false + + // lambdas in Kotlin implement Serializable, but will crash if you really try to save them. + // we check for both Function and Serializable (see kotlin.jvm.internal.Lambda) to support + // custom user defined classes implementing Function interface. + if (value is Function<*>) return false + + return true +} + +private fun ByteString.toMap(): Map>? { + return parse { source -> + val size = source.readInt() + if (size == 0) return null + + val inputStream = ObjectInputStream(source.inputStream()) + buildMap(capacity = size) { + repeat(size) { + val key = source.readUtf8WithLength() + + @Suppress("UNCHECKED_CAST") + val arrayList = inputStream.readObject() as ArrayList + put(key, arrayList) + } + } + } +} + +private fun Map>.writeTo(sink: BufferedSink) { + // sink.writeInt(values.size) + // val outputStream = ObjectOutputStream(sink.outputStream()) + // values.forEach { (key, list) -> + // val arrayList = if (list is ArrayList) list else ArrayList(list) + // sink.writeUtf8WithLength(key) + // outputStream.writeObject(arrayList) + // outputStream.flush() + // } +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/ComposeRenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/ComposeRenderTester.kt new file mode 100644 index 000000000..672036b96 --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/ComposeRenderTester.kt @@ -0,0 +1,212 @@ +package com.squareup.workflow1.testing + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowIdentifier +import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.LocalWorkflowComposableRenderer +import com.squareup.workflow1.compose.WorkflowComposableRenderer +import com.squareup.workflow1.compose.internal._DO_NOT_USE_invokeComposeWorkflowProduceRendering +import com.squareup.workflow1.identifier +import com.squareup.workflow1.internal.compose.ComposeWorkflowState +import com.squareup.workflow1.internal.compose.runtime.launchSynchronizedMolecule +import com.squareup.workflow1.internal.compose.withCompositionLocals +import com.squareup.workflow1.testing.RealRenderTester.Expectation +import com.squareup.workflow1.testing.RealRenderTester.Expectation.ExpectedWorkflow +import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch.Matched +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +// TODO move this to RealComposeRenderTester +@OptIn(WorkflowExperimentalApi::class) +internal class ComposeRenderTester( + private val workflow: ComposeWorkflow, + private val props: PropsT, + private val runtimeConfig: RuntimeConfig, +) : RenderTester(), + WorkflowComposableRenderer, + RenderTestResult { + + private data class OutputWithHandler( + val output: ChildOutputT, + val handler: (ChildOutputT) -> Unit + ) + + /** + * List of [Expectation]s that are expected when the workflow is rendered. New expectations are + * registered into this list. Once the render pass has started, expectations are moved from this + * list to [consumedExpectations] as soon as they're matched. + */ + private val expectations: MutableList = mutableListOf() + + /** + * Empty until the render pass starts, then every time the workflow matches an expectation that + * has `exactMatch` set to true, it is moved from [expectations] to this list. + */ + private val consumedExpectations: MutableList> = mutableListOf() + + private var processedOutputHandler: OutputWithHandler<*>? = null + + /** + * Tracks the identifier/key pairs of all calls to [renderChild], so it can emulate the behavior + * of the real runtime and throw if a workflow is rendered twice in the same pass. + */ + private val renderedChildren: MutableList = mutableListOf() + + override fun expectWorkflow( + description: String, + exactMatch: Boolean, + matcher: (RenderChildInvocation) -> ChildWorkflowMatch + ): RenderTester = apply { + expectations += ExpectedWorkflow(matcher, exactMatch, description) + } + + override fun expectSideEffect( + description: String, + exactMatch: Boolean, + matcher: (key: String) -> Boolean + ): RenderTester { + throw AssertionError( + "Expected ComposeWorkflow to have side effect $description, " + + "but ComposeWorkflows use Compose effects." + ) + } + + override fun expectRemember( + description: String, + exactMatch: Boolean, + matcher: (RememberInvocation) -> Boolean + ): RenderTester { + throw AssertionError( + "Cannot validate calls to Compose's remember {} function through RenderTester." + ) + } + + override fun requireExplicitWorkerExpectations(): + RenderTester { + // Noop + return this + } + + override fun requireExplicitSideEffectExpectations(): + RenderTester { + // Noop + return this + } + + override fun requireExplicitRememberExpectations(): + RenderTester { + // Noop + return this + } + + override fun render( + block: (rendering: RenderingT) -> Unit + ): RenderTestResult { + val emitOutput: (OutputT) -> Unit = { output -> + TODO() + } + + val scope = CoroutineScope(Dispatchers.Unconfined) + try { + val molecule = scope.launchSynchronizedMolecule(onNeedsRecomposition = {}) + val rendering = molecule.recomposeWithContent { + withCompositionLocals(LocalWorkflowComposableRenderer provides this) { + _DO_NOT_USE_invokeComposeWorkflowProduceRendering(workflow, props, emitOutput) + } + } + block(rendering) + } finally { + scope.cancel() + } + return this + } + + @Composable + override fun renderChild( + childWorkflow: Workflow, + props: ChildPropsT, + onOutput: ((ChildOutputT) -> Unit)? + ): ChildRenderingT { + val identifier = childWorkflow.identifier + require(identifier !in renderedChildren) { + "Expected keys to be unique for ${childWorkflow.identifier}" + } + renderedChildren += identifier + + val description = buildString { + append("child ") + append(childWorkflow.identifier) + // if (key.isNotEmpty()) { + // append(" with key \"$key\"") + // } + } + val invocation = createRenderChildInvocation(childWorkflow, props, renderKey = "") + val matches = expectations.mapNotNull { + val matchResult = it.matcher(invocation) + if (matchResult is Matched) Pair(it, matchResult) else null + } + if (matches.isEmpty()) { + throw AssertionError("Tried to render unexpected $description") + } + + val exactMatches = matches.filter { it.first.exactMatch } + val (_, match) = when { + exactMatches.size == 1 -> { + exactMatches.single() + .also { (expected, _) -> + expectations -= expected + consumedExpectations += expected + } + } + + exactMatches.size > 1 -> { + throw AssertionError( + "Multiple expectations matched $description:\n" + + exactMatches.joinToString(separator = "\n") { " ${it.first.describe()}" } + ) + } + // Inexact matches are not consumable. + else -> matches.first() + } + + if (match.output != null) { + check(processedOutputHandler == null) { + "Expected only one output to be expected: $description expected to emit " + + "${match.output.value} but ${emittedOutput?.debuggingName} was already processed." + } + processedOutputHandler = OutputWithHandler(match.output, onOutput) + @Suppress("UNCHECKED_CAST") + processedAction = handler(match.output.value as ChildOutputT) + } + + @Suppress("UNCHECKED_CAST") + return match.childRendering as ChildRenderingT + } + + override fun verifyAction( + block: (WorkflowAction) -> Unit + ): RenderTestResult { + TODO("Not yet implemented") + } + + override fun verifyActionResult( + block: (newState: ComposeWorkflowState, appliedResult: WorkflowOutput?) -> Unit + ): RenderTestResult { + TODO("Not yet implemented") + } + + override fun testNextRender(): RenderTester = + testNextRenderWithProps(props) + + override fun testNextRenderWithProps( + newProps: PropsT + ): RenderTester { + TODO("Not yet implemented") + } +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt index 88062084b..88e64e4db 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt @@ -11,10 +11,12 @@ import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.compose.ComposeWorkflow import com.squareup.workflow1.config.JvmTestRuntimeConfigTools import com.squareup.workflow1.identifier import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch import com.squareup.workflow1.testing.RenderTester.Companion +import com.squareup.workflow1.testing.compose.ComposeRenderTesterWrapper import com.squareup.workflow1.workflowIdentifier import kotlinx.coroutines.CoroutineScope import kotlin.reflect.KClass @@ -34,6 +36,11 @@ public fun Workflow.t props: PropsT, runtimeConfig: RuntimeConfig? = null, ): RenderTester { + if (this is ComposeWorkflow) { + // TODO + return ComposeRenderTesterWrapper(workflow = this) + } + val statefulWorkflow = asStatefulWorkflow() as StatefulWorkflow return statefulWorkflow.testRender( props = props, diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTester.kt new file mode 100644 index 000000000..a764f0e0f --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTester.kt @@ -0,0 +1,146 @@ +package com.squareup.workflow1.testing.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.compose.WorkflowComposable +import com.squareup.workflow1.testing.RenderTester +import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch +import com.squareup.workflow1.testing.RenderTester.RenderChildInvocation + +@WorkflowExperimentalApi +public fun testRender( + produceRendering: @WorkflowComposable @Composable (emitOutput: (OutputT) -> Unit) -> RenderingT +): ComposeRenderTester { + TODO() +} + +/** + * Create a [RenderTester] to unit test an individual render pass of this workflow. + * + * See [RenderTester] for usage documentation. + */ +@WorkflowExperimentalApi +public fun + ComposeWorkflow.testRender( + // props: PropsT, + runtimeConfig: RuntimeConfig? = null +): ComposeRenderTester = TODO() + +@WorkflowExperimentalApi +public interface ComposeRenderTester { + + /** + * Specifies that this render pass is expected to render a particular child workflow. + * + * @param description String that will be used to describe this expectation in error messages. + * The description is required since no human-readable description can be derived from the + * predicate alone. + * + * @param exactMatch If true, then the test will fail if any other matching expectations are also + * exact matches, and the expectation will only be allowed to match a single child workflow. + * If false, the match will only be used if no other expectations return exclusive matches (in + * which case the first match will be used), and the expectation may match multiple children. + * + * @param matcher A function that determines whether a given [RenderChildInvocation] matches this + * expectation by returning a [ChildWorkflowMatch]. If the expectation matches, the function + * must include the rendering and optional output for the child workflow. + */ + public fun expectWorkflow( + description: String, + exactMatch: Boolean = true, + matcher: (RenderChildInvocation) -> ChildWorkflowMatch + ): ComposeRenderTester + + /** + * Verifies that some snapshot state that was read by the composition was changed. If state was + * changed, then [block] is called to allow you to perform your own assertions on your own state + * objects. + * + * E.g. + * ```kotlin + * var myState by mutableStateOf(0) + * testRender { MyComposable(myState, onChildOutput = { myState++ } } + * .expectWorkflow(…, output = Unit) + * .render { rendering -> + * assertEquals(expectedRendering, rendering) + * assertEquals(1, myState) + * } + * ``` + */ + public fun render( + block: (RenderingT) -> Unit = {} + ): ComposeRenderTestResult +} + +@WorkflowExperimentalApi +public interface ComposeRenderTestResult { + + /** + * Verifies that some snapshot state that was read by the composition was changed. If state was + * changed, then [block] is called to allow you to perform your own assertions on your own state + * objects. + * + * E.g. + * ```kotlin + * var myState by mutableStateOf(0) + * testRender { MyComposable(myState, onChildOutput = { myState++ } } + * .expectWorkflow(…, output = Unit) + * .render() + * .verifyStateChanged { assertEquals(1, myState) } + * ``` + */ + // TODO do we even need this method? You can just do state assertions in the render callback. + public fun verifyStateChanged( + block: () -> Unit = {} + ): ComposeRenderTestResult + + /** + * Passes [block] a list of all the values passed to `emitOutput` from child workflows from the + * last render pass. If `emitOutput` was never called, the list will be empty. + * + * To verify a single call to `emitOutput`, call [verifyOutput]. + */ + public fun verifyOutputs( + block: (List) -> Unit + ): ComposeRenderTestResult + + /** + * Returns a [ComposeRenderTester] that allows you to perform a recomposition. + */ + public fun testNextRender(): ComposeRenderTester +} + +/** + * Verifies that the workflow called `emitOutput` exactly once and runs [block] with the value + * passed to `emitOutput`. + * + * To verify multiple calls to `emitOutput` from a single render pass, call + * [ComposeRenderTestResult.verifyOutputs]. + * + * E.g. + * ```kotlin + * testRender { emitOutput -> + * MyComposable( + * myState, + * onChildOutput = { if (it == "boo!") emitOutput("ahh!") } + * ) + * } + * .expectWorkflow(…, output = "boo!") + * .render() + * .verifyOutput { output -> assertEquals("ahh!", output) } + * ``` + */ +@WorkflowExperimentalApi +public fun ComposeRenderTestResult.verifyOutput( + block: (OutputT) -> Unit +): ComposeRenderTestResult = verifyOutputs { outputs -> + if (outputs.size != 1) { + throw AssertionError( + "Expected emitOutput to have been called exactly once, " + + "but was called ${outputs.size} times." + ) + } + block(outputs.single()) +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTesterWrapper.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTesterWrapper.kt new file mode 100644 index 000000000..d568301a1 --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/ComposeRenderTesterWrapper.kt @@ -0,0 +1,117 @@ +package com.squareup.workflow1.testing.compose + +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi +import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.compose.ComposeWorkflow +import com.squareup.workflow1.internal.compose.ComposeWorkflowState +import com.squareup.workflow1.testing.RenderTestResult +import com.squareup.workflow1.testing.RenderTester + +/** + * Makes a [ComposeRenderTester] look like a [RenderTester]. + */ +@OptIn(WorkflowExperimentalApi::class) +internal class ComposeRenderTesterWrapper( + workflow: ComposeWorkflow, + private val composeRenderTester: ComposeRenderTester +) : RenderTester() { + + override fun expectWorkflow( + description: String, + exactMatch: Boolean, + matcher: (RenderChildInvocation) -> ChildWorkflowMatch + ): RenderTester = apply { + composeRenderTester.expectWorkflow(description, exactMatch, matcher) + } + + override fun expectSideEffect( + description: String, + exactMatch: Boolean, + matcher: (key: String) -> Boolean + ): RenderTester { + throw AssertionError( + "Expected ComposeWorkflow to have side effect $description, " + + "but ComposeWorkflows use Compose effects." + ) + } + + override fun expectRemember( + description: String, + exactMatch: Boolean, + matcher: (RememberInvocation) -> Boolean + ): RenderTester { + throw AssertionError( + "Cannot validate calls to Compose's remember {} function through RenderTester." + ) + } + + override fun requireExplicitWorkerExpectations(): + RenderTester { + // Noop + return this + } + + override fun requireExplicitSideEffectExpectations(): + RenderTester { + // Noop + return this + } + + override fun requireExplicitRememberExpectations(): + RenderTester { + // Noop + return this + } + + override fun render( + block: (rendering: RenderingT) -> Unit + ): RenderTestResult { + val result = composeRenderTester.render(block) + return ComposeRenderTestResultWrapper(result) + } +} + +@OptIn(WorkflowExperimentalApi::class) +private class ComposeRenderTestResultWrapper( + private val composeResult: ComposeRenderTestResult +) : RenderTestResult { + + override fun verifyAction( + block: (WorkflowAction) -> Unit + ): RenderTestResult { + throw AssertionError( + "ComposeWorkflows do not generate WorkflowActions for child workflow" + + "outputs. Use verifyActionResult instead." + ) + } + + override fun verifyActionResult( + block: (newState: ComposeWorkflowState, appliedResult: WorkflowOutput?) -> Unit + ): RenderTestResult = apply { + composeResult.verifyOutputs { outputs -> + if (outputs.isEmpty()) { + block(ComposeWorkflowState, null) + } else if (outputs.size == 1) { + block(ComposeWorkflowState, WorkflowOutput(outputs.single())) + } else { + throw AssertionError( + "Expected emitOutput to have been called zero or one time, " + + "but was called ${outputs.size} times." + ) + } + } + } + + override fun testNextRender(): RenderTester { + TODO("Not yet implemented") + composeResult.testNextRender() + } + + override fun testNextRenderWithProps( + newProps: PropsT + ): RenderTester { + TODO("Not yet implemented") + composeResult.testNextRender() + } +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/RealComposeRenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/RealComposeRenderTester.kt new file mode 100644 index 000000000..6d6f2fb08 --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/compose/RealComposeRenderTester.kt @@ -0,0 +1,9 @@ +package com.squareup.workflow1.testing.compose + +import com.squareup.workflow1.WorkflowExperimentalApi + +@OptIn(WorkflowExperimentalApi::class) +internal class RealComposeRenderTester( + +) : ComposeRenderTester { +} diff --git a/workflow-tracing/build.gradle.kts b/workflow-tracing/build.gradle.kts index 87f9dc6e8..fb877c01a 100644 --- a/workflow-tracing/build.gradle.kts +++ b/workflow-tracing/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("kotlin-jvm") id("published") + alias(libs.plugins.compose.compiler) } dependencies { diff --git a/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt b/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt index 8759b2679..a7d29c3e5 100644 --- a/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt +++ b/workflow-tracing/src/main/java/com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInterceptor.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.diagnostic.tracing +import androidx.compose.runtime.Composable import com.squareup.tracing.TraceEncoder import com.squareup.tracing.TraceEvent.AsyncDurationBegin import com.squareup.tracing.TraceEvent.AsyncDurationEnd @@ -17,6 +18,7 @@ import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.BaseRenderContext import com.squareup.workflow1.Snapshot import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowIdentifierType.Snapshottable import com.squareup.workflow1.WorkflowIdentifierType.Unsnapshottable @@ -215,6 +217,32 @@ public class TracingWorkflowInterceptor internal constructor( return rendering } + @OptIn(WorkflowExperimentalApi::class) + @Composable + override fun onRenderComposeWorkflow( + renderProps: P, + emitOutput: (O) -> Unit, + proceed: @Composable (P, (O) -> Unit) -> R, + session: WorkflowSession + ): R { + if (session.isRootWorkflow) { + // Track the overall render pass for the whole tree. + onBeforeRenderPass(renderProps) + } + onBeforeWorkflowRendered(session.sessionId, renderProps, null) + + val rendering = proceed(renderProps, /*emitOutput=*/ { output -> + onComposeWorkflowOutput(session.sessionId, output) + emitOutput(output) + }) + + onAfterWorkflowRendered(session.sessionId, rendering) + if (session.isRootWorkflow) { + onAfterRenderPass(rendering) + } + return rendering + } + override fun onSnapshotState( state: S, proceed: (S) -> Snapshot?, @@ -446,6 +474,20 @@ public class TracingWorkflowInterceptor internal constructor( ) } + private fun onComposeWorkflowOutput( + workflowId: Long, + output: Any?, + ) { + val name = workflowNamesById.getValue(workflowId) + logger?.log( + Instant( + name = "emitOutput received: $name", + category = "update", + args = mapOf("output" to output) + ), + ) + } + private fun createMemoryEvent(): Counter { val freeMemory = memoryStats.freeMemory() val usedMemory = memoryStats.totalMemory() - freeMemory