移动端页面加载耗时监控方案

6,517 阅读19分钟

本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。

前言

移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。

而优化的效果体现,需要置信的指标进行衡量(常见方法论:寻找方向->确定指标->实践->量化收益),而本文想要分享的就是:如何真实、完整、方便的获得页面加载时间,并会向线上监控环节,有一定延伸。

本文的示例代码都是OC(因为Java和kotlin我也不会😅),但相关思路和方案也适用于Android(Android端已实现并上线)。

页面加载耗时

常见方案

页面加载时长是一直以来大家都在攻坚的方向,所以市面上也有非常非常多的度量方案,从节点划分角度看:

较为基础的:ViewController 的 init -> viewDidLoad -> viewDidAppear

更进一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable

主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable

还有什么地方可以改进的吗?

对于这些成熟方案,我还有什么可以更进一步的吗?主要总结为以下几个方面吧:

  • 完整反映用户体感

我们做性能优化,归根结底,更是用户体验优化,在满足功能需要的同时,不影响用户的使用体验。 所以,我个人认为,大多数的性能指标,都要考虑到用户体验这个方向;页面启动速度这一块,更是如此;而传统的方案,能够完整的反应用户体感吗? 我觉得还是有一部分的缺失的:用户主动发起交互到ViewController这个阶段。这一部分有什么呢,不就是直接tap触发的action里vc就初始化了吗? 实际在一些较为复杂、大型的项目中,并不然,中间可能会有很多其他处理,例如:方法hook、路由调度、参数解析、containerVC的初始化、动态库加载等等。这一部分的耗时,实际上也是用户体感的一部分,而这一部分的耗时,如果不加监控的话,也会对整体耗时产生劣化。(这里可能会有小伙伴问了,这些东西,不应该由各自负责的同学,例如负责路由的同学,自行监控吗?这里我想阐述的一个观点时,时长类的监控,如果由几个时间段拼接,相比于endTime - startTime,难免会产生gap,即,加入endTime = 10,startTime = 0,那么中间分成两段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成总时长不准。总而言之,还是希望得到一个能够完整反映用户体感的时长。)

  • 数据采集与业务解耦

这一点其实市面上的很多方案已经做得很好了。解耦,一方面是为了,提效:避免后续有新的页面需要监控时,需要进行新的开发;另一方面,也是避免业务迭代对于监控数据的影响:如果是手动侵入性埋点,很难保证后续新增的耗时任务对监控数据不产生影响。 而本文方案,不需要在业务代码中插入任何代码,大都是通过方法hook来实现数据采集的;而对范围、以及匹配关系等的控制,也都是通过配置来完成的。

具体实现

节点确定&数据采集方式

页面加载流程节点 根据一个页面(ViewController)的加载过程中,开发主要进行的处理,以及可能对用户体感产生影响的因素,将页面加载过程划分为如上图所示的11个节点,具体解释及实现方案如下:

1. 用户行为触发页面跳转

由于页面的跳转一般是通过用户点击、滑动等行为触发的,因此这里监听用户触摸屏幕的时间点;但有效节点仅为VC在初始化前的最后一次点击/交互。

具体实现: hook UIWidow 的 sendEvent:方法,在swizzle方法内记录信息;为了性能考虑,目前仅记录一个uint64_t的时间戳,且仅内存写; 注意这里需要记录手指抬起的时间,即 touch.phase == UITouchPhaseEnded,因为一般action被调用的时机就是此时; 同时,为了适配各种行为触发的新页面出现,还增加了一个手动添加该节点的方法,使一些较复杂且不通用,业务特性较强的初始化场景,也能够有该节点数据,且不依赖hook;但注意该手动方法为侵入式数据采集方式。

2. ViewController的初始化

具体实现:hook UIViewController或你的VC基类 的 - (instancetype)init 的方法;

3. 本地UI初始化

不依赖于网络数据的UI开始初始化。

这个节点,我实际上并没有在本次实现,这里的一个理想态是:将这部分行为(即UI初始化的代码),通过协议的方式,约束到指定方法中;例如,架构层面约束一个setupSubviews的接口,回调给各业务VC,供其进行基础UI绘制(目前这种方式再一些更复杂的业务场景下实现并运行较好);有这个基础约束的前提下,才能准确的采集我理想中该节点的耗时。而我目前所负责的模块,并没有这种强约束,而又不能简单的去认为所有基础UI都是在viewDidLoad中去完成的。因此需要 对原有架构的一定修改 或 能够保证所有基础UI行为都在viewDidLoad中实现,才能够实现该节点数据的准确采集。 因此2 ~ 3和3 ~ 4间的耗时,被融合为了一段2 ~ 4的耗时。

4. 本地UI初始化完成

不依赖于网络数据的UI初始化完成。

具体实现:监听主线程的闲时状态,VC初始化 节点后的首个闲时状态表示 本地UI初始化完成;(闲时状态即runloop进入kCFRunLoopBeforeWaiting

5. 发起网络请求

调用网络SDK的时间点。

这里描述的就是上面的节点划分图的第二条线,因为两条线的节点间没有强制的线性关系,虽然图中当前节点是放在了VC初始化平行的位置,但实际上,有些实现会在VC初始化之前就发起网络请求,进行预加载,这种情况在实现的时候也是需要兼容的。

具体实现:hook 业务调用网络SDK发起请求方法的api;这里的网络库各家实现方案就可能有较大差异了,根据自身情况实现即可。

6. 网络SDK回调

网络SDK的回调触发的时间点。

具体实现:hook 网络SDK向业务层回调的api;差异性同5。

7. send request
8. receive response

真正 发出网络请求 和 收到response 的时间点,用于计算真正的网络层耗时。 这俩和5、6是不是重复了啊?并不然,因为,网络库在接收到发起网络请求的请求后,实际上在端阶段,还会进行很多处理,例如公参的处理、签名、验签、json2Model等,都会产生耗时;而真正离开了端,在网上逛荡那一段,更是几乎“完全不可控”的状态。所以,分开来统计:端部分 和 网络阶段,才能够为后续的优化提供数据基础,这也是数据监控的意义所在

具体实现: 实际上系统网络api中就有对网络层详细性能数据的收集

- (void)URLSession:(NSURLSession *)session 
              task:(NSURLSessionTask *)task 
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;

根据官方文档中的描述 image 可以发现,我们实际上需要的时长就是从 fetchStartDateresponseEndDate 间的时间。 因此可以该delegate,获取这两个时间点。

9. 详细UI初始化

详细UI指,依赖于网络接口数据的UI,这部分UI渲染完成才是页面达到对用户可见的状态。

具体实现:这里我们认为从网络SDK触发回调时,即开始进行详细UI的渲染,因此该节点和节点6是同一个节点。

10. 详细UI渲染完成

页面对用户来说,真正达到可见状态的节点。

具体实现: 对于一个常规的App页面来说,如何定义一个页面是否真正渲染完成了呢?

被有效的视图铺满

什么是有效视图呢?视频,图片,文字,按钮,cell,能向用户传递信息,或者产生交互的view; 铺满,并不是指完全铺满,而是这些有效视图填充到一定比例即可,因为按照正常的视觉设计和交互体验,都不会让整个屏幕的每一个像素点都充满信息或具备交互能力;而这个比例,则是根据业务的不同而不同的。 下面则是上述逻辑的实现思路:

确定有效视图的具体类
UITextView 
UITextField
UIButton
UILabel
UIImageView
UITableViewCell
UICollectionViewCell

主流方案中比较常见的,是前几种类,并不包括最后的两个cell;而这里为什么将cell也作为有效视图类呢? 首先,出于业务特征考虑,目前应用该套监控方案的页面,主要是以卡片列表样式呈现的;而且个人认为,市面上很多App的页面也都是列表形式来呈现内容的;当然,如果业务特征并不相符,例如全屏的视频播放页,就可以不这样处理。 其次,将cell作为有效视图,确实能够极大的降低每次计算覆盖率的耗时的。性能监控本身产生的性能消耗,是性能方向一直以来需要着重关注的点,毕竟你一个为了性能优化服务的工具,反而带来了不小的劣化,怎样也说不太过去啊😂~ 我也测试了是否包含cell对计算耗时的影响: 下表中为,在一个层级较为复杂的业务页面,页面完全渲染完成之后,完成一次覆盖率达到阈值的扫描所需的时长。

有效视图包含 cell不包含 cell
检测一次覆盖率耗时(ms)1~515~18
耗时减少15ms/次(83%)

而且,有效视图的类,建议支持在线配置,也可以是一些自定义类。

将cell作为有效视图,大家可能会产生一个新的顾虑:占位cell的情况,再具体点,就是常见的骨架图怎么办?骨架图是什么,就是在网络请求未返回的时候,用缓存的data或者模拟样式,渲染出一个包含大致结构,但不包含具体内容的页面状态,例如这种:

这种情况下,cell已经铺满了屏幕,但实际上并未完成渲染。这里就要依赖于节点的前后顺序了,详细UI是依赖于网络数据的,而骨架图是在网络返回之前绘制完成的,所以真正的覆盖率计算,是从网络数据返回开始的,因此骨架图的填充完成节点,并不会被错误统计未详细UI渲染完成的节点。
覆盖率的计算方式
如上图所示,开辟两个数组a、b,数组空间分别为屏幕长宽的像素数,并以0填充,分别代表横纵坐标; 从ViewController的view开始递归遍历他的subView,遇见有效视图时,将其frame的width和height,对应在数组a、b中的range的内存空间,都填充为1,每次遍历结束后,计算数组a、b中内容为1的比例,当达到阈值比例时,则视为可见状态。 示例代码如下:
- (void)checkPageRenderStatus:(UIView *)rootView {
    if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) {
        return;
    }

    memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
    memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);

    [self recursiveCheckUIView:rootView];
}

- (void)recursiveCheckUIView:(UIView *)view {
    if (_isCurrentPageLoaded) {
        return;
    }

    if (view.hidden) {
        return;
    }

    // 检查view是否是白名单中的实例,直接用于填充bitmap
    for (Class viewClass in _whiteListViewClass) {
        if ([view isKindOfClass:viewClass]) {
            [self fillAndCheckScreenBitMap:view isValidView:YES];
            return;
        }
    }

    // 最后递归检查subviews
    if ([[view subviews] count] > 0) {
        for (UIView *subview in [view subviews]) {
            [self recursiveCheckUIView:subview];
        }
    }
}

- (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {

    CGRect rectInWindow = [view convertRect:view.bounds toView:nil];

    NSInteger widthOffsetStart = rectInWindow.origin.x;
    NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width;
    if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) {
        return NO;
    }
    if (widthOffsetStart < 0) {
        widthOffsetStart = 0;
    }
    if (widthOffsetEnd > _screenWidth) {
        widthOffsetEnd = _screenWidth;
    }
    if (widthOffsetEnd > widthOffsetStart) {
        memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart);
    }

    NSInteger heightOffsetStart = rectInWindow.origin.y;
    NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height;
    if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) {
        return NO;
    }
    if (heightOffsetStart < 0) {
        heightOffsetStart = 0;
    }
    if (heightOffsetEnd > _screenHeight) {
        heightOffsetEnd = _screenHeight;
    }
    if (heightOffsetEnd > heightOffsetStart) {
        memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart);
    }

    NSUInteger widthP = 0;
    NSUInteger heightP = 0;
    for (int i=0; i< _screenWidth; i++) {
        widthP += _screenWidthBitMap[i];
    }
    for (int i=0; i< _screenHeight; i++) {
        heightP += _screenHeightBitMap[i];
    }

    if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) {
        _isCurrentPageLoaded = YES;
        return YES;
    }

    return NO;
}

但是也会有极端情况(类似下图)

无法正确反应有效视图的覆盖情况。但是出于性能考虑,并不会采用二维数组,因为w*h的量太大,遍历和计算的耗时,会有指数级的激增;而且,正常业务形态,应该不太会有类似的极端形态。

即使真的会较高频的出现类似情况,也有一套备选方案:计算有效视图的面积 占 总面积 的比例;该种方式会涉及到UI坐标系的频繁转换,耗时也会略差于当前的方式。

在某些业务场景下,例如 无/少结果情况,关于页面等,完全渲染后,也无法达到铺满阈值。 这种情况,会以用户发生交互(同 1、用户行为触发页面跳转 的获取方式)和 主线程闲时状态超过5s (可配)来做兜底,看是否属于这种状态,如果是,则相关性能数据不上报,因为此种页面对性能的消耗较正常铺满的情况要低,并不能真实的反应性能消耗、瓶颈,因此,仅正常铺满的业务场景进行监控并优化,即可。

扫描的触发时机

以帧刷新为准,因为只有每次帧刷新后,UI才会真正产生变化;出于性能考虑,不会每帧都进行扫描,每间隔x帧(x可配,默认为1),扫描一次;同时,考虑高刷屏 和 大量UI绘制时会丢帧 的情况,设置 扫描时间间隔 的上下限,即:满足 隔x帧 的前提下,如果和上次扫描的时间差小于 下限,仍不扫描;如果 某次扫描时,和上次扫描的时间间隔 大于 上限,则无论中间隔几帧,都开启一次扫描。

11. 用户可交互

用户可见之后的下一个对用户来说至关重要的节点。如果只是可见,然后就疯狂占用主线程或其他资源,造成用户的点击等交互行为,还是会被卡主,用户只能看,不能动,这个体感也是很差的;

具体实现:详细UI渲染完成 后的 首次主线程闲时状态。

监控方案

这里由于各家的基建并不相同,因此只是总结一些小的建议,可能会比较零散,大家见谅。

  1. 建议采样收集

首先,数据的采集或者其他的新增行为/方法,一定是会产生耗时的,虽然可能不多,但还是秉着尽善尽美的原则,还是能少点就少点的,所以数据的采集,包括前面的hook等等一切行为,都只是随机的面向一部分用户开放,降低影响范围; 而且,如果数据量极大,全量的数据上报,其实对数据链路本身也会产生压力、增加成本。 当前,采样的前提是基本数据量足够,不然的话,采样样本量过小,容易对统计结果产生较大波动,造成不置信的结果。

  1. 可配置

除了基本的是否开启的开关之外,还有其他的很多的点 需要/可以/建议 使用线上配置控制。个人认为,线上配置,除了实现对逻辑的控制,更重要的一个作用,就是出现问题时及时止损。 举一些我目前使用的配置中的例子: - 有效视图类 - 渲染完成状态,横纵坐标的填充百分比阈值 - 终态的兜底阈值 - VC的类名、对应的网络请求 等等。

  1. 本地异常数据过滤

由于我们的样本数据量会非常大,所以对于异常数据我们不需要“手软”,我们需要有一套本地异常数据过滤的机制,来保证上报的数据都是符合要求的;不然我们后续统计处理的时候,也会因此出现新的问题需要解决。

后续还能做的事

这一部分,是对后续可实现方案的一个美好畅想~

1)页面可见态的终点,不只是覆盖率

其实,实际业务场景中,很多cell,即使绘制完,并渲染到屏幕上,此时,用户可见的也没有达到我们真正希望用户可见的状态,很多内容,都还是一个placeholder的状态。例如,通过url加载的image,我们一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再进一步,如果是一个视频的播放卡片,即使网络图片加载好了,还要等待视频帧的返回,才能真正达到这张卡片的业务终态\color{red}{业务终态}(求教这里标红后如何能够让字体大小一致)。

这个非常后置,而且我们端上可能也影响不了什么的节点,采集起来有意义吗?

我觉得这是一个非常有价值的节点。一直都在说“技术反哺业务”,那么业务想要用户真正看到的那个终态,就是很重要的一环;因此,用户能在什么时间点看到,从业务角度说,能够影响其后续的方案设计(表现形式),完善用户体感对业务指标的影响;从技术角度说,可以感知真实的全链路的表现(不只是端),从而有针对性的进行优化。

如何获取到所有的业务终态呢?

这里一定是和业务有所耦合的,因为每个业务的终态,只有业务自身才知道;但是我们还是要尽量降低耦合度。 这里可以用协议的方式,为各个业务增加一个达到终态的标识,那么在某个业务达到终态之后,设置该标识即可,这里就是唯一对业务的侵入了;然后和计算覆盖率类似,这里的遍历,是业务维度(这里想象为卡片更好理解一点),只有全部业务的标识都ready之后,才是真正达到业务上的终态。

2)性能指标 关联 业务行为

其实,现在性能监控,各类平台,各个团队,或多或少的都在做,我相信,性能数据采集的代码,在工程中,也不仅仅只有一份;这个现状,在很多成一定规模的互联网公司中都可能存在。

而如果您和我一样,作为一个业务团队,如何在不重复造轮子的情况下,夹缝中求生存呢?

我个人目前的理解:将 性能表现 与 业务场景 相关联。

帧率、启动耗时、CPU、内存等等,这些性能指标数据的获取,在业界都有非常成熟的方案,而且我们的工程里,一定也有相关的代码;而我们能做的,仅仅是,调一下人家的api,然后把数据再自己上传一份(甚至有的连上传都包含了),就完事了吗?

这样我觉得并不能体现出我们自建监控的价值。个人理解,监控的意义在于:暴露问题 + 辅助定位问题 + 验证问题的解决效果

所以我们作为业务团队,将 性能数据 和 我们的业务做了什么 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?

我们可以明确,我们什么样的业务行为,会影响我们的性能数据,也就是影响我们的用户基础体验。这样,不仅会帮助我们定位问题的原因,甚至会影响产品侧的一些产品能力设计方案。

完成这些建设之后,可能我们的监控就可以变成这样,甚至更好的状态: image.png

3)完善全链路对性能表现的关注

性能数据的关注、监控,不应该仅仅在线上阶段,开发期 → 测试期 → 线上,全链路各个环节都应该具有。

  • 目前各家都比较关注线上监控,相信都已经较为完善;

  • 测试期的业务流程性能脚本;对于测试的性能测试方案,开放应该参与共建或者有一定程度的参与,这样才能从一定程度上保证数据的准确性,以及双方性能数据的相互认可;

  • 开发期,目前能够提供展示实时CPU、FPS、内存数据的基础能力的工具很常见,也比较容易实现;但实际上,在日常开发的过程中,很难让RD同时关注需求情况与性能数据表现。因此,还是需要一些工具来辅助:例如,我们可以对某些性能指标,设置一些阈值,当日常开发中,超过阈值时,则弹窗提醒RD确认是否原因、是否需要优化,例如,详细UI绘制阶段的耗时阈值是800ms,如果某位同学在进行变更后,实际绘制耗时多次超越该值,则弹窗提醒。