调试小工具-录屏分析

背景

某月某天、下午四时许。
正顶着 2 倍任务量提测后陷入修复Bug的疯狂之中,领导的头像突然晃动了起来。心头一紧...“来者不善啊!”



三言两语后便扬长而去。
“要怎么证明?说了业务是这样的,后端的接口请求数据都有还要什么数据证明?要给你弄个场景复现配上所有业务的可视化展现?“
话说回来,要是我能在场景复现的同时把性能和业务都可视化出来是不是可以完全杜绝掉此类疑问...光是想一想就爽翻了天。线上监控流程太长,也不太可能得到资源支持,那就先搞个纯前端的吧。
Let‘s rock n out ......

本文为曾经所开发工具的回忆系列...仅作为自己的总结

  1. 页面可交互时间监控(TTI)
  2. FlutterFPS监控
  3. 颜色标尺
  4. 组件抓手
  5. (本文)录屏分析
  6. 调试工具重构

主要功能

  1. [录屏]:
    采用录屏回放作为场景可视化复现的核心方式
  2. [数据记录类型]:
    页面生命周期、TTI数据、设备性能记录(内存、cpu、网速等)业务场景(接口、打点、视频图像加载等)
  3. [时间强相关]:
    场景回放本身与视频播放是关联的,与时间是强相关,在采集数据时需要将所有数据格式化为与时间关联的格式
  4. [场景回放可控]:
    提供筛选条、可控时间条(信息概览)、查看单一数据详情等功能
  5. [监控不受技术栈限制]:
    除了技术栈特殊的监控(如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用渐变的折线图,内存用白色的折线,页面生命周期直接就在时间条上一条蓝线。

数据的展示实现、模块之间的精准联动、交互手势的微调,都是精细活:)

效果展示

效果图

工具发布后,便立刻投入到了使用中:

  1. 为页面打开速度提供了完整的数据说明
  2. 发现某页面打开过程中主流程请求优先级较低的问题,经过优化提升了该页面约300ms的打开速度
  3. 协助排查某页面内存泄漏的问题
  4. 辅助新人快速熟悉业务场景、流程
  5. 因性能监控模块完全独立,基于该模块单独实现了一个性能监控的外显面板以供开发中的性能预警

结语

在开发过程中积累了不少对设备性能采集的经验,以及数据集成如何调优的思考。在工具上线后,再也没人提什么“数据证明”的要求。

工具是完全一时兴起均为利用业余时间断断续续开发,时间跨度较长,细节上可能存在问题,仅供参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,313评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,369评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,916评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,333评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,425评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,481评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,491评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,268评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,719评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,004评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,179评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,832评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,510评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,153评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,402评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,045评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,071评论 2 352

推荐阅读更多精彩内容