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 anche 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 il concetto 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:2.0.0-alpha01")
// ...
}
}
jvmMain {
dependencies {
api("androidx.tracing:tracing-wire:2.0.0-alpha01")
// ...
}
}
}
}
Dichiara una dipendenza da androidx.tracing:tracing-wire:2.0.0-alpha01 se
hai come target una libreria Android, un'applicazione Android o 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.
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.
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 le chiamate processImage e loadImage e sharpen si trovano 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 su 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)
}
}
}
Viene prodotto il seguente risultato. Tieni presente che la sezione Arguments contiene coppie chiave-valore aggiunte durante la produzione del slice.
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 i 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")
}
}
Viene prodotto il seguente risultato.
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 notare 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()
}
}
Viene prodotto il seguente risultato.
Figura 5. Acquisizione schermo di una traccia Perfetto di base con propagazione manuale del contesto.
Puoi vedere 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 tracciamento in-process con un overhead molto basso.
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 traccia di sistema è attiva. Una volta ottenuti entrambi i file di traccia, puoi utilizzare l'opzione
Open Multiple Trace Files in Perfetto.
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 come parte 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)
}
}
}
Viene prodotto il seguente risultato.
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 inoltre 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)
}
}
}
Viene prodotto il seguente risultato.
Figura 8. Acquisizione dello schermo di una traccia Perfetto con informazioni sullo stack di chiamate.