一个实用的Android Perfetto分析器

297 阅读15分钟

本文译自「Building a Deterministic Perfetto Analyzer for Android」,原文链接proandroiddev.com/building-a-…,由Sumeet Singh Arora发布于2026年1月6日。

插图由作者使用 AI 图像生成工具生成。

每个 Android 性能工程师都面临的问题

当你打开一个 Perfetto trace。

会有成千上万个切片。数十个线程。数百万个数据点。一条看起来像抽象艺术的时间线。

而在这其中的某个地方——被层层系统噪声、框架回调和线程交错所掩盖——隐藏着一个简单问题的答案:为什么我的应用运行缓慢?

我花了数年时间大规模调试 Android 性能。模式总是千篇一律:

  1. 用户抱怨启动卡顿/缓慢

  2. 工程师捕获跟踪信息

  3. 工程师打开 Perfetto 用户界面

  4. 工程师花费 2 小时滚动、缩放、眯眼查看

  5. 工程师_可能_找到了根本原因

跟踪信息无法讲述故事。它们只是罗列事实——带有时间戳、层层嵌套、令人眼花缭乱。“拥有跟踪信息”和“理解性能”之间存在着巨大的鸿沟。

本文旨在通过编程方式弥合这一鸿沟。

在过去的几周里,我一直在构建一个Perfetto 跟踪分析器,它可以将原始跟踪信息转换为结构化的、易于理解的洞察。没有仪表盘。只有确定性分析,它反映了经验丰富的工程师实际思考性能问题的方式。

读完本文,你将了解如何:

  1. 使跟踪信息可读——启动时间、长时间任务、帧健康状况

  2. 使跟踪信息可归属——进程、线程、组件所有权

  3. 分离职责——应用程序、框架和系统

  4. 使用时间窗口查找关键事件发生的时间点

  5. 生成确定性嫌疑对象——用于调查的优先级信号

为什么 Perfetto 跟踪难以阅读

Perfetto 功能极其强大。它可以捕获:

  • CPU 调度

  • 帧渲染

  • Binder 事务

  • 应用自定义跟踪部分

  • 系统服务活动

但强大的功能也带​​来了复杂性。一个典型的跟踪可能包含:

  • 超过 50,000 个切片

  • 超过 100 个不同的线程名称

  • 超过 10 层的嵌套层级结构

当你调试卡顿的滚动或缓慢的启动问题时,你并不需要所有这些信息。你需要的是:

  1. 发生了什么 — 启动时间、耗时较长的任务、帧健康状况

  2. 谁做的 — 哪个进程、哪个线程

  3. 什么类型的工作 — 应用代码、框架还是系统代码

让我们逐一解决这些问题。

第一步:使跟踪更易读

首要目标是提取人们真正关心的高级指标。

启动时间

分析器 使用尽力而为的启发式方法来估算启动时间:从最早的切片到第一个 Choreographer#doFrame 的时间。

这种方法并不完美——它无法涵盖所有​​启动场景——但它提供了一个有用的基准进行比较。

以下是 TraceToy 测试跟踪的示例(TraceToy 是我专门构建的一个示例应用程序,用于生成有趣的测试跟踪——稍后会详细介绍):

{
  "startup_ms": 2910.15
}

在生产环境中,对于优化良好的冷启动,你通常会看到 500–1500 毫秒的启动时间;而对于初始化繁重的应用程序,启动时间则可能达到 2000–5000 毫秒甚至更长。具体的数值并不重要,重要的是跟踪其随时间的变化——无论基线是 800 毫秒还是 3000 毫秒,20% 的性能下降就是 20% 的性能下降。

生产环境的应用通常会更精确地检测启动过程——使用 reportFullyDrawn()、自定义跟踪标记(例如 Startup#complete)或 Jetpack App Startup 等库。当这些方法不可用时,分析器的启发式方法是一个合理的备选方案,但你可以(也应该)对其进行调整,使其与应用对“启动完成”的实际定义相匹配。

一个数值。可立即用于性能下降检测。

UI 线程上的长任务

长任务是指超过阈值并阻塞主线程的任务片段。这些是导致卡顿和无响应的主要原因。

分析器默认使用 50 毫秒的阈值(可通过 --long-task-ms 配置):

{
  "ui_thread_long_tasks": {
    "threshold_ms": 50,
    "count": 12,
    "top": [
      {"name": "<internal slice>", "dur_ms": 16572.1},
      {"name": "216", "dur_ms": 4103.2},
      {"name": "Handler: android.view.View", "dur_ms": 312.5}
    ]
  }
}

看出问题了吗?是数字 ID。内部切片。无上下文。无归属信息。

技术上正确。实际应用价值有限。

我们知道_某些东西_阻塞了主线程 16 秒。但我们不知道_是什么_,也不知道_是谁_造成的。这就是原始分析失效的地方——也是为什么步骤 2(归属信息)至关重要的原因。

帧健康状况

丢帧会导致明显的卡顿。分析器从 Choreographer#doFrame 切片中提取帧时间:

{
  "frame_features": {
    "total_frames": 888,
    "janky_frames": 37,
    "p95_frame_ms": 14.35
  }
}

在这里,百分位数比平均值更重要——即使大多数帧都很快,少数几个坏帧也会破坏流畅度。

局限性

此时,我们可以回答:发生了什么?

但我们无法回答:谁该负责?

步骤 2:使跟踪数据可归因

归因是指将性能数据与所有权关联起来。在调试实际问题时,工程师会问:

  • 哪个 进程 执行了此操作?

  • 哪个 线程*?*

  • 是我的 应用 还是其他程序?_

聚焦进程过滤

首先,我们需要过滤掉噪声。跟踪数据会捕获设备上的_所有_活动——系统服务、其他应用、内核活动。其中大部分都无关紧要。

分析器支持聚焦模式:

python3 -m perfetto_agent.cli analyze \
  --trace tracetoy_trace.perfetto-trace \
  --focus-process com.example.tracetoy \
  --out analysis.json

这会将输出从“设备上发生的一切”转移到“与_此_应用相关的内容”。这是一个小小的标志,却能极大地提高信噪比。

进程和线程识别

设置聚焦进程后,每个切片都会包含所有权数据。分析器会将 pidtid 连接到进程名和线程名:

{
  "name": "BG#churn",
  "dur_ms": 267.29,
  "pid": 5664,
  "tid": 5664,
  "thread_name": "xample.tracetoy",
  "process_name": "com.example.tracetoy"
}

将其与步骤 1 中无用的 "name": "216" 进行比较。现在我们知道:

  • ✅ 它来自我的应用程序 (com.example.tracetoy)

  • ✅ 它运行在主线程上 (tid == pid)

  • ✅ 这是一个应用程序定义的段(BG# 前缀)

主线程检测

这里有一个微妙但重要的细节:主线程在跟踪信息中并不总是标记为“main”

分析器采用尽力而为的启发式方法:

  1. 如果应用进程存在名为 "main" 的线程 → 使用该线程

  2. 否则,回退到 tid == pid(在 Android 中通常适用于主线程)

这模拟了经验丰富的工程师在元数据不完整时的推理方式——为了透明起见,分析器会记录所使用的启发式方法。

应用自定义节成为一等公民

Android 的 Trace.beginSection() API 允许开发者标记自定义 span:

Trace.beginSection("UI#loadUserProfile")
// ... work ...
Trace.endSection()

分析器会显式地提取并聚合这些信息:

{
  "app_sections": {
    "top_by_total_ms": [
      {"name": "BG#churn", "total_ms": 11811.05, "count": 194},
      {"name": "UI#stall_button_click", "total_ms": 818.05, "count": 4}
    ]
  }
}

这回答了以下问题:我的代码的哪些部分_主导了执行?

至此,我们已将匿名切片转换为具有属性的、可操作的数据。但我们仍然没有回答最重要的问题:究竟是谁的错——我的应用、框架还是系统?

第三步:职责分离

关于移动性能,有一个令人不安的真相:

当你的应用运行缓慢时,并非所有问题都出在你身上。

各种数据混杂在一起:

  • 你的应用代码

  • Android 框架内部代码

  • 系统和内核活动

如果不将这些数据分离,很容易得出错误的结论,并优化错误的地方。

确定性分类

每个切片现在都被标记为以下类别之一:

  • 🟢 app — 焦点进程上的应用程序标记(例如,UI#stall_button_click

  • 🔵 framework — 焦点进程上的框架标记(例如,Choreographer#doFrame

  • 🔴 system — 非焦点进程 ID 或系统标记(例如,binder transaction

  • unknown — 无法确定分类(例如,数字 ID)

分类器有意采用保守的分类方式:

  • 基于进程 ID 的所有权

  • 基于线程的所有权

  • 基于明确的名称标记规则

如果一个切片无法明确归类,则会被标记为“unknown”。这并非错误,而是诚实。

带有责任标签的长任务

以下是一个已分类的长任务切片示例:

{
  "name": "BG#churn",
  "dur_ms": 267.29,
  "pid": 5664,
  "tid": 5664,
  "thread_name": "xample.tracetoy",
  "process_name": "com.example.tracetoy",
  "category": "app"
}

以及一个框架密集型任务切片示例:

{
  "name": "dequeueBuffer - VRI[MainActivity]#0(BLAST Consumer)0",
  "dur_ms": 180.4,
  "pid": 5664,
  "tid": 5688,
  "thread_name": "RenderThread",
  "category": "framework"
}

相同的跟踪记录,但对优化方向的影响却截然不同。

聚合分解

分析器无需手动查看数百个切片,而是计算以下内容:

{
  "work_breakdown": {
    "by_category_ms": {
      "app": 12603.72,
      "framework": 305.77,
      "system": 0.0,
      "unknown": 5167.50
    }
  }
}

这一个代码块回答了一个重要的问题:

此性能问题主要是应用程序本身的问题吗?

有时答案令人不安,有时却令人欣慰。无论如何,它都基于数据。

主线程阻塞:究竟是谁的责任?

分析器按类别分解主线程阻塞时间:

{
  "main_thread_blocking": {
    "app_ms": 12603.72,
    "framework_ms": 305.77,
    "system_ms": 0.0,
    "unknown_ms": 2840.88
  }
}

责任归属在此变得不可回避。

如果主线程主要被应用程序工作阻塞(就像这个跟踪记录中显示的那样),你就能准确地知道应该把优化工作集中在哪些方面

摘要块

所有内容都汇总成一个最简洁的摘要:

{
  "summary": {
    "main_thread_found": true,
    "top_app_sections": ["BG#churn", "dequeueBuffer - VRI[MainActivity]#0...", "UI#stall_button_click"],
    "top_long_slice_name": "BG#churn",
    "dominant_work_category": "app",
    "main_thread_blocked_by": "app"
  }
}

这不是解释,而是一个信号——告诉你应该关注哪里,以及哪些地方不应该关注。

第四步:时间窗口和嫌疑对象

知道是谁执行了操作固然重要,但性能下降很少是全局性的——它们往往是暂时性的

典型的问题如下:

  • 为什么启动这么慢?

  • 为什么卡顿只在第一个屏幕之后才出现?

  • 为什么 UI 线程在交互过程中被阻塞,但在启动时却没有?

缺乏时间上下文的归因分析仍然需要手动梳理时间线。第四步可以减轻这种认知负担。

时间窗口:明确“何时”⏱️

分析器不再将跟踪信息视为一条长长的时间线,而是将其划分为粗略的、确定性的窗口:

  • 启动窗口 — 从进程启动到启动结束

  • 稳定状态窗口 — 启动后的时间段(默认值:5 秒)

{
  "time_windows": {
    "startup": {
      "start_ms": 0.0,
      "end_ms": 2910.15,
      "method": "startup_ms"
    },
    "steady_state": {
      "start_ms": 2910.15,
      "end_ms": 7910.15,
      "method": "startup_end + 5000ms"
    }
  }
}

仅此一项就改变了你对跟踪信息的推理方式。

窗口分解:上下文中的责任归属 📊

对于每个窗口,分析器计算:

  • 按类别划分的总工作量(appframeworksystemunknown

  • 按类别划分的主线程阻塞

来自 TraceToy 跟踪:

{
  "window_breakdown": {
    "startup": {
      "by_category_ms": {"app": 0.0, "framework": 0.0, "system": 378.43, "unknown": 101.34},
      "main_thread_blocking_ms": {"app_ms": 0.0, "framework_ms": 0.0, "system_ms": 0.0, "unknown_ms": 101.34}
    },
    "steady_state": { /* ... */ }
  }
}

这可以确定性地回答以下问题:

启动阶段主要由系统工作主导。稳定阶段有大量未知工作阻塞了主线程。

无需猜测。

从数据到嫌疑人 🕵️

一旦掌握了归因、责任和时间窗口,就可以找出嫌疑人

嫌疑人并非解释,而是一个_优先级信号_。

分析器生成一个确定性列表:

{
  "suspects": [
    {"label": "Startup main thread dominated by unknown work", "window": "startup", "evidence": {"unknown_ms": 101.34}},
    {"label": "Startup dominated by system work", "window": "startup", "evidence": {"system_ms": 378.43}}
    // ... more suspects for steady_state
  ]
}

这些标签是模板化的、可复现的,并且有证据支持。

它们不解释问题,而是指明方向。

摘要现在更有意义了🧭

摘要块变得可操作:

{
  "summary": {
    "startup_dominant_category": "system",
    "steady_state_dominant_category": "unknown",
    "top_suspect": "Startup main thread dominated by unknown work"
  }
}

如果你只阅读此摘要,你就已经知道:

  • 从哪里开始调查

  • 应该查看哪种类型的工作

  • 哪个阶段最重要

这与原始跟踪信息相比是一个巨大的转变。

整合所有步骤

以下是完整的工作流程:

1. 捕获跟踪

使用 Android Studio 的性能分析器或命令行:

adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace -t 10s \
  sched freq idle am wm gfx view binder_driver hal dalvik camera input res memory

2. 运行分析器

python3 -m perfetto_agent.cli analyze \
  --trace trace.perfetto-trace \
  --focus-process com.example.myapp \
  --out analysis.json \
  --long-task-ms 50 \
  --top-n 10

3.阅读输出

首先查看 summary 块,了解主要类别和主要嫌疑人,然后深入查看具体信息:

  • work_breakdown 用于查看类别分布

  • features.long_slices_attributed 用于查看单个嫌疑人

  • features.app_sections 用于查看你的插桩代码

此功能无法实现的功能

让我们明确一下它的局限性:

  • 不追究责任 — 仅显示证据

  • 不生成建议 — 这需要理解意图

  • 不声明因果关系 — 相关性不等于因果关系

该系统使责任可见。这就足够了。

使用 TraceToy 进行测试

我构建了一个名为 TraceToy 的示例应用程序,用于生成有趣的跟踪信息,以便测试分析器。

它包含:

  • 启动初始化(使用 StartupInit 标记)

  • UI 阻塞按钮 — 使用 UI#stall_button_click 故意阻塞 UI 线程 200 毫秒

  • 后台加载切换 — 生成 BG#churn 标记

  • 可滚动列表 — 生成帧渲染事件

截图由作者从 Android TraceToys 示例应用中截取。

快速入门

# Record a 20-second trace
adb shell perfetto -o /data/misc/perfetto-traces/trace -t 20s sched gfx view dalvik
# Interact with the app (tap UI Stall, scroll, toggle background load)# Pull and analyze
adb pull /data/misc/perfetto-traces/trace tracetoy_trace.perfetto-trace
python3 -m perfetto_agent.cli analyze \
  --trace tracetoy_trace.perfetto-trace \
  --focus-process com.example.tracetoy \
  --out analysis.json

输出应包含:

  • 已识别 TraceToy 进程(com.example.tracetoy,进程 ID 5664)

  • 应用部分中出现 BG#churn(194 次,总计 11.8 秒)和 UI#stall_button_click(4 次,总计 818 毫秒)

  • 199 个耗时超过 50 毫秒阈值的长任务

  • 888 帧,其中 37 帧卡顿(p95:14.35 毫秒)

实现亮点:切片分类

分析器中最有趣的部分是分类器。它使用显式标记列表,并且有意采用保守的分类方法:

def classify_slice_name(name: str | None, pid: int | None, focus_pid: int | None) -> str:
    """Classify a slice into app/framework/system/unknown using pid + name tokens."""
    # If not from focus process, it's system
    if focus_pid is not None and pid is not None and pid != focus_pid:
        return "system"    
    if not name:
        return "unknown"    
    
    lower_name = name.lower()        
    app_tokens = ["ui#", "bg#", "startupinit"]
    framework_tokens = [
        "choreographer", "doframe", "renderthread", 
        "viewrootimpl", "dequeuebuffer", "blast", "hwui"
    ]
    system_tokens = ["binder", "surfaceflinger", "sched", "kworker", "irq", "futex"]    
    # Check app tokens first (highest confidence)
    if focus_pid is not None and pid == focus_pid:
        if any(token in lower_name for token in app_tokens):
            return "app"
        if any(token in lower_name for token in framework_tokens):
            return "framework"    
    if any(token in lower_name for token in system_tokens):
        return "system"    
    return "unknown"

关键见解:保守的分类方法优于错误的置信度。如果无法准确分类,则“未知”是 诚实的答案。

请参阅完整实现,了解启动检测、主线程解析和帧健康计算。

为你的应用添加插桩

为了最大限度地利用此分析,请对你的代码进行插桩:

import android.os.Trace
class UserProfileLoader {
    fun loadProfile(userId: String): UserProfile {
        Trace.beginSection("UI#loadUserProfile")
        try {
            Trace.beginSection("UI#fetchFromCache")
            val cached = cache.get(userId)
            Trace.endSection()                        
            if (cached != null) return cached                        
            
            Trace.beginSection("IO#fetchFromNetwork")
            val profile = api.fetchProfile(userId)
            Trace.endSection()                        
            return profile
        } finally {
            Trace.endSection()
        }
    }
}

使用一致的命名约定:

  • UI# — 主线程/UI 操作

  • BG# — 后台线程操作

  • IO# — I/O 操作

  • DB# — 数据库操作

这使得分析器的输出能够立即转化为实际操作。

亲自尝试

该分析器是开源的:

🔗 Perfetto 分析器github.com/singhsume12…

🔗 TraceToy 测试应用github.com/singhsume12…

该仓库包含:

  • Python 命令行分析器

  • 示例跟踪和输出 (analysis.json)

  • 设置说明

要点总结

  1. Perfetto 跟踪功能强大但信息量庞大 — 程序化分析能够从噪声中提取有效信息

  2. 归属至关重要 — 了解谁执行了工作(进程、线程、组件)至关重要

  3. 明确职责分离 — 应用、框架和系统之间的职责划分最重要的区别

  4. 时间窗口至关重要 — 启动阶段和稳定阶段通常具有截然不同的特征

  5. 嫌疑人指出问题所在,而非解释问题原因 — 确定性信号告诉你应该首先从哪里入手

  6. 保守的分类胜过错误的自信 — “未知”总比错误好

  7. 为你的代码添加插桩Trace.beginSection() 是你的好帮手

性能调试并非要找出_所有_发生的事情。而是要找出:

  • 什么才是关键的

  • 何时才是关键的

  • 谁负责

此分析器将这个模型清晰地呈现出来,并且机器可读。

_有任何问题或反馈?请在 LinkedIn 上联系我。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!