ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

客户端卡顿优化实战:从掉帧原理到线上监控

客户端卡顿优化实战:从掉帧原理到线上监控 手机App用着用着突然开始掉帧列表滑动像放PPT用户转头就去应用商店打了三星。这个场景我相信做客户端的同学都不陌生。卡顿和掉帧这个问题说难也难说简单也简单——难是因为根因可能藏在渲染链路、资源竞争、系统调度甚至硬件降频的任何一个环节简单是因为一旦建立起一套从量化指标到定位工具再到优化动作的完整方法论绝大多数卡顿问题都能在半天之内锁定方向。作为“稳定性性能系列”的第十篇这篇文章我想把卡顿问题拆开揉碎从掉帧的底层原理讲到一线排障的实操手段最后落到线上监控和回归验证。内容会偏向客户端方向但排查思路和工具链设计对其他端同样适用。1. 掉帧不是卡顿的全部先搞清卡顿的底层链路1.1 一帧画面的完整旅程很多人一听到卡顿就想到掉帧一听到掉帧就去看FPS这个习惯要改。FPS只是个表面数字真正决定流畅度的是每一帧从产生到显示所花的时间。我们得先搞清楚一帧画面在设备上到底经历了什么。拿Android举例一次完整帧渲染包含这么几个阶段应用侧用CPU处理输入事件、执行布局和测量、调用draw方法生成显示列表用GPU执行渲染指令把画面内容画到缓冲区系统合成器SurfaceFlinger把所有窗口的缓冲区合成成最终画面显示硬件按照固定的刷新节奏比如60Hz就是每16.6ms一次把画面推上屏幕。这四步里任何一步超过了垂直同步的周期这一帧就赶不上当前的显示窗口屏幕只能继续显示上一帧的内容于是出现掉帧。掉一帧你可能感知不到连续掉几帧人眼就察觉到卡了。所以卡顿的本质不是“某一帧慢”而是“帧与帧之间的间隔出现了明显波动”。iOS那边原理类似只是合成器换成了Core Animation和Render Server但关键的垂直同步机制和双缓冲/三缓冲策略基本一致。理解了这个链路你才能明白为什么有些卡顿问题在Profile工具里根本看不到主线程耗时超标因为瓶颈可能在合成阶段也可能在GPU渲染阶段。1.2 卡顿量化指标FPS之外还要看什么既然掉帧是帧间隔波动我们就不能只盯平均FPS。平均60帧但隔三差五卡一下的情况太常见了平均数这个统计口径会把毛刺吞掉。我建议至少同时看这几个指标指标含义怎么用帧耗时P95/P9995%或99%的帧耗时落在多少毫秒以内反映极端场景下的卡顿风险帧间隔最大波动相邻两帧间隔的最大偏差找到单次严重掉帧Jank次数单次滑动过程中掉帧的次数评估交互流畅度卡顿率卡顿时长占总时长的比例线上监控的核心指标严重卡顿次数单帧耗时超过700ms以上的次数对应ANR级别的体验问题我自己在团队里定的标准是60Hz场景下单帧耗时超过16.6ms算掉帧超过100ms算严重卡顿连续3帧超过40ms就算一次可感知卡顿。这个阈值不是拍脑袋定的人眼对连续卡顿的感知阈值大约在100ms级别但对不连续的轻微掉帧容忍度高一些所以“连续持续时长”比单帧绝对值更重要。1.3 帧耗时采集怎么做采集帧耗时最直接的方式是注册Choreographer的FrameCallback每次帧回调时记录当前时间戳相邻两次的时间差就是这一帧的实际耗时。代码大概长这样Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { val now System.nanoTime() val cost (now - lastFrameTime) / 1_000_000 if (cost 16f) { // 上报帧耗时异常附带当前页面和堆栈 } lastFrameTime now Choreographer.getInstance().postFrameCallback(this) } })注意这个回调的触发时机是应用侧开始绘制之前算出来的耗时主要反映的是应用侧的工作量不会覆盖SurfaceFlinger的合成耗时。要拿到端到端的完整链路数据在Android上需要配合GPU渲染分析工具iOS上可以用Xcode的Instruments里的Metal System Trace。线上采集方案我会在后面单独说这里先记住一个原则帧耗时是过程量卡顿率是结果量监控体系里两者都要有。2. 卡顿现场还原从“有点卡”到快速锁定瓶颈方向2.1 先复现再猜测卡顿问题最怕一上来就猜。用户说“滑动卡”工程师闷头翻代码找了半天最后发现复现不了白白浪费时间。我踩过这个坑所以现在接手任何卡顿反馈第一件事是拿着真机按用户的操作路径复现复现不了直接让反馈方提供录屏和手机型号。复现阶段的注意点其实很多用低端机或中端机复现很多卡顿在高端机上根本感知不到因为CPU和GPU的余量太大如果用户反馈发生在电量低于20%或机身发热时需要先把设备调到这个状态充电过程中做一些高负载操作让设备降频打开开发者选项里的“显示屏幕刷新率”和“GPU渲染分析”能直观看到帧率变化网络类卡顿要切换到弱网环境4G弱信号或带宽限制都能模拟。跑通了一个稳定复现路径之后再上工具定位原因。这顺序不能反先有稳定的复现后面的分析才有意义。2.2 工具链配合把“有点卡”变成可读的数据定位卡顿看两样东西主线程在忙什么渲染链路卡在哪一步。对应到工具选择上主线程耗时分析用Android Studio自带的CPU Profiler或者更轻量的Systrace能直观看到主线程上每个方法的耗时占比想看渲染链路就用GPU Rendering分析打开开发者选项里的Profile GPU Rendering看柱状图里蓝线绿线的高度想看线程调度可以用Perfetto或Systrace重点观察应用主线程的调度延迟线上问题排查还需要配合自研的卡顿监控栈采集主线程Looper每次dispatchMessage的执行耗时超阈值就dump主线程堆栈这个下面详说。iOS端对应的是Instruments的Time Profiler、Core Animation工具以及MetricKit采集线上的卡顿诊断。工具不重要重要的是你会不会看数据。有一次我看Systrace数据主线程的Runnable显示空闲但帧率就是上不去后来发现是vsync-event延迟极高SurfaceFlinger一直在等待GPU完成合成问题出在GPU渲染指令过于复杂。如果只看主线程CPU Profile这个问题会被完美漏掉。所以定位卡顿永远要从帧链路的角度去找而不是只盯应用主线程。2.3 用一张排查表快速圈定嫌疑范围在复现机子上跑了工具之后我习惯按下面这张表快速归类现象特征可能根因优先排查方向列表快速滑动时偶发掉帧列表项布局复杂/图片加载/内存抖动RecyclerView复用、图片压缩、GC频率页面滑动前半段流畅、滑到中间开始卡懒加载逻辑触发大量任务/图片请求集中返回分帧处理、预加载策略、降低主线程回调压力开始动画时卡一下动画触发了layout/measure或同帧有资源加载动画属性选对translation/alpha而非layout异步加载资源页面总有规律性掉帧定时任务/循环动画/网络轮询占用主线程检查主线程上的周期任务不同机型表现差异大CPU/GPU性能不足、线程数限制关注算法复杂度降低渲染指令量充电或发热时明显卡顿系统降频锁核削减高负载逻辑避免主线程长时间运算这一套分类基本覆盖了90%的客户端卡顿场景。归类之后再深入具体的技术点去优化。3. 渲染链路里的常见瓶颈UI线程上那些偷时间的操作3.1 布局层级和measure的连锁反应UI卡顿有一个经典场景一个二级页面打开后首帧要等很久滑起来也卡。打开布局文件一看LinearLayout嵌套了五六层每层还套了weight活活把measure从O(n)变成了O(n的反复迭代)。在Android里measure和layout的开销是叠加递归的。每个ViewGroup的measure会传递给所有子View而RelativeLayout和LinearLayout各有个坑——RelativeLayout会让子View测量两次线性布局里大量使用weight也会触发二次测量。层级越深递归次数越多。应对措施优先级我个人排成这样减少层级用ConstraintLayout重写布局一个扁平的结构能省掉大量measure开销避免不必要的重绘给View设置background时要小心是否引发了整页重绘使用invalidate(honorRequest)局部刷新把高频变化的区域独立成自定义View避免触发整个ViewGroup的layout复用列表项禁止在getView中创建新对象和inflate新视图用RecyclerView的ViewHolder机制管理。举一个实际例子我们App商品详情页原来有六层嵌套低端机首帧耗时380ms。用ConstraintLayout拍平成三层后首帧降到120ms滑动针耗从平均21ms降到14ms直接跨过了卡顿阈值。这种优化不需要任何黑科技就是把布局写干净。3.2 主线程上不能碰的重活所有超过16ms的任务都不应该出现在主线程上但实际上最常见的卡顿恰恰来自主线程上做了一些本不该做的重活。典型的有在主线程执行磁盘读写比如直接读写SharedPreferences的大字段或同步写数据库在主线程做JSON解析、图片的压缩和缩放、Bitmap的内存复用计算在主线程加载某个模块的插件化类、执行反射调用在主线程等待网络请求回调的CountDownLatch这种写法我不知道哪个天才发明的但就是有人写。我遇到过一个极其离谱的案例某版本上线后首页滑动掉帧率翻了7倍后来定位到是启动时把之前异步加载的一份30MB的配置解析放到了主线程因为负责的同事说“反正启动反正要等”。结果是用户等启动时没事进了首页反而卡因为解析任务太大主线程一直处于繁忙状态页面滑动后消息队列积压严重。这类问题排查起来其实不难CPU Profiler里主线程的火焰图一眼就能看到耗时大头。难的是防再犯团队里必须有CR检测或Code Review红线把主线程IO、主线程网络、主线程大JSON解析列为禁止操作。3.3 列表滑动的帧预算分配列表滑动流畅度的优化不仅要看单项渲染耗时还要看整个滑动过程中的帧预算分配。一次完整的滑动帧在60Hz刷新率下只有16.6ms的预算。RecyclerView即便有ViewHolder复用如果onBindViewHolder里做了一些费事的操作比如设置图片时没有使用已解码的缓存、动态创建渐变Drawable、绑定数据时触发重量级计算预算依然会超。我的做法是把每个item的绑定逻辑拆成两部分必做且轻量的放onBind重活放异步线程预计算。比如图文卡片文字排版和高精度图片解码全部放到后台线程主线程只做View属性的赋值这样单帧时间可以稳定控制在10ms以内。另外合理使用setHasFixedSize可以跳过layout的重复计算用setItemPrefetchEnabled提前加载列表项也能降低首次滑动的掉帧风险。还有一个很容易被忽略的坑列表的ItemAnimator默认动画在每次数据变化时都会触发。如果数据变化频繁动画排期会积压导致滑动时掉帧。数据实时刷新场景建议关闭默认动画自己实现轻量的闪烁提示即可。4. 隐蔽的掉帧凶手资源竞争、帧调度与系统负载4.1 帧调度延迟和垂直同步对齐问题有些掉帧不是应用主动耗时长而是帧调度延迟。Android的Choreographer依赖垂直同步信号驱动如果SurfaceFlinger或GPU的工作负载很高垂直同步信号本身会变得不稳定应用拿到信号的时间就晚了。这种情况在视频播放类应用特别常见。视频的每一帧解码、渲染、合成都要和显示链路抢资源如果播放器没有使用SurfaceView配合硬件解码而是把Bitmap画到普通View上每帧都要经历一次GPU上传掉帧是跑不掉的。我在视频项目里做过对比同一条视频SurfaceView硬件解码方案和普通View绘制方案帧间隔波动差了将近4倍。另一个经典场景是双窗口或多任务分屏。分屏时两个应用共享SurfaceFlinger的合成能力窗口数翻倍每帧的合成时间也翻倍。这时候App侧能做的很有限但至少可以监测帧间隔异常时自动降低动画特效的复杂度比如关闭毛玻璃模糊、降低阴影半径。4.2 CPU降频和系统限流的应对策略设备发热之后CPU会限频这是很多低端机卡顿的直接原因。你无法阻止系统降频但可以设计一套自适应策略当检测到帧耗时连续多帧超过阈值时动态降低页面效果。我把这套机制叫“渲染降级”检测到持续掉帧自动关闭页面里的高斯模糊和阴影图片统一走低分辨率加载避免GPU显存压力过大暂停自动播放的动画和视频预览等用户主动交互时再恢复把后台预加载的任务挂起优先保证前台帧率稳定。这套机制上线后我们App在低端机上的Jank率下降了40%。原理很简单既然系统已经给了你更少的CPU和GPU资源你就必须把资源花在用户最能感知到的地方——响应滑动和点击。4.3 内存抖动和IO抖动内存抖动引发GCGC引发挂起挂起引发掉帧这条链路在Java/Kotlin里太常见了。尤其是列表滑动场景每次创建大量短生命周期对象GC频繁触发主线程被反复打断。排查内存抖动的方法有两个一个是看Memory Profiler里的对象分配时间线如果呈现锯齿状就是典型的分配频繁另一个是看GC日志系统日志里打印了每次GC的触发时间和耗时如果GC间隔小于两秒且耗时超过5ms基本可以断定在抖动。优化内存抖动没有银弹只有三板斧对象复用特别是避免在getView里创建新对象、减少装箱拆箱、把高频路径中的数据尽量用手写数据结构替代自动装箱集合。有些代码为了“函数式风格”大量使用stream和lambda实际上每次迭代都在创建中间对象这种写法在性能敏感路径上是必须避免的。IO抖动相对隐蔽一些。如果应用的磁盘写入任务没有被统一收敛到单独的IO线程池而是散落在各个业务线程里频繁的落盘操作会争抢磁盘带宽。Android的IO调度在闪存碎片化之后尤其脆弱一次落盘可能阻塞全进程的文件操作。遇到这类情况把所有磁盘操作收口到一个线程配合IO队列和合并策略效果常常立竿见影。5. 从优化到固化线上监控、回归验证与体验闭环5.1 卡顿监控到底采集什么优化做得再好没有监控体系过一个版本照样劣化回去。卡顿监控的采集点设计我认为至少要覆盖三层第一层是主线程消息队列耗时。通过Looper的setMessageLogging或者更底层的Hook在dispatchMessage前后打点超过50ms就记录堆栈这些堆栈聚合成卡顿Top列表。第二层是帧耗时。用前面说的Choreographer回调采集每一帧的实际耗时加上页面信息、设备信息、进程内存水位上报帧耗时超过16.6ms的样本。第三层是系统级ANR和系统重启事件。Android的ANR 会带official stack traceiOS的卡死可以通过RunLoop的Runnable超时检测这两块属于传统稳定性的范畴但和卡顿监控的体系共享同一套数据仓库会非常方便。需要注意采样率的设计。全量采集帧耗时样本量太大客户端性能影响也不能忽略我的经验是帧耗时全量算指标、堆栈按1/10概率采样命中严重卡顿时单帧超过300ms再强制抓一次完整堆栈。这样既控制了开销又不丢关键问题。5.2 线上优化效果的验证方法做任何优化最终都要回答一个问题用户的卡顿变好了没。这需要一套对比验证流程。我通常的做法是先确定核心指标Jank率、卡顿率、ANR率每个指标给一份优化前后的数值按版本维度对比同一指标在优化上线前后一周取均值按用户设备档次分组对比中低端机的降幅比整体均值更有说服力结合灰度验证把新版发给5%用户跑3天出初步结论再决定要不要全量。我用一个之前做过的项目举例。某个大版本优化了首页列表的布局和图片加载方式上灰度后Jank率从4.3%降到2.1%但发现低端机型上的P95帧耗时仍然是34ms没有达到我们预设的25ms目标。于是又针对低端机增加了渲染降级策略第二次灰度后低端机P95帧耗时降到21ms数据才好看了。这个案例里如果没有分机型对比第一次灰度结果的降幅已经很好看很容易直接全量放过。但分机型拆开一看其实低端机的体验还没达标。所以做性能验证必须养成拆维度的习惯至少拆设备档次、网络环境、页面路径三个维度。5.3 防回退和日常性能守护监控只能发现问题防止问题回退需要建立日常开发流程里的守护机制。我们团队现在的做法是每次MR触发布局耗时和主线程耗时的自动静态检查超过阈值直接拦截以周为维度跑自动化性能用例在固定机型上执行核心路径的操作首页点击、列表滑动、详情页打开用软件渲染方式记录帧耗时生成的报表直接对比上周性能相关的CR必须由有性能优化经验的同学review禁止出现“先上线再说”的态度。这层守护机制看着繁琐但长期跑下来收益极大。很多卡顿问题在刚引入的时候就拦截了投放市场出问题再去定位的成本是完全不一样的。稳定性项目到后期拼的不是一次大型优化的幅度而是能不能让优化结果长期不劣化。最后的实操体会做了这么多年的卡顿治理我自己的感受是掉帧问题大多数时候不是无解的黑魔法而是把“正确做事的流程”走完整——先量化再定位后优化终监控。尤其要提醒一点做性能优化的同学别只盯着热门机型的表现用户手里最多的往往是两年前的中低端机他们的体感才是这款产品流畅度的真实水位。建议团队维护一批老旧低端测试机长期放在回归流程里跑这里发现的每个问题背后都站着一片相似设备的用户。另外一个很省钱但很有效的操作是每次发布前自己用低端机真实滑10分钟页面再放手。任何自动化监控都替代不了这种原始而又真实的感知流畅度这个东西最终还是体感说了算。
返回列表