0%

无痕埋点记录 VC 加载时间

对页面的性能衡量很重要的一个指标就是页面的加载时间

如何定义页面的加载时间

只关注 UIViewController 的 viewDidLoad、viewWillAppear、viewDidLayoutSubviews 等生命周期去衡量一个页面真实的加载时间明显不够准确。

因为一旦我们需要关注一个页面的加载时间,那势必会涉及 tableView 的 reload。如下图所示
页面加载

一个非常主观的感知:当 frame 和层级不再发生变动了,我们就认定这个页面已经加载完了。
ps:这里有个缺陷,如果 vc 中有动来动去的子 view 就会被认定为不稳定。
我认为这样定义页面的加载时间可能更加符合用户体验。

以 viewDidLoad 调用之前的一瞬间记为开始
当视图结构稳定记为结束

怎么判断视图结构稳定

在这里做了一个大胆的近似计算
如果当某次布局的开始距离上次布局的结束

在每次 layout 之后触发一个「计算页面加载」的行为,并且延迟1秒执行。这里的1秒就是需要进行调整的阈值,

1
2
3
4
5
6
7
8
calculateBlock = dispatch_block_create(0, ^{
JFTVCLoadModel *model = self.vcLoadModelCache[p];
model.stable = YES;
NSLog(@"vc end load");
NSLog(@"vc load time:%f", (model.endTime - model.startTime));
});
self.vcBlockCache[p] = calculateBlock;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), calculateBlock);

在每次 layout 之前触发一个取消上一次计算的操作

1
2
3
4
5
6
dispatch_block_t calculateBlock = self.vcBlockCache[p];
if (calculateBlock) {
dispatch_block_cancel(calculateBlock);
NSLog(@"cancle block %p", calculateBlock);
_vcBlockCache[p] = nil;
}

那么当最后一次 layout 结束以后一秒以内没有其他 view 需要被 layout,则认为这个视图结构已经稳定了,可以计算真实的页面加载时间

无痕埋点的实现

给View打标记

从 window 往下的整个视图有很多很多,既然要无痕埋点势必需要全局 hook。
思路就是把VC下所有的 view 都打上一个标记,当 view layout 的时候判断标记,把页面加载的时间点进行一个记录。

1
2
3
4
5
6
7
8
9
10
11
12
@interface JFWeakWarraper : NSObject
@property (nonatomic, weak) UIViewController *vc;
- (instancetype)initWithVC:(UIViewController *)vc;
@end

@interface UIView (JFTVCLife)
@property (nonatomic, strong) JFWeakWarraper *jftVCRef;
- (void)prepareVCLifeRecord;
- (void)recordVCLife;

- (void)jft_layoutSublayersOfLayer:(CALayer *)layer;
@end

jftVCRef 就是一个标记,让 View 能够通过这个属性快速判断是否属于某个 VC

如何打标记

如果自身有一个标记,那么就把 subviews 都添加相关标记,并且把这个事件丢给一个专门处理页面生命周期的类JFTVCLoadCenter

1
2
3
4
5
6
7
8
- (void)prepareVCLifeRecord {
UIViewController *vc = self.jftVCRef.vc;
if (!vc) return;
[self.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
obj.jftVCRef = [[JFWeakWarraper alloc] initWithVC:vc];
}];
[[JFTVCLoadCenter defaultCenter] postVCStartLayoutNotification:vc];
}

当 layout 结束的时候会调用 recordVCLife,在这个地方同样把事件丢给 JFTVCLoadCenter 处理

1
2
3
4
5
- (void)recordVCLife {
UIViewController *vc = self.jftVCRef.vc;
if (!vc) return;
[[JFTVCLoadCenter defaultCenter] postVCEndLayouttNotification:vc];
}

为什么只遍历一层subView,而不是把所有的子 view 直接都打上标记

假设我们有这么一个结构 :

VC–>View–>TableView–>View(a)–>View(b)–>View(b)

layout 从 View 开始左到右进行。
网络回调中触发 TableView 的 reload,layout 从 TableView 开始重新向右进行。

方案一:如果每次 layout 都遍历其下整个视图树,并打标记
越靠近左边的 view 触发打标记的行为也就越多,到了叶子结点,很多打标记的行为都是不必要的。

方案二:如果只在起点打标记,后续直接忽略。
那么当 TableView 的 reload 时,动态创建 Cell 其所有的子 View 都不会被打上标记。如果把这个案例当作特殊情况处理明显不合理。

这样统计会不会有问题?
子view layout 的是由父view 触发的,这就意味着父亲的调用永远在孩子之前,这样的调用显然没问题。

JFTVCLoadCenter

这是处理页面的加载周期消息处理的核心代码

一个初始化方法,两个接收「消息」的方法

1
2
3
4
5
@interface JFTVCLoadCenter : NSObject
+ (instancetype)defaultCenter;
- (void)postVCStartLayoutNotification:(UIViewController *)vc;
- (void)postVCEndLayouttNotification:(UIViewController *)vc;
@end

每次vc下面的 view 在layout调用以前需要调用 postVCStartLayoutNotification
每次vc下面的 view 在layout调用完成以后需要调用 postVCEndLayouttNotification

布局开始之前记录调用时间,并且取消所有之前调用的 block。

1
2
3
4
5
6
7
8
9
10
- (void)postVCStartLayoutNotification:(UIViewController *)vc {
NSString *p = [NSString stringWithFormat:@"%p", vc];
[self addStartTimeIfNeed:p];
dispatch_block_t calculateBlock = self.vcBlockCache[p];
if (calculateBlock) {
dispatch_block_cancel(calculateBlock);
NSLog(@"cancle block %p", calculateBlock);
_vcBlockCache[p] = nil;
}
}

布局结束以后记录调用时间,也需要取消所有之前调用的 block。
这里取消之前的调用不能省,因为调用是这样的 1start–>2start–>2end–>1end

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)postVCEndLayouttNotification:(UIViewController *)vc {
NSString *p = [NSString stringWithFormat:@"%p", vc];
[self updateEndTimeIfNeed:p];
dispatch_block_t calculateBlock = self.vcBlockCache[p];
if (calculateBlock) {
dispatch_block_cancel(calculateBlock);
NSLog(@"cancle block %p", calculateBlock);
_vcBlockCache[p] = nil;
}

calculateBlock = dispatch_block_create(0, ^{
JFTVCLoadModel *model = self.vcLoadModelCache[p];
model.stable = YES;
NSLog(@"vc end load");
NSLog(@"vc load time:%f", (model.endTime - model.startTime));
});
self.vcBlockCache[p] = calculateBlock;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), calculateBlock);
}

这里用一个字典保存了之前提交的 block,key 是 vc 的指针。

1
2
3
4
@interface JFTVCLoadCenter ()
@property (nonatomic, strong) NSMutableDictionary *vcBlockCache;
@property (nonatomic, strong) NSMutableDictionary *vcLoadModelCache;
@end

用另外一个字典保存了页面加载的时间节点信息。包含了页面开始的时间和结束的时间。用一个布尔值标记页面是否已经稳定。

1
2
3
4
5
@interface JFTVCLoadModel : NSObject
@property (nonatomic, assign) CFTimeInterval startTime;
@property (nonatomic, assign) CFTimeInterval endTime;
@property (nonatomic, assign) BOOL stable;
@end

页面生命周期只记录最早的一次 start

1
2
3
4
5
6
7
8
9
10
- (void)addStartTimeIfNeed:(NSString *)p {
JFTVCLoadModel *model = self.vcLoadModelCache[p];
if (!model) {
model = [JFTVCLoadModel new];
self.vcLoadModelCache[p] = model;
}
if (model.startTime == 0) {/// need update
model.startTime = CACurrentMediaTime();
}
}

页面生命周期稳定以后不需要再更新结束时间

1
2
3
4
5
6
7
8
9
- (void)updateEndTimeIfNeed:(NSString *)p {
JFTVCLoadModel *model = self.vcLoadModelCache[p];
NSAssert(model, @"结束必须比开始先执行");
if (model.stable) {
NSLog(@"vc have stable, no need to update end time");
} else {
model.endTime = CACurrentMediaTime();
}
}

hook

1
2
3
4
5
6
7
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[UIViewController jr_swizzleMethod:@selector(setView:) withMethod:@selector(jft_setView:) error:nil];
[UIViewController jr_swizzleMethod:@selector(viewWillLayoutSubviews) withMethod:@selector(jft_viewWillLayoutSubviews) error:nil];
[UIViewController jr_swizzleMethod:@selector(viewDidLayoutSubviews) withMethod:@selector(jft_viewDidLayoutSubviews) error:nil];
[UIView jr_swizzleMethod:@selector(layoutSublayersOfLayer:) withMethod:@selector(jft_layoutSublayersOfLayer:) error:nil];
return YES;
}

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@implementation UIViewController (JFTLife)

- (void)jft_setView:(UIView *)view {
JFWeakWarraper *weakWapper = [[JFWeakWarraper alloc] initWithVC:self];
view.jftVCRef = weakWapper;
[self jft_setView:view];
}

- (void)jft_viewWillLayoutSubviews {
[self.view prepareVCLifeRecord];
[self jft_viewWillLayoutSubviews];
}

- (void)jft_viewDidLayoutSubviews {
[self.view recordVCLife];
[self jft_viewDidLayoutSubviews];
}

@end

后续优化

如果认为布局稳定了不算加载完成,也可以把其他的指标也纳入进来
图片加载也和布局是同等级别的,逻辑直接复用就可以了。

如果认为图片加载比布局更重要
比方说布局稳定了,但是前面有个图片加载已经开始了,那布局稳定的计算的操作就 cancel 掉,等图片加载完成再计算。

总之就看你怎么界定界面稳定这一事情,并且函数调用的开始&结束转化为两个消息触发计算和 cancel。这样就能完美不需要前端埋点。

Demo

本文的例子可以在 github 获取