Such as you, we have seen a marked shift to declarative programming for cellular UIs. Late final yr, we found this variation on our weblog, be aware that though React Native and Flutter have been declared from the beginning, Android and iOS have each launched assist via Jetpack Compose and SwiftUI respectively. Earlier this yr, we formally rolled out assist for Jetpack Compose in Java SDKand me held an AMA together with the remainder of the built-in crew. With such a placing change occurring within the discipline of cellular improvement, it’s crucial that not solely do developer instruments sustain, however we additionally share the teachings realized to maintain up. can proceed to develop this house collectively.
Persevering with our sequence of articles that began with Begin the tutorial course Earlier this yr, my crew and I’ll share our expertise constructing Jetpack Compose integrations, beginning with person engagement monitoring. With our SDK being open supply and shortly adopting Jetpack Compose assist, we invite you to study from our successes and errors as you energy the way forward for declarative cellular improvement.
Jetpack Compose and Monitor
In Jetpack Compose Begin the tutorial course which we launched earlier this yr, one of many suggestions is to make use of a efficiency and bug monitoring device like Sentry to cut back studying time and be sure that your app is bug free. On this submit, we element how we applied person interplay monitoring for Jetpack Compose, which is on the market as a part of our Android SDK.
Our request for declarative programming assist
Our Android SDK supplies builders with in-depth context, resembling machine particulars, circulate info, and screenshots, making it simpler to analyze points. It additionally supplies person interplay samples (click on, scroll or swipe) to totally perceive the reason for the issue. And, like all of our different SDKs, our Android SDK is designed to immediately present this helpful info with out cluttering your code with calls to the Sentry SDK.
We had the next objectives in thoughts when constructing person interplay monitoring for Jetpack Compose:
- Detect each click on, swipe or scroll globally
- Know which UI parts the person has interacted with
- Outline an identifier for the UI aspect and create the corresponding breadcrumb
- Minimal setup necessities
Detect clicks, scrolls and swipes
In Jetpack Compose UI, click on conduct is normally added by way of Modifier.clickable
, the place you present a lambda expression as an argument. Scrolling and swiping work equally. That is a variety of API floor to cowl and unfold out within the person’s code. So how can the SDK monitor all these calls with out requiring the developer so as to add any customized code to every name? The reply is a few nifty mixture of current system callbacks:
- On the Sentry SDK init, register a
ActivityLifecycleCallbacks
to get maintain present seenExercise
- entry
Window
viaExercise.getWindow()
- Put a
Window.Callback
usewindow.setCallback()
Let’s go a bit deeper Window.Callback
show. It defines a number of strategies, however the fascinating one for us is dispatchTouchEvent
. It means that you can intercept any movement occasions despatched to a Exercise
. That is fairly highly effective and is the premise for a lot of options. For instance, the great outdated one Dialog
use this callback to detect clicks exterior of content material to set off dismiss dialog.
It is vital to notice right here you can solely order one Window.Callback
, it’s subsequently crucial that you just have in mind any beforehand set callbacks (e.g., as a result of system or different software code past your management) and delegate all calls to the callback. there. This ensures any current logic will nonetheless be executed, avoiding any conduct violations.
val previousCallback = window.getCallback() ?: EmptyCallback()
val newCallback = SentryWindowCallback(previousCallback)
window.setCallback(newCallback)
class SentryWindowCallback(val delegate: Window.Callback) : Window.Callback {
override enjoyable dispatchTouchEvent(occasion: MotionEvent?): Boolean {
// our logic ...
return delegate.dispatchTouchEvent(occasion)
}
}
Find and establish widgets
However that is solely half the work, as we additionally wish to know which widget the person interacted with. For the normal Android XML format, that is fairly simple:
- Iterate the View Hierarchy and discover the View that matches the contact coordinates
- Retrieve numeric view ID by way of
view.getId()
- Translate the ID again to its useful resource identify to get a readable identifier
enjoyable coordinatesWithinBounds(view: View, x: Float, y: Float): Boolean
enjoyable isViewTappable(view: View) {
return view.isClickable() && view.getVisibility() == View.VISIBLE
}
val x = motionEvent.getX()
val y = motionEvent.getY()
if (coordinatesWithinBounds(view, x, y) && isViewTappable(view)) {
val viewId = view.getId()
return view.getContext()
.getResources()?
.getResourceEntryName(viewId); // e.g. button_login
)
Because the Jetpack Compose UI doesn’t use the Android System widget, we can not apply the identical mechanism right here. Should you have a look at Android’s format hierarchy, all you get is a AndroidComposeView
which takes care of your rendering @Composables
and acts as a bridge between the system and the Jetpack Compose runtime.
Our first method is to make use of some Accessibility Companies API to retrieve an outline of the UI aspect at a particular location on the display screen. The official documentation on semantics offered a great place to begin, and we rapidly discovered ourselves digging into AndroidComposeViewAccessibilityDelegateCompat.android.kt
for a greater understanding of the way it works beneath the hood.
// From
/**
* Hit check the format tree for semantics wrappers.
* The return worth is a digital view id, or InvalidId if an embedded Android View was hit.
*/
@OptIn(ExperimentalComposeUiApi::class)
@VisibleForTesting
inside enjoyable hitTestSemanticsAt(x: Float, y: Float): Int
However after an early prototype, we rapidly deserted the thought as a result of the potential efficiency price of enabling accessibility did not justify the worth being created. Since Compose UI parts should not a part of the normal Android View system, the Compose runtime must synchronize the “semantic tree” with the Android system’s accessibility service if the accessibility characteristic is enabled. For instance, any adjustments to format bounds are synced each 100 milliseconds.
// From
/**
* This droop perform loops for the whole lifetime of the Compose occasion: it consumes
* current format adjustments and sends occasions to the accessibility framework in batches separated
* by a 100ms delay.
*/
droop enjoyable boundsUpdatesEventLoop() {
// ...
}
We even have little management over what the API returns, e.g. localized extension descriptions, making it unsuitable for our use case.
Dive into the contents of Compose
So it is time to double-check how Compose works in secret.
Not like the normal Android View system, Jetpack Compose builds the View Hierarchy for you. Your @Composable
the code “emits” all the data wanted to construct its inside node hierarchy. For Android, the tree consists of two various kinds of nodes: Or LayoutNode
(e.g. one Field
) or VNode
(used for Vector drawings).
talked about earlier than AndroidComposeView
perform the androidx.compose.ui.node.Proprietor
interface, which itself supplies a local sort LayoutNode
.
Sadly, a few of these APIs are marked as inside and subsequently can’t be used from an exterior module, as it is going to generate a Kotlin compiler error. We did not wish to use reflection to resolve this, so we got here up with one other trick: Should you’re accessing the APIs by way of Java, you may get a compiler warning. 🙂 It is true that this is not very best, however it provides us some compile-time security and permits us to rapidly uncover breaking adjustments related to a more moderen model of the time run Jetpack Compose. On prime of that, reflection will not work for obfuscated builds, since any Class.forName()
runtime calls will not work with renamed Compose runtime lessons.
After engaged on the Java different, we rapidly bumped into one other drawback including Java sources to our current sentry-compose Kotlin cross-platform module. The construct fails for those who attempt to combine Java into one Kotlin Cellular Cross-Platform (KMM) allow android library. it is a recognized drawbackand as a brief workaround we created a separate JVM module known as sentry-compose-helper which incorporates all related Java code.
just like a View
One LayoutNode
additionally present some API to retrieve its location and bounds on the display screen. LayoutNode.getCoordinates()
present coordinates that may be included within the LayoutCoordinates.positionInWindow()
then returns a Offset
.
// From:
/**
* The place of this format relative to the window.
*/
enjoyable LayoutCoordinates.positionInWindow(): Offset
You might have used Offset
earlier than, however do you know it is truly a Lengthy
in a flowery outfit? 🤡 x
And y
packed into the primary and final 32 bits solely. This Kotlin characteristic is known as Inline classand it is a highly effective trick to enhance runtime efficiency whereas nonetheless offering the comfort and sort security of lessons.
@Immutable
@kotlin.jvm.JvmInline
worth class Offset inside constructor(inside val packedValue: Lengthy) {
@Steady
val x: Float
get() // ...
@Steady
val y: Float
get() // ...
}
Since we’re accessing Compose API in Java, we now have to manually extract x and y parts from Offset.
personal static boolean layoutNodeBoundsContain(@NotNull LayoutNode node, last float x, last float y) {
last int nodeHeight = node.getHeight();
last int nodeWidth = node.getWidth();
// positionInWindow() returns an Offset in Kotlin
// if accessed in Java, you may get a protracted!
last lengthy nodePosition = LayoutCoordinatesKt.positionInWindow(node.getCoordinates());
last int nodeX = (int) Float.intBitsToFloat((int) (nodePosition >> 32));
last int nodeY = (int) Float.intBitsToFloat((int) (nodePosition));
return x >= nodeX && x <= (nodeX + nodeWidth) && y >= nodeY && y <= (nodeY + nodeHeight);
}
Decide Composables
Retrieve an identical identifier for a LayoutNode
It isn’t easy both. Our first method is to go to sourceInformation
. When the Compose Compiler plugin processes @Composable
perform, it provides sourceInformation
into your methodology physique. This could then be picked up utilizing the Compose device to e.g. hyperlink the Format Inspector to your supply code.
For example this a bit higher, let’s outline so simple as potential @Composable
perform:
@Composable
enjoyable EmptyComposable() {
}
Now, compile this code and test how the Compose Compiler plugin enriches the perform physique:
import androidx.compose.runtime.Composer;
import androidx.compose.runtime.ComposerKt;
import androidx.compose.runtime.ScopeUpdateScope;
import kotlin.Metadata;
public last class EmptyComposableKt {
public static last void EmptyComposable(Composer $composer, int $modified) {
Composer $composer2 = $composer.startRestartGroup(103603534);
ComposerKt.sourceInformation($composer2, "C(EmptyComposable):EmptyComposable.kt#llk8wg");
if ($modified != 0 || !$composer2.getSkipping()) {
if (ComposerKt.isTraceInProgress()) {
ComposerKt.traceEventStart(103603534, $modified, -1, "com.instance.EmptyComposable (EmptyComposable.kt:5)");
}
if (ComposerKt.isTraceInProgress()) {
ComposerKt.traceEventEnd();
}
} else {
$composer2.skipToGroupEnd();
}
ScopeUpdateScope endRestartGroup = $composer2.endRestartGroup();
if (endRestartGroup == null) {
return;
}
endRestartGroup.updateScope(new EmptyComposableKt$EmptyComposable$1($modified));
}
}
Let’s concentrate on ComposerKt.sourceInformation()
name: The second argument is a String, containing details about the perform identify and the supply file. Sadly, sourceInformation
not essentially out there in obfuscated launch builds, so we will not reap the benefits of that both.
After some extra analysis, we stumbled upon the built-in Modifier.testTag(“
methodology, generally used to jot down person interface assessments. Seems that is a part of the accessibility semantics we checked out earlier!
At this level, it isn’t shocking to see that these semantics are being modeled as Modifiers
beneath the hood (Modifiers
like a secret ingredient that makes Jetpack Compose highly effective!). From Modifiers
hooked up on to a LayoutNode
we are able to simply iterate over them and discover a appropriate one.
enjoyable retrieveTestTag(node: LayoutNode) : String? {
for (modifier in node.modifiers) {
if (modifier is SemanticsModifier) {
val testTag: String? = modifier
.semanticsConfiguration
.getOrNull(SemanticsProperties.TestTag)
if (testTag != null) {
return testTag
}
}
}
return null
}
pack it up
After finishing the ultimate piece, it is time to wrap it up, wrap some edge covers, and ship the ultimate product. Jetpack Compose person interplay now out there, beginning with 6.10.0
our Android SDK model.
Presently, this characteristic remains to be opt-in, so it must be enabled by way of AndroidManifest.xml
:
However after enabling it, it simply works. Granted, it nonetheless asks you to offer a Modifier.testTag(…)
, however that already exists for those who’re writing UI assessments. 😉 Try our documentation to get began.
Subsequent step
Subsequent step
in a single Facet chat with Riot and NextdoorSubjects of Jetpack Compose and declarative programming is right here, as we talk about the vital shift within the cellular house. Now’s the time to get began with Jetpack Compose and as you do, do not forget Sentry has your surveillance wants. Try the record of sources beneath and tell us what you suppose in discord or in ours GitHub Dialogue.