Traçage en cours (expérimental)

La nouvelle bibliothèque androidx.tracing:tracing:2.0.0-alpha04 est une API Kotlin peu gourmande en ressources qui permet de capturer des événements de trace en cours de traitement. Ces événements peuvent capturer des tranches de temps 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 Perfetto format de paquet de trace que celui que les développeurs Android connaissent bien. De plus, Tracing 2.0 (contrairement aux API 1.0.0-*) est compatible avec la notion de backends de traçage enfichables et de récepteurs, de sorte que d'autres bibliothèques de traçage peuvent 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 build.gradle.kts.

kotlin {
  androidLibrary {
    namespace = "com.example.library"
    // ...
  }
  sourceSets {
    androidMain {
      dependencies {
        api("androidx.tracing:tracing-wire:2.0.0-alpha04")
        // ...
      }
    }
    jvmMain {
      dependencies {
        api("androidx.tracing:tracing-wire:2.0.0-alpha04")
        // ...
      }
    }
  }
}

Déclarez une dépendance sur androidx.tracing:tracing-wire:2.0.0-alpha04 si vous ciblez une bibliothèque Android, une application Android ou 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 le 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 d'application. Les futures API du 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 d'un TraceSink et d'un 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.wire.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 disposez d'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 {
        it.tracer.trace(category = CATEGORY_MAIN, name = "basic") {
            Thread.sleep(100L)
        }
    }
}

La trace suivante est générée.

Capture d'écran d'une trace Perfetto de base

Figure 1. Capture d'écran d'une trace Perfetto de base.

Vous pouvez voir que les pistes de processus et de thread correctes sont renseignées et qu'une seule section de trace basic a été produite, qui s'est exécutée pendant 100ms.

Les sections de trace (ou segments) 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 {
        it.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.

Capture d'écran d'une trace Perfetto de base avec des sections imbriquées

Figure 2. Capture d'écran d'une trace Perfetto de base avec des sections imbriquées.

Vous pouvez voir qu'il existe des événements qui se chevauchent dans la piste de 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 à un segment de trace pour obtenir plus de détails. Par exemple, ces métadonnées peuvent inclure la nav destination sur laquelle se trouve l'utilisateur ou les input arguments qui peuvent déterminer la durée d'une fonction.

fun main() {
    val driver = createTraceDriver()
    driver.use {
        it.tracer.trace(
            category = CATEGORY_MAIN,
            name = "basicWithContext",
            // Add additional metadata
            metadataBlock = {
                // Add key value pairs.
                addMetadataEntry("key", "value")
                addMetadataEntry("count", 1L)
            }
        ) {
            Thread.sleep(100L)
        }
    }
}

Le résultat suivant est généré. Notez que la section Arguments contient des paires clé-valeur ajoutées lors de la production du slice.

Capture d'écran d'une trace Perfetto de base avec des métadonnées supplémentaires

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 facilitent les charges de travail simultanées), Tracing 2.0 est compatible avec la notion de propagation du contexte. Le mieux est d'expliquer cela à l'aide d'un exemple.

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 {
        it.tracer.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
            coroutineScope {
                launch { taskOne(tracer) }
                launch { taskTwo(tracer) }
            }
        }
        println("All done")
    }
}

Le résultat suivant est généré.

Capture d'écran d'une trace Perfetto avec propagation du contexte

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 (connectées à d'autres) et à quel moment précis les Threads ont été suspendus 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 combinez des charges de travail simultanées à l'aide de coroutines Kotlin avec des instances de Java Executor, 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 {
        it.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()
    }
}

Le résultat suivant est généré.

Capture d&#39;écran d&#39;une trace Perfetto avec propagation manuelle du contexte

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 des 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 en cours de traitement très peu gourmand en ressources.

Toutefois, il est extrêmement simple de fusionner des traces système avec des 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 les instructions ici.

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 disposez des deux fichiers de trace, vous pouvez utiliser l'option Open Multiple Trace Files dans Perfetto.

Ouverture de plusieurs fichiers de trace dans l&#39;interface utilisateur de Perfetto

Figure 6. Ouverture de plusieurs fichiers de trace dans l'interface utilisateur de Perfetto.

Workflows avancés

Corréler des segments

Il est parfois utile d'attribuer des segments d'une trace à une action utilisateur de niveau supérieur ou à un événement système. Par exemple, pour attribuer tous les segments qui correspondent à un travail en arrière-plan dans le cadre d'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 {
        it.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)
        }
    }
}

Le résultat suivant est généré.

Capture d&#39;écran d&#39;une trace Perfetto avec des tranches corrélées

Figure 7. Capture d'écran d'une trace Perfetto avec des segments corrélés.

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 {
        it.tracer.trace(
            category = CATEGORY_MAIN,
            name = "callStackEntry",
            metadataBlock = {
                addCallStackEntry(
                    name = "main",
                    lineNumber = 14,
                    sourceFile = "Basic.kt"
                )
            }
        ) {
            Thread.sleep(100L)
        }
    }
}

Le résultat suivant est généré.

Capture d&#39;écran d&#39;une trace Perfetto avec des informations sur la pile d&#39;appels

Figure 8. Capture d'écran d'une trace Perfetto avec des informations sur la pile d'appels.