Traçage dans le processus

La bibliothèque androidx.tracing:tracing:2.0.2 est une API Kotlin à faible surcharge qui vous permet de capturer des é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 dans votre build.gradle.kts.

Projets Kotlin Multiplatform

Les bibliothèques qui n'ont besoin d'émettre que des événements de trace doivent dépendre de l'API androidx.tracing:tracing légère. Les applications qui configurent le backend de trace doivent également dépendre de androidx.tracing:tracing-wire.

kotlin {
  sourceSets {
    commonMain {
      dependencies {
        // API definition
        implementation("androidx.tracing:tracing:2.0.2")
      }
    }
    androidMain {
      dependencies {
        // Android implementation (includes the Perfetto Sink and automatic initialization)
        implementation("androidx.tracing:tracing-wire:2.0.2")
      }
    }
    jvmMain {
      dependencies {
        // JVM implementation
        implementation("androidx.tracing:tracing-wire:2.0.2")
      }
    }
  }
}

Projets Android uniquement

Si vous ne ciblez qu'Android, ajoutez les éléments suivants au fichier build.gradle.kts de votre application ou de votre bibliothèque :

dependencies {
    // For libraries and applications to emit events
    implementation("androidx.tracing:tracing:2.0.2")

    // For applications to configure the tracing backend
    implementation("androidx.tracing:tracing-wire:2.0.2")
}

Initialisation et découverte

Avant de pouvoir enregistrer des événements de trace, vous devez initialiser l'infrastructure de traçage. Cela implique de créer un AbstractTraceDriver et d'enregistrer son Tracer à l'échelle mondiale.

Android

Sur Android, si vous incluez la dépendance androidx.tracing:tracing-wire, l'initialisation se produit automatiquement au démarrage de l'application à l'aide de la bibliothèque androidx.startup.

Par défaut, cette initialisation automatique effectue les opérations suivantes :

  • Crée un TraceDriver avec un TraceSink qui écrit des fichiers de trace Perfetto dans Context.noBackupFilesDir/perfetto_traces/.

  • Elle enregistre les lignes Tracer résultantes au niveau mondial.

Personnaliser l'instance TraceDriver

Si vous devez personnaliser la configuration, par exemple pour modifier l'emplacement d'enregistrement des fichiers de trace ou pour utiliser un TraceSink personnalisé, vous pouvez fournir votre propre instance AbstractTraceDriver.

Pour personnaliser la configuration, faites en sorte que votre classe Application implémente AbstractTraceDriver.Factory :

import android.app.Application
import androidx.tracing.AbstractTraceDriver
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

class App : Application(), AbstractTraceDriver.Factory {
    override fun create(): AbstractTraceDriver {
        val sink = TraceSink(
            context = this,
            fileProvider = { File(noBackupFilesDir, "traces") },
        )
        // Return the custom TraceDriver
        // You can also fully customize the instance of Tracer
        return TraceDriver(context = this, sink = sink)
    }
}

L'initialiseur automatique détecte que votre sous-classe Application implémente Factory et utilise votre fabrique de pilotes personnalisée.

JVM

Sur la JVM, il n'existe aucun mécanisme d'amorçage automatique. L'application est responsable de l'initialisation de TraceDriver et de l'enregistrement de Tracer à l'échelle mondiale au démarrage, le plus souvent dans votre fonction main.

Pour enregistrer le traceur, appelez Tracer.setGlobalTracer().

import androidx.tracing.Tracer
import androidx.tracing.DelicateTracingApi
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

fun main() {
    // Create the TraceSink, and the `TraceDriver`
    val outputDirectory = File("/tmp/perfetto")
    val sink = TraceSink(directory = outputDirectory)
    val driver = TraceDriver(sink = sink, isEnabled = true)

    // Register the tracer
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    // Call driver.close() as a result of the process shutdown hook.
    Runtime.getRuntime().addShutdownHook(Thread {
        driver.close()
    })
}

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 Sink qui utilise le format de paquet de trace Perfetto. Un TraceDriver fournit un handle au Tracer et peut être utilisé pour finaliser une trace.

Une fois que Tracer est initialisé, automatiquement sur Android ou manuellement sur la JVM, utilisez l'instance Tracer.global globale pour émettre des événements de 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. Vous pouvez éventuellement activer des points de trace pour un category donné en fournissant une implémentation pour isCategoryEnabled lors de la création d'une instance de TraceDriver.

val driver = TraceDriver(
    sink = sink,
    isCategoryEnabled = { category ->
        // Only enable trace points in the "com.example" package
        category.startsWith("com.example")
    }
)

Voici un exemple de base d'émission d'un événement de trace à l'aide de Tracer.global sur la JVM, y compris la configuration manuelle :

import androidx.tracing.Tracer
import androidx.tracing.DelicateTracingApi
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

// Category names should also follow the same convention used for package names
// on Android and Java. This makes them easier to identify and filter.
internal const val CATEGORY_MAIN = "com.example"

fun createSink(): TraceSink {
    val outputDirectory = File("/tmp/perfetto")
    if (!outputDirectory.exists()) {
        outputDirectory.mkdirs()
    }
    return TraceSink(directory = outputDirectory)
}

fun createTraceDriver(): TraceDriver {
    return TraceDriver(sink = createSink(), isCategoryEnabled = {true})
}

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(category = CATEGORY_MAIN, name = "basic") {
            // The block of code that needs to be traced.
            Thread.sleep(100L)
        }
    }
}

Cela génère la trace suivante.

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

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 produit une seule section de trace basic, qui a duré 100 ms.

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() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "processImage",
        ) {
            // Load the data first, then apply the sharpen filter
            sharpen(output = loadImage())
        }
    }
}

internal fun loadImage(): ByteArray {
    return Tracer.global.trace(CATEGORY_MAIN, "loadImage") {
        // Loads an image
        // ...
        // A placeholder
        ByteArray(0)
    }
}

internal fun sharpen(output: ByteArray) {
    // ...
    Tracer.global.trace(CATEGORY_MAIN, "sharpen") {
        // ...
    }
}

Cela génère la trace suivante.

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 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()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.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.

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 prend en charge la propagation du contexte. Prenons un exemple pour mieux illustrer nos propos.

suspend fun taskOne() {
    Tracer.global.traceCoroutine(category = CATEGORY_MAIN, "taskOne") {
        delay(timeMillis = 100L)
    }
}

suspend fun taskTwo() {
    Tracer.global.traceCoroutine(category = CATEGORY_MAIN, "taskTwo") {
        delay(timeMillis = 50L)
    }
}

fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
              taskOne()
              taskTwo()
            }
        }
        println("All done")
    }
}

Vous obtenez le résultat suivant.

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 (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, car 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(
    token: PropagationToken,
    executor: Executor,
    callback: () -> Unit
) {
    executor.execute {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "executeTask",
            token = token,
        ) {
            // Do something
            Thread.sleep(100)
            callback()
        }
    }
}

fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    val executor = Executors.newSingleThreadExecutor()
    driver.use {
        Tracer.global.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
            coroutineScope {
                val deferred = CompletableDeferred<Unit>()
                executorTask(
                    // Obtain the propagation token from the CoroutineContext
                    token = Tracer.global.tokenFromCoroutineContext(),
                    executor = executor,
                    callback = {
                        deferred.complete(Unit)
                    }
                )
                deferred.await()
            }
        }
        executor.shutdownNow()
    }
}

Vous obtenez le résultat suivant.

Capture d'écran d'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 les traces système

La bibliothèque androidx.tracing ne capture pas d'informations telles que la planification du processeur, l'utilisation de la mémoire et l'interaction de l'application avec le système d'exploitation en général. En effet, la bibliothèque permet d'effectuer un traçage de processus à 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.

Ouvrir plusieurs fichiers de trace dans l'interface utilisateur de Perfetto

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

Workflows avancés

Cette section décrit les workflows avancés que vous pouvez implémenter avec la bibliothèque de traçage intégré.

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()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        onEvent(eventId = EVENT_ID)
    }
}

fun onEvent(eventId: Long) {
    Tracer.global.trace(
        category = CATEGORY_MAIN,
        name = "step-1",
        metadataBlock = {
            addCorrelationId(eventId)
        }
    ) {
        Thread.sleep(100L)
    }

    Thread.sleep(20)

    Tracer.global.trace(
        category = CATEGORY_MAIN,
        name = "step-2",
        metadataBlock = {
            addCorrelationId(eventId)
        }
    ) {
        Thread.sleep(180)
    }
}

Vous obtenez le résultat suivant.

Capture d'écran d'une trace Perfetto avec des tranches corrélées

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, tels que les plug-ins de compilation et les processeurs d'annotations, 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()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "callStackEntry",
            metadataBlock = {
                addCallStackEntry(
                    name = "main",
                    lineNumber = 14,
                    sourceFile = "Basic.kt"
                )
            }
        ) {
            Thread.sleep(100L)
        }
    }
}

Vous obtenez le résultat suivant.

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

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