船新版本之学习屏幕刷新机制引发的画面卡顿监控与优化的思考

想当标题党,但是发现标题怎么都理不顺,语文老师发现应该是会揍我,但是无所谓了,我感觉也算是很清楚了。

咸鱼时间看了一篇屏幕刷新机制的好文,引发了对Choreographer的思考,虽然我完全忘记这篇文章讲了什么,因为点进作者的主页,往下滑一段距离,是一篇作者亲身的初恋经历,很懵懂,很青涩,很有初恋的感觉,虽然故事的结尾是be(bad end),不过从第三者的视角去看,当时的他们确实不太适合,好像有点偏题偏大了,助力每一个梦想,我直接贴出来恋爱小故事的简书链接,https://www.jianshu.com/p/fafc37d80013

这篇文章尽量用轻快的语气去讲,第一人称小LING同学,望读者海涵作者贫乏的文字功底和对该技术的浅显理解。

本文将采用四个标题论述,第一章 屏幕的刷新机制、第二章 System Trace和Java/Kotlin Method Trace的使用体验、第三章 基于性能分析的简单优化理解、第四章 未来的展望与惋惜,通过承上启下的方式贯穿全文,表达了作者的思乡之情…

第一章 屏幕的刷新机制

正如引言所说,小LING同学咸鱼时间(需求暂时干完bushi)看了一篇关于屏幕刷新机制的文章,然后就引发了一系列故事。如果对显示器有所了解的朋友大概会知道几个参数,分辨率,帧数,垂直同步,安卓设备也拥有可视化屏幕,自然会有类似的概念,搬一点gpt的回答。

屏幕刷新帧数: 在安卓设备中,屏幕刷新帧数是指屏幕每秒钟能够更新的图像次数,屏幕刷新帧数通常用每秒帧数FPS来表示,例如60 FPS、90 FPS、120 FPS等。高刷新帧数通常带来更流畅的视觉体验,尤其在游戏、视频播放和图形处理应用中。

垂直同步: VSync是垂直同步的缩写,用于协调CPU和GPU的帧输出速度与显示器的刷新速率。启用VSync能够防止“屏幕撕裂”(tearing)现象,即显示器在刷新期间显示的图像由多个帧组成。

屏幕卡顿: 屏幕卡顿(Screen Stuttering or Jitter)是指显示画面在播放动态内容时出现的不连贯、不流畅的现象。这种现象通常表现为图像更新停滞、跳跃或不规律,导致观众体验到明显的中断。

简单点说,帧数为60的手机屏幕一秒钟能够切换60张图片,如果呆在页面没动,系统可以根据策略选择跟着你一起呆,也可以使用垂直同步(AI?补帧?),切换60张相同的画面,但是如果你的手机很差(好像只要足够好,画面就不可能卡,卡就加机器!)/ 代码很差(代码不可能有问题,是Java的问题),一秒钟不足以生成60张图片,就会肉眼可见不连贯,画面撕裂,也就是卡顿了。

解释完一些概念,小LING同学开始感到困扰,既想把东西讲细一点,毕竟这是一个庞大的系列,又想把东西讲简单化,由深入浅,这样观看门槛不会很高,所以后面的文章风格可能会很杂交?当个乐子看吧。

  • 一些屏幕刷新的思考

假如是60帧的手机,代表一秒切换60张图片,每张图片存在的周期是1s/60=1000ms/60=16.6ms,所以我们要在16.6ms计算完图片交给GPU和显示器去渲染上屏。

那我们什么时候开始干事情,什么时候开始渲染,我们又怎么去上屏???好在不用手动控制,系统会发送vsync信号通知我们进行屏幕刷新,我们只要在信号到来时保证cpu能够成功处理完图像交给底层就行,如下图:

1924341-d8ebbbd67051dd6b.webp

小LING同学开始懵逼,这是啥,1.既然是系统发送vsync信号,我更新UI的时候怎么知道vsync什么时候来? 2.画面不变的时候vsync来了我需要做什么吗,告诉它快点走,我不更新UI了?

我们可以反过来去理解,手动调用invalidate方法更新UI。

  • 通过invalidate找到vsync信号

上才艺:

binding?.tvTest1?.setOnClickListener {
    it.invalidate()
}
// 简单解释一下,点击textview调用了自身的invalidate方法

偷懒上才艺:

企业微信截图_17163483993511.png

思考一下怎么用这张图把这个事情说清楚,怎么说呢,小LING同学属于是看完源码自己爽了就不想动的那种人,石锤懒鬼,简单解释一下吧,读者可以自行去看源码。

最左边是我们点击事件触发自身invalidate函数的流程,走到了scheduleTraversals函数,中间是向底层注册vsync信号的流程,最右边是监听到vsync信号然后进行performTraversals函数的流程,听到这里懵逼了吗,我说了啥,我是不是啥也没说,毕竟还是技术科普类的文章,贴一点别人的源码解释:

我们知道一个 View 发起刷新的操作时,最终是走到了 ViewRootImpl 的 scheduleTraversals() 里去,然后这个方法会将遍历绘制 View 树的操作 performTraversals() 封装到 Runnable 里,传给 Choreographer,以当前的时间戳放进一个 mCallbackQueue 队列里,然后调用了 native 层的方法向底层注册监听下一个屏幕刷新信号事件。
​
当下一个屏幕刷新信号发出的时候,如果我们 app 有对这个事件进行监听,那么底层它就会回调我们 app 层的 onVsync() 方法来通知。当 onVsync() 被回调时,会发一个 Message 到主线程,将后续的工作切到主线程来执行。
​
切到主线程的工作就是去 mCallbackQueue 队列里根据时间戳将之前放进去的 Runnable 取出来执行,而这些 Runnable 有一个就是遍历绘制 View 树的操作 performTraversals()。在这次的遍历操作中,就会去绘制那些需要刷新的 View。
​
所以说,当我们调用了 invalidate(),requestLayout(),等之类刷新界面的操作时,并不是马上就会执行这些刷新的操作,而是通过 ViewRootImpl 的 scheduleTraversals() 先向底层注册监听下一个屏幕刷新信号事件,然后等下一个屏幕刷新信号来的时候,才会去通过 performTraversals() 遍历绘制 View 树来执行这些刷新操作。
  • 屏幕刷新的总结

综上所述,小LING同学做个总结,对View进行invalidate函数调用的时候,会遍历寻找父亲节点,最后走到ViewRootImpl的scheduleTraversals函数:

void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
            Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        ......
    }
}
// mTraversalScheduled标识防止一帧内多次调用scheduleTraversals,上屏是个整体操作
// postSyncBarrier()同步屏障,优先执行异步消息
// mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);发送CALLBACK_TRAVERSAL类型的消息进入队列并且对底层进行监听,待底层返回vsync刷新信号

这样对应了上图的最左边和中间的链路,感兴趣的同学可以去看源码,接下来是native操作,我们不用考虑什么时候底层什么时候告诉我们刷新页面,根据策略不同可能是16.6ms通知我们,也可能是没有UI操作的时候节约能耗不通知我们,待vsync信号上来的时候会执行刚才给出去的mTraversalRunnable,代码如下:

void doTraversal() {
    if (mTraversalScheduled) {
        mTraversalScheduled = false;
        mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
​
        performTraversals();
    }
}
// vsync信号上来了,mTraversalScheduled标识重新置为false,为下一轮屏幕刷新服务
// removeSyncBarrier()移除同步屏障,执行同步消息
// performTraversals()屏幕刷新测量、布局、绘制三大流程
​
private void performTraversals() {
    ......
    performMeasure() 测量
    ......
    perfromLayout() 布局
    ......
    performDraw() 绘制
    ......
}
// 根据一些状态标识位判断是否需要执行测量、布局、绘制三大流程

这样对应了上图最右边的部分,至此,整个屏幕的刷新流程结束。

回过头解答一开始提出的问题,

1.既然是系统发送vsync信号,我更新UI的时候怎么知道vsync什么时候来? 是的我们不需要知道,只要我们发起了刷新View的请求,就会往底层注册vsync信号,vsync信号上来的时候正常执行performTraversals函数即可。

2.画面不变的时候vsync来了我需要做什么吗,告诉它快点走,我不更新UI了? 理论上vsync信号不来的时候,我们确实不需要做什么,因为我们本来就没有更新UI操作,帧数维持1即可,但是1到60可能存在一个帧率启动问题?画面即使不变化,垂直同步还是会拿60帧画面,这里看是通过每16.6ms发vsync信号还是底层自己取上一帧的画面循环了。

3.第一帧,没人主动调invalidate,画面哪来的? 涉及到Activity,attach(),onCreate()和onReume()的一些骚操作,大意就是attach()创建phoneWindow,wm,onCreate()生成decorView并且添加,onReume()调用wm.addView初始化ViewRootImpl,主动触发requestLayout(),如下:

public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        scheduleTraversals();
    }
}
// checkThread()涉及到了一些onCreate函数时机更新UI,和子线程使用wm更新UI的骚操作
// mLayoutRequested更新布局标签
// scheduleTraversals()因此最后还是走到了上面分析的流程

小LING同学完美撒花,屏幕刷新机制完结, 但是看完这种东西心血来潮,就是想捣鼓啥,于是乎我打开了Profiler,走上了一条不归路。

第二章 System Trace和Java/Kotlin Method Trace的使用体验

打开Profiler之后,小LING同学从 “富婆,饿,饭饭””大佬,救救,看不懂“ 的心理历程转变,当然也可能是反过来的。

“看不懂,这是啥,我在干嘛,这是什么东西,怎么这么卡,我该怎么办” ,打开Profiler的真实心声,于是我去互联网冲浪,翱翔,漫游,弄了套自己的浅显理解。

话不多说,来个例子:

private fun anima(view: TextView?) {
    if (view == null) {
        return
    }
    // 循环播放放大缩小动画
    val scaleUpX = ObjectAnimator.ofFloat(view, "scaleX", 1.0f, 1.5f)
    val scaleUpY = ObjectAnimator.ofFloat(view, "scaleY", 1.0f, 1.5f)
    val scaleDownX = ObjectAnimator.ofFloat(view, "scaleX", 1.5f, 1.0f)
    val scaleDownY = ObjectAnimator.ofFloat(view, "scaleY", 1.5f, 1.0f)
​
    val scaleUp = AnimatorSet()
    scaleUp.playTogether(scaleUpX, scaleUpY)
    scaleUp.setDuration(500)
​
    val scaleDown = AnimatorSet()
    scaleDown.playTogether(scaleDownX, scaleDownY)
    scaleDown.setDuration(500)
​
    val animatorSet = AnimatorSet()
    animatorSet.playSequentially(scaleUp, scaleDown)
​
    animatorSet.addListener(object : Animator.AnimatorListener {
        override fun onAnimationStart(animation: Animator) {}
        override fun onAnimationEnd(animation: Animator) {
            animation.start()
        }
​
        override fun onAnimationCancel(animation: Animator) {}
        override fun onAnimationRepeat(animation: Animator) {}
    })
​
    animatorSet.start()
}
​
private fun test() {
    anima(binding?.tvTest1)
    binding?.tvTest1?.setOnClickListener {
        test1()
    }
}
​
private fun test1() {
    Thread.sleep(1000)
}
// anima()循环播放动画
// 点击view就睡眠1s,也可以模拟成主线程的耗时操作
  • 打开System Trace查看点击一次的变化

企业微信截图_17163632538888.png

1.选择需要分析的应用程序,点击CPU准备Record,

2.All Frames代表所有帧,JankyFrames代表丢掉的帧,

3.可以很明显看出来,点击后出现了一个卡顿帧,页面沉睡了1000ms,正好卡帧了1s。

  • 打开Java/Kotlin Method Trace查看点击一次的变化

企业微信截图_17163640152881.png

小tips:系统 API 的调用显示为橙色,应用自写方法的调用显示为绿色,第三方 API(包括 Java 语言 API)的调用显示为蓝色。

1.可以看到橙色是系统API,绿色是自己写的函数test1,也就是后面我们能优化的函数,蓝色是三方API,

2.Flame Chart?帧记录表?Flame Chart提供了调用栈的反向调用图,Flame Chart中的水平条表示出现在相同的调用序列中同一方法的执行时间,从图中我们很容易发现哪个方法消耗的时间最多。

3.可以看到onClick这条是比较长的,几乎占了一半,鼠标移动到上面能够发现也是1s。

  • 专门开个小标题吐个槽

是我小LING同学使用姿势不对吗,这两个功能也太割裂了,我通过System Trace拿到了卡顿帧和日志,但是里面可用的情报少得可怜,我他么怎么知道是哪些函数堵住了,于是乎这时候我又打开Java/Kotlin Method Trace模拟刚才的操作,方法耗时倒是有了,由于打开Trace会让程序变慢,实际项目中log只用了10ms的方法它跑了500ms!

System Trace完美模拟丢帧需要尽可能不干扰代码执行,而Java/Kotlin Method Trace拿到耗时又需要尽可能干扰函数,但是这样太割裂,没有折中的方法吗,比如System Trace塞几个可用信息的方法链路进去,或者Java/Kotlin Method Trace跟着帧模拟?应该是有的,所以说 ”大佬,救救,看不懂“

第三章 基于性能分析的简单优化理解

简单的用Profiler进行了观测,但是我们不可能只观测不解决吧,为什么会卡顿呢,其实一开始有说到两个原因:

1.手机很差(好像只要足够好,画面就不可能卡,卡就加机器!)

2.代码很差(代码不可能有问题,是Java的问题)

显然我们的科技和经济实力是不允许一直加机器的,更何况还有一招Thread.sleep(365天),一年触发一次,代码差也不太可能,世界上没有人不会写代码,只可能是idea的问题,更或者是编程语言的问题。

这下已经陷入了死局,小LING同学绞尽脑汁,咬文嚼字,博览群书,总算发现了一些小原因:

  • 主线程耗时太久,留给画面刷新的时间不够

换一下test1的代码:

private fun test1() {
    Thread {
        Thread.sleep(1000)
    }.start()
}
// 将耗时操作交给线程去处理,拿到操作的结果后再交给主线程使用

这代码有点抽象但是又没那么抽象,把Thread.sleep(1000)看作主线程的耗时操作,比如看作Json数据转换,文件读取,一个操作就是大几十ms,在你没注意的情况,你就是有可能放在了主线程。

例如本地存了userInfo的Json,每次获取userId,userPhone都写了一个方法去转换然后取值(一次10ms),再当作网络接口的参数:

MainHttp.getData(UserUtils.getUserId(), UserUtils.getUserPhone()).subscribe()
// 恭喜,这个方法至少堵住了主线程10ms+10ms的时间,如果一个页面100个请求呢?
// 我们还在疑惑为什么每次跳转这个页面都会卡一下,我明明用了rxjava啊,都是异步请求了,难道是手机不行?
// 嘻嘻嘻嘻嘻嘻

具体怎么改造小LING同学也是懵懂状态,手动rxjava发送emitter确实有点蠢,我想到的完美的解决方法是协程:

val userId = 协程版UserUtils.getUserId()
val userPhone = 协程版UserUtils.getUserPhone()
MainHttp.getData(userId, userPhone).subscribe() // 我都协程了怎么还是rxjava请求网络,不会这个人不会写吧(bushi
// 顶多1ms+1ms

总结,主线程执行了耗时任务,这需要时间,自然没有时间去刷新UI,可以通过Java/Kotlin Method Trace查看main线程是不是有这些绿色的方法存在,少年去干掉他们吧!

  • 不合理、过量的页面刷新、层级嵌套、UI绘制

来个test2的代码:

private fun test2() {
    for (index in 0..1000) {
        val tempTextView = TextView(this)
        tempTextView.text = "我在放大缩小哦"
        tempTextView.layoutParams = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        )
        binding?.llBg?.addView(tempTextView)
        anima(tempTextView)
    }
    // 1000个你说肉眼不卡?好好好?那这样呢
    // tempTextView.postDelayed({ test2() }, 100)
}

其实代码有点夸张了,但是不可否认,不合理、过量的页面刷新、层级嵌套、UI绘制,也会造成页面卡顿,我们简单看看Profiler分析结构:

企业微信截图_17163689481506.png

企业微信截图_1716369209676.png

可以看到卡顿帧频繁出现,也可以看到绿色的动画调用函数频繁出现,一个函数在观测模式下只有5ms,但是100个,1000个!

总结,虽然主线程没有什么耗时任务,全心全意的在跑UI绘制的代码,但是不合理、过量的UI绘制过多,始终不能很好的顾及每个View,导致了结局必定出现的卡顿。

第四章 未来的展望与惋惜

洋洋洒洒4000字,文章总算来到了尾声。

小LING同学一开始只是看了一篇屏幕刷新机制的文章,后面升起了玩一下Profiler的想法,再后面开始感叹这个过程还能继续不停的延伸。

  • 先说未来

只关注Profiler的局限性还是很大:

1.开启方法Trace后,函数运行效率大大降低,能作为参考和排查问题的思路,得不到比较真实的优化数据。该问题的解决方法应该是采用插桩之类的方案,对方法进行计时,发起技术埋点,进行上报,进行标准的制定,根据标准控制卡顿代码。

2.debug环境下的优化始终算事前,并做不到线上的监控分析,该问题的解决方法应该是采用类似于watchdog方案监听线上的函数执行情况。

这算是小LING同学的弱项,深度拉得比较高了,有点跟不上,未来有机会可以对这个进行研究,自己玩玩。

  • 再说惋惜

光阴似箭,岁月如梭,谁能想到预料到未来的自己还有多少热情、多少时间去写这样的文章。

引言上写自己亲身经历恋爱小故事的作者,已经两三年没有更新文章了,现在的他是否已经释怀了,是否已经结婚了,是否还会打开简书去看自己的私信呢?

或许过好当下,珍惜身边人才是需要考虑的问题。

最后碍于规模等问题,实际应用存在方案存在很大的局限性和未验证性,欢迎大佬的美好意见。

  • 18
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值