ספריית androidx.tracing:tracing:2.0.0-alpha01 החדשה היא Kotlin API עם תקורה נמוכה, שמאפשרת ללכוד אירועי מעקב בתהליך. האירועים האלה יכולים לתעד פלחים של זמן והקשר שלהם. בנוסף, הספרייה תומכת בהעברת הקשר לשגרות המשנה של Kotlin.
הספרייה משתמשת באותו פורמט של מנות נתונים למעקב של Perfetto שמוכר למפתחי Android. בנוסף, Tracing 2.0 (בניגוד לממשקי 1.0.0-* API) תומך במושגים של pluggable tracing backends ו-sinks, כך שספריות Tracing אחרות יכולות להתאים אישית את פורמט הפלט של Tracing ואת אופן הפעולה של context propagation בהטמעה שלהן.
תלויות
כדי להתחיל במעקב, צריך להגדיר את התלות הבאה בקובץ 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")
// ...
}
}
}
}
מצהירים על תלות ב-androidx.tracing:tracing-wire:2.0.0-alpha01 אם אתם מטארגטים ספריית Android, אפליקציית Android או אם אתם מטארגטים את JVM.
שימוש בסיסי
TraceSink מגדיר איך חבילות של נתוני מעקב עוברות סריאליזציה. Tracing 2.0.0 כולל הטמעה של Sink שמשתמש בפורמט מנות המעקב Perfetto. TraceDriver מספק נקודת אחיזה ל-Tracer ואפשר להשתמש בו כדי להשלים מעקב.
אפשר גם להשתמש ב-TraceDriver כדי להשבית את כל נקודות המעקב באפליקציה, אם בוחרים לא לעקוב בכלל בחלק מהווריאציות של האפליקציה.
ממשקי API עתידיים ב-TraceDriver יאפשרו למפתחים גם לקבוע אילו קטגוריות של מעקב הם רוצים לתעד (או להשבית אם קטגוריה מסוימת רועשת).
כדי להתחיל, יוצרים מופע של TraceSink ושל 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
}
אחרי שיש לכם מופע של TraceDriver, צריך לקבל את Tracer שמגדיר את נקודת הכניסה לכל ממשקי ה-API של מעקב.
// 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)
}
}
}
נוצרת העקבה הבאה.
איור 1. צילום מסך של מעקב Perfetto בסיסי.
אפשר לראות שהתהליך והשרשור הנכונים מתועדים, ונוצר קטע מעקב יחיד basic, שפעל במשך 100ms.
אפשר להציב חלקים (או פרוסות) של מעקב בתוך אותו מסלול כדי לייצג אירועים חופפים. לדוגמה:
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") {
// ...
}
}
נוצרת העקבה הבאה.
איור 2. צילום מסך של מעקב Perfetto בסיסי עם קטעים מוטמעים.
אפשר לראות שיש חפיפה בין האירועים ב-track של ה-thread הראשי. ברור מאוד ש-processImage קורא ל-loadImage ול-sharpen באותו השרשור.
הוספת מטא-נתונים נוספים בקטעים של מעקב
לפעמים כדאי לצרף פרוסת נתונים של מעקב עם מטא-נתונים נוספים לפי הקשר, כדי לקבל פרטים נוספים. דוגמאות למטא-נתונים כאלה יכולות לכלול את nav destination שבה נמצא המשתמש או input arguments שעשויים לקבוע את משך הזמן שלוקח לפונקציה לפעול.
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)
}
}
}
זו התוצאה שמתקבלת. שימו לב: בקטע Arguments מופיעים צמדים של מפתח/ערך שהוספו במהלך יצירת slice.
איור 3. צילום מסך של מעקב Perfetto בסיסי עם מטא-נתונים נוספים.
העברת הקשר
כשמשתמשים ב-Kotlin Coroutines (או במסגרות דומות אחרות שעוזרות בניהול עומסי עבודה מקבילים), Tracing 2.0 תומך בהעברת הקשר. ההסבר הטוב ביותר הוא באמצעות דוגמה.
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")
}
}
זו התוצאה שמתקבלת.
איור 4. צילום מסך של מעקב Perfetto בסיסי עם העברת הקשר.
התכונה 'העברת הקשר' מאפשרת לראות את זרימת הביצוע בצורה פשוטה יותר. תוכלו לראות בדיוק אילו משימות היו קשורות (מחוברות למשימות אחרות),
ובדיוק מתי Threads הושעו וחודשו.
לדוגמה, אפשר לראות שהפלח main יצר את הפלחים taskOne ו-taskTwo.
אחרי זה שני השרשורים לא היו פעילים (בהנחה שהקורוטינות הושעו – בגלל השימוש ב-delay).
הפצה ידנית
לפעמים, כשמשלבים עומסי עבודה מקבילים באמצעות קורוטינות של Kotlin עם מופעים של Java Executor, כדאי להעביר את ההקשר מאחד לשני. לדוגמה:
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()
}
}
זו התוצאה שמתקבלת.
איור 5. צילום מסך של מעקב Perfetto בסיסי עם העברת הקשר ידנית.
אפשר לראות שהביצוע התחיל ב-CoroutineContext, ואז עבר ל-Executor של Java, אבל עדיין הצלחנו להשתמש בהעברת הקשר.
שילוב עם עקבות מערכת
הכלי החדש androidx.tracing לא מתעד מידע כמו תזמון של מעבד (CPU), שימוש בזיכרון והאינטראקציה של האפליקציות עם מערכת ההפעלה באופן כללי. הסיבה לכך היא שהספרייה מספקת דרך לבצע מעקב בתהליך עם תקורה נמוכה מאוד.
עם זאת, קל מאוד למזג בין עקבות מערכת לבין עקבות בתהליך ולהציג אותם כעקבה אחת, אם צריך. הסיבה לכך היא ש-Perfetto UI
תומך בהצגה חזותית של כמה קובצי מעקב ממכשיר בציר זמן מאוחד.
כדי לעשות זאת, אפשר להתחיל סשן של מעקב אחר המערכת באמצעות Perfetto UI על ידי ביצוע ההוראות שמופיעות כאן.
אפשר גם להקליט אירועי מעקב בתהליך באמצעות Tracing 2.0 API, בזמן שהמעקב אחר המערכת מופעל. אחרי שיהיו לכם שני קובצי ה-trace, תוכלו להשתמש באפשרות Open Multiple Trace Files ב-Perfetto.
איור 6. פתיחה של כמה קובצי מעקב בממשק המשתמש של Perfetto.
תהליכי עבודה מתקדמים
השוואה בין רצועות
לפעמים כדאי לשייך את הפלחים בנתוני ה-trace לפעולת משתמש ברמה גבוהה יותר או לאירוע מערכת. לדוגמה, כדי לשייך את כל הפרוסות שמתאימות לעבודה ברקע כחלק מהתראה, אפשר לעשות משהו כזה:
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)
}
}
}
זו התוצאה שמתקבלת.
איור 7. צילום מסך של עקבות Perfetto עם פרוסות מתואמות.
הוספת מידע על call stack
בנוסף, כלים בצד המארח (תוספי קומפיילר, מעבדי הערות וכו') יכולים לבחור להטמיע מידע על מחסנית הקריאות במעקב, כדי שיהיה נוח לאתר את הקובץ, המחלקה או השיטה שאחראים ליצירת קטע מעקב במעקב.
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)
}
}
}
זו התוצאה שמתקבלת.
איור 8. צילום מסך של עקבות Perfetto עם מידע על מחסנית הקריאות.