背景
某月某天、下午四时许。
正顶着 2 倍任务量提测后陷入修复Bug的疯狂之中,领导的头像突然晃动了起来。心头一紧...“来者不善啊!”
三言两语后便扬长而去。
“要怎么证明?说了业务是这样的,后端的接口请求数据都有还要什么数据证明?要给你弄个场景复现配上所有业务的可视化展现?“
话说回来,要是我能在场景复现的同时把性能和业务都可视化出来是不是可以完全杜绝掉此类疑问...光是想一想就爽翻了天。线上监控流程太长,也不太可能得到资源支持,那就先搞个纯前端的吧。
Let‘s rock n out ......
本文为曾经所开发工具的回忆系列...仅作为自己的总结
- 页面可交互时间监控(TTI)
- FlutterFPS监控
- 颜色标尺
- 组件抓手
- (本文)录屏分析
- 调试工具重构
主要功能
-
[录屏]:
采用录屏回放作为场景可视化复现的核心方式 -
[数据记录类型]:
页面生命周期、TTI数据、设备性能记录(内存、cpu、网速等)业务场景(接口、打点、视频图像加载等) -
[时间强相关]:
场景回放本身与视频播放是关联的,与时间是强相关,在采集数据时需要将所有数据格式化为与时间关联的格式 -
[场景回放可控]:
提供筛选条、可控时间条(信息概览)、查看单一数据详情等功能 -
[监控不受技术栈限制]:
除了技术栈特殊的监控(如FlutterFPS)均以Native监控为主,这样就可适用于所有平台,未来也不需要因为技术栈的变更而完全推到重来
录屏
采用 RPScreenRecorder
来录制APP的音视频,需要记录的是视频录制的实际开始与结束的时间,以方便后续去筛选过滤并行采集的数据集。
@available(iOS 14.0.0, *)
@objc open class RJScreenRecordManager: NSObject {
// 采用单例以全局控制
@objc public static let share = RJScreenRecordManager()
// 开始录制时间
@objc public var startRecordDateTime:NSDate?
// 结束录制的时间
@objc public var endRecordDateTime:NSDate?
// 录制视频的工具
@objc public var recorder:RPScreenRecorder = RPScreenRecorder.shared()
// 同时仅会存在一个视频文件、写死即可
let videoURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("RJDebugScreenRecord.mp4")
@objc public override init() {
super.init()
// 不需要麦克风
recorder.isMicrophoneEnabled = false;
// 当前记录时间(此处获取用户上一次记录的缓存)
startRecordDateTime = UserDefaults.standard.value(forKey: kScreenRecordStartTimeKey) as? NSDate
endRecordDateTime = UserDefaults.standard.value(forKey: kHScreenRecordEndTimeKey) as? NSDate
}
////////////////////////////////////////
//////// 开始录制
////////////////////////////////////////
@objc static public func startRecord(time:CGFloat,
resultBlock:@escaping (_ success:Bool,
_ error:Error?) ->Void) {
if share.recorder.isAvailable && !share.recorder.isRecording{
// 清除已有记录
clearAllData()
// 开始记录
share.recorder.startRecording { error in
if(share.recorder.isRecording) {
// 设置超时
let maxTime = max(time, 5.0)
DispatchQueue.main.asyncAfter(deadline: (.now() + maxTime),
execute: {
// 停止录制 并 通知外部监控停止
})
}
if(error == nil) {
// 此时录制真实开始的时间
let currentDate = NSDate()
UserDefaults.standard.set(currentDate, forKey: kScreenRecordStartTimeKey)
share.startRecordDateTime = currentDate;
share.endRecordDateTime = nil
}
resultBlock(error == nil, nil)
}
} else {
share.startRecordDateTime = nil;
resultBlock(false, nil)
}
}
////////////////////////////////////////
//////// 结束录制
////////////////////////////////////////
@objc static public func stopRecord(completion: ( (_ url:URL?, _ error:Error?) -> Void)?) {
if(share.recorder.isRecording) {
Task {
let currentDate = NSDate()
UserDefaults.standard.set(currentDate, forKey: kHScreenRecordEndTimeKey)
share.endRecordDateTime = currentDate
// 结束录制并设置视频地址
let error = try? await share.recorder.stopRecording(withOutput: share.videoURL, completionHandler: { error in
})
completion?(share.videoURL, nil)
}
} else {
completion?(nil, NSError(domain: "", code: -1))
}
}
// 当前录制状态
@objc static public func isRecording() -> Bool {
// ......
}
// 清理相关数据
@objc public static func clearAllData() {
// ......
}
}
录屏本身没什么难点,记录准确开始结束的时间即可。
性能采集
两个计时器
性能数据的采集方式均为循环主动获取,如CPU,内存,要么就是与屏幕的刷新相关,如FPS、TTI。
每个性能指标都去单独创建个循环过去暴力,参数越多定时器的管理就越乱,定时器本身也会消耗一定的系统资源。所有主动采集的数据,只是采集的逻辑不同,基于这个特点可以简化为整个性能类数据的采集仅保留两个定时器,而所有的性能采集整合成队列的结构
TTI采集
TTI(Time To Interact):页面可交互时间,指页面从展示到用户可交互的时间
测试在测试打开时间时是通过录屏然后逐帧查看直到首次有数据的页面渲染,最终的方案是“每次页面刷新时判断页面数据填充比例”
具体采集策略这里就不展开了,感兴趣的话可以看看 iOS 页面可交互时间监控(TTI)
CPU
说明:CPU的显示与Xcode对齐,以单核满载表示100%为标准。
/// 当前CPU占用率
- (CGFloat)usedCpu {
kern_return_t kr = { 0 };
task_info_data_t tinfo = { 0 };
mach_msg_type_number_t task_info_count = TASK_INFO_MAX;
kr = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t)tinfo, &task_info_count);
if (kr != KERN_SUCCESS) {
return 0.0f;
}
task_basic_info_t basic_info = { 0 };
thread_array_t thread_list = { 0 };
mach_msg_type_number_t thread_count = { 0 };
thread_info_data_t thinfo = { 0 };
thread_basic_info_t basic_info_th = { 0 };
basic_info = (task_basic_info_t)tinfo;
kr = task_threads(mach_task_self(), &thread_list, &thread_count);
if (kr != KERN_SUCCESS) {
return 0.0f;
}
long tot_sec = 0;
long tot_usec = 0;
float tot_cpu = 0;
for (int i = 0; i < thread_count; i++) {
mach_msg_type_number_t thread_info_count = THREAD_INFO_MAX;
kr = thread_info(thread_list[i], THREAD_BASIC_INFO, (thread_info_t)thinfo, &thread_info_count);
if (kr != KERN_SUCCESS) {
return 0.0f;
}
basic_info_th = (thread_basic_info_t)thinfo;
if ((basic_info_th->flags & TH_FLAGS_IDLE) == 0) {
tot_sec = tot_sec + basic_info_th->user_time.seconds + basic_info_th->system_time.seconds;
tot_usec = tot_usec + basic_info_th->system_time.microseconds + basic_info_th->system_time.microseconds;
tot_cpu = tot_cpu + basic_info_th->cpu_usage / (float)TH_USAGE_SCALE;
}
}
kr = vm_deallocate( mach_task_self(), (vm_offset_t)thread_list, thread_count * sizeof(thread_t) );
if (kr != KERN_SUCCESS) {
return 0.0f;
}
return (CGFloat)tot_cpu * 100;
// 方式2: 用核心数算平均值(在百分制下更能体现出性能峰谷)
// return (CGFloat)tot_cpu * 100/(CGFloat)self.cpuCores;
}
Tips:简易采用与 Xcode 标准对齐的方式,也更能凸显出CPU的性能损耗。例如在开发过程中发现 Flutter 的 CPU 占用是 Native 的 2 倍以上,CPU的持续高负荷在低端机上可能就会造成设备发热、卡顿现象。
内存
/// 当前占用内存
- (CGFloat)usedMemory {
int64_t memoryUsageInByte = 0;
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if(kernelReturn == KERN_SUCCESS) {
memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
return (CGFloat)memoryUsageInByte / (1024 * 1024);
} else {
return 0;
}
}
+ (double)totalMemorySize {
return (double)[NSProcessInfo processInfo].physicalMemory / (1024 * 1024);
}
FPS
FPS (Frame Per Second): 每秒帧数,是用来监控流畅的重要指标, 这里可使用 CADisplayLink 配合队列来记录每一帧的数据,也可仅计算FPS的值。
- (void)frameRefresh {
NSTimeInterval time = [[NSDate date] timeIntervalSince1970];
[_dataQueue safeAddObject:@(time)];
if(_dataQueue.count > _queueMaxCount) {
[_dataQueue removeObjectsInRange:NSMakeRange(0, _dataQueue.count - _queueMaxCount)];
}
if(_queueMaxCount > _dataQueue.count) {
return;
}
// 计算当前帧数
NSTimeInterval startTime = [[_dataQueue firstObject] doubleValue];
double allTime = (time - startTime);
NSInteger fps = (_dataQueue.count / allTime) ;
NSNumber * num = @(fps);
if(self.frameResultBlock) {
self.frameResultBlock(num);
}
if(self.timeBlock) {
double space = time - _lastCallBackTime;
if(space >= 1.0) {
_lastCallBackTime = time;
NSInteger fps = (_lastCallBackTime == 0 && _cacheFPSArray.count == 0) ? 60 : _cacheFPSArray.count ;
self.timeBlock(@(fps));
[_cacheFPSArray removeAllObjects];
} else {
[_cacheFPSArray safeAddObject:num];
}
}
}
上面仅是 iOS Native的监控,而在不同平台上的FPS监控略有不同,Flutter 可直接通过系统回调方法来获取到当前绘制时间段的所有帧信息,todo Flutter FPS监控(未填坑)
网速
说明:我们可以获取到当前设备已消耗的流量,两秒之差即可表示当前秒的网速,注意单位转换标准。
- (void)frameRefresh {
if(!self.frameResultBlock) {
return;
}
[self updateCurrentNetSpeed];
NSString * dStr = [[self stringWithbytes:_downloadSpeedBytes] stringByAppendingString:@"/s"];
NSString * uStr = [[self stringWithbytes:_uploadSpeedBytes] stringByAppendingString:@"/s"];
NSString * totalDownloadStr = [[self stringWithbytes:_iBytes] stringByAppendingString:@"/s"];
NSString * totalUploadStr = [[self stringWithbytes:_oBytes] stringByAppendingString:@"/s"];
NSString * desc = [NSString stringWithFormat:@"⬇️:%@ ⬆️:%@", dStr, uStr];
// NSString * desc = [NSString stringWithFormat:@"⬇️:%@ ⬆️:%@\n[总下载:%@,总上传:%@]", dStr, uStr, totalDownloadStr, totalUploadStr];
self.frameResultBlock(desc);
}
- (void)updateCurrentNetSpeed {
struct ifaddrs *ifa_list = 0, *ifa;
if (getifaddrs(&ifa_list) == -1) return;
uint32_t iBytes = 0;
uint32_t oBytes = 0;
uint32_t allFlow = 0;
for (ifa = ifa_list; ifa; ifa = ifa->ifa_next) {
if (AF_LINK != ifa->ifa_addr->sa_family) continue;
if (!(ifa->ifa_flags & IFF_UP) && !(ifa->ifa_flags & IFF_RUNNING)) continue;
if (ifa->ifa_data == 0) continue;
if (strncmp(ifa->ifa_name, "lo0", 2)) {
struct if_data* if_data = (struct if_data*)ifa->ifa_data;
iBytes += if_data->ifi_ibytes;
oBytes += if_data->ifi_obytes;
allFlow = iBytes + oBytes;
}
}
freeifaddrs(ifa_list);
if (iBytes != 0) {
_downloadSpeedBytes = iBytes - _iBytes;
}
_iBytes = iBytes;
if (oBytes != 0) {
_uploadSpeedBytes = oBytes - _oBytes;
}
_oBytes = oBytes;
}
- (NSString *)stringWithbytes:(int)bytes {
if (bytes < 1024) { // B
return [NSString stringWithFormat:@"%dB", bytes];
} else if (bytes >= 1024 && bytes < 1024 * 1024) { // KB
return [NSString stringWithFormat:@"%.0fKB", (double)bytes / 1024];
} else if (bytes >= 1024 * 1024 && bytes < 1024 * 1024 * 1024) { // MB
return [NSString stringWithFormat:@"%.1fMB", (double)bytes / (1024 * 1024)];
} else { // GB
return [NSString stringWithFormat:@"%.1fGB", (double)bytes / (1024 * 1024 * 1024)];
}
}
业务采集
业务采集需要根据具体业务特点而定。为不影响业务本身,尽可能降低采集功能本身与业务的耦合程度,采用面向切面编程的方式,整体保持 “小心Hook, 大胆采集”
具体业务的实现介绍下方案, 具体细节就不做过多描述了,都是体力活 :)
接口
接口数据我们重点关注开始结束时间、具体出入参,Hook网络库的方式可获取所有信息,不要在hook方法中去做耗时操作、写入数据库的操作也可通过异步队列来完成。
手势
手势是设备信息的一种,可通过监控手势判断交互状态来辅助分析卡顿原因,可作为卡顿分析中重要的参考依据。例如在手势交互的过程中的FPS下降具有更高分析权重。
采用的方案是在整个屏幕上盖个View,所有手势都放过去,通过手势回调来记录所有信息,如起始点、离开点、滑动位置等。
视频、图像的加载信息
通过 Hook 视频播放组件、图片加载组件来进行监控、可以配合性能数据来分析是否存在性能瓶颈。
整合
所有采集的数据均格式化为与时间戳强相关的格式, 配合录制的起始时间,在展示时可以很容易的与视频的当前时间关联上,视频回放模块与数据列表按需展示即可。 时间条上的显示可根据每个数据的特点选择点、折线、柱状图等方式来显示。
数据结构
上面仅介绍了各数据的如何采集,具体的数据记录方式使用了与时间强相关的结构来保证后续在功能回放时所有的数据整合展示:
@interface RJDebugOpenPageModel : NSObject
@property (assign) NSUInteger lid;
@property (assign) RJDebugOpenPageModelType type;
@property (strong) NSString *name;
@property (assign) double data;
@property (assign) double startTime;
@property (assign) double endTime;
+ (RJDebugOpenPageModel *)convertModelWithData:(NSDictionary *)data;
@end
功能整合
回放就是在播放录制视频的同时,将对应时间节点的所有数据展示出来,如何展示需要根据具体数据特点而定:
例如网络请求是一个持续的过程,可以在时间控制条上使用一个矩形来表示其占据的时间,而打点这样的操作就单独提提一个时间线来展示,CPU用渐变的折线图,内存用白色的折线,页面生命周期直接就在时间条上一条蓝线。
数据的展示实现、模块之间的精准联动、交互手势的微调,都是精细活:)
效果展示
工具发布后,便立刻投入到了使用中:
- 为页面打开速度提供了完整的数据说明
- 发现某页面打开过程中主流程请求优先级较低的问题,经过优化提升了该页面约300ms的打开速度
- 协助排查某页面内存泄漏的问题
- 辅助新人快速熟悉业务场景、流程
- 因性能监控模块完全独立,基于该模块单独实现了一个性能监控的外显面板以供开发中的性能预警
结语
在开发过程中积累了不少对设备性能采集的经验,以及数据集成如何调优的思考。在工具上线后,再也没人提什么“数据证明”的要求。
工具是完全一时兴起均为利用业余时间断断续续开发,时间跨度较长,细节上可能存在问题,仅供参考