前两天一个老项目的反馈群里又热闹起来了,用户直接甩了张截图过来:“这个页面滑到第三屏就卡,头像转圈转半天,你们是不是没做优化就发版了?”这种问题最磨人,因为它不像崩溃那样有堆栈可查,也不像ANR那样有系统日志可捞,全靠开发者对卡顿本质的理解一点点剥洋葱。今天我想把“应用层卡顿优化”这件事从头到尾拆开讲一遍,涵盖帧节奏原理、布局渲染、列表滑动、内存GC、IO异步这几条主线,也把我自己踩过的坑和实际排查链路放出来,给正在跟卡顿搏斗的Android开发一个可以直接上手的参考。
先明确边界:所谓“应用层”,指的是我们作为App开发者能直接控制和修改的那部分代码和资源,包括Activity、View体系、业务逻辑、依赖库的集成方式等等。跟它对应的还有系统层(如CPU调频、内核调度)和框架层(如Binder、WindowManager)的问题,这两类通常不是普通App开发者能轻易改动的。所以这篇内容只讲“自己家里能修好的事”,但前提是你得先能分清问题到底出在谁身上,不然很容易对着一个系统级的调度问题在业务代码里白忙一夜。
1. 卡顿的底层账本:一帧16.6ms怎么算出来的
1.1 刷新率、VSYNC与帧预算
要聊卡顿,绕不开“帧预算”这个概念。现在的手机屏幕绝大多数是60Hz刷新率,意思是屏幕每秒刷新60次,每两次刷新之间的间隔是 1000 ÷ 60 ≈ 16.6ms。屏幕每一次刷新都需要一张对应的画面,如果App在16.6ms内拿不出新画面,屏幕就只能继续展示上一帧,表现出来的就是肉眼可见的停顿和滑动不跟手。现在很多中高端机已经上了90Hz甚至120Hz,帧预算被压缩到11.1ms、8.3ms,对应用层的性能要求反而更苛刻了。
这个过程中最关键的是VSYNC(垂直同步信号)。硬件每过一个刷新周期就发出一次VSYNC,Android的Choreographer会在这个信号到来时触发主线程去处理input事件、执行动画、完成measure/layout/draw,然后把渲染指令交给GPU。可以把这个机制理解成一条流水线:屏幕定时来催货,你必须在这个节拍内把货准备好。一旦某一帧超时,后面几帧的节奏也会被打乱,这就是为什么卡顿经常是“连续掉几帧”而不是单帧丢失。
1.2 先复现,再抓Trace,别靠猜
我见过太多人一上来就翻代码,觉得哪块逻辑丑就重写哪块,结果改了半天卡顿还在。正确顺序是先复现、再抓trace、最后才动手改代码。Android官方这些年一直在推Perfetto,systrace已经逐渐被它取代。抓trace的命令很简单,复现问题的时候在终端执行:
adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace -t 10s sched freq idle gfx view binder_driver hal app10秒后把trace文件拉出来,直接拖进Perfetto UI(ui.perfetto.dev)打开。我们要重点关注的区域是主线程那一行:看每一帧的“Choreographer#doFrame”是否超时、主线程上有没有特别长的task、有没有密集的binder耗时、有没有在measure/layout/draw阶段拉出很宽的条。Perfetto还会自动把关键路径标出来,顺着它很容易找到导致掉帧的直接方法。
很多新手会问:能不能不抓trace,直接用Android Studio的CPU Profiler?可以用,但Profiler适合定位“某个方法为什么慢”,不适合从全局视角判断“这一帧为什么没画完”。排查卡顿的第一现场永远是trace,Profiler是第二现场。
1.3 判断问题归属:这一帧卡在谁身上
拿到trace之后,最重要的一步是确认卡顿到底发生在哪一段。主线程上一帧的完整生命周期大致是:输入事件处理 -> 动画回调 -> measure/layout -> draw -> 渲染同步,之后还有GPU栅格化。如果耗时主要卡在measure/layout和draw,说明布局复杂度或绘制内容出了问题,属于应用层;如果主线程上有一个很长的业务方法占了几十毫秒,那也是应用层问题,只是性质不同;但如果你看到CPU频率异常低、多个线程长期抢占CPU、或者掉帧发生在Binder等待上,就要往系统调度和跨进程通信那边想。
一个实用的判断技巧:把应用切到后台,然后在后台持续操作,如果卡顿依旧,说明大概率不是应用层主线程的问题;如果一切恢复正常,那几乎可以确定就是你的主线程被什么东西拖住了。
2. 布局渲染减负:先关掉过度绘制,再谈其他
2.1 开发者选项里的“过度绘制”检查
布局渲染是应用层卡顿里最直观的一块。Android的开发者选项里有一个“调试GPU过度绘制”的开关,打开之后界面会变成带颜色的:蓝色代表绘制一次,浅绿是两次,粉红是三次,红色是四次及以上。正常页面的主体区域应该保持蓝色或浅绿,如果大范围出现粉红和红色,说明这一帧里很多像素被重复绘制了多次,GPU的填充率被白白浪费。
最常见的过度绘制来源是背景重复设置。一个Activity如果主题里配了windowBackground,布局的根布局又设了一个背景色,每个item的根View再设一层背景,这就等于同一个像素被刷了三遍。前面说过每帧预算只有16.6ms,GPU负载越高,留给CPU做layout和draw的时间越少,帧率自然上不去。
2.2 层级扁平化:不是所有页面都需要ConstraintLayout
布局层级过深是另一个经典问题。每多一层ViewGroup,measure和layout阶段的遍历次数就多一轮;如果有RelativeLayout嵌套LinearLayout再套FrameLayout,一次measure可能触发多次往返,这种成本在列表滚动时会被无限放大。现在普遍推荐ConstraintLayout,因为它的扁平化能力确实强,几乎可以只用一个层级完成复杂的相对布局关系。
但我得说句公道话:ConstraintLayout不是银弹。它的measure成本在同层级下比传统布局高一些,尤其在ConstraintLayout嵌套ConstraintLayout的时候,约束求解的开销很可观。我的习惯是:简单的线性排列用LinearLayout,对齐关系复杂但层级可控的用ConstraintLayout,完全不要为了“用新框架”而强行把简单布局改成约束布局。另外,merge标签可以去掉多余的根层级,ViewStub可以延迟加载不紧急的模块,这些老手段在性能优化上依然好用。
2.3 自定义View的绘制纪律
自定义View是应用层卡顿的高发区,因为绘制代码完全掌握在开发者自己手里,水平差距能拉开很大。首先要明确:onDraw方法每一帧都会被调用,绝不能在onDraw里new对象、创建Paint、分配数组,这些操作会直接推高内存分配速率,引发GC,这点后面单独讲。其次要养成按需绘制的习惯,用invalidate(Rect)指定脏区域,比调用无参invalidate()整块重绘省得多。
另一个容易被忽略的点是clipRect。如果自定义View只需要绘制圆形、圆角或者其他不规则区域内的一部分内容,主动调用clipRect把绘制范围裁剪出来,能有效减少GPU对透明区域的处理开销。还有android:layerType这个属性,很多人图省事给它设成software或hardware,实际上大部分场景都不需要手动设置,胡乱设置反而会增加离屏缓冲的内存和性能负担。
3. RecyclerView卡顿调试实录:一次真实掉帧的完整排查链路
3.1 现场现象与初次抓取
去年优化过一个聊天列表,现象很典型:前两屏滑动流畅,滑到历史消息较多的第三屏开始掉帧,越往上滑越明显,偶尔还会出现头像闪烁。用户反馈的时间点集中在手机发热之后,这就很有意思了——发热会导致系统降频,CPU性能下降,但为什么只在第三屏之后才卡?说明问题不是单纯的CPU算力不足,而是这个列表在滑动过程中累积了什么重活。
我先用Perfetto抓了一条滑动过程的trace,主线程的Choreographer#doFrame一片飘红,掉帧幅度平均在20~30ms,个别帧甚至到了60ms。展开主线程的调用栈,赫然看到BitmapFactory.decodeStream在doFrame里被反复调用,单次执行接近40ms。这就解释了为什么滑动越久越卡——每滚入一个不可见的item,都触发了一次完整的图片解码,解码既耗CPU又产生大量临时内存,还容易把线程卡住,三管齐下,不卡才怪。
3.2 根因:把图片解码放错了线程
顺着调用栈找到业务代码,发现聊天头像的加载逻辑是一段“祖传代码”:先判断本地缓存文件是否存在,如果存在就直接用BitmapFactory.decodeStream读文件。这个逻辑放在bindViewHolder里,天然就在主线程执行。单个头像解码20~40ms,一屏同时可见七八个item,就算RecyclerView只bind可见项,这几百毫秒的累积也足够让每一帧都超时。
修复方案其实不复杂,无外乎换用成熟的图片加载库。我们当时选用Glide,因为它自带内存缓存、磁盘缓存、采样降级和异步解码,把解码工作从主线程彻底挪走。改完之后同一场景重新抓trace,doFrame区域干净了很多,掉帧从均值25ms降到5ms以内。这里我想多说一句:很多人觉得图片加载库只是“加载快一点”,其实它对帧率稳定性的贡献远不止快,更重要的是它帮你把“不该在主线程做的事”强制隔离出去了。
3.3 列表优化的全局清单
除了上面的案例,列表场景还有几个高频坑值得一起说。
第一,RecyclerView一定要用ViewHolder模式,不要每次bind都findViewById。这属于基本功,但老项目里总有漏网之鱼。
第二,setHasFixedSize(true)的设置要谨慎。它能在item尺寸不变时跳过某些measure流程,但如果item内容高度会动态变化,设置true反而会导致显示异常,所以得确认了item的尺寸确实是固定的再用。
第三,notifyDataSetChanged是万恶之源。它会让所有可见item全部重新bind一次,哪怕只是改了一个item的一个字段。优先使用notifyItemChanged、notifyItemInserted这类细粒度通知,配合DiffUtil做新旧列表对比,需要刷新指定区域的项目可以用Payload局部更新,这样能把刷新成本压到最低。
第四,默认的item回收池是5个,如果item类型很多,可以在RecycledViewPool里按类型单独调容量,避免频繁创建新ViewHolder。LinearLayoutManager在API 21以上自带预取机制,会在空闲时提前准备下一个item,尽量不要自己重复实现类似逻辑,反而干扰它。
4. 内存抖动与GC:看不见的卡顿元凶
4.1 分配速率比内存总量更致命
很多人只关注内存会不会OOM,却忽略了内存分配速率对帧率的直接影响。Android的ART虚拟机采用分代垃圾回收策略,年轻代的对象存活时间短、回收频繁,每次回收时为了维持堆的一致性,往往需要挂起所有线程,包括主线程。如果应用在短时间内疯狂创建临时对象,GC就会反复被触发,每一帧的预算里都硬生生挤掉一段“暂停时间”,表现就是画面突然跳一下、滑动时有轻微卡顿,但trace里看不到任何单个耗时方法。
内存抖动问题用Android Studio的Memory Profiler录制一段操作过程最直观,可以看到内存曲线呈锯齿状忽高忽低,这就是GC不断被触发的信号。配合Allocation Tracker可以定位到临时对象是在哪些方法里分配的。
4.2 高频抖动场景:日志、拼接、装箱
应用层最常见的内存抖动来源有三个。第一个是循环里的字符串拼接,比如for循环里用“+”拼日志或拼URL,每一轮循环都产生新的String对象,在列表滚动这种高频场景下,瞬间就能造出成千上万个垃圾。正确的做法是用StringBuilder,或者干脆在生产环境关闭这些调试日志。第二个是自动装箱,比如map里put(int, Object)的时候把int隐式转成Integer,或者用keySet遍历map,都会产生新对象。第三个是onDraw和onBind这类被高频调用的方法里new对象,前面已经说过。
还有一个很容易被忽略的点:我们自己的日志框架。有些团队喜欢封装一套Log工具,在发布版也保留日志输出,结果Log的字符串格式化和I/O操作在主线程上成了隐性负担。我处理过的case里,有一个就是Log.d在列表滚动时每帧都输出,直接让掉帧率从2%涨到15%。生产环境请务必关闭或者降级日志输出,这是成本最低收益最高的优化。
4.3 位图内存:被低估的占用大户
位图是App内存的大头。一张1080×1920的ARGB_8888图片,换算下来是 1080×1920×4 ≈ 7.9MB,一张全屏图就快8MB了,要是列表里几十张高清图同时驻留内存,GC压力可想而知。加载图片时用inJustDecodeBounds先读宽高,再按目标尺寸计算inSampleSize做采样缩放,把解码尺寸降到实际显示尺寸量级;或者直接用Glide、Coil这类库,它们内置了采样策略,能在很大程度上避免大图直接进内存。
再补充一个细节:图片缓存要有明确的层级意识。内存缓存用LruCache,磁盘缓存用DiskLruCache或者交给图片库处理,不要自己搞一套无上限的强引用缓存,那是给内存埋雷。我见过一个团队用静态HashMap存所有用户头像,导致内存直接飙到几百MB,卡顿和OOM同时找上门。
5. 主线程减负工程:IO、线程池与启动路径的改造
5.1 SharedPreferences:一个看着无害的IO陷阱
SharedPreferences是Android应用层最容易被忽视的卡顿来源之一。先说写入:commit()是同步写磁盘,直接卡主线程,绝对不能在主线程调用;apply()虽然异步写,但在Activity的onPause和onStop时系统会等待写盘完成,造成掉帧的“虚假停顿”。再说读取:SharedPreferences首次访问时会把整个文件一次性加载到内存,如果这个文件有几百KB甚至更大,第一次获取数据时主线程就会卡一下。
我自己踩过一个坑:有个配置页,每次启动都要读一个存了账号信息和用户配置的SP文件,文件很大,首读直接把冷启动时间拉长了快300ms。后来把场景拆分,把大SP文件按业务域拆成多个小文件,再替换掉高频读写的部分,换成MMKV或DataStore,冷启动时间明显降下来了。判断标准很简单:如果你发现某个SP文件被大量、频繁地读写,就应该考虑迁移方案,不要继续在旧架构里打补丁。
5.2 线程池的配置逻辑
应用层减负的另一半,是让耗时任务真正跑到后台线程去,但线程池用不对也会适得其反。很多人图方便写Executors.newCachedThreadPool(),它的核心线程数是0,最大线程数是Integer.MAX_VALUE,如果任务频繁提交且执行速度跟不上,会无限创建线程,导致线程切换开销和内存压力飙升。Executors.newFixedThreadPool()好一点,但默认用的是无界队列,极端情况下任务排队越来越多,反而延迟了关键任务的处理。
我建议的配置方式是手动构建ThreadPoolExecutor:核心线程数设为CPU核数减一,最大线程数设为核心线程数的两倍左右,队列用有界ArrayBlockingQueue,拒绝策略根据业务选择CallerRunsPolicy或者丢弃策略。同时给线程池命名,这样抓trace和看日志的时候能一眼认出是哪个任务在跑。网络请求、数据库操作、复杂计算这三类任务,都应该走后台线程,主线程只保留UI相关的操作。
5.3 启动阶段的卡顿预防
卡顿不只是在滑动时发生,冷启动阶段掉帧同样属于应用层优化范畴。一个常见的反面教材:Application的onCreate里做了大量同步初始化,包括创建数据库、读取配置、初始化推送SDK、加载广告SDK,所有这些都抢在首帧绘制之前执行,用户会看到白屏或黑屏时间明显变长。
优化思路是分级处理:不依赖网络和用户操作的初始化,可以放到子线程或延迟到首帧绘制完成后;必须提前的少量初始化,也要精简到最低限度。这里可以用IdleHandler在主线程空闲时执行非紧急的初始化,把最重的SDK推迟到首页展示出来之后再加载。另外,ContentProvider的init过程在应用启动时是逐个执行的,第三方SDK如果通过ContentProvider自动初始化,每一个都会增加启动耗时,选型时可以关注SDK是否支持手动初始化,这是很多团队容易忽略的隐性成本。
6. 验证与防回归:用数据证明卡顿修完了
6.1 修复前后怎么对比
优化做完别急着发版,先用同一套复现路径重新抓trace对比。手机尽量保持同一台、同一系统版本、同样的亮度和加载数据,因为不同机型、不同温度下CPU调度差异很大,对比结果会失真。对比的指标不建议只盯着“流畅感”这种主观感受,要看数据:掉帧次数、每帧平均耗时、90分位帧耗时、GC次数和暂停时间。Perfetto里可以直接查看单帧的耗时分布,也可以通过Choreographer的FrameCallback自己在App里统计掉帧率。
6.2 线上卡顿监控方案
本地验证只能覆盖特定场景,真正防止卡顿回归必须靠线上监控。简单可靠的做法是利用Looper的setMessageLogging拦截主线程消息,当某条消息的执行时间超过阈值时,把当时的堆栈、线程状态和进程内存快照记录下来,这就是BlockCanary的原理。想要更全面、性能开销更可控的监控,可以直接借鉴微信Matrix的思路,它通过编译期插桩和自定义Choreographer回调,能统计每一帧的耗时并上报。
需要注意的一点是,卡顿监控本身也会占用性能,上线时要设好采样率,一般控制在1%~5%就够发现问题了。我见过有团队开100%全量卡顿监控,结果监控SDK自身成了卡顿元凶,这样的监控方案就是一个反例。
6.3 沉淀一份可执行的分层优化手册
最后说点长期的建议:把卡顿优化的经验沉淀成一份团队内部的分层检查手册,比每次从零开始排查高效得多。我的手册大致分三层,第一层是代码习惯,比如onDraw和onBind里不new对象、生产环境关日志、bitmap按需采样;第二层是架构设计,比如列表刷新用DiffUtil、图片一律走加载库、SP大文件拆分、线程池统一收口管理;第三层是监控体系,包括Perfetto本地抓trace的流程、线上卡顿埋点和定期的性能回归测试。每一层都配上真实案例和trace截图,新人照着查就能解决80%的常见卡顿问题。
按照这套方法走一遍,你会发现“卡顿优化”并没有那么玄乎:无非是尊重帧预算、给主线程减负、管好对象分配和IO路径这几件事。但要做到体系化而不是东一榔头西一棒子,确实需要实践和积累。