diff --git a/Chapter1 - iOS/1.101.md b/Chapter1 - iOS/1.101.md index f329529..754a353 100644 --- a/Chapter1 - iOS/1.101.md +++ b/Chapter1 - iOS/1.101.md @@ -1,7 +1,7 @@ # 离屏渲染 ## 什么是离屏渲染 -如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的 Frame Buffer(帧缓冲区),作为像素数据存储区域,然后由显示控制器把帧缓冲区的数据显示到屏幕上。如果因为面临一些限制,比如阴影、光栅、遮罩等,CPU 无法把渲染结果直接写写入 Frame Buffer,而是先暂时把中间的临时状态保存在额外的内存区域,之后再写入 Frame Buffer,那么这个过程被称为离屏渲染。 +如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的 Frame Buffer(帧缓冲区),作为像素数据存储区域,然后由显示控制器把帧缓冲区的数据显示到屏幕上。如果因为面临一些限制,比如阴影、光栅、遮罩等,CPU 无法把渲染结果直接写入 Frame Buffer,而是先暂时把中间的临时状态保存在额外的内存区域,之后再写入 Frame Buffer,那么这个过程被称为离屏渲染。 系统如果没有直接把渲染结果直接写入到 GPU FrameBuffer 中,则认为发生了一次离屏渲染。(离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作) @@ -60,8 +60,8 @@ self.imageView.layer.cornerRadius = YES; ``` ## 离屏渲染的影响? -离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。由上面的一个结论视图和圆角的大小对帧率并没有什么影响,数量才是伤害的核心输出啊。可以知道离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。 -上下文切换,不管是在GPU渲染过程中,还是一直所熟悉的进程切换,上下文切换在哪里都是一个相当耗时的操作。首先我要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到On-Screen Rendering 或者再开始一个新的离屏渲染重复之前的操作。 +离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。由上面的一个结论视图和圆角的大小对帧率并没有什么影响,数量的多少才显著影响性能。可以知道离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。 +上下文切换,不管是在GPU渲染过程中,还是广为人知的进程切换,上下文切换都是一个相当耗时的操作。首先要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到On-Screen Rendering 或者再开始一个新的离屏渲染重复之前的操作。 一次mask发生了两次离屏渲染和一次主屏渲染。即使忽略昂贵的上下文切换,一次mask需要渲染三次才能在屏幕上显示,这已经是普通视图显示3陪耗时,若再加上下文环境切换,一次mask就是普通渲染的n(n>3)倍以上耗时操作 @@ -81,7 +81,7 @@ self.imageView.layer.cornerRadius = YES; GPU 利用片元将整个图片分为一个个像素,并且并行计算了每一个像素的颜色。在同一个栅格内可能存在多个视图,根据距离眼睛的远近,存在多个不同的物体。显而易见,我们应该将最近物体的颜色作为该栅栏的颜色,后面物体的颜色应该被遮挡(如果后面物体的颜色被传递给片元着色器,这时候就是一个显示错误,比如我们打游戏的时候可以看到墙后的人) -画家算法带来2个问题。第一个问题上相互交错的物体,按照画家算法,这样的情况,GPU 会无从下手。所以早期的时候,设计师总是避免这样相互交错的设计。 +画家算法带来2个问题。第一个问题是相互交错的物体(类似于死循环,无法pick出谁应该最先被渲染),按照画家算法,这样的情况,GPU 会无从下手。所以早期的时候,设计师总是避免这样相互交错的设计。 第二个问题是过度绘制,因为画家算法总是一层层绘制,所以存在重合叠加的情况,层级较低的物体总是会被过度绘制,浪费资源。 因为 GPU 的设计是并发、无序的,所以我们期望的画家算法是不希望浪费、等待,同时为了绘制速度,所以在此基础上引入了 Depth Buffer 和 Early-Z 和深度缓冲。 @@ -91,8 +91,7 @@ GPU 利用片元将整个图片分为一个个像素,并且并行计算了每 明白了画家算法的工作原理,也就明白了为什么会发生离屏渲染。 - 离屏渲染需要创建额外的帧缓冲区 - 渲染相关的上下文对象、帧缓冲区都比较大,切换会带来性能损耗 -- 内存拷贝,需要将临时帧缓冲区的内容拷贝到真正的帧缓冲区 -单帧渲染都会比较耗费性能了,如果屏幕上多个视图渲染都存在离屏渲染,整个界面会发生卡顿。 +- 内存拷贝,需要将临时帧缓冲区的内容拷贝到真正的帧缓冲区。单帧渲染都会比较耗费性能了,如果屏幕上多个视图渲染都存在离屏渲染,整个界面会发生卡顿。 ## 如何检测离屏渲染 diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index ea5d8ba..6363f3b 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -458,6 +458,103 @@ static mach_port_t main_thread_id; } ``` +### 6. 精确堆栈如何还原 + +当检测到卡顿的时候再去 dump 堆栈,可能已经错过第一案发现场了,所以我们按照"悲观策略",每50ms抓取一次堆栈,维护最近30组堆栈。 + +这里有个策略,卡顿是由于主线程某个或某组方法执行过长而造成的卡顿,所以卡顿堆栈只需要分析主线程即可,但是此时是未符号化的方法(完成流程如下) + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/callStackSymbolicate'.png) + +测试过,单次抓取主线程符号耗时大概1ms左右。因此连续抓取堆栈是可取方案。 + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/callstackCostTime.png) + +按照栈顶函数地址和当前堆栈的深度作为判断依据,如果某个堆栈栈顶地址和深度相同,则出现次数最多的一个堆栈,可以认为是一个卡顿堆栈,因为认为当发生卡顿的时候,函数堆栈大概率不会变化。 + +``` +1 func1 - func2 - func3 - func4 - func5 +2 func1 - func2 - func3 - func4 - func6 +3 func1 - func2 - func3 - func4 - func6 +4 func1 - func2 - func3 - func4 - func7 +5 func1 - func2 - func3 - func4 +6 func1 - func2 - func3 - func4 +7 func1 - func2 - func3 - func4 +8 func1 - func2 - func3 - func4 - func8 +``` + +这个情况下,卡顿堆栈为:func1 - func2 - func3 - func4 + +另外,卡顿堆栈和 Crash 堆栈还原不太一样。Crash 堆栈是一个完整的文件,`symbolicatecrash` 可以对整个文件进行符号化。但是卡顿只记录了地址符号,所以是不能利用 symbolicatecrash 能力。需要使用 `atos` 实现。 + +因为 iOS 侧存在 ASLR 技术,所以仅凭借当前的符号地址是无法符号化的,所以还需要一些基础信息,各个 DYLD 的信息,binary image 加载的地址和名称等。 + +``` +{ + arch = "arm64 v8"; + "dyld_images" = ( + { + "addr_slide" = 0x5c18000; + "base_addr" = 0x1e9ead000; + name = "/usr/lib/libBacktraceRecording.dylib"; + uuid = 31239ad326ce32dfb01b3eb5703fac81; + }, + // ... +} +``` + +上传这些信息到服务端后,APM 后端调用符号化服务的能力,符号化机器找到对应的 DSYM 文件,调用 atos 的指令进行符号化,如下效果。 + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LagStackSymbolicate.png) + +系统堆栈符号化的问题,也就是获取系统符号文件的问题。可以通过 ipsw,也就是刷机所需要的固件信息。 + +因为目前最低兼容 iOS10, iOS 10 以后我们只需要支持 arm64 和 arm64e 两个架构,也就是我们只要下载同一个版本下任意 arm64 的固件和任意一个 arm64e 的一个固件即可。 + +另外一种策略就是火焰图 thread_profile,类似 instruments 上看到的树型调用栈,比如50ms 抓取1次堆栈,维护最近的30个方法堆栈。其中堆栈信息为主线程的符号地址. + +深度遍历,每一层找到该层方法出现次数最多的方法符号。最后拼接组成完整方法堆栈。 + +服务端聚合策略 + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CallStackGroupHash.png) + +找到最接近栈顶的符合占比条件的业务代码方法,作为卡顿堆栈归类 key。 + +key 的判定条件为: 当前节点方法耗时大于等于父节点耗时的 1.1/n,且子节点方法耗时大于父节点总耗时的20%。其中,n为当前深度的总节点个数。 + +```java +curNode.costTime > (parentNode.costTime * 1.1/n) && curNode.costTime > parentNode.costTime * 0.2 +``` + +裁剪策略:count=1~60,每次 filterCostTime =5ms,第一次裁剪1*5ms的节点,第二次裁剪2*5ms的节点,每次遍历时判断剩余节点数满足上传的阈值上限,则不再继续裁剪 + +上报堆栈数据为:depth 代表方法深度,count 代表方法访问次数;methodId 是Android 方法插桩的方法标记,该节点 iOS 是没有值的。另外 methodAddress 是 iOS 才有的符号地址。 + +``` +[ + { + "depth": 0, + "count": 1, + "costTime": 90, + "methodId": 181, + "methodAddress": "0x104d0824f" + } +] +``` + +方法调用的深度约定为100,现在方法调用层级不会那么深,如果真有那么多节点都是很小的子节点,排查意义不大,所以阈值没必要设置太大。 + +### 7. 功能设计小思考 + +业务团队经常有关心某个页面或者某个关键业务流程卡顿情况的诉求,比如门店经营团队的同学很关心商品加购到开单整个链路的卡顿情况。基于这个背景,可以参考 iOS 系统留给开发者标记某个场景的功能(需要搭配 Instrucments 使用)使用需要导入 `#include ` 文件。 + +所以 APM 也提供了类似的能力,`-(void)setPhase:(NSString *)phase` + +这样情况下,业务方可以针对特定的业务流程做性能统计分析 + + + ## 二、 App 启动时间监控 ### 1. App 启动时间的监控 @@ -5600,7 +5697,7 @@ parseJSError(line, column); kscrash_callCompletion(onCompletion, reports, YES, nil); return; } - + if(self.sink == nil) { kscrash_callCompletion(onCompletion, reports, NO, @@ -5609,7 +5706,7 @@ parseJSError(line, column); description:@"No sink set. Crash reports not sent."]); return; } - + [self.sink filterReports:reports onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) { @@ -6257,7 +6354,293 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 ![符号化服务架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-SymbolicateServerArch.png) -## 八、子线程 UI 监控 +## 八、Weex、Flutter 异常监控 + +Weex 由于历史原因,不做性能监控了,现有业务代码继续跑着,只业务问题和稳定性问题。 + +## Weex 异常监控 + +### 背景 + +Weex 由于历史原因,不能很好的统计异常。比如在页面的模版代码中绑定了 data 中的一个对象,此时对象可能并没有值,而是依赖后续的网络请求完成,对象才有了具体的值 data 改变,数据驱动,页面再次 render。所以监控代码会认为第一次 render 的时候访问对象不存在的属性。 + +真正有问题的代码和不影响业务的异常信息,都会被 Vue 官方认为是一场。基于这样的背景,我们无法 pick 出真正异常或者是开发者判空代码没写好的问题。 + +### 解决思路 + +按照异常的等级,可以划分为影响业务和不影响业务。问题来了,什么叫做“影响业务”?这是我们自己定义的词,也就是影响用户是否正常操作 App。比如说:页面白屏、点击某个按钮无响应等等。定义为 Error。其他不影响业务的定义为 Warning。 + +### 技术实现 + +#### Warning 异常 + +采用主流方案,React、Vue 都提供了框架自己的异常监控方案,由于 Weex 是在 Vue 基础上实现。包括 Native 和 Vue 的 UI 双线程机制、事件机制等等。其余我们不关心,Weex 开发中,开发和都是写 Vue 去实现页面和逻辑,所以我们监控 Vue 的异常就满足了。 + +```js +/** + * APM 监控,目前是 JS 异常监控,ROI 情况下 Weex 只维护旧的页面,新的几乎是 Flutter,所以性能监控暂时未做。有需求可以企业微信联系:魅影 + */ + class APM { + /** + * 获取当前组件的名称 + * @param {*} vm Vue 对象 + * @returns 组件名称 + */ + fetchComponentName (vm) { + const componentName = vm._isVue + ? (vm.$options && vm.$options.name) || + (vm.$options && vm.$options._componentTag) + : vm.name; + return componentName ? componentName : 'unknown' + } + + /** + * 获取当前组件路径 + * @param {*} vm Vue 对象 + * @returns 组件路径 + */ + fetchComponentPath (vm) { + return vm._isVue && vm.$options && vm.$options.__file ? vm.$options.__file : 'unknown' + } + + /** + * 处理Vue错误提示 + */ + monitor(Vue) { + if(!Vue){ + return; + } + // 错误处理 + Vue.config.errorHandler = (err, vm, info) => { + let componentName = '', componentPath = '' + if (Object.prototype.toString.call(vm) === '[object Object]') { + componentName = this.fetchComponentName(vm) + componentPath = this.fetchComponentPath(vm) + } + + let errorInfo = { + name: err.name, + reason: err.message, + callStack: err.stack, + componentName: componentName, + componentPath: componentPath, + info: info, + level: 'VUE_ERROR', + } + try { + const APMUploader = weex.requireModule('APM') + APMUploader.exceptionReport(errorInfo) + } catch (error) { + console.error('Weex 异常模块未注册,请升级 ZanWeexiOS、 ZanWeexAndroid SDK') + } + } + } +} + +export default APM; +``` + +由于 Weex 代码是单独可运行和部署的,因此前端没有统一的入口,所以在发布阶段监控代码需要配合打包机,利用脚本动态插入到页面代码中。 + +#### Error 监控 + +根据我们对“影响业务”的定义,Error 包括:页面白屏 + 事件无法响应。 + +所以我们来拆解下问题。 + +##### 页面白屏 + +根据 Weex SDK 整个完整流程得出,白屏包括以下几个可能: + +- Weex 资源网络请求失败,存在 Error(_mainBundleLoader.onFinished 里的 Error) + + ```objectivec + _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { + if (error) { + // Weex 资源网络请求失败 + NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": [NSString stringWithFormat:@"download bundle error :%@", WeexSafeString([error localizedDescription])], @"pageName": WeexSafeString(strongSelf.pageName)}; + NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] userInfo:errorDic]; + [[WeexAPM sharedInstance] renderFailed:renderError]; + // ... + return; + } + ``` + + +- Weex 资源网络请求成功,但是数据内容为空 + + ```objectivec + _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { + // ... + + if (!data) { + // Weex 资源网络请求成功,但是数据内容为空。也就是下载下来的资源无法消费,页面无法正常渲染 + NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ With no data return", WeexSafeString(request.URL)]; + NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": errorMessage, @"pageName": WeexSafeString(strongSelf.pageName)}; + NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] userInfo:errorDic]; + [[WeexAPM sharedInstance] renderFailed:renderError]; + // ... + return; + } + } + ``` + + +- Weex 资源网络请求成功,但是数据 JSON 序列化失败 + + ```objectivec + _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { + // ... + + NSString *jsBundleString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (!jsBundleString) { + // Weex 资源网络请求成功,但是网络数据 JSON 序列化失败,也就是下载下来的资源无法消费,页面无法正常渲染 + NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": @"data converting to string failed.", @"pageName": WeexSafeString(strongSelf.pageName)}; + NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_ERR_JSBUNDLE_STRING_CONVERT] userInfo:errorDic]; + [[WeexAPM sharedInstance] renderFailed:renderError]; + // ... + return; + } + } + ``` + + +- Weex 资源网络请求异常(网络请求 _mainBundleLoader.onFailed) + + ```objectivec + _mainBundleLoader.onFailed = ^(NSError *loadError) { + NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ occurs an error:%@, info:%@", request.URL, loadError.localizedDescription, loadError.userInfo]; + long wxErrorCode = [loadError.domain isEqualToString:NSURLErrorDomain] && loadError.code == NSURLErrorNotConnectedToInternet ? WX_ERR_NOT_CONNECTED_TO_INTERNET : WX_ERR_JSBUNDLE_DOWNLOAD; + // Weex 资源网络请求失败回调。得不到 Weex 资源,也就是页面无法正常渲染 + NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": errorMessage, @"pageName": WeexSafeString(weakSelf.pageName)}; + NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", wxErrorCode] userInfo:errorDic]; + [[WeexAPM sharedInstance] renderFailed:renderError]; + // ... + }; + ``` + + + +##### 点击事件无响应 + +调试 WeexSDK 发现 SDK 内部通过给 JSContext 注册了 `callNativeModule` 方法来实现调用 Native Module 的 Method。 + +其中有2种可能。 + +第一种:Weex 调用了 Native 的方法,但是 Native 方法的参数类型不匹配,这种 case 下 WeexSDK 会 Crash。要做的事情有2步: + +- 给 `WX_ARGUMENTS_SET(invocation, signature, i, argument, freeList);` 添加 try...catch...,保护代码不要崩溃 +- 收集案发信息。当前 Module 名称、方法名称、参数等信息 + +```objectivec +- (NSInvocation *)invocationWithTarget:(id)target selector:(SEL)selector +{ + // ... + for (int i = 0; i < arguments.count; i ++ ) { + // ... + if (!strcmp(parameterType, blockType)) { + // ... + } else { + argument = obj; + @try { + WX_ARGUMENTS_SET(invocation, signature, i, argument, freeList); + } @catch (NSException *exception) { + NSDictionary *errorDict = @{ + @"page": WeexSafeString([WXSDKEngine topInstance].pageName ?: (self.arguments.count > 0 ? ([self.arguments.firstObject isKindOfClass:[NSDictionary class]] ? [self.arguments.firstObject objectForKey:@"url"] : @"") : @"")), + @"ModuleName": WeexSafeString([self respondsToSelector:@selector(moduleName)] ? [self performSelector:@selector(moduleName)] : @""), + @"MethodName": WeexSafeString(self.methodName), + @"MethodArguments": argument ?: @"", + @"reasone": @"调用 Native Module 方法的参数不符合要求", + @"type": @(WeexAPMTypeCallNativeMethodFailed) + }; + [[WeexAPM sharedInstance] reportError:errorDict]; + } @finally { + + } + } + } + // ... +} +``` + +第二种:Weex 调用 Native Module 的方法,但是方法没有注册或者方法名调错。这种会走到 invoke 代码里。 + +```objectivec +- (NSInvocation *)invoke +{ + // ... + if (![moduleInstance respondsToSelector:selector]) { + // if not implement the selector, then dispatch default module method + if ([self.methodName isEqualToString:@"addEventListener"]) { + [self.instance _addModuleEventObserversWithModuleMethod:self]; + } else if ([self.methodName isEqualToString:@"removeAllEventListeners"]) { + [self.instance _removeModuleEventObserverWithModuleMethod:self]; + } else { + NSString *errorMessage = [NSString stringWithFormat:@"method:%@ for module:%@ doesn't exist, maybe it has not been registered", self.methodName, _moduleName]; + WX_MONITOR_FAIL(WXMTJSBridge, WX_ERR_INVOKE_NATIVE, errorMessage); + } + return nil; + } +``` + +该 `WX_MONITOR_FAIL` 宏定义最终会走到 。所以这一步的有效监控代码就是下面部分 + +```objectivec ++ (void)monitoringPoint:(WXMonitorTag)tag isSuccss:(BOOL)success error:(NSError *)error onPage:(NSString *)pageName +{ + if (!success) { + WXLogError(@"%@", error.localizedDescription); + NSDictionary *errorDict = @{ + @"page": pageName ? : ([WXSDKEngine topInstance].pageName ?: @""), + @"error": @{ + @"errorCode": @(error.code), + @"errorUserInfo": error.userInfo ?: error.userInfo, + }, + @"type": @(WeexAPMTypeCallNativeMethodFailed) + }; + [[WeexAPM sharedInstance] reportError:errorDict]; + } + // ... +} +``` + +#### JS ExceptionHandler + +JS 引擎中异常回调中上报错误 + +```objectivec +- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args +{ + WXLogDebug(@"Calling JS... method:%@, args:%@", method, args); + JSValue *value = nil; + @try { + value = [[_jsContext globalObject] invokeMethod:method withArguments:args]; + } @catch (NSException *exception) { + NSDictionary *errorDic = @{@"errorName": WeexSafeString(exception.name), @"errorReason": WeexSafeString(exception.reason), @"errorInfo": WeexSafeString(exception.userInfo), @"methodName": WeexSafeString(method), @"args": args ?: @"", @"type": @(WeexAPMTypeJSException)}; + [[WeexAPM sharedInstance] reportError:errorDic]; + } + return value; +} +``` + +其中为了后续统计方便,定义了 Weex 异常枚举 + +```objectivec +typedef NS_ENUM(NSUInteger, WeexAPMType) { + WeexAPMTypeDefault = 0, // 默认错误,基本用不到,防止取值判断为0默认值的case + WeexAPMTypeRenderFailed, // 白屏 + WeexAPMTypeCallNativeMethodFailed, // 调用 Native 方法出错 + WeexAPMTypeJSException, // JS ExceptionHandler +}; +``` + + + +### 2. Flutter 异常监控 + + + +## 九、子线程 UI 监控 ### 1. 背景介绍 @@ -6293,7 +6676,59 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 具体可以参考这个 [Demo](https://github.com/FantasticLBP/MainThreadChecker) -## 九、 APM 小结 + + +## 十、页面渲染时长统计 + +当我们的产品经理、TL、领导或者任何关心我们产品质量的某个角色问你,你们的 App 看上去好像比较卡,很难用,这时候我们心里一阵空虚,卡吗? + +好像用 APM 的卡顿没发现啥问题。仔细看看发现了问题,我们的卡顿只是监控主线程方法耗时。一个页面加载后可能会触发一些网络请求功能,子线程请求完网络,可能会做一些数组裁剪、组装,拼接为 UI 所需要的信息,这个时候很可能会发生卡顿,所以这种 case 下卡顿监控是无法覆盖的。用下图表示 + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/PageLoadFullTime.png) + +页面作为承载用户交互的具体战场,我们需要对页面的性能有个直观的指标。业界一般有2个指标:**页面渲染时长、页面可交互时长**。 + +页面可交互时长业界有好几种方案: + +- 基于图像识别,利用 CoreML 来做识别,判断页面可见区域的变化 + +- 基于页面配置配置接口信息,yml 文件key为页面名称,value 为接口数组。动态监控网络 + +- 8060算法。MaxX(水平视图)>60%页面宽度,MaxY(垂直高度)>80%页面高度,且检查主线程心跳判断是否渲染完成。 + +其中每个方案各有优缺点,都不太满足需求。假设采用网络这个方案,关键步骤如下: + +- 首先切面统计每个页面的 `viewDidLoad` 方法,标记当前页面开始加载流程 + +- NSURLProtocol 监控当前页面的网络请求 + +- 当该页面所有的网络请求成功后,不管是直接 dispatch 任务到主线程去刷新 UI,还是先做数据的聚合裁剪,再 dispatch 任务到主线程去刷新 UI + +这里可能某些场景下监控到的网络请求不一定是当前页面发起的,比如 App 存在 Socket 长连通道,这个时候心跳时间到了,发起网络请求刚到被当前的机制所判定为页面所需网络请求。或者某些其他特殊 case 也会导致。但是这些场景应该比较少,可控,针对耗时监控需要增加黑名单口子,过滤可能会被误判的网络请求。 + +这里存在一个问题,就是业务代码在网络请求成之后数据裁剪聚合是在子线程,最后 dispatch 一个刷新任务到主队列。但是 APM 监控本身也需要 dispatch 任务到主队列。需要做到无痕,那么如何保证 APM 自己的任务一定是在业务代码 dispatch 的任务之后? + +还有个问题就是可能在 viewDidLoad 中触发的网络请求,异步任务结束时刻是在 viewDidAppear 之后,所以如何判断页面渲染结束?比如可以判断 RunLoopMode。一个页面是可以滚动的,如何识别页面网络真正加载完成?假如页面加载完成后,用户下拉加载更多,此时 RunLoopMode 会从 kCFRunLoopDefaultMode 切换到 UITrackingRunLoopMode。辅助结合 RunLoopMode 来判断页面时候发生了滑动。 + +还有一个问题。页面所需的数据不一定是网络,可能是 DB 的 CURD,所以针对网络的 hook 并不能满足所有场景,假如想到一个 case 去写一些兼容代码,那这个方案本身就很不优雅了。 + +最后想了想,还是需要回到 8060算法 + 主线程心跳。页面布局一般分为2种情况: + +页面布局紧凑型。紧凑型的话,页面排版占比满足 MaxX > KScreenWith * 80%,MaxY > KScreenHeight * 60%。在此基础上假如页面还在布局,则可以结合主线程心跳,来判断。只要在8060基础上,后续 dispatch 的任务得到执行,大概率认为页面渲染完毕了。 + +页面布局稀疏型。稀疏的情况上不满足8060,则可以直接判断主线程心跳,心跳得到执行,则这个时刻可以判定为页面渲染完毕。 + +检测时机:那么何时去判断页面是否满足8060?可以基于当前 ViewController 的 `viewDidLayoutSubviews` 。任何 IO 数据主要驱动 UI 渲染,最后都会收口于 viewDidLayoutSubViews 这个方法上。所以可以在这个方法之后做8060检测。 + +> Called to notify the view controller that its view has just laid out its subviews. + +其实没有十全十美方案,目前看上去是一个折中的方案。只要保证统计口径是不变的,那监控数据本身就有参考意义。 + +另外随着问题的暴露或者技术研究的渗入,监控方案本身是可以迭代演进的。 + + + +## 十一、 APM 小结 1. 通常来说各个端的监控能力是不太一致的,技术实现细节也不统一。所以在技术方案评审的时候需要将监控能力对齐统一。每个能力在各个端的数据字段必须对齐(字段个数、名称、数据类型和精度),因为 APM 本身是一个闭环,监控了之后需符号化解析、数据整理,进行产品化开发、最后需要监控大盘展示等 diff --git a/assets/CallStackGroupHash.png b/assets/CallStackGroupHash.png new file mode 100644 index 0000000..dd7178e Binary files /dev/null and b/assets/CallStackGroupHash.png differ diff --git a/assets/LagStackSymbolicate.png b/assets/LagStackSymbolicate.png new file mode 100644 index 0000000..b72e85f Binary files /dev/null and b/assets/LagStackSymbolicate.png differ diff --git a/assets/LagSymbolicate.png b/assets/LagSymbolicate.png new file mode 100644 index 0000000..289c17e Binary files /dev/null and b/assets/LagSymbolicate.png differ diff --git a/assets/PageLoadFullTime.png b/assets/PageLoadFullTime.png new file mode 100644 index 0000000..e810f04 Binary files /dev/null and b/assets/PageLoadFullTime.png differ diff --git a/assets/callStackSymbolicate'.png b/assets/callStackSymbolicate'.png new file mode 100644 index 0000000..4ae97c9 Binary files /dev/null and b/assets/callStackSymbolicate'.png differ diff --git a/assets/callstackCostTime.png b/assets/callstackCostTime.png new file mode 100644 index 0000000..6588fbf Binary files /dev/null and b/assets/callstackCostTime.png differ