cover_image

Android TTI 治理经验分享

快手大前端技术 快手大前端技术
2021年09月17日 13:00
图片

导读


本文介绍了快手主站 Android 端 TTI 的概念,详述了量化 TTI 指标的过程,并分享相关的信息收集方案和常规治理手段,以期为处理 App 冷启动后的卡顿问题提供参考。


图片

文 / Wander

编辑 / finn

本文共5524字,预计阅读时间15分钟。


#

背景介绍

#

TTI的定义

Interactive

FPS 的缺陷

FrameTime

#

信息收集方案

线下

线上

TTI 性能实验室

#

治理手段

TTI 调度框架

耗时任务治理思路

#

总结与展望


背景介绍

图片


我们先来看一个视频,了解什么是 TTI(Time To Interactive):


视频加载失败,请刷新页面再试

刷新


视频对应的 Systrace 如下图:


图片


在 App 冷启动结束后,从视频首帧界面显示到流畅播放,再到流畅使用 App,这期间存在一段较长的卡顿。


这不是一个 BUG,而是一个老问题。“冷启动完成后”这一时间节点比较特殊,整体 CPU 负载高,很多主线程任务都集中在这个节点处理。启动优化会将一些任务后移到这个节点,加上底层 SDK Kswitch 出现的一次 BUG ,冷启动后的首页卡顿问题就彻底爆发出来了,由此引入了 TTI 的概念。


TTI的定义

图片


TTI (Time To Interactive),即可交互时长,针对快手而言,是指冷启动结束后到用户可以流畅使用快手 App 的所需时长。


这个概念听上去很好理解,但由于指标难以界定,想要将其量化也并不容易。


首先,可交互时长的重点在于“可交互”,即用户交互后才能确定时长。但短视频 App 通常在启动后就开始播放视频,用户可能不会及时进行交互,这种情况下可交互时长应如何计算?其次,如何定义可交互状态?从 Android 技术层面来说,只要用户的 Input 能被迅速处理就能被称之为“可交互”。但是,用户每次交互的方式和频率都是未知的,不同 Input 对应的处理也不尽相同,这给判断是否“可交互”带来了很大难度。


因此,要不要考虑用户的交互行为,如何判断是否达到可交互状态——这些都是需要明确的问题,这也导致 TTI 的计算口径难以统一。


由于考虑用户交互情况的实现复杂度较高,也没有较为统一的标准,所以我们化繁为简,不考虑用户是否交互、何时交互等因素,只要页面达到了观感上的流畅并且没有出现明显卡顿,就判断其达到了可交互状态。换言之,无论用户有没有交互行为,只要页面在某个节点达到了前期预设的流畅标准,那就判断其处于可交互状态。


对于这一问题,我们来看一下业界其他公司对类似概念的定义:主线程连续产生 XX 个 IdleTime 即被认为可交互。从 Android 技术角度来说,这似乎符合可交互的定义。但快手作为短视频 App,从用户体验的角度出发,以画面流畅度来判断是否可交互是更加“因地制宜”的选择。


FPS的缺陷


接下来,我们需要确定适用于快手 App 的“页面达到稳定流畅”的标准。最初我们是根据 FPS 来确定此标准:


  • TTI Start Time:首页展示第一帧的时间戳

  • TTI End Time:非静止页面的每秒平均帧率大于 50,且前后 4 个采样点(一秒一个采样点)的帧率波动不大于 5;使用第一个满足上述两个条件的采样点的时间戳

  • TTI = TTI End Time - TTI Start Time


试用一段时间后,我们发现 FPS 口径有两个比较明显的缺陷:一是 FPS 不能反映真实的卡顿情况;二是 FPS 统计的精度不够。


FPS不能反映真实的卡顿情况

图片


首先我们需要了解以下两点:


1、FPS(画面刷新率) ≠ 屏幕刷新率,这是由屏幕刷新率、当前 App 注册 VSYNC 的频率以及掉帧情况共同决定的。


2、目前,大部分 Android 设备的屏幕刷新率是 60。


图片


假设 App 使用流畅,没有任何掉帧,那么播放视频时的帧率基本上是由当前视频的帧率和动画的帧率决定的。但是视频的帧率是不固定的,大部分是 30,也有一部分是 60。另外,当前画面里是否会出现动画也不可控。所以,即使 App 完全流畅,正常情况下 FPS 的上限也是不固定的。


那么,是不是这个原因导致 FPS 不能反映真实的卡顿情况呢?


我们再来看一下如何监控当前界面的 FPS。计算 FPS 其实就是计算一秒内画面内容发生了多少次改变。这时,问题就转化成了如何判断画面发生了一次改变。


屏幕画面真正发生改变的时间点是 Display 在收到 VSYNC 信号切换 Buffer,并且 Buffer 数据改变过。这就意味着上层每执行一次 doFrame() 后,屏幕画面都会发生一次改变(*严格意义上不准确但是可以这么理解),这一流程中我们有很多可以监控的点。


我们选择利用系统公开的 API postFrameCallback(见下图)进行监控,具体做法是:在合适的时机通过 postFrameCallback 注册一个 ANIMATION 类型的 doFrame 回调,并且在每个回调中继续注册下一个回调,直到监控结束。这样一来,我们就统一了监控期内注册 VSYNC 的频率,即在没有任何卡顿掉帧的情况下, FPS 是 60。所以上述原因在监控方案里不成立。


图片


那么,为什么 FPS 不能反映真实的卡顿情况呢? 来看以下两种情况:


1、一秒内绘制了 50 帧,每帧耗时 20ms 左右。


图片


2、一秒内绘制了 50 帧,其中有一帧耗时近 200ms,其余 49 帧每帧耗时 16ms。


图片


以上两种情况计算出的 FPS 都是 50 帧,但是用户体验却完全不同。

第一种情况:画面流畅,用户感受不到卡顿;

第二种情况:画面会出现一次严重卡顿,影响用户体验,但是 FPS 的口径却不能检测出问题。


FPS统计的精度不够

图片


我们计算的帧率是平均帧率,即每秒的帧率,从 FPS 方式的 TTI 计算口径来看,这种计算方式精度为 1s,上报的数据一定是 1s 的整数倍。反映到大盘上的均值是可以接受的,但是除了均值以外,我们还需要看 P50 和 P90 等数据,P50 上会出现 1s 间的跳变。


FrameTime


我们选择使用 FrameTime 指标来判断界面是否达到流畅,这样可以有效避免上述两个弊端。


FrameTime 即两帧画面间隔耗时。对于 FrameTime 的监控和获取,我们沿用了之前 FPS 采用的方式,获取的耗时信息可以理解为单帧渲染耗时。虽然不是真正意义上的两帧画面间隔耗时,但实际差别不大。经过多次实验,我们确立了如下 Android 端 TTI 口径标准:


  • TTI Start Time:首页展示第一帧的时间戳

  • TTI End Time:  连续 400 帧的帧耗时小于 83ms 的第一帧时间戳

  • TTI = TTI End Time - TTI Start Time


关于“为什么 83ms 代表一般意义的卡顿”这一问题可以参考 PerfDog 的这篇文章[1]。


信息收集方案

图片


线下


Systrace埋点

图片


我们前期治理的主要分析手段是线下抓取 Systrace,其 Framework 层代码的埋点在 Android 中已经提供得较为丰富,使用时只需根据不同策略去打开开关即可。但 App 代码需要人工插桩埋点,现阶段的性能包选择了 AspectJ 在编译阶段以字节码的方式插入到代码中进行埋点,在 Pointcut 中添加需要追踪的类和方法即可。


插桩虽然简单,但插桩方法增多会导致编译时间变长,并带来一定程度的性能损耗。另外,在抓取 Systrace 的过程中,我们可能还会遇到:Buffer 设置偏小导致的信息丢失、超出最大调用层级带来的显示问题等情况。


随着分析不断深入,我们发现上述的插桩方式不是万能的。如 EventBus 和 RxJava / RxBus 这类耗时问题,由于使用方式的特殊性以及大量线程切换,在 SysTrace 中没有好的办法显示比较完整的调用栈,这给定位耗时任务来源造成了很大困难。


于是我们分析了 EventBus 和 RxJava / RxBus 的源码逻辑,通过反射的方式获取了关键的属性,进而拿到了业务代码调用逻辑所在的类并显示在 SysTrace 中。有了原始调用类再去定位源头就相较容易一些了。



实时帧耗时图表

图片


我们已经确定了 TTI 口径,大盘的 TTI 数据可以通过客户端上传至服务端统计后查看。但是如何在本地测试时便捷地查看快手与同类产品的实时 TTI 数据和帧耗时情况呢?


为了解决这一问题,技术同学开发了实时帧耗时图表,其具体原理是使用 Xposed / Frida 工具 Hook 快手和同类产品的关键代码点(UI 绘制、视频首帧绘制完成),动态获取运行时的帧耗时数据,自动计算和输出每一次启动的 TTI 时间。同时,注入实时显示帧耗时的图表挂件,以便于本地调试、体感对比以及测试同学验证数据的可置信性。


一旦发生卡顿,实时帧耗时曲线会出现红色的柱形(表示单个渲染帧耗时过长);曲线展示与 App 体验没有延时,瞬时卡顿可以反映在帧耗时曲线上。同时,红色柱形高度越高或柱形越密集,表示体感卡顿越明显。


图片
图片

图片源自快手平台随机截取,如有侵权请联系删除


线上


线下数据适合分析单个问题,但无法反馈出大盘问题,所以收集线上数据非常关键。


对于线上大盘数据,我们主要从长耗时任务和线上火焰图两个维度去获取。


  • 细分耗时任务


由于 TTI 指标(或是实际用户体验)基本是由主线程的耗时任务决定的,尤其与最后一次出现的长耗时任务息息相关。所以,获取线上大盘的耗时任务信息有助于对大盘数据等问题进行归因。


具体做法是:通过线上开关设置不同的超时阈值,再通过采样的方式开启一个子线程进行 FrameTime 的监控。一旦发现 FrameTime 超过设定的阈值,立刻抓取主线程的堆栈信息并且上报。


但是,直接上报原始的耗时堆栈信息存在两个缺陷:第一,原始堆栈层级深,数量大,问题聚合度不高;第二,前端显示不友好,无法直观看出实际上的耗时任务(原始的 runable 或者 message)。


因此,我们设计了双层定位方案。首先,对耗时堆栈信息进行处理,提取出这个堆栈对应的实际耗时任务,将提取出的耗时 TaskName 进行聚合形成 MD5-1,再将精简后的堆栈信息进行聚合形成 MD5-2。同一个 MD5-1 对应着多个 MD5-2,一个 MD5-1 代表这一个原始耗时任务,一个 MD5-2 代表某个原始耗时任务的具体耗时子函数。最终,在前端默认显示的是友好的耗时 TaskName。同时,我们会上传耗时任务时间戳、耗时任务 index、设定的耗时阈值以及原始堆栈等信息,最终服务端就能通过不同的组合来查询获取需要的信息。


该方案的难度主要在于如何从形形色色的堆栈中提取出耗时 TaskName。


我们利用树来储存主线程的常见堆栈,形成一个堆栈树模版。每一个叶子节点代表一种 Task 类型,拥有着相同的堆栈提取规则,关联着一种堆栈提取逻辑(具体为一个 Funtion 接口的实现)。每捕获一个耗时堆栈,我们遍历这个树找到匹配的叶子结点,通过该叶子结点对应的堆栈提取逻辑来提取出对应的耗时 TaskName。树模型如下图所示,目前仍在不断迭代中。


图片


  • 线上火焰图


快手内部有既成的线上火焰图方案 Chronos。Chronos 提供了接口给各阶段的业务去抓取线上火焰图,做一些简单的参数配置。比如 TTI 阶段起始和结束的时间点、兜底时间以及 Buffer 大小等即可接入,配合线上开关的命中策略即可抓取用户手机的 Systrace,适用于分析线上单点用户的性能问题。由于目前的 TTI 优化尚未达到瓶颈阶段,所以线上火焰图暂时仅作为补充手段。


TTI性能实验室


除了上述方案,技术团队还建立了 TTI 性能实验室,每天通过定时任务拿到每个版本的 TTI 数据和耗时的堆栈信息,这是目前线下主要的防劣化手段。具体方案主要有如下几点:


  • 每天 0 点和 12 点,进行定时任务,监控 12 小时内提交的 commit 是否导致 TTI 劣化,劣化超阈值报警机制提醒相关同学;

  • TTI 耗时任务中,通过抓取火焰图并统计头部函数和帧耗时图,可以精准定位增长函数;

  • 实验室环境下,过滤图集等特殊 Feed 流,跳过开屏广告等机制,减少偶然影响因素,增强实验室稳定性;

  • 通过定时任务和手动对比任意提交 commit id,可实现常态化防劣化并精确定位耗时增长原因。


图片


治理手段

图片


造成卡顿的源头已经被找到了,接下来我们需要对其进行治理。首先,统计启动后主线程任务的来源,大概有下面几种情况:


  • 原本在启动过程中的主线程任务的后移

  • 业务监听冷启动后一些关键节点的回调逻辑(多见于 EventBus / RxJava)

  • 布局和上下滑相关

  • 播放器相关

  • 其他业务引入


我们发现很多任务对执行时机并没有很严格的要求,但由于缺少统一的调度框架,导致这些任务在冷启动完成后集中爆发。


我们的优化思路分为两步:


一、开发一套合理的任务调度框架。

二、处理所有需要治理的任务,优先采用框架调度,不能调度的再针对性优化。


TTI 调度框架


基于上述分析,一套合理的 TTI 框架应满足以下基本要求:


1、实现帧耗时监控、采集以及 TTI 数据上报

2、有合理的调度方案,至少不能影响或者最小限度的影响 doframe 的正常执行

3、实现调度任务的耗时信息和上报,可以更及时地定位框架内的任务导致的劣化问题


下面我们从三个方面简要介绍 TTI 调度框架。


FrameTime 监听

图片

FrameTime 监听方案在上文「TTI 的定义」一节中已有提及,此处不再赘述。


任务调度

图片


我们将 TTI 任务分为主线程任务和工作线程任务。


  • 主线程任务


首先,我们将主线程任务分为四个不同的优先级:高优低耗时、高优高耗时、低优低耗时和低优高耗时。


将以上四个不同优先级的任务纳入对应的队列,利用主线程 IdleTime 结合当前是否已经经过 TTI 阶段的 Flag 来决定四类任务的调度情况,总体方案是:主线程 IdleTime 期间可以调度低耗时任务,TTI 阶段结束后方可调用高耗时任务。在同时满足两个条件的情况下,高优任务优先。这样就可以最大限度减少框架任务对 TTI 指标和用户体验的影响。


任务的初步分类由业务方评估后选择,框架通过获取线上大盘数据来判断任务属于高耗时还是低耗时,再在框架内实现动态二次分类。以下是具体流程图:


图片


  • 工作线程任务


工作线程的任务调度相对比较简单,主要目的是尽可能降低启动后的 CPU 负载。我们使用 HandlerThread 进行任务调度,每个任务间会延迟一个时间间隔。这里,我们将线上大盘监控的工作线程任务平均耗时作为时间间隔,并使用线上开关进行控制。


数据上报

图片


数据的上报主要分为以下三个部分:


一、框架内任务的调度信息


框架内任务调度信息的上报,可以更及时地定位框架内任务导致的劣化问题。我们分别从上报量、任务耗时和任务延迟时长三个维度,来统计主线程和工作线程任务的耗时,同时上报至服务端。


二、TTI 数据


在每次监控结束后,TTI 结果会通过子线程按上述口径计算后上报至服务端。


三、长耗时任务 task 和堆栈


这里的长耗时任务是指超过 83ms、影响到 TTI 指标的关键耗时任务,具体的 task 提取和堆栈细分方案在上文中已有介绍。最后,我们可以通过服务端的相关看板详细查询每个版本、每天的 top 耗时 task,提高问题优劣化的归因能力。


耗时任务治理思路


长耗时任务

图片


按照已确定的 TTI 口径,超过 83ms 的长耗时任务是首要治理目标。针对长耗时任务,我们的优化思路主要是:异步、协程和打散。


短耗时任务

图片


短耗时任务本身不会造成明显卡顿,但是短时间内若有大量短耗时任务集中插入主线程消息队列,也会出现卡顿的情况。此时只有异步消息能进行插队处理,一些更新 UI 的操作很可能被滞后处理,从而导致注册 VSYNC 操作的滞后,即增加了两个画面帧之间的耗时。如下图这种情况:虽然两个上层 doFrame 绘制操作之间没有明显的长耗时,但是中间却有 100ms 以上的时间间隔,造成较为明显的卡顿。针对短耗时任务,我们的优化策略主要是:异步和框架调度。


图片


基础库和 API

图片


有些耗时任务不是由于单一函数所导致,而是大量同一个基础库或者基础 API 的调用造成的累积耗时。尤其涉及到大量级的数据操作,这时对基础库和 API 进行优化会有更加明显的收益。


上下滑框架

图片


将“上下滑时产生的耗时任务”单独摘出来有以下两个原因:一是在使用短视频 App 的过程中,上下滑操作非常频繁;二是它的耗时与否和 TTI 数据有很大关联。


总结与展望

图片


后续工作中,我们将朝着以下几个方向努力:


1、TTI调度框架的健壮性和智能性

2、进一步健全 Review 机制和防劣化机制

3、优化上下滑框架和播放器相关的耗时任务

4、完善 TTI 线上耗时任务提取的 Case,增强问题归因能力

  

相关链接

图片

[1]https://perfdog.qq.com/article_detail?id=10162&issue_id=0&plat_id=1


欢迎加入

图片

快手主站技术团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术。自2011年成立以来,团队全面赋能快手生态,建立起业内领先的大前端技术体系,为数亿用户打造极致体验。


在这里你可以获得:

  • 提升架构设计能力和代码质量

  • 通过大数据解决用户痛点的能力 

  • 持续优化业务架构、挑战高效研发效能

  • 和行业大牛并肩作战


我们期待你的加入!请发简历到:

app-eng-hr@kuaishou.com


修改于2021年09月18日
继续滑动看下一个
快手大前端技术
向上滑动看下一个