La nouvelle bibliothèque androidx.tracing:tracing:2.0.0-alpha01 est une API Kotlin à faible surcharge qui permet de capturer les événements de trace en cours de traitement. Ces événements peuvent capturer des périodes et leur contexte. La bibliothèque est également compatible avec la propagation du contexte pour les coroutines Kotlin.
La bibliothèque utilise le même format de paquet de trace Perfetto que celui que connaissent les développeurs Android. De plus, le traçage 2.0 (contrairement aux API 1.0.0-*) prend en charge la notion de backends de traçage enfichables et de sinks. D'autres bibliothèques de traçage peuvent donc personnaliser le format de traçage de sortie et le fonctionnement de la propagation du contexte dans leur implémentation.
Dépendances
Pour commencer le traçage, vous devez définir les dépendances suivantes dans votre fichier build.gradle.kts.
kotlin {
androidLibrary {
namespace = "com.example.library"
// ...
}
sourceSets {
androidMain {
dependencies {
api("androidx.tracing:tracing-wire-android:2.0.0-alpha01")
// ...
}
}
jvmMain {
dependencies {
api("androidx.tracing:tracing-wire-desktop:2.0.0-alpha01")
// ...
}
}
}
}
Déclarez une dépendance sur androidx.tracing:tracing-wire-android:2.0.0-alpha01 si vous ciblez une bibliothèque ou une application Android. Vous pouvez utiliser la dépendance androidx.tracing:tracing-wire-desktop:2.0.0-alpha01 si vous ciblez la JVM.
Utilisation de base
Un TraceSink définit la manière dont les paquets de trace sont sérialisés. Tracing 2.0.0 est fourni avec une implémentation d'un récepteur qui utilise le format de paquet de trace Perfetto. Un TraceDriver fournit un handle au Tracer et peut être utilisé pour finaliser une trace.
Vous pouvez également utiliser TraceDriver pour désactiver tous les points de trace dans l'application si vous choisissez de ne pas effectuer de traçage dans certaines variantes de l'application.
Les futures API de TraceDriver permettront également aux développeurs de contrôler les catégories de trace qu'ils souhaitent capturer (ou désactiver lorsqu'une catégorie est bruyante).
Pour commencer, créez une instance de TraceSink et de TraceDriver.
/**
* A [TraceSink] defines how traces are serialized.
*
* [androidx.tracing.wire.TraceSink] uses the `Perfetto` trace packet format.
*/
fun createSink(): TraceSink {
val outputDirectory = File(/* path = */ "/tmp/perfetto")
if (!outputDirectory.exists()) {
outputDirectory.mkdirs()
}
// We are using the factory function defined in androidx.tracing.wire
return TraceSink(
sequenceId = 1,
directory = outputDirectory
)
}
/**
* Creates a new instance of [androidx.tracing.TraceDriver].
*/
fun createTraceDriver(): TraceDriver {
// We are using a factory function from androidx.tracing.wire here.
// `isEnabled` controls whether tracing is enabled for the application.
val driver = TraceDriver(sink = createSink(), isEnabled = true)
return driver
}
Une fois que vous avez une instance de TraceDriver, obtenez le Tracer qui définit le point d'entrée pour toutes les API de traçage.
// Tracing Categories identify subsystems that are responsible
// in generating trace sections. Future APIs in `TraceDriver` will allow the
// application to specify which categories they are interested in tracing.
// This lets the application disable entire trace categories, without
// needing to disable trace instrumentation at the call sites for those
// categories.
internal const val CATEGORY_MAIN = "main"
fun main() {
val driver = createTraceDriver()
driver.use {
driver.tracer.trace(category = CATEGORY_MAIN, name = "basic") {
Thread.sleep(100L)
}
}
}
La trace suivante est générée.
Figure 1. Capture d'écran d'une trace Perfetto de base.
Vous pouvez constater que les pistes de processus et de thread appropriées sont renseignées et qu'elles ont généré une seule section de trace basic, qui a duré 100ms.
Les sections (ou tranches) de trace peuvent être imbriquées sur la même piste pour représenter des événements qui se chevauchent. Voici un exemple :
fun main() {
// Initialize the tracing infrastructure to monitor app performance
val driver = createTraceDriver()
val tracer = driver.tracer
driver.use {
tracer.trace(
category = CATEGORY_MAIN,
name = "processImage",
) {
// Load the data first, then apply the sharpen filter
sharpen(tracer = tracer, output = loadImage(tracer))
}
}
}
internal fun loadImage(tracer: Tracer): ByteArray {
return tracer.trace(CATEGORY_MAIN, "loadImage") {
// Loads an image
// ...
// A placeholder
ByteArray(0)
}
}
internal fun sharpen(tracer: Tracer, output: ByteArray) {
// ...
tracer.trace(CATEGORY_MAIN, "sharpen") {
// ...
}
}
La trace suivante est générée.
Figure 2. Capture d'écran d'une trace Perfetto de base avec des sections imbriquées.
Vous pouvez constater que des événements se chevauchent dans la piste du thread principal. Il est très clair que processImage appelle loadImage et sharpen sur le même thread.
Ajouter des métadonnées supplémentaires dans les sections de trace
Il peut parfois être utile d'associer des métadonnées contextuelles supplémentaires à une tranche de trace pour obtenir plus de détails. Par exemple, ces métadonnées peuvent inclure le nav destination sur lequel se trouve l'utilisateur ou le input arguments qui peut déterminer la durée d'une fonction.
fun main() {
val driver = createTraceDriver()
driver.use {
driver.tracer.trace(
category = CATEGORY_MAIN,
name = "basicWithContext",
// Add additional metadata
metadataBlock = {
// Add key value pairs.
addMetadataEntry("key", "value")
addMetadataEntry("count", 1L)
}
) {
Thread.sleep(100L)
}
}
}
Vous obtenez le résultat suivant. Notez que la section Arguments contient les paires clé-valeur ajoutées lors de la production de slice.
Figure 3. Capture d'écran d'une trace Perfetto de base avec des métadonnées supplémentaires.
Propagation du contexte
Lorsque vous utilisez des coroutines Kotlin (ou d'autres frameworks similaires qui aident à gérer les charges de travail simultanées), Tracing 2.0 prend en charge la notion de propagation du contexte. Prenons un exemple pour mieux illustrer nos propos.
suspend fun taskOne(tracer: Tracer) {
tracer.traceCoroutine(category = CATEGORY_MAIN, "taskOne") {
delay(timeMillis = 100L)
}
}
suspend fun taskTwo(tracer: Tracer) {
tracer.traceCoroutine(category = CATEGORY_MAIN, "taskTwo") {
delay(timeMillis = 50L)
}
}
fun main() = runBlocking(context = Dispatchers.Default) {
val driver = createTraceDriver()
val tracer = driver.tracer
driver.use {
tracer.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
coroutineScope {
launch { taskOne(tracer) }
launch { taskTwo(tracer) }
}
}
println("All done")
}
}
Vous obtenez le résultat suivant.
Figure 4. Capture d'écran d'une trace Perfetto de base avec propagation du contexte.
La propagation du contexte facilite grandement la visualisation du flux d'exécution. Vous pouvez voir exactement quelles tâches étaient liées (associées à d'autres) et à quel moment Threads ont été suspendues et reprises.
Par exemple, vous pouvez voir que le segment main a généré taskOne et taskTwo.
Après cela, les deux threads étaient inactifs (étant donné que les coroutines étaient suspendues en raison de l'utilisation de delay).
Propagation manuelle
Parfois, lorsque vous mélangez des charges de travail simultanées à l'aide de coroutines Kotlin avec des instances de Executor Java, il peut être utile de propager le contexte de l'une à l'autre. Voici un exemple :
fun executorTask(
tracer: Tracer,
token: PropagationToken,
executor: Executor,
callback: () -> Unit
) {
executor.execute {
tracer.trace(
category = CATEGORY_MAIN,
name = "executeTask",
token = token,
) {
// Do something
Thread.sleep(100)
callback()
}
}
}
@OptIn(DelicateTracingApi::class)
fun main() = runBlocking(context = Dispatchers.Default) {
val driver = createTraceDriver()
val executor = Executors.newSingleThreadExecutor()
val tracer = driver.tracer
driver.use {
tracer.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
coroutineScope {
val deferred = CompletableDeferred<Unit>()
executorTask(
tracer = tracer,
// Obtain the propagation token from the CoroutineContext
token = tracer.tokenFromCoroutineContext(),
executor = executor,
callback = {
deferred.complete(Unit)
}
)
deferred.await()
}
}
executor.shutdownNow()
}
}
Vous obtenez le résultat suivant.
Figure 5. Capture d'écran d'une trace Perfetto de base avec propagation manuelle du contexte.
Vous pouvez voir que l'exécution a commencé dans un CoroutineContext, puis est passée à un Executor Java, mais nous avons quand même pu utiliser la propagation du contexte.
Combiner avec les traces système
Le nouveau androidx.tracing ne capture pas d'informations telles que la planification du processeur, l'utilisation de la mémoire et l'interaction des applications avec le système d'exploitation en général. En effet, la bibliothèque permet d'effectuer un traçage de processus à très faible surcharge.
Toutefois, il est extrêmement simple de fusionner les traces système avec les traces en cours de traitement et de les visualiser sous forme de trace unique si nécessaire. En effet, Perfetto UI permet de visualiser plusieurs fichiers de trace d'un appareil sur une chronologie unifiée.
Pour ce faire, vous pouvez démarrer une session de traçage système à l'aide de Perfetto UI en suivant ces instructions.
Vous pouvez également enregistrer des événements de trace en cours de traitement à l'aide de l'API Tracing 2.0 lorsque le traçage système est activé. Une fois que vous avez les deux fichiers de trace, vous pouvez utiliser l'option Open Multiple Trace Files dans Perfetto.
Figure 6. Ouverture de plusieurs fichiers de trace dans l'interface utilisateur de Perfetto.
Workflows avancés
Corréler les segments
Il est parfois utile d'attribuer des tranches d'une trace à une action utilisateur ou à un événement système de niveau supérieur. Par exemple, pour attribuer toutes les tranches correspondant à un travail en arrière-plan dans une notification, vous pouvez procéder comme suit :
fun main() {
val driver = createTraceDriver()
onEvent(driver, eventId = EVENT_ID)
}
fun onEvent(driver: TraceDriver, eventId: Long) {
driver.use {
driver.tracer.trace(
category = CATEGORY_MAIN,
name = "step-1",
metadataBlock = {
addCorrelationId(eventId)
}
) {
Thread.sleep(100L)
}
Thread.sleep(20)
driver.tracer.trace(
category = CATEGORY_MAIN,
name = "step-2",
metadataBlock = {
addCorrelationId(eventId)
}
) {
Thread.sleep(180)
}
}
}
Vous obtenez le résultat suivant.
Figure 7. Capture d'écran d'une trace Perfetto avec des tranches corrélées.
Ajouter des informations sur la pile d'appels
Les outils côté hôte (plug-ins de compilateur, processeurs d'annotations, etc.) peuvent également choisir d'intégrer des informations sur la pile d'appels dans une trace, afin de faciliter la localisation du fichier, de la classe ou de la méthode responsable de la production d'une section de trace dans une trace.
fun main() {
val driver = createTraceDriver()
driver.use {
driver.tracer.trace(
category = CATEGORY_MAIN,
name = "callStackEntry",
metadataBlock = {
addCallStackEntry(
name = "main",
lineNumber = 14,
sourceFile = "Basic.kt"
)
}
) {
Thread.sleep(100L)
}
}
}
Vous obtenez le résultat suivant.
Figure 8. Capture d'écran d'une trace Perfetto avec des informations sur la pile d'appels.