From e56d1765dbe677bc83b653dbd42764840f207adf Mon Sep 17 00:00:00 2001 From: Eli Hart Date: Mon, 1 Apr 2024 11:42:37 -0700 Subject: [PATCH 1/5] Expose call group chain information --- .../test/compose/ComposeViewTest.kt | 65 +++++++++++++++++-- radiography/api/radiography.api | 48 +++++++++----- .../main/java/radiography/ScannableView.kt | 21 ++++++ .../radiography/internal/ComposeLayoutInfo.kt | 35 ++++++---- 4 files changed, 136 insertions(+), 33 deletions(-) diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt index 0804ac1..5dd8ce7 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt @@ -8,13 +8,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicText import androidx.compose.material.Button +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutModifier import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.tooling.data.UiToolingDataApi import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp @@ -34,7 +37,8 @@ class ComposeViewTest { @get:Rule val composeRule = createAndroidComposeRule() - @Test fun androidView_children_includes_ComposeViews() { + @Test + fun androidView_children_includes_ComposeViews() { composeRule.setContentWithExplicitRoot { BasicText("hello") } @@ -44,7 +48,8 @@ class ComposeViewTest { assertThat(textComposable.children.asIterable()).isEmpty() } - @Test fun composeView_children_includes_AndroidView() { + @Test + fun composeView_children_includes_AndroidView() { composeRule.setContentWithExplicitRoot { Column { androidx.compose.ui.viewinterop.AndroidView(::TextView) @@ -59,7 +64,8 @@ class ComposeViewTest { assertThat(androidViews.count { it.view is TextView }).isEqualTo(1) } - @Test fun composeView_reports_children() { + @Test + fun composeView_reports_children() { composeRule.setContentWithExplicitRoot { Column { BasicText("hello") @@ -80,7 +86,8 @@ class ComposeViewTest { .isEqualTo(1) } - @Test fun composeView_reports_LayoutModifiers() { + @Test + fun composeView_reports_LayoutModifiers() { composeRule.setContentWithExplicitRoot { Box(TestModifier) } @@ -90,7 +97,8 @@ class ComposeViewTest { assertThat(boxView.modifiers).contains(TestModifier) } - @Test fun composeView_reports_size() { + @Test + fun composeView_reports_size() { var density: Density? = null composeRule.setContentWithExplicitRoot { density = LocalDensity.current @@ -109,6 +117,53 @@ class ComposeViewTest { assertThat(boxView.height).isEqualTo(heightPx) } + @OptIn(UiToolingDataApi::class) + @Test + fun composeView_reports_call_chain_info() { + composeRule.setContentWithExplicitRoot { + + @Composable + fun Call1() { + BasicText(text = "hello") + } + + @Composable + fun Call2() { + Call1() + } + + @Composable + fun Call3() { + Call2() + } + + Column { + Call3() + } + } + + val columnView = findRootComposeView() + .allDescendentsDepthFirst + .first { it.displayName == "Column" } + + val columnChildren = columnView.children.toList() + assertThat(columnChildren).hasSize(1) + + // Expect that the chain of custom Call functions is collapsed, so that the BasicText + // is represented with the name "Call3" but contains the semantic information of the text "hello". + val callGroup = columnChildren[0] as ComposeView + assertThat(callGroup.children.count()).isEqualTo(0) + assertThat(callGroup.displayName).isEqualTo("Call3") + assertThat(callGroup.semanticsConfigurations.firstNotNullOfOrNull { it[SemanticsProperties.Text] }?.map { it.toString() }) + .containsExactly("hello") + + assertThat(callGroup.callChain).hasSize(6) + assertThat(callGroup.callChain.map { it.name }) + .containsExactly("Call3", "Call2", "Call1", "BasicText", "Layout", "ReusableComposeNode") + assertThat(callGroup.callChain.map { it.location?.sourceFile }) + .containsExactly("ComposeViewTest.kt", "ComposeViewTest.kt", "ComposeViewTest.kt", "ComposeViewTest.kt", "BasicText.kt", "Layout.kt") + } + private object TestModifier : LayoutModifier { override fun MeasureScope.measure( measurable: Measurable, diff --git a/radiography/api/radiography.api b/radiography/api/radiography.api index 44c9f70..e2e8138 100644 --- a/radiography/api/radiography.api +++ b/radiography/api/radiography.api @@ -44,6 +44,19 @@ public final class radiography/ScannableView$AndroidView : radiography/Scannable public fun toString ()Ljava/lang/String; } +public final class radiography/ScannableView$CallGroupInfo { + public fun (Ljava/lang/String;Landroidx/compose/ui/tooling/data/SourceLocation;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Landroidx/compose/ui/tooling/data/SourceLocation; + public final fun copy (Ljava/lang/String;Landroidx/compose/ui/tooling/data/SourceLocation;)Lradiography/ScannableView$CallGroupInfo; + public static synthetic fun copy$default (Lradiography/ScannableView$CallGroupInfo;Ljava/lang/String;Landroidx/compose/ui/tooling/data/SourceLocation;ILjava/lang/Object;)Lradiography/ScannableView$CallGroupInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun getLocation ()Landroidx/compose/ui/tooling/data/SourceLocation; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class radiography/ScannableView$ChildRenderingError : radiography/ScannableView { public fun (Ljava/lang/String;)V public fun getChildren ()Lkotlin/sequences/Sequence; @@ -51,7 +64,8 @@ public final class radiography/ScannableView$ChildRenderingError : radiography/S } public final class radiography/ScannableView$ComposeView : radiography/ScannableView { - public fun (Ljava/lang/String;IILjava/util/List;Ljava/util/List;Lkotlin/sequences/Sequence;)V + public fun (Ljava/lang/String;Ljava/util/List;IILjava/util/List;Ljava/util/List;Lkotlin/sequences/Sequence;)V + public final fun getCallChain ()Ljava/util/List; public fun getChildren ()Lkotlin/sequences/Sequence; public fun getDisplayName ()Ljava/lang/String; public final fun getHeight ()I @@ -121,16 +135,18 @@ public final class radiography/internal/ComposeLayoutInfo$AndroidViewInfo : radi } public final class radiography/internal/ComposeLayoutInfo$LayoutNodeInfo : radiography/internal/ComposeLayoutInfo { - public fun (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;)V + public fun (Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroidx/compose/ui/unit/IntRect; - public final fun component3 ()Ljava/util/List; - public final fun component4 ()Lkotlin/sequences/Sequence; - public final fun component5 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;)Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo; - public static synthetic fun copy$default (Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo;Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;ILjava/lang/Object;)Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Landroidx/compose/ui/unit/IntRect; + public final fun component4 ()Ljava/util/List; + public final fun component5 ()Lkotlin/sequences/Sequence; + public final fun component6 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;)Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo; + public static synthetic fun copy$default (Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo;Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;ILjava/lang/Object;)Lradiography/internal/ComposeLayoutInfo$LayoutNodeInfo; public fun equals (Ljava/lang/Object;)Z public final fun getBounds ()Landroidx/compose/ui/unit/IntRect; + public final fun getCallChain ()Ljava/util/List; public final fun getChildren ()Lkotlin/sequences/Sequence; public final fun getModifiers ()Ljava/util/List; public final fun getName ()Ljava/lang/String; @@ -140,14 +156,16 @@ public final class radiography/internal/ComposeLayoutInfo$LayoutNodeInfo : radio } public final class radiography/internal/ComposeLayoutInfo$SubcompositionInfo : radiography/internal/ComposeLayoutInfo { - public fun (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;)V + public fun (Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Landroidx/compose/ui/unit/IntRect; - public final fun component3 ()Lkotlin/sequences/Sequence; - public final fun copy (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;)Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo; - public static synthetic fun copy$default (Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo;Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;ILjava/lang/Object;)Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Landroidx/compose/ui/unit/IntRect; + public final fun component4 ()Lkotlin/sequences/Sequence; + public final fun copy (Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;)Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo; + public static synthetic fun copy$default (Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo;Ljava/lang/String;Ljava/util/List;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;ILjava/lang/Object;)Lradiography/internal/ComposeLayoutInfo$SubcompositionInfo; public fun equals (Ljava/lang/Object;)Z public final fun getBounds ()Landroidx/compose/ui/unit/IntRect; + public final fun getCallChain ()Ljava/util/List; public final fun getChildren ()Lkotlin/sequences/Sequence; public final fun getName ()Ljava/lang/String; public fun hashCode ()I @@ -155,8 +173,8 @@ public final class radiography/internal/ComposeLayoutInfo$SubcompositionInfo : r } public final class radiography/internal/ComposeLayoutInfoKt { - public static final fun computeLayoutInfos (Landroidx/compose/ui/tooling/data/Group;Ljava/lang/String;Landroidx/compose/ui/semantics/SemanticsOwner;)Lkotlin/sequences/Sequence; - public static synthetic fun computeLayoutInfos$default (Landroidx/compose/ui/tooling/data/Group;Ljava/lang/String;Landroidx/compose/ui/semantics/SemanticsOwner;ILjava/lang/Object;)Lkotlin/sequences/Sequence; + public static final fun computeLayoutInfos (Landroidx/compose/ui/tooling/data/Group;Ljava/util/List;Landroidx/compose/ui/semantics/SemanticsOwner;)Lkotlin/sequences/Sequence; + public static synthetic fun computeLayoutInfos$default (Landroidx/compose/ui/tooling/data/Group;Ljava/util/List;Landroidx/compose/ui/semantics/SemanticsOwner;ILjava/lang/Object;)Lkotlin/sequences/Sequence; } public final class radiography/internal/ComposeViewsKt { diff --git a/radiography/src/main/java/radiography/ScannableView.kt b/radiography/src/main/java/radiography/ScannableView.kt index 3ba3bb7..44593c6 100644 --- a/radiography/src/main/java/radiography/ScannableView.kt +++ b/radiography/src/main/java/radiography/ScannableView.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsModifier import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.tooling.data.SourceLocation +import androidx.compose.ui.tooling.data.UiToolingDataApi import radiography.ScannableView.AndroidView import radiography.ScannableView.ComposeView import radiography.internal.ComposeLayoutInfo @@ -44,6 +46,17 @@ public sealed class ScannableView { @ExperimentalRadiographyComposeApi public class ComposeView( override val displayName: String, + /** + * This [ComposeView] represents a collapsed chain of Compose nodes, with the [children] and + * and other details only coming from nodes that emit values. For simplicity, the "Call" nodes + * (ie, Compose functions that don't emit values) are not directly included in the + * [children] of this [ComposeView]; however, the top most "Call" node provides the [displayName] + * of this [ComposeView]. + * + * This [callChain] provides the complete chain of Compose Call Groups wrapping the ultimate layout + * node, which you can use to see granular data about the exact compose hierarchy. + */ + public val callChain: List, public val width: Int, public val height: Int, public val modifiers: List, @@ -61,6 +74,12 @@ public sealed class ScannableView { } } + @OptIn(UiToolingDataApi::class) + public data class CallGroupInfo( + val name: String, + val location: SourceLocation?, + ) + /** * Indicates that an exception was thrown while rendering part of the tree. * This should be used for non-fatal errors, when the rest of the tree should still be processed. @@ -100,6 +119,7 @@ private fun View.scannableChildren(): Sequence = sequence { internal fun ComposeLayoutInfo.toScannableView(): ScannableView = when (val layoutInfo = this) { is LayoutNodeInfo -> ComposeView( displayName = layoutInfo.name, + callChain = layoutInfo.callChain, // Can't use width and height properties because we're not targeting 1.8 bytecode. width = layoutInfo.bounds.run { right - left }, height = layoutInfo.bounds.run { bottom - top }, @@ -110,6 +130,7 @@ internal fun ComposeLayoutInfo.toScannableView(): ScannableView = when (val layo is SubcompositionInfo -> ComposeView( displayName = layoutInfo.name, + callChain = layoutInfo.callChain, width = layoutInfo.bounds.run { right - left }, height = layoutInfo.bounds.run { bottom - top }, modifiers = emptyList(), diff --git a/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt b/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt index 94813d0..001ba28 100644 --- a/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt +++ b/radiography/src/main/java/radiography/internal/ComposeLayoutInfo.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.data.NodeGroup import androidx.compose.ui.tooling.data.UiToolingDataApi import androidx.compose.ui.tooling.data.asTree import androidx.compose.ui.unit.IntRect +import radiography.ScannableView.CallGroupInfo import radiography.internal.ComposeLayoutInfo.AndroidViewInfo import radiography.internal.ComposeLayoutInfo.LayoutNodeInfo import radiography.internal.ComposeLayoutInfo.SubcompositionInfo @@ -34,6 +35,7 @@ import radiography.internal.ComposeLayoutInfo.SubcompositionInfo internal sealed class ComposeLayoutInfo { data class LayoutNodeInfo( val name: String, + val callChain: List, val bounds: IntRect, val modifiers: List, val children: Sequence, @@ -42,6 +44,7 @@ internal sealed class ComposeLayoutInfo { data class SubcompositionInfo( val name: String, + val callChain: List, val bounds: IntRect, val children: Sequence ) : ComposeLayoutInfo() @@ -62,33 +65,37 @@ internal sealed class ComposeLayoutInfo { * the groups in between each of these nodes, but uses the top-most Group under the previous node * to derive the "name" of the [ComposeLayoutInfo]. The other [ComposeLayoutInfo] properties come directly off * [NodeGroup] values. + * + * To preserve details about the Call Groups between Layout Nodes, the call chain is preserved in order + * to provide granular detail about the hierarchy if desired. */ internal fun Group.computeLayoutInfos( - parentName: String = "", + parentCallChain: List = emptyList(), /** * The semantics owner for this Group. This is used to look up the semantics nodes for each * layout node. */ semanticsOwner: SemanticsOwner? = null, ): Sequence { - val name = parentName.ifBlank { this.name }.orEmpty() + val callChain = this.name?.let { parentCallChain + CallGroupInfo(it, this.location) } ?: parentCallChain + // Things that we want to consider children of the current node, but aren't actually child nodes // as reported by Group.children. - val irregularChildren = subComposedChildren(name, semanticsOwner) + androidViewChildren() + val irregularChildren = subComposedChildren(callChain, semanticsOwner) + androidViewChildren() // Certain composables produce an internal structure that is hard to read if we report it exactly. // Instead, we use heuristics to recognize subtrees that match certain expected structures and // aggregate them somewhat before reporting. - tryParseSubcomposition(name, irregularChildren, semanticsOwner) + tryParseSubcomposition(callChain, irregularChildren, semanticsOwner) ?.let { return it } - tryParseAndroidView(name, irregularChildren, semanticsOwner) + tryParseAndroidView(callChain, irregularChildren, semanticsOwner) ?.let { return it } // This is an intermediate group that doesn't represent a LayoutNode, so we flatten by just // reporting its children without reporting a new subtree. if (this !is NodeGroup) { return children.asSequence() - .flatMap { it.computeLayoutInfos(name, semanticsOwner) } + irregularChildren + .flatMap { it.computeLayoutInfos(callChain, semanticsOwner) } + irregularChildren } val children = children.asSequence() @@ -101,7 +108,8 @@ internal fun Group.computeLayoutInfos( ?: emptyList() val layoutInfo = LayoutNodeInfo( - name = name, + name = callChain.firstOrNull()?.name.orEmpty(), + callChain = callChain, bounds = box, modifiers = modifierInfo.map { it.modifier }, semanticsNodes = semanticsNodes, @@ -116,12 +124,13 @@ internal fun Group.computeLayoutInfos( * The compositionData val is marked as internal, and not intended for public consumption. * The returned [SubcompositionInfo]s should be collated by [tryParseSubcomposition]. */ -private fun Group.subComposedChildren(name: String, semanticsOwner: SemanticsOwner?): Sequence = +private fun Group.subComposedChildren(callChain: List, semanticsOwner: SemanticsOwner?): Sequence = getCompositionContexts() .flatMap { it.tryGetComposers().asSequence() } .map { subcomposer -> SubcompositionInfo( - name = name, + name = callChain.firstOrNull()?.name.orEmpty(), + callChain = callChain, bounds = box, children = subcomposer.compositionData.asTree().computeLayoutInfos(semanticsOwner = semanticsOwner) ) @@ -162,14 +171,14 @@ private fun Group.androidViewChildren(): List { * - That LayoutNode has no children of its own. */ private fun Group.tryParseSubcomposition( - name: String, + callChain: List, irregularChildren: Sequence, semanticsOwner: SemanticsOwner? ): Sequence? { if (this.name != "SubcomposeLayout") return null val (subcompositions, regularChildren) = - (children.asSequence().flatMap { it.computeLayoutInfos(name, semanticsOwner) } + irregularChildren) + (children.asSequence().flatMap { it.computeLayoutInfos(callChain, semanticsOwner) } + irregularChildren) .partition { it is SubcompositionInfo } .let { // There's no type-safe partition operator so we just cast. @@ -217,7 +226,7 @@ private fun Group.tryParseSubcomposition( * the logic of both if that happens if they're completely independent. */ private fun Group.tryParseAndroidView( - name: String, + callChain: List, irregularChildren: Sequence, semanticsOwner: SemanticsOwner? ): Sequence? { @@ -225,7 +234,7 @@ private fun Group.tryParseAndroidView( if (this !is CallGroup) return null val (androidViews, regularChildren) = - (children.asSequence().flatMap { it.computeLayoutInfos(name, semanticsOwner) } + irregularChildren) + (children.asSequence().flatMap { it.computeLayoutInfos(callChain, semanticsOwner) } + irregularChildren) .partition { it is AndroidViewInfo } .let { // There's no type-safe partition operator so we just cast. From aff7c5b0e7c8f48e8e84975cb86fcd901237cc17 Mon Sep 17 00:00:00 2001 From: Eli Hart Date: Tue, 2 Apr 2024 16:31:22 -0700 Subject: [PATCH 2/5] Split up test --- .../test/compose/ComposeViewTest.kt | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt index 5dd8ce7..aedf464 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt @@ -117,9 +117,60 @@ class ComposeViewTest { assertThat(boxView.height).isEqualTo(heightPx) } + @Test + fun composeView_inherits_name_of_top_most_call() { + renderCallChainUI() + + val columnView = findRootComposeView() + .allDescendentsDepthFirst + .first { it.displayName == "Column" } + + val columnChildren = columnView.children.toList() + assertThat(columnChildren).hasSize(1) + + // Expect that the chain of custom Call functions is collapsed, so that the BasicText + // is represented with the name "Call3" but contains the semantic information of the text "hello". + val callGroup = columnChildren[0] as ComposeView + assertThat(callGroup.children.count()) + .isEqualTo(0) + + assertThat(callGroup.displayName) + .isEqualTo("Call3") + + assertThat(callGroup.semanticsConfigurations.firstNotNullOfOrNull { it[SemanticsProperties.Text] } + ?.map { it.toString() }) + .containsExactly("hello") + } + @OptIn(UiToolingDataApi::class) @Test fun composeView_reports_call_chain_info() { + renderCallChainUI() + + val columnView = findRootComposeView() + .allDescendentsDepthFirst + .first { it.displayName == "Column" } + + val callGroup = columnView.children.first() as ComposeView + + assertThat(callGroup.callChain) + .hasSize(6) + + assertThat(callGroup.callChain.map { it.name }) + .containsExactly("Call3", "Call2", "Call1", "BasicText", "Layout", "ReusableComposeNode") + + assertThat(callGroup.callChain.map { it.location?.sourceFile }) + .containsExactly( + "ComposeViewTest.kt", + "ComposeViewTest.kt", + "ComposeViewTest.kt", + "ComposeViewTest.kt", + "BasicText.kt", + "Layout.kt" + ) + } + + private fun renderCallChainUI() { composeRule.setContentWithExplicitRoot { @Composable @@ -141,27 +192,6 @@ class ComposeViewTest { Call3() } } - - val columnView = findRootComposeView() - .allDescendentsDepthFirst - .first { it.displayName == "Column" } - - val columnChildren = columnView.children.toList() - assertThat(columnChildren).hasSize(1) - - // Expect that the chain of custom Call functions is collapsed, so that the BasicText - // is represented with the name "Call3" but contains the semantic information of the text "hello". - val callGroup = columnChildren[0] as ComposeView - assertThat(callGroup.children.count()).isEqualTo(0) - assertThat(callGroup.displayName).isEqualTo("Call3") - assertThat(callGroup.semanticsConfigurations.firstNotNullOfOrNull { it[SemanticsProperties.Text] }?.map { it.toString() }) - .containsExactly("hello") - - assertThat(callGroup.callChain).hasSize(6) - assertThat(callGroup.callChain.map { it.name }) - .containsExactly("Call3", "Call2", "Call1", "BasicText", "Layout", "ReusableComposeNode") - assertThat(callGroup.callChain.map { it.location?.sourceFile }) - .containsExactly("ComposeViewTest.kt", "ComposeViewTest.kt", "ComposeViewTest.kt", "ComposeViewTest.kt", "BasicText.kt", "Layout.kt") } private object TestModifier : LayoutModifier { From 9d557b0411839d99fdb65f7f364df9fa1cf2418e Mon Sep 17 00:00:00 2001 From: Eli Hart Date: Tue, 2 Apr 2024 17:22:52 -0700 Subject: [PATCH 3/5] Add details and examples to readme --- README.md | 98 ++++++++++++++++++- .../test/compose/ComposeViewTest.kt | 13 +-- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dd4cb77..8d88393 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,57 @@ window-focus:false This sample app lives in this repo in the `sample` directory. +## Custom Hierarchy Exploration + +The `Radiography.scan` function provides a String representation of the view hierarchy, but you can also work +with the raw hierarchy data directly if you want to programmatically explore it and integrate it +into your own custom tools. + +Use the `ScanScopes` object to identify the root of a hierarchy you want to explore. You can use the +resulting `ScannableView` objects, and their `ScannableView.children`, to explore the hierarchy. + +For example, to get the hierarchy starting with a specific Compose test tag in a given activity, +you can do this: +```kotlin +@OptIn(ExperimentalRadiographyComposeApi::class) +fun Activity.findComposableRootWithTestTag(tag: String): ScannableView.ComposeView { + val rootView = window.decorView.findViewById(R.id.content) + + return ScanScopes.composeTestTagScope( + testTag = tag, + inScope = ScanScopes.singleViewScope(rootView) + ).findRoots() + .firstOrNull() + as? ScannableView.ComposeView + ?: error("No composable found with test tag $tag") +} +``` + +The information provided by the ScannableView.ComposeView can be powerful. In particular, the +semantic information can be used to validate certain expectations and programmatically test your +application. For example: +```kotlin +val myComposableScreen = findComposableRootWithTestTag("My screen tag") + +myComposableScreen.allDescendentsDepthFirst + .filterIsInstance() + .onEach { composeView -> + + // Validate that all images have a content description set + if (composeView.displayName == "Image") { + check(composeView.semanticsConfigurations.any { it.contains(SemanticsProperties.ContentDescription) }) + } + + // Perform a click on all clickable elements to ensure none of them cause a crash + composeView.semanticsConfigurations + .map { it[SemanticsActions.OnClick] } + .onEach { clickAction -> clickAction.action?.invoke() } +} + +val ScannableView.allDescendentsDepthFirst: Sequence + get() = children.flatMap { sequenceOf(it) + it.allDescendentsDepthFirst } +``` + ## Jetpack Compose support [Jetpack Compose](https://developer.android.com/jetpack/compose) is Google's new declarative UI @@ -108,7 +159,7 @@ library is on the classpath. If you are using Compose, you're probably already u (the `@Preview` annotation lives in the Tooling library). On the other hand, if you're not using Compose, Radiography won't bloat your app with transitive dependencies on any Compose artifacts. -Compose changes frequently, and while in beta, is being released every two weeks. If you are using +Compose occasionally has internal implementation changes that affect Radiography. If you are using Radiography with an unsupported version of Compose, or you don't depend on the Tooling library, then Radiography will still try to detect compositions, but instead of rendering the actual hierarchy, it will just include a message asking you to upgrade Radiography or add the Tooling library. @@ -264,6 +315,51 @@ parent. Reflection is used to pull the actual subcompositions out of the parent those compositions' slot tables are analyzed in turn, and its root composables are rendered as childrens of the node that owns the `CompositionReference`. +### Call group collapsing +To simplify the hierarchy output of the Compose tree, only nodes that are "emitted" to the layout are +included. This means that intermediate "call" nodes are collapsed together with the emitted node +that they wrap. Each emitted node inherits the display name of the top level call node wrapping it. + +For example, with this Compose code: +```kotlin +@Composable +fun Call3() { + BasicText(text = "hello") +} + +@Composable +fun Call2() { + Call3() +} + +@Composable +fun Call1() { + Call2() +} + +Column { + Call1() +} +``` + +The hierarchy output will simply look like this: `Column -> Call1`. + +The `BasicText` composable and its text are represented by the name `Call1`, because that is the +composable function that wraps it. The `Call3` and `Call2` nodes are not shown because they don't emit a layout, +and they are wrapped by `Call1`. + +This approach helps to manage the many levels of nesting that can occur in a Compose tree. However, +sometimes you may want to see the granular view of all the calls in the tree. To do this, you can use +the `ComposeView.callChain` property to see the full call chain that led to the emitted node. In this +case, the values of the property would look like this: +``` +"Call1", "Call2", "Call3", "BasicText", "Layout", "ReusableComposeNode" +``` + +You can access this property if you implement a custom `ViewStateRenderer` or use [Custom Hierarchy Exploration](#custom-hierarchy-exploration). + +For more details on how Radiography works with Compose, see [How are compositions rendered?](#How are compositions rendered?) + ### Compose example output ![screenshot](assets/compose_sample_screenshot.png) diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt index aedf464..ae68e49 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt @@ -135,7 +135,7 @@ class ComposeViewTest { .isEqualTo(0) assertThat(callGroup.displayName) - .isEqualTo("Call3") + .isEqualTo("Call1") assertThat(callGroup.semanticsConfigurations.firstNotNullOfOrNull { it[SemanticsProperties.Text] } ?.map { it.toString() }) @@ -157,7 +157,7 @@ class ComposeViewTest { .hasSize(6) assertThat(callGroup.callChain.map { it.name }) - .containsExactly("Call3", "Call2", "Call1", "BasicText", "Layout", "ReusableComposeNode") + .containsExactly("Call1", "Call2", "Call3", "BasicText", "Layout", "ReusableComposeNode") assertThat(callGroup.callChain.map { it.location?.sourceFile }) .containsExactly( @@ -173,23 +173,24 @@ class ComposeViewTest { private fun renderCallChainUI() { composeRule.setContentWithExplicitRoot { + @Composable - fun Call1() { + fun Call3() { BasicText(text = "hello") } @Composable fun Call2() { - Call1() + Call3() } @Composable - fun Call3() { + fun Call1() { Call2() } Column { - Call3() + Call1() } } } From f7786a73d8327416ebf0f67c23c73d4e2280d9d7 Mon Sep 17 00:00:00 2001 From: Eli Hart Date: Tue, 2 Apr 2024 19:16:49 -0700 Subject: [PATCH 4/5] address ktlint --- .../androidTest/java/radiography/test/compose/ComposeViewTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt index ae68e49..6215ad1 100644 --- a/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt +++ b/compose-tests/src/androidTest/java/radiography/test/compose/ComposeViewTest.kt @@ -173,7 +173,6 @@ class ComposeViewTest { private fun renderCallChainUI() { composeRule.setContentWithExplicitRoot { - @Composable fun Call3() { BasicText(text = "hello") From c92245b326f591efea74fd9df0df2b85092e7167 Mon Sep 17 00:00:00 2001 From: Eli Hart Date: Wed, 3 Apr 2024 09:25:51 -0700 Subject: [PATCH 5/5] Force rerun tests