UIKit中如何准确捕获首帧渲染耗时?
问题背景
为应用实现遥测功能,尝试捕获WWDC 2019《Optimizing App Launch》视频中提到的各阶段耗时,目前难以精准捕获首帧及扩展阶段的时间。
我的假设
application:didFinishLaunchingWithOptions:方法在首帧渲染前调用(如视频所述)- 首帧会在当前RunLoop结束后完成渲染
捕获代码
void PerformWhenIdle(dispatch_block_t block) { CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAfterWaiting, NO, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { block(); }); } // ---------------------------- // AppDelegate.m - (BOOL)application:(UIApplication *)app didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [logger logFirstFrameRenderingStarted]; PerformWhenIdle(^{ [logger logFirstFrameRenderingFinished]; [logger logExtendedStageStarted]; }); } // ---------------------------- // in TableVC.m - (void)viewDidAppear { [self.tableView reloadData]; PerformWhenIdle(^{ [logger logExtendedStageFinished]; }); }
遇到的问题
通过Instruments查看Signpost时,首帧的起始点较为准确,但结束点偏差较大,导致扩展阶段时长显示异常:Signpost显示225.23ms,而App生命周期仪器显示仅1.78ms。
疑问
- 我的假设是否错误?若错误,是否意味着仪器数据有误?
- 若假设错误,应如何精准捕获首帧渲染的起止时间?
解答
1. 你的假设存在错误,仪器数据是准确的
application:didFinishLaunchingWithOptions:确实在首帧渲染前调用,但首帧渲染完成的时机并非当前RunLoop结束后。你使用的kCFRunLoopAfterWaiting事件触发时机太晚,它会等到RunLoop进入等待状态才执行,而首帧渲染实际上在RunLoop的kCFRunLoopBeforeWaiting之前的循环迭代中就已经完成——具体来说,首帧渲染是在UIApplicationMain事件循环处理CATransaction提交阶段完成的,通常发生在applicationDidFinishLaunching返回后的第一个RunLoop周期内的kCFRunLoopBeforeWaiting之前。
另外,用viewDidAppear触发的PerformWhenIdle标记扩展阶段结束也不准确,viewDidAppear执行后RunLoop可能还会处理其他任务,且扩展阶段的定义是首帧渲染完成后到应用进入idle状态前的初始化工作,和viewDidAppear没有直接绑定关系。
仪器的App生命周期数据是系统层面的精准统计,不存在错误,问题出在你的捕获逻辑对首帧和扩展阶段的时机判断有误。
2. 精准捕获首帧渲染起止时间的正确方式
方式一:利用系统Signpost或私有API(调试用,注意上架风险)
苹果系统本身会标记首帧渲染的Signpost,可直接监听com.apple.UIKit.signpost类别下的first-render事件。如果需要手动标记,可借助UIKit私有API(仅用于调试,不可上架):
// 首帧开始标记 extern void UIApplicationNotifyFirstRenderStart(void); // 首帧结束标记 extern void UIApplicationNotifyFirstRenderEnd(void);
调用这两个函数能和系统仪器的数据完全对齐。
方式二:基于RunLoop观察者的正确时机
若不想用私有API,可调整RunLoop观察者的事件类型,结合CATransaction提交状态判断:
void ObserveFirstFrameCompletion(void) { static BOOL firstFrameCompleted = NO; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting | kCFRunLoopExit, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { if (!firstFrameCompleted && [UIApplication sharedApplication].keyWindow) { // 检查无未提交的CATransaction,判定首帧完成 if (![CATransaction flush]) { firstFrameCompleted = YES; [logger logFirstFrameRenderingFinished]; [logger logExtendedStageStarted]; CFRunLoopObserverInvalidate(observer); } } }); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); CFRelease(observer); } // 在application:didFinishLaunchingWithOptions:中调用 - (BOOL)application:(UIApplication *)app didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [logger logFirstFrameRenderingStarted]; ObserveFirstFrameCompletion(); return YES; }
该逻辑会在主RunLoop即将进入等待状态前,检查KeyWindow存在且无未提交事务时,判定首帧渲染完成,时机更接近系统实际的首帧结束点。
扩展阶段结束的标记
扩展阶段结束的标志是应用进入真正的idle状态,即主RunLoop无待处理任务。可通过监听kCFRunLoopAfterWaiting事件,连续检测到多次RunLoop进入等待状态后标记:
void ObserveExtendedStageCompletion(void) { static NSInteger idleCount = 0; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAfterWaiting, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { idleCount++; // 连续2次进入等待状态,判定为进入idle if (idleCount >= 2) { [logger logExtendedStageFinished]; CFRunLoopObserverInvalidate(observer); } }); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); CFRelease(observer); } // 在首帧完成后调用此函数
内容的提问来源于stack exchange,提问作者Emanuel

