Skip to content

Commit

Permalink
Merge pull request #160 from elihart/eli-add_call_chain_info
Browse files Browse the repository at this point in the history
Expose call group chain information
  • Loading branch information
rjrjr authored Apr 3, 2024
2 parents 4b6a58f + c92245b commit a32b0a8
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 34 deletions.
98 changes: 97 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewGroup>(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<ScannableView.ComposeView>()
.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<ScannableView>
get() = children.flatMap { sequenceOf(it) + it.allDescendentsDepthFirst }
```

## Jetpack Compose support

[Jetpack Compose](https://developer.android.com/jetpack/compose) is Google's new declarative UI
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,7 +37,8 @@ class ComposeViewTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test fun androidView_children_includes_ComposeViews() {
@Test
fun androidView_children_includes_ComposeViews() {
composeRule.setContentWithExplicitRoot {
BasicText("hello")
}
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -80,7 +86,8 @@ class ComposeViewTest {
.isEqualTo(1)
}

@Test fun composeView_reports_LayoutModifiers() {
@Test
fun composeView_reports_LayoutModifiers() {
composeRule.setContentWithExplicitRoot {
Box(TestModifier)
}
Expand All @@ -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
Expand All @@ -109,6 +117,83 @@ 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("Call1")

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("Call1", "Call2", "Call3", "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
fun Call3() {
BasicText(text = "hello")
}

@Composable
fun Call2() {
Call3()
}

@Composable
fun Call1() {
Call2()
}

Column {
Call1()
}
}
}

private object TestModifier : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
Expand Down
48 changes: 33 additions & 15 deletions radiography/api/radiography.api
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,28 @@ public final class radiography/ScannableView$AndroidView : radiography/Scannable
public fun toString ()Ljava/lang/String;
}

public final class radiography/ScannableView$CallGroupInfo {
public fun <init> (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 <init> (Ljava/lang/String;)V
public fun getChildren ()Lkotlin/sequences/Sequence;
public fun getDisplayName ()Ljava/lang/String;
}

public final class radiography/ScannableView$ComposeView : radiography/ScannableView {
public fun <init> (Ljava/lang/String;IILjava/util/List;Ljava/util/List;Lkotlin/sequences/Sequence;)V
public fun <init> (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
Expand Down Expand Up @@ -121,16 +135,18 @@ public final class radiography/internal/ComposeLayoutInfo$AndroidViewInfo : radi
}

public final class radiography/internal/ComposeLayoutInfo$LayoutNodeInfo : radiography/internal/ComposeLayoutInfo {
public fun <init> (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Ljava/util/List;Lkotlin/sequences/Sequence;Ljava/util/List;)V
public fun <init> (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;
Expand All @@ -140,23 +156,25 @@ public final class radiography/internal/ComposeLayoutInfo$LayoutNodeInfo : radio
}

public final class radiography/internal/ComposeLayoutInfo$SubcompositionInfo : radiography/internal/ComposeLayoutInfo {
public fun <init> (Ljava/lang/String;Landroidx/compose/ui/unit/IntRect;Lkotlin/sequences/Sequence;)V
public fun <init> (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
public fun toString ()Ljava/lang/String;
}

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 {
Expand Down
Loading

0 comments on commit a32b0a8

Please sign in to comment.