Tracciamento in corso (sperimentale)

La nuova libreria androidx.tracing:tracing:2.0.0-alpha01 è un'API Kotlin a basso overhead che consente di acquisire eventi di traccia in-process. Questi eventi possono acquisire intervalli di tempo e il relativo contesto. La libreria supporta inoltre la propagazione del contesto per le coroutine Kotlin.

La libreria utilizza lo stesso formato di pacchetto di traccia Perfetto che gli sviluppatori Android conoscono bene. Inoltre, Tracing 2.0 (a differenza delle API 1.0.0-*) supporta la nozione di backend di tracciamento plug-in e sink, in modo che altre librerie di tracciamento possano personalizzare il formato di tracciamento dell'output e il funzionamento della propagazione del contesto nella loro implementazione.

Dipendenze

Per iniziare il tracciamento, devi definire le seguenti dipendenze nel file 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")
        // ...
      }
    }
  }
}

Dichiara una dipendenza da androidx.tracing:tracing-wire-android:2.0.0-alpha01 se hai come target una libreria o un'applicazione Android. Puoi utilizzare la dipendenza androidx.tracing:tracing-wire-desktop:2.0.0-alpha01 se hai come target la JVM.

Utilizzo di base

Un TraceSink definisce la modalità di serializzazione dei pacchetti di traccia. La versione 2.0.0 di Tracing include un'implementazione di un Sink che utilizza il formato del pacchetto di traccia Perfetto. Un TraceDriver fornisce un handle per Tracer e può essere utilizzato per finalizzare una traccia.

Puoi anche utilizzare TraceDriver per disattivare tutti i punti di traccia nell'applicazione, se scegli di non eseguire il tracciamento in alcune varianti dell'applicazione. Le future API in TraceDriver consentiranno inoltre agli sviluppatori di controllare le categorie di traccia che sono interessati ad acquisire (o disattivare quando una categoria è rumorosa).

Per iniziare, crea un'istanza di un TraceSink e di 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.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
}

Dopo aver creato un'istanza di TraceDriver, ottieni Tracer che definisce il punto di ingresso per tutte le API di tracciamento.

// 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)
        }
    }
}

Viene generata la seguente traccia.

Acquisizione schermo di una traccia Perfetto di base

Figura 1. Acquisizione schermata di una traccia Perfetto di base.

Puoi vedere che le tracce di processo e thread corrette sono popolate e hanno prodotto una singola sezione di traccia basic, che è stata eseguita per 100ms.

Le sezioni (o i segmenti) della traccia possono essere nidificate sulla stessa traccia per rappresentare eventi sovrapposti. Ecco un esempio.

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") {
        // ...
    }
}

Viene generata la seguente traccia.

Acquisizione dello schermo di una traccia Perfetto di base con sezioni nidificate

Figura 2. Acquisizione schermo di una traccia Perfetto di base con sezioni nidificate.

Puoi notare che ci sono eventi sovrapposti nella traccia del thread principale. È molto chiaro che processImage chiama loadImage e sharpen nello stesso thread.

Aggiungere metadati aggiuntivi nelle sezioni della traccia

A volte, può essere utile allegare metadati contestuali aggiuntivi a una sezione della traccia per ottenere maggiori dettagli. Alcuni esempi di questi metadati potrebbero includere il nav destination in cui si trova l'utente o input arguments che potrebbe finire per determinare la durata di una funzione.

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)
        }
    }
}

Questo produce il seguente risultato. Tieni presente che la sezione Arguments contiene coppie chiave-valore aggiunte durante la produzione di slice.

Acquisizione schermo di una traccia Perfetto di base con metadati aggiuntivi

Figura 3. Screenshot di una traccia Perfetto di base con metadati aggiuntivi.

Propagazione del contesto

Quando utilizzi le coroutine Kotlin (o altri framework simili che aiutano con carichi di lavoro simultanei), Tracing 2.0 supporta il concetto di propagazione del contesto. Il modo migliore per spiegarlo è con un esempio.

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")
    }
}

Questo produce il seguente risultato.

Acquisizione dello schermo di una traccia Perfetto con propagazione del contesto

Figura 4. Acquisizione schermo di una traccia Perfetto di base con propagazione del contesto.

La propagazione del contesto semplifica notevolmente la visualizzazione del flusso di esecuzione. Puoi vedere esattamente quali attività erano correlate (collegate ad altre) e quando esattamente Threads sono state sospese e ripristinate.

Ad esempio, puoi vedere che la sezione main ha generato taskOne e taskTwo. Dopodiché entrambi i thread sono diventati inattivi (dato che le coroutine sono state sospese a causa dell'utilizzo di delay).

Propagazione manuale

A volte, quando combini carichi di lavoro simultanei utilizzando le coroutine Kotlin con istanze di Executor Java, potrebbe essere utile propagare il contesto da uno all'altro. Ecco un esempio:

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()
    }
}

Questo produce il seguente risultato.

Acquisizione dello schermo di una traccia Perfetto con propagazione manuale del contesto

Figura 5. Acquisizione schermo di una traccia Perfetto di base con propagazione manuale del contesto.

Puoi notare che l'esecuzione è iniziata in un CoroutineContext e successivamente è passata a un Executor Java, ma siamo comunque riusciti a utilizzare la propagazione del contesto.

Combinare con le tracce di sistema

Il nuovo androidx.tracing non acquisisce informazioni come la pianificazione della CPU, l'utilizzo della memoria e l'interazione delle applicazioni con il sistema operativo in generale. Questo perché la libreria fornisce un modo per eseguire una tracciabilità in-process a basso overhead.

Tuttavia, è estremamente semplice unire le tracce di sistema con le tracce in-processo e visualizzarle come una singola traccia, se necessario. Questo perché Perfetto UI supporta la visualizzazione di più file di traccia di un dispositivo in una sequenza temporale unificata.

Per farlo, puoi avviare una sessione di tracciamento del sistema utilizzando Perfetto UI seguendo le istruzioni riportate qui.

Puoi anche registrare gli eventi di traccia in-process utilizzando l'API Tracing 2.0, mentre la tracciatura del sistema è attiva. Una volta ottenuti entrambi i file di traccia, puoi utilizzare l'opzione Open Multiple Trace Files in Perfetto.

Apertura di più file di traccia in Perfetto UI

Figura 6. Apertura di più file di traccia in Perfetto UI.

Flussi di lavoro avanzati

Correlare le sezioni

A volte è utile attribuire le sezioni di una traccia a un'azione utente di livello superiore o a un evento di sistema. Ad esempio, per attribuire tutte le sezioni che corrispondono a un lavoro in background nell'ambito di una notifica, potresti fare qualcosa del genere:

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)
        }
    }
}

Questo produce il seguente risultato.

Acquisizione dello schermo di una traccia Perfetto con sezioni correlate

Figura 7. Acquisizione schermo di una traccia Perfetto con sezioni correlate.

Aggiungere informazioni sullo stack di chiamate

Gli strumenti lato host (plug-in del compilatore, processori di annotazioni e così via) possono anche scegliere di incorporare informazioni sullo stack di chiamate in una traccia, per facilitare l'individuazione del file, della classe o del metodo responsabile della produzione di una sezione di traccia in una traccia.

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)
        }
    }
}

Questo produce il seguente risultato.

Acquisizione schermo di una traccia Perfetto con informazioni sullo stack di chiamate

Figura 8. Acquisizione dello schermo di una traccia Perfetto con informazioni sullo stack di chiamate.