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.
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 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.
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.
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.
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.
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.
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.
Figura 8. Acquisizione dello schermo di una traccia Perfetto con informazioni sullo stack di chiamate.