前两天一个老项目的反馈群里又热闹起来了用户直接甩了张截图过来“这个页面滑到第三屏就卡头像转圈转半天你们是不是没做优化就发版了”这种问题最磨人因为它不像崩溃那样有堆栈可查也不像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垂直同步信号。硬件每过一个刷新周期就发出一次VSYNCAndroid的Choreographer会在这个信号到来时触发主线程去处理input事件、执行动画、完成measure/layout/draw然后把渲染指令交给GPU。可以把这个机制理解成一条流水线屏幕定时来催货你必须在这个节拍内把货准备好。一旦某一帧超时后面几帧的节奏也会被打乱这就是为什么卡顿经常是“连续掉几帧”而不是单帧丢失。1.2 先复现再抓Trace别靠猜我见过太多人一上来就翻代码觉得哪块逻辑丑就重写哪块结果改了半天卡顿还在。正确顺序是先复现、再抓trace、最后才动手改代码。Android官方这些年一直在推Perfettosystrace已经逐渐被它取代。抓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 UIui.perfetto.dev打开。我们要重点关注的区域是主线程那一行看每一帧的“Choreographer#doFrame”是否超时、主线程上有没有特别长的task、有没有密集的binder耗时、有没有在measure/layout/draw阶段拉出很宽的条。Perfetto还会自动把关键路径标出来顺着它很容易找到导致掉帧的直接方法。很多新手会问能不能不抓trace直接用Android Studio的CPU Profiler可以用但Profiler适合定位“某个方法为什么慢”不适合从全局视角判断“这一帧为什么没画完”。排查卡顿的第一现场永远是traceProfiler是第二现场。1.3 判断问题归属这一帧卡在谁身上拿到trace之后最重要的一步是确认卡顿到底发生在哪一段。主线程上一帧的完整生命周期大致是输入事件处理 - 动画回调 - measure/layout - draw - 渲染同步之后还有GPU栅格化。如果耗时主要卡在measure/layout和draw说明布局复杂度或绘制内容出了问题属于应用层如果主线程上有一个很长的业务方法占了几十毫秒那也是应用层问题只是性质不同但如果你看到CPU频率异常低、多个线程长期抢占CPU、或者掉帧发生在Binder等待上就要往系统调度和跨进程通信那边想。一个实用的判断技巧把应用切到后台然后在后台持续操作如果卡顿依旧说明大概率不是应用层主线程的问题如果一切恢复正常那几乎可以确定就是你的主线程被什么东西拖住了。2. 布局渲染减负先关掉过度绘制再谈其他2.1 开发者选项里的“过度绘制”检查布局渲染是应用层卡顿里最直观的一块。Android的开发者选项里有一个“调试GPU过度绘制”的开关打开之后界面会变成带颜色的蓝色代表绘制一次浅绿是两次粉红是三次红色是四次及以上。正常页面的主体区域应该保持蓝色或浅绿如果大范围出现粉红和红色说明这一帧里很多像素被重复绘制了多次GPU的填充率被白白浪费。最常见的过度绘制来源是背景重复设置。一个Activity如果主题里配了windowBackground布局的根布局又设了一个背景色每个item的根View再设一层背景这就等于同一个像素被刷了三遍。前面说过每帧预算只有16.6msGPU负载越高留给CPU做layout和draw的时间越少帧率自然上不去。2.2 层级扁平化不是所有页面都需要ConstraintLayout布局层级过深是另一个经典问题。每多一层ViewGroupmeasure和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因为它自带内存缓存、磁盘缓存、采样降级和异步解码把解码工作从主线程彻底挪走。改完之后同一场景重新抓tracedoFrame区域干净了很多掉帧从均值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路径这几件事。但要做到体系化而不是东一榔头西一棒子确实需要实践和积累。