一、卡顿的相关概念
视觉概念
1、视觉惯性 视觉预期帧率,用户潜意识里认为下帧也应该是当前帧率刷新比如一直60帧,用户潜意识里认为下帧也应该是60帧率。刷新一直是25帧,用户潜意识里认为下帧也应该是25帧率。但是刷新如果是60帧一下跳变为25帧,扰乱用户视觉惯性。这个时候就会出现用户体验的卡顿感。
2、电影帧 电影帧率(18-24),一般是24帧。电影帧单帧耗时:1000ms/24≈41.67ms。电影帧率是一个临界点。低于这个帧率,人眼基本能感觉画面不连续性,也就是感觉到了卡顿。
卡顿概念
1. Google Jank思路
Google Jank 计算思路:考虑视觉惯性,以硬件vsync时间间隔,连续1次vsync没有新画面刷新,则认为是一次卡顿,也就是说下一次vsync时间点没有新画面刷新,则认为是一次Jank
google jank条件较为苛刻
2.PerfDog卡顿原理
电影帧:低于这个帧率,人眼基本能感觉画面不连续性,也就是感觉到了卡顿。
Stutter计算思路:基于PerfDog Jank的基础上,一次Jank卡顿,会有一次卡顿时间Jank time。测试过程中可能有多次Jank卡顿,即有多次卡顿时间Jank time。测试总时长为Time。 PerfDog Stutter(卡顿率) = ∑Jank time / Time
误区:帧率不能直接反映是否卡顿!
FPS的定义:帧率(1秒内平均画面刷新次数)。
平均帧率:传统常说的FPS,1秒内平均画面刷新次数。
帧率FPS高并不能反应流畅或不卡顿。比如:FPS为50帧,前200ms渲染一帧,后800ms渲染49帧,虽然帧率50,但依然觉得非常卡顿。同时帧率FPS低,并不代表卡顿,比如无卡顿时均匀FPS为15帧。所以平均帧率FPS与卡顿无直接关系) 如图
左边非均匀渲染,帧率虽高,但看起来更卡顿
二、卡顿原因有哪些?
1.直接卡顿原因
单点耗时
多点耗时
复杂或频繁执行
2.间接卡顿原因
三、如何排查和定位卡顿?
原理:监听Handler消息执行,定时抓栈
优点:可线上使用,查看整体大盘数据,可聚集和搜索,使用成本较低 缺点:52ms阈值过大,性能消耗,小概率误报
1.2 Systrace/CPU Profiler(simpleperf)
公司APM平台无法满足我们所有的需求,由于52ms阈值较大,有一定的误报,还有抓栈本身有一定的性能消耗,线上用户并没有全量开启,因此我们无法保证所有的卡顿问题都被APM抓到了。另外还有个问题是,修改了之后无法立即验证改动效果。
Systrace:
simpleperf
优点:性能消耗很小,使用简单,修改后可立即验证 缺点:只能线下使用,用户卡顿路径无法收集完全
综合分析
主线程占用CPU比率,GC日志,线程数综合分析,后面case举例
经过5.65和5.70版本两次优化,歌房进房卡顿率(PerfDog卡顿率)优化近50%
测试方法:本地验证,进程冷启动,点击开始进房,停留8s待房间UI稳定
测试机型:OnePlus 10 Pro,Android12
一句话概括:一条Message里面做了太多事情,需要拆分成多条消息优化
case1:歌房采取微服务框架,每个业务有一个独立的Service,由框架派发Activity/Fragment与音视频生命周期,解耦运行,随着业务的增多,所有创建服务和派发生命周期集中在一个消息中运行,造成卡顿严重。
歌房生命周期派发架构图
a.创建微服务实例耗时长达312ms
分析:框架设计之初有考虑到服务不规范使用逐渐会造成卡顿问题,设计了懒加载接口,但由于业务开发者为了便捷,绝大多数都是直接选择进房预加载,导致预加载服务臃肿,卡顿缓慢。
优化方案:将服务进行筛选,不需要预加载的服务改造成懒加载。同时将服务端默认创建为懒加载,如业务需要预加载,需要手动显示设置。
b.handleGameTypeChanged分发耗时长40ms
问题分析:此问题对应“多点耗时”类型场景,这个生命周期方法派发都是需要更新UI界面元素的,因此无法切到子线程派发更新,很容易想到的主线程延迟执行的方法Handler.post(),将当前任务分发到下一个消息执行。
遇到问题:发现主线程有较多消息执行,使得刷新UI的消息执行比较靠后,Ui 刷新出现不顺畅,甚至出现时序问题导致NPE
进一步源码分析:这里分析Handler源码得知postAtFontOfQueue方法可以使得当前消息放到队列最前端,能够保证任务尽可能先执行。如下图源码
最终优化方案:
2.2. 子线程异步加载优化
a.进入歌房初始化音视频sdk 115ms(Running:27ms)
b.操作bitmap模糊背景 103ms
c.主线程解析wns配置的json文件
……
优化方案:切换到线程池中的工作线程进行处理
2.3.预加载优化
case:经过1中复杂任务分解后,发现还是有进房后立即需要使用的服务耗时较长的现象。如下图
类加载过程
分析:通过分析Systrace发现核心服务类加载和验证花费了很多时间,很容易触发“多点耗时”造成卡顿,另外还有NetCore网络单例框架可能造成“单点耗时”卡顿,然而这些类是一进房就立即要使用的,因此无法使用懒加载。此时换一种思路,将类放到子线程进行预加载。
时机:由于预加载类会造成内存占用,那么如果一进入主页就针对所有用户加载的话,可能对那些从来没有加入歌房的用户造成内存紧张,因此,这里针对“时间”和“空间”做一个平衡。仅针对进入过歌房的用户开启,并设置灰度开关
具体方案:
在MainTabActivity_doOnCreateAfterLogin里面,针对符合条件的用户需要预加载的类在子线程进行预加载
结果:线上针对进房服务和网络框架的预加载,进房平均耗时大盘数据减少250ms
2.4.懒加载优化
在1中复杂任务拆解时,很容易发生单个任务耗时稍微严重,多个不耗时任务累加就会造成卡顿非常严重,因此针对单个任务中稍微耗时的任务进行懒加载,直到使用时才加载,用以平衡一个消息中的繁多个任务 case1 :成员变量实例懒加载
case2:companion变量配置解析懒加载
case3:成员变量控件解析构造懒加载
case4:进房过程提前拉起子进程7.6ms
…..
共修复类似卡顿问题20+个 优化方案:
1. kotlin提供了一个很好用的by lazy,使用by lazy进行初始化很容易改造懒加载
by lazy源码分析:
可以看到by lazy虽然很好用,但是有加锁操作。因此可以进一步优化,确认无线程安全问题时使用by lazy(LazyThreadSafetyMode.NONE).如:
2.必须在主线程中执行的,延迟到下一个消息执行
2.5.布局层级与按需加载优化
View.inflate涉及IO耗时、反射耗时、构造方法耗时,是一个无法回避的老问题 如图:
方案: 1.针对View层级多的,采用merge标签和动态添加View进行减少View的层级和数量,以减少inflate时长。 2.针对单个View构造耗时长的,优化构造方法和成员变量初始化 3.按需加载,使用ViewStub进行懒加载,如游客模式的布局只需要在游客时进行加载,其他时候无须加载
case:wesing项目分别使用的火眼日志,Bugly日志,wns日志,sdk内部为确保线程安全,均加锁,造成多线程调用日志框架,非常容易造成卡顿
本地复现
线上堆栈
方案:使用单独日志线程,创建单独HandlerThread,在sdk打印日志之前切换到单独线程,解决锁耗时问题
case:LogUtil.d打印耗时竟达到18ms
分析:意识里面LogUtil.d不会写入文件,但是我们很容易忽略了方法参数里面的表达式是在方法调用时就执行的,并不是在具体方法执行时运行,所以,项目里面有大量的在日志里面拼接请求参数,序列化json数据等操作,造成了较大的耗时。
解决:集中将日志里面拼接的字符串进行优化,LogUtil.d的日志加上if(isDebug),LogUtil.d以上的对日志拼接做精简和删减。
内存紧张时,系统会频繁GC,造成"stop the world",对卡顿的影响不容小觑
版本水位性能测试时,多次发现歌房进退房存在内存持续增长不释放的问题,导致进房越来越卡,排查后发现有多处内存泄露
a.弹窗动画未关闭导致泄露,开播聊天房设置背景音乐时,当弹窗关闭时动画没有关闭导致泄露
解决方案:弹窗关播即动画不显示的时候将动画停止并销毁
b.在某一款机型上发现,反复进退歌房,内存会一直增长
解决方案:此类问题应在日常开发中关注APM监控平台(火眼,Bugly)上报修复;系统测试期间针对主路径跑一套完整水位测试,系统性的解决内存泄露
case2:内存优化方案
针对内存紧张时进房间,内容易内存触顶,GC频繁,造成卡顿 分析:直播间内使用的ViewPager2作为上下滑动的框架,因此如果能进入当前Item时,不预加载下一个直播,这样就可以避免一个房间对象实例的创建,可以优化较多的内存,缓解卡顿
方案:在内存紧张时侯,设置ViewPager2#setOffscreenPageLimit为1,不同于ViewPager,ViewPager2设置setOffscreenPageLimit为1是有效的,不会预加载下个Item。
结果:经测试同学测试后发现内存优化41M
GC监控建设:
GC日志可以帮助开发查看和分析应用当前的内存使用情况,帮助定位内存泄露,内存抖动问题,以及由于GC导致的卡顿问题,如GC频率过高,非常容易导致卡顿 方案一:自定义一个对象使用弱引用包裹,然后放入自定义的引用队列中,开启一个子线程,循环查看该用于是否被弱引用队列移除。类似LeakCanary监听内存泄露原理
方案二:模仿ActivityThread监听GC,自定义一个弱引用对象,实现其finalize(),当对象要被回收时,说明发生了GC,此时打印一些日志信息
方案二简单有效,无须新建监听线程,最终具体实现
问题:wesing 5.68版本性能测试,发现线程比上版本多增加了近30个,fd新增250个,卡顿率由15%增加到20%。
分析与排查:经过adb命令排查增量线程,发现增加的是以".tencent.wesing"和"wesing-default-"线程名,没有明显命名特征,退出歌房仍然不减少。排查新版本feature改动,发现升级trtc sdk,利用升级前后apk对比复测,必现。
解决:因此提交给sdk方,由于引入一个业务不相关的功能导致。修复后各项指标正常
疑问:这里新增的线程,跟主线程没有直接关系,为何影响卡顿?
进一步分析:通过perffto sql分析CPU使用情况,发现trtc升级后,DefaultDispatch线程占用超过主线程和RenderThread。
结论: 1.线程增多影响主线程CPU切换时间片时间,从而抢占主线程时间,导致卡顿 2.新增30个线程会显著增加app内存,造成不必要的GC
五、方法与经验总结
优化方法导图
经验总结
1.使用APM平台查询卡顿和ANR问题
业务的卡顿和ANR问题可通过类名或者函数名进行过滤搜索,能更准确地找到问题。也可以根据控制版本查看新增问题
CPU Profiler抓取的方法调用trace,在横向上是按时序分布的,可以抓取一个阶段的trace,做横向和纵向的分析及优化
4.在分析cpu占用过高问题时,可以通过Perftto分析线程cpu时间
Perftto支持sql查询分析,sql模式下,可以通过sql可以算出进程各个线程的cpu时长占用,锁竞争问题
5.通过adb命令分析线程数量
线程过多造成内存增量大,CPU时间抢占,我们通过Perftto发现了trtc线程数量过多导致的内存增长问题
adb shell ps -T | grep xxx
6.分析不合理内存占用问题
通过分析和解决内存泄露以及根据当前内存使用状态采取降级策略,进一步优化内存问题,降低GC频率,整体提升程序性能。
7.版本性能测试
测试同学在系统测试期间会跑性能水位(CPU,内存,FD),但水位波动是表象,开发人员自己也需要针对主路径进行性能复测,根据水位的波动差异做进一步的排查和验证,确认没有漏网之鱼。
8.系统全面地分析问题
直接耗时容易发现和解决,但对于间接耗时如内存问题,CPU占用等问题则隐藏较深,我们既要针对常见的直接耗时问题加以预防和治理,针对间接卡顿和耗时问题也需要做出系统的分析和排查。