ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android UI卡顿定位实战:Systrace从抓取到解读全指南

Android UI卡顿定位实战:Systrace从抓取到解读全指南

简介:这份资源是面向Android开发者的Systrace性能分析实战Demo,聚焦UI卡顿与帧率问题的定位与优化,适合已具备一定Android基础、希望深入掌握系统级追踪工具的中高级开发者。压缩包共1844个文件,约25.77MB,以xml布局与配置、png图片资源、json数据、class与dex字节码、java源码及jar依赖为主,另含apk安装包、rawproto追踪数据与gradle构建脚本,完整保留了可运行的工程结构。资源围绕Choreographer、ViewRootImpl、OpenGL ES、Input事件、RenderThread及同步边界等关键指标展开,帮助读者理解时间线视图中各组件交互,进而定位过度绘制、布局层级过深、锁竞争等性能瓶颈。目前已有2080人学习下载,可结合示例项目动手实践Systrace录制与报告解读,逐步建立从数据采集到问题归因的排错思路。

1. 为什么你的 UI 卡顿总是找不到元凶

做 Android 性能优化,很多人第一反应是打开 Profiler 看 CPU 曲线,或者凭经验猜「大概是布局嵌套太深」。但真实项目里,掉帧往往发生在你意想不到的地方:主线程被一个 Binder 调用堵了 8ms,或者 RenderThread 在等 GPU 完成上一帧。这些细节,Profiler 的采样粒度根本抓不住。

Systrace 就是干这个的。它把系统里 CPU 调度、SurfaceFlinger 合成、应用主线程和 RenderThread 的每一段耗时,按时间轴拼成一张 HTML 报告。你打开它,就能看到某一帧从Choreographer#doFrame到draw再到queueBuffer的完整链路,哪一段超了 16.6ms 一目了然。这篇笔记面向的是已经会写 Android 但被卡顿折磨过的开发者,我会用一个模拟的 UI 性能 Demo,把 Systrace 从抓取、解读到定位问题的完整路径走一遍。不聊虚的,直接上命令和报告截图里该看什么。

2. Systrace 抓取环境搭建与最小 Demo 工程

2.1 为什么不用 Profiler 而选 Systrace

Android Studio 自带的 CPU Profiler 擅长看方法级耗时,但它的采样间隔和调用栈深度在 UI 线程上经常失真。Systrace 走的是另一条路:它在系统关键路径上埋了 trace marker,比如Choreographer、ViewRootImpl、RenderThread,这些 marker 是 Android 框架自己打的,精度到微秒级。你要查的是「这一帧为什么没在 16.6ms 内完成」,Systrace 能直接告诉你主线程在等什么、RenderThread 在等什么、SurfaceFlinger 有没有及时合成。

选型上,如果你要查的是「哪个方法调用次数太多」,用 Profiler;如果你要查的是「这一帧的时间都花在哪了」,用 Systrace。两者不冲突,但 UI 卡顿的根因八成在帧调度层面,所以 Systrace 是首选。

2.2 用命令行抓取 Systrace 的最小步骤

先确认设备是 userdebug 或 eng 版本,普通 user 版本很多 tag 抓不到。连接设备后,用adb shell atrace或systrace.py脚本抓取。我一般用 Python 脚本,因为可以指定 tag 和时长。

# 进入 Android SDK 的 platform-tools 和 systrace 目录 cd $ANDROID_HOME/platform-tools/systrace # 抓取 5 秒的 trace,包含 gfx、view、wm、am、sched 等关键 tag python systrace.py -t 5 -o my_trace.html gfx view wm am sched freq idle # 如果设备是 Android 10 以上,推荐用 perfetto 抓,但 systrace 兼容模式依然可用 # 抓取前先清一下 log,避免干扰 adb logcat -c

参数说明:-t 5是抓 5 秒,UI 卡顿通常抓 3 到 10 秒就够;-o指定输出 HTML 文件;后面的 tag 列表里,gfx包含所有图形相关,view包含 View 系统,wm是 WindowManager,sched是 CPU 调度。如果你只关心 UI,gfx view sched这三个是必加的。抓的时候手动在 Demo 里滑动列表或触发动画,让卡顿复现。

2.3 模拟 Demo 的 UI 结构设计

为了复现典型卡顿,我写了一个模拟项目,主界面是一个 RecyclerView,每个 item 里有一个自定义 View,在onDraw里做了一点「看起来不重但实际很重」的计算。另外在点击按钮时,主线程会做一个 50ms 的Thread.sleep来模拟同步 IO。

// 模拟项目:MainActivity.kt class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val recyclerView = findViewById<RecyclerView>(R.id.recycler) recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.adapter = DemoAdapter() // 模拟主线程同步耗时操作 findViewById<Button>(R.id.btn_sync).setOnClickListener { Thread.sleep(50) // 模拟数据库查询或网络请求 Toast.makeText(this, "done", Toast.LENGTH_SHORT).show() } } } // 自定义 View 的 onDraw 里做浮点运算 class HeavyView(context: Context) : View(context) { private val paint = Paint() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) var sum = 0.0 for (i in 0 until 100000) { sum += Math.sin(i.toDouble()) * Math.cos(i.toDouble()) } canvas.drawCircle(width / 2f, height / 2f, 50f, paint) } }

这个 Demo 的卡顿点有两个:一是onDraw里的循环计算,二是按钮点击时的Thread.sleep。抓 Systrace 后,前者会表现为draw阶段耗时过长,后者会表现为Choreographer#doFrame里出现一个明显的空隙。你照着这个结构搭一遍,就能在报告里看到对应的痕迹。

3. 读懂 Systrace 报告里的 UI 线程时间轴

3.1 主线程和 RenderThread 的分工

打开 HTML 报告,左侧进程列表里找到你的应用包名,展开后能看到几个关键线程:main、RenderThread、hwuiTask0等。main线程负责处理输入事件、执行Choreographer#doFrame里的input、animation、traversal三个阶段。traversal里又分measure、layout、draw。draw阶段只是把绘制命令录进 DisplayList,真正的 GPU 渲染在RenderThread里。

所以当你看到main线程的draw很长时,问题在录制绘制命令;如果main线程的draw很短但RenderThread的drawFrame很长,问题在 GPU 或渲染管线。这个区分是定位卡顿的第一步。

3.2 用 Choreographer 和 doFrame 定位掉帧

在main线程的时间轴上,你会看到一排排的Choreographer#doFrame块。每个块代表一帧。正常情况下,相邻两个doFrame的间隔是 16.6ms(60Hz 设备)。如果某个doFrame块特别宽,或者两个块之间出现了大段空白,说明这一帧超时了。

把鼠标悬停在doFrame上,会显示它的耗时。如果超过 16.6ms,就要展开看内部。doFrame下面通常有三个子块:input、animation、traversal。input处理触摸事件,animation执行属性动画,traversal是 View 树的测量布局绘制。哪个子块最长,问题就在哪。

3.3 识别 measure/layout/draw 的异常耗时

展开traversal,你会看到measure、layout、draw三个块。measure和layout的耗时通常很短,除非布局嵌套过深或使用了RelativeLayout导致多次测量。draw的耗时如果超过 5ms,就要警惕了。

在模拟 Demo 里,HeavyView的onDraw循环会让draw块变得很长。你可以在报告里看到draw下面有一个onDraw的 marker,耗时几十毫秒。这就是元凶。解决办法很简单:把循环计算移到onDraw外面,或者用canvas.drawCircle之前先算好结果缓存起来。

注意:Systrace 报告里每个块的颜色也有含义。绿色表示正常,黄色表示警告,红色表示严重超时。但颜色只是辅助,关键还是看耗时数字。

4. 用 Systrace 定位三类典型 UI 卡顿

4.1 主线程同步 IO 导致的掉帧

在模拟 Demo 里点击按钮触发Thread.sleep(50),抓到的 Systrace 会显示main线程上出现一个 50ms 的空白块,标记为Sleeping。这个块会直接导致Choreographer#doFrame被推迟,下一帧的doFrame间隔变成 66ms 左右,用户感知就是明显卡顿。

定位方法:在main线程时间轴上找Sleeping或Blocked状态的长块。如果这个块出现在doFrame之间,说明主线程被同步操作占用了。解决办法是把 IO 放到工作线程,用Coroutine或RxJava切线程。改完后重新抓 Systrace,确认main线程上不再有超过 5ms 的Sleeping。

4.2 布局嵌套过深引发的 measure 耗时

另一个常见问题是measure耗时过长。在 Systrace 里,measure块会显示为measure或onMeasure,如果嵌套层级太深,你会看到多个measure块串联,总耗时超过 8ms。常见原因是RelativeLayout或LinearLayout的weight属性导致子 View 被测量两次。

排查时,在报告里展开traversal下的measure,看哪个onMeasure耗时最长。然后去代码里检查对应的布局文件。优化手段包括:用ConstraintLayout减少层级,避免在onMeasure里做耗时计算,或者用ViewStub延迟加载不重要的视图。

4.3 RenderThread 过载与 GPU 等待

有时候main线程的draw很快,但RenderThread的drawFrame很长。在 Systrace 里,RenderThread时间轴上会看到drawFrame块下面有dequeueBuffer、queueBuffer、flush等子块。如果dequeueBuffer耗时很长,说明 GPU 忙不过来,可能是绘制内容太复杂,比如过度绘制或大图缩放。

在模拟 Demo 里,如果HeavyView的onDraw里画了很多圆,RenderThread的drawFrame也会变长。解决办法是减少过度绘制,用canvas.clipRect限制绘制区域,或者把复杂绘制放到RenderNode里缓存。

5. Systrace 分析中的避坑与常见问题

5.1 抓不到 trace 或报告为空

现象:执行systrace.py后生成的 HTML 打开是空白,或者提示No trace data。原因通常是设备没有开启 trace 权限,或者 tag 列表写错了。user 版本设备很多 tag 不可用,需要 userdebug 或 eng 版本。另外,systrace.py在 Python 3 下可能有兼容问题,建议用 Python 2.7 或直接用perfetto命令行。

解决:先执行adb shell atrace --list_categories确认设备支持哪些 tag。如果gfx不在列表里,说明设备权限不够。换 userdebug 设备,或者用adb shell setprop debug.atrace.tags.enableflags 1手动开启。

5.2 报告里线程名看不懂

现象:打开报告,左侧一堆线程名,不知道哪个是主线程。原因:Systrace 默认显示的是线程 ID 或系统线程名,应用的主线程通常叫main,但有些设备会显示成app_process或包名。RenderThread 通常叫RenderThread,但有些设备叫hwuiTask。

解决:在报告里搜索你的包名,找到对应的进程,展开后第一个线程通常就是main。如果还是不确定,在抓取时加-a参数指定应用包名,这样报告里会直接标注。

5.3 时间轴缩放导致误判

现象:在报告里看到某个块很长,但实际代码里那段逻辑很快。原因:Systrace 的时间轴可以缩放,如果你放得太大,一个 1ms 的块看起来像 100ms。另外,不同 CPU 核心上的块对齐方式不同,容易看错。

解决:看耗时一定要把鼠标悬停在块上,看弹出的数字。不要凭视觉宽度判断。另外,按W键可以放大,按S键缩小,按A和D左右平移。先把时间轴缩放到能看到完整帧,再逐帧检查。

5.4 忽略 CPU 调度的影响

现象:主线程的doFrame块本身不长,但帧间隔很大。原因:主线程被调度到一个小核上,或者被其他高优先级线程抢占了 CPU。在 Systrace 里,CPU 调度信息在schedtag 下,你会看到cpu0、cpu1等行,上面有Running、Runnable、Sleeping状态。

解决:如果主线程频繁处于Runnable但没Running,说明 CPU 被抢占了。可以尝试提升主线程优先级,或者减少后台线程数量。在报告里,把main线程和 CPU 行对齐看,就能发现调度延迟。

6. 把 Systrace 变成日常习惯:一个可复用的检查清单

抓 Systrace 不是一次性的活,我一般会在三个节点固定抓:冷启动后第一次滑动、点击按钮触发业务逻辑、页面切换动画。每次抓 5 秒,存成带日期的 HTML 文件。这样出问题时可以对比历史报告,看是哪个版本引入的退化。

具体操作上,我习惯用一条命令搞定抓取和标记:

# 抓取并自动打开报告,同时记录当前页面 python systrace.py -t 5 -o trace_$(date +%Y%m%d_%H%M%S).html gfx view sched freq adb shell am start -n com.example.demo/.MainActivity

参数里freq是 CPU 频率,加上它可以看到主线程是否因为降频而变慢。sched是调度,必加。抓完后,我按这个顺序看:先找main线程上超过 16.6ms 的doFrame,再看traversal里哪个阶段最长,然后看RenderThread有没有等待,最后看 CPU 调度有没有异常。

一个容易忽略的技巧:在报告里按Ctrl+F搜索doFrame,浏览器会高亮所有匹配项,然后按Enter逐个跳转。这样比手动拖时间轴快得多。另外,如果报告太大,可以用--no-fix-thread-names参数减少文件体积,但会丢失线程名信息,不建议。

我踩过最深的坑是:有一次看到draw耗时 20ms,以为是自己写的自定义 View 有问题,查了半天才发现是系统主题里一个ripple动画在后台跑。Systrace 里那个动画的 marker 叫RippleDrawable,藏在draw下面。所以看报告一定要展开到最底层,不要只看第一层块。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进