From 145dcb4d48a186f4369dd1488ecb73a8c06f146c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=9F=8E=E5=B0=8F=E5=88=98?= Date: Thu, 19 Nov 2020 03:18:27 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20APM=E3=80=81=E5=A4=9A=E5=8F=A5=E6=9F=84?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=B8=8A=E6=8A=A5SDK=E3=80=81=E8=BD=AF?= =?UTF-8?q?=E4=BB=B6=E6=B5=8B=E8=AF=95=E5=B0=8F=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Chapter1 - iOS/1.39.md | 4 +- Chapter1 - iOS/1.72.md | 20 +- Chapter1 - iOS/1.74.md | 6596 ++++++++++++++++++++++- Chapter1 - iOS/1.75.md | 1152 +++- Chapter1 - iOS/1.80.md | 2511 ++++++++- Chapter1 - iOS/1.90.md | 4 +- Chapter1 - iOS/1.93.md | 1 + Chapter1 - iOS/chapter1.md | 1 + Chapter2 - Web FrontEnd/2.41.md | 12 +- Chapter9 - Ragdoll/9.1.md | 4 +- Chapter9 - Ragdoll/9.2.md | 10 +- Chapter9 - Ragdoll/9.3.md | 24 +- README.md | 6 +- SUMMARY.md | 1 + assets/2020-06-17-APMServerArch.png | Bin 54590 -> 59754 bytes assets/2020-06-17-APMServerWorker.png | Bin 40582 -> 36167 bytes assets/2020-06-17-symolication_flow.png | Bin 64204 -> 58518 bytes 17 files changed, 10302 insertions(+), 44 deletions(-) create mode 100644 Chapter1 - iOS/1.93.md diff --git a/Chapter1 - iOS/1.39.md b/Chapter1 - iOS/1.39.md index 67a6311..4d6e1d8 100644 --- a/Chapter1 - iOS/1.39.md +++ b/Chapter1 - iOS/1.39.md @@ -1004,7 +1004,7 @@ CF_EXPORT CFRunLoopRef _CFRunLoopGet0(_CFThreadRef t) { } ``` - ![Mach Port Test Demo](./../assets/2020-08-13-MachPortTest.png) + ![Mach Port Test Demo](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-MachPortTest.png) 可以看到 main.m 中,还没有 return 的时候当前 RunLoop 的内部结构中存在一个 **wakeup port** 的端口。查看 RunLoop 源代码 **wakeup port** 就是 mach port 的一种。 @@ -1096,7 +1096,7 @@ CF_EXPORT CFRunLoopRef _CFRunLoopGet0(_CFThreadRef t) { 7. RunLoop lifecycle - ![RunLoop lifecycle](./../assets/2020-08-13-RunLoopLifeCycle.png) + ![RunLoop lifecycle](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-RunLoopLifeCycle.png) 休眠时 RunLoop 被 mach_msg 阻塞,等到消息到来,可以是手动给 RunLoop 的 wakeUpPort 发消息 mach_msg,或者是 timer、Source1。然后继续走到 RunLoop run,不断循环 diff --git a/Chapter1 - iOS/1.72.md b/Chapter1 - iOS/1.72.md index 79a1eba..82030cf 100644 --- a/Chapter1 - iOS/1.72.md +++ b/Chapter1 - iOS/1.72.md @@ -24,19 +24,19 @@ #endif ``` - 对于你的某个 SDK,你在为某个方法、某个类、某个宏定义命名的时候需要注意选择合适的前缀 - 比如。你的某个项目是在做监控,SDK 的名字叫做 Prism-Client。那么你的类名称、类方法名称、宏定义、分类名称、分类方法名称等都需要合适且统一的前缀,一般选取 `前3个字母组合`。当前的项目叫做 `PCT`。类前面加 PCT,类里面的方法不加前缀。分类名称加前缀 PCT,分类里面的方法前面加前缀,小写的 pct。 + 比如。你的某个项目是在做监控,SDK 的名字叫做 Hermes-Client。那么你的类名称、类方法名称、宏定义、分类名称、分类方法名称等都需要合适且统一的前缀,一般选取 `前3个字母组合`。当前的项目叫做 `HCT`。类前面加 HCT,类里面的方法不加前缀。分类名称加前缀 HCT,分类里面的方法前面加前缀,小写的 HCT。 普通类的方法不加前缀是因为普通类已经通过类名的唯一性确定了方法的唯一。 分类里面方法加前缀是因为分类的方法在工程里面这个类都可以访问。所以要在方法前面区分 ```Objective-C // 安全的数据获取方法 - #ifndef PCT_SAFE_STRING - #define PCT_SAFE_STRING(x) (x) != nil ? (x) : @"" + #ifndef HCT_SAFE_STRING + #define HCT_SAFE_STRING(x) (x) != nil ? (x) : @"" #endif - NSData+PCTAES.h - - (NSData *)pct_AES128EncryptWithKey:(NSString *)key gIv:(NSString *)Iv; + NSData+HCTAES.h + - (NSData *)hct_AES128EncryptWithKey:(NSString *)key gIv:(NSString *)Iv; - PCTRequestFactory.h + HCTRequestFactory.h + (void)fetchUploadConfigurationWithRequestURL:(NSString *)requestUrlString params:(NSDictionary *)params success:(void (^)(PRCConfigurationModel*model))success @@ -60,11 +60,11 @@ - 内联函数的定义须在调用之前 - Objective-C 中内联函数用 NS_INLINE ,等价于 static inline。且内联函数的命名需要注意,在该模块内的内联函数需要加前缀。 ```Objective-C - NS_INLINE NSString * PCTGetTableNameFromType(PCTLogTableType type){ - if (type == PCTLogTableTypeMeta) { + NS_INLINE NSString * HCTGetTableNameFromType(HCTLogTableType type){ + if (type == HCTLogTableTypeMeta) { return PRC_LOG_TABLE_META; } - if (type == PCTLogTableTypePayload) { + if (type == HCTLogTableTypePayload) { return PRC_LOG_TABLE_PAYLOAD; } return @""; @@ -94,6 +94,6 @@ NSLog(@"%zd", [AFNetworkReachabilityManager sharedManager].networkReachabilityStatus); } ``` - 之前在做一个类 `PCTRequestFactory` 用来管理网络相关的逻辑。需要判断网络状态,我们都知道 AFNetWorking 第一次判断网络状态得到的是 AFNetworkReachabilityStatusUnknown。而我的逻辑需要 SDK 启动的时候判断网络状态,然后去上报数据。所以刚开始 AFNetworkReachabilityStatusUnknown 显然不能上报 Crash 数据,所以想着是将第一次的网络状态获取放到 **load** 方法里。这样是没问题的,可以拿到网络状态,但是我们知道 load 是类加载的时候调用的,打开 Xcode 看到 Build Phases 里面 `Link BiBinary With Libraries` 这个里面的库的顺序决定了里面的类加载顺序。我们知道 Pod 的原理是在 Podfile 里面描述的 pod 库依赖,然后会按照字典序(首字母排序去)引入,所以 AFNetWorking 这个肯定早,所以会成功的。但是万一是人工手动去引入或者修改库的位置,则在 PCTRequestFactory 里面的 load 方法执行的时候不一定可以保证 AFNetworkReachabilityManager 已经加载好。所以将 load 逻辑移动到 init 里面。 + 之前在做一个类 `HCTRequestFactory` 用来管理网络相关的逻辑。需要判断网络状态,我们都知道 AFNetWorking 第一次判断网络状态得到的是 AFNetworkReachabilityStatusUnknown。而我的逻辑需要 SDK 启动的时候判断网络状态,然后去上报数据。所以刚开始 AFNetworkReachabilityStatusUnknown 显然不能上报 Crash 数据,所以想着是将第一次的网络状态获取放到 **load** 方法里。这样是没问题的,可以拿到网络状态,但是我们知道 load 是类加载的时候调用的,打开 Xcode 看到 Build Phases 里面 `Link BiBinary With Libraries` 这个里面的库的顺序决定了里面的类加载顺序。我们知道 Pod 的原理是在 Podfile 里面描述的 pod 库依赖,然后会按照字典序(首字母排序去)引入,所以 AFNetWorking 这个肯定早,所以会成功的。但是万一是人工手动去引入或者修改库的位置,则在 HCTRequestFactory 里面的 load 方法执行的时候不一定可以保证 AFNetworkReachabilityManager 已经加载好。所以将 load 逻辑移动到 init 里面。 另外,load 方法一般只做和本类有关系的逻辑,比如 hook 方法。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index eec183b..3983a5d 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -1,3 +1,6597 @@ # 带你打造一套 APM 监控系统 -内容脱敏中... \ No newline at end of file +> APM 是 Application Performance Monitoring 的缩写,监视和管理软件应用程序的性能和可用性。应用性能管理对一个应用的持续稳定运行至关重要。所以这篇文章就从一个 iOS App 的性能管理的纬度谈谈如何精确监控以及数据如何上报等技术点 + +App 的性能问题是影响用户体验的重要因素之一。性能问题主要包含:Crash、网络请求错误或者超时、UI 响应速度慢、主线程卡顿、CPU 和内存使用率高、耗电量大等等。大多数的问题原因在于开发者错误地使用了线程锁、系统函数、编程规范问题、数据结构等等。解决问题的关键在于尽早的发现和定位问题。 + +本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 + + + + +## 一、卡顿监控 + +卡顿问题,就是在主线程上无法响应用户交互的问题。影响着用户的直接体验,所以针对 App 的卡顿监控是 APM 里面重要的一环。 + +FPS(frame per second)每秒钟的帧刷新次数,iPhone 手机以 60 为最佳,iPad 某些型号是 120,也是作为卡顿监控的一项参考参数,为什么说是参考参数?因为它不准确。先说说怎么获取到 FPS。CADisplayLink 是一个系统定时器,会以帧刷新频率一样的速率来刷新视图。 `[CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]`。至于为什么不准我们来看看下面的示例代码 + + +```Objective-C +_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)]; +[_displayLink setPaused:YES]; +[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; +``` + +代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 + + + + +### 1. 屏幕绘制原理 + +![老式 CRT 显示器原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_screen_scan.png) + +讲讲老式的 CRT 显示器的原理。 CRT 电子枪按照上面方式,从上到下一行行扫描,扫面完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;当一帧画面绘制完成后,电子枪恢复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(Vertical synchronization),简称 VSync。显示器通常以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏,但是原理保持不变。 + + + +![显示器和 CPU、GPU 关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-02-screen_display_gpu.png) + +通常,屏幕上一张画面的显示是由 CPU、GPU 和显示器是按照上图的方式协同工作的。CPU 根据工程师写的代码计算好需要现实的内容(比如视图创建、布局计算、图片解码、文本绘制等),然后把计算结果提交到 GPU,GPU 负责图层合成、纹理渲染,随后 GPU 将渲染结果提交到帧缓冲区。随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过数模转换传递给显示器显示。 + +在帧缓冲区只有一个的情况下,帧缓冲区的读取和刷新都存在效率问题,为了解决效率问题,显示系统会引入2个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入帧缓冲区,让视频控制器来读取,当下一帧渲染好后,GPU 直接把视频控制器的指针指向第二个缓冲区。提升了效率。 + +目前来看,双缓冲区提高了效率,但是带来了新的问题:当视频控制器还未读取完成时,即屏幕内容显示了部分,GPU 将新渲染好的一帧提交到另一个帧缓冲区并把视频控制器的指针指向新的帧缓冲区,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂的情况。 + +为了解决这个问题,GPU 通常有一个机制叫垂直同步信号(V-Sync),当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新。这样的几个机制解决了画面撕裂的情况,也增加了画面流畅度。但需要更多的计算资源 + + +![IPC唤醒 RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-ios_vsync_runloop.png) + +答疑 + +可能有些人会看到「当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新」这里会想,GPU 收到 V-Sync 才进行新的一帧渲染和帧缓冲区的更新,那是不是双缓冲区就失去意义了? + +设想一个显示器显示第一帧图像和第二帧图像的过程。首先在双缓冲区的情况下,GPU 首先渲染好一帧图像存入到帧缓冲区,然后让视频控制器的指针直接直接这个缓冲区,显示第一帧图像。第一帧图像的内容显示完成后,视频控制器发送 V-Sync 信号,GPU 收到 V-Sync 信号后渲染第二帧图像并将视频控制器的指针指向第二个帧缓冲区。 + +**看上去第二帧图像是在等第一帧显示后的视频控制器发送 V-Sync 信号。是吗?真是这样的吗? 😭 想啥呢,当然不是。 🐷 不然双缓冲区就没有存在的意义了** + + + +揭秘。请看下图 + +![多缓冲区显示原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-Comparison_double_triple_buffering.png) + + + +当第一次 V-Sync 信号到来时,先渲染好一帧图像放到帧缓冲区,但是不展示,当收到第二个 V-Sync 信号后读取第一次渲染好的结果(视频控制器的指针指向第一个帧缓冲区),并同时渲染新的一帧图像并将结果存入第二个帧缓冲区,等收到第三个 V-Sync 信号后,读取第二个帧缓冲区的内容(视频控制器的指针指向第二个帧缓冲区),并开始第三帧图像的渲染并送入第一个帧缓冲区,依次不断循环往复。 + +请查看资料,需要梯子:[Multiple buffering](https://en.m.wikipedia.org/wiki/Multiple_buffering) + + + +### 2. 卡顿产生的原因 + + + +![卡顿原因](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_frame_drop.png) + + + +VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图创建、布局计算、图片解码、文本绘制等)。然后将计算的内容提交到 GPU,GPU 经过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示之前渲染好的结果。在垂直同步机制的情况下,如果在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会造成该帧的丢弃,等待下一次机会再显示,这时候屏幕上还是之前渲染的图像,所以这就是 CPU、GPU 层面界面卡顿的原因。 + + +目前 iOS 设备有双缓存机制,也有三缓冲机制,Android 现在主流是三缓冲机制,在早期是单缓冲机制。 +[iOS 三缓冲机制例子](https://ios.developreference.com/article/12261072/Metal+newBufferWithBytes+usage) + + +CPU 和 GPU 资源消耗原因很多,比如对象的频繁创建、属性调整、文件读取、视图层级的调整、布局的计算(AutoLayout 视图个数多了就是线性方程求解难度变大)、图片解码(大图的读取优化)、图像绘制、文本渲染、数据库读取(多读还是多写乐观锁、悲观锁的场景)、锁的使用(举例:自旋锁使用不当会浪费 CPU)等方面。开发者根据自身经验寻找最优解(这里不是本文重点)。 + + + + +### 3. APM 如何监控卡顿并上报 + + +CADisplayLink 肯定不用了,这个 FPS 仅作为参考。一般来讲,卡顿的监测有2种方案:**监听 RunLoop 状态回调、子线程 ping 主线程** + + + +#### 3.1 RunLoop 状态监听的方式 + +RunLoop 负责监听输入源进行调度处理。比如网络、输入设备、周期性或者延迟事件、异步回调等。RunLoop 会接收2种类型的输入源:一种是来自另一个线程或者来自不同应用的异步消息(source0事件)、另一种是来自预定或者重复间隔的事件。 + +RunLoop 状态如下图 + +![RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/4.png) + +第一步:通知 Observers,RunLoop 要开始进入 loop,紧接着进入 loop +```Objective-c +if (currentMode->_observerMask & kCFRunLoopEntry ) + // 通知 Observers: RunLoop 即将进入 loop + __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry); +// 进入loop +result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode); +``` + +第二步:开启 do while 循环保活线程,通知 Observers,RunLoop 触发 Timer 回调、Source0 回调,接着执行被加入的 block +```Objective-c + if (rlm->_observerMask & kCFRunLoopBeforeTimers) + // 通知 Observers: RunLoop 即将触发 Timer 回调 + __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); +if (rlm->_observerMask & kCFRunLoopBeforeSources) + // 通知 Observers: RunLoop 即将触发 Source 回调 + __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); +// 执行被加入的block +__CFRunLoopDoBlocks(rl, rlm); +``` + +第三步:RunLoop 在触发 Source0 回调后,如果 Source1 是 ready 状态,就会跳转到 handle_msg 去处理消息。 +```Objective-c +// 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息 +if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { +#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI + msg = (mach_msg_header_t *)msg_buffer; + + if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) { + goto handle_msg; + } +#elif DEPLOYMENT_TARGET_WINDOWS + if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) { + goto handle_msg; + } +#endif +} +``` + +第四步:回调触发后,通知 Observers 即将进入休眠状态 +```Objective-c +Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR); +// 通知 Observers: RunLoop 的线程即将进入休眠(sleep) +if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting); + __CFRunLoopSetSleeping(rl); +``` + +第五步:进入休眠后,会等待 mach_port 消息,以便再次唤醒。只有以下4种情况才可以被再次唤醒。 +- 基于 port 的 source 事件 +- Timer 时间到 +- RunLoop 超时 +- 被调用者唤醒 +```Objective-c +do { + if (kCFUseCollectableAllocator) { + // objc_clear_stack(0); + // + memset(msg_buffer, 0, sizeof(msg_buffer)); + } + msg = (mach_msg_header_t *)msg_buffer; + + __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); + + if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { + // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer. + while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue)); + if (rlm->_timerFired) { + // Leave livePort as the queue port, and service timers below + rlm->_timerFired = false; + break; + } else { + if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); + } + } else { + // Go ahead and leave the inner loop. + break; + } +} while (1); +``` + +第六步:唤醒时通知 Observer,RunLoop 的线程刚刚被唤醒了 +```Objective-C +// 通知 Observers: RunLoop 的线程刚刚被唤醒了 +if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); + // 处理消息 + handle_msg:; + __CFRunLoopSetIgnoreWakeUps(rl); +``` + +第七步:RunLoop 唤醒后,处理唤醒时收到的消息 +- 如果是 Timer 时间到,则触发 Timer 的回调 +- 如果是 dispatch,则执行 block +- 如果是 source1 事件,则处理这个事件 +```Objective-C +#if USE_MK_TIMER_TOO + // 如果一个 Timer 到时间了,触发这个Timer的回调 + else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) { + CFRUNLOOP_WAKEUP_FOR_TIMER(); + // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled. + // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754 + if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { + // Re-arm the next timer + __CFArmNextTimerInMode(rlm, rl); + } + } +#endif + // 如果有dispatch到main_queue的block,执行block + else if (livePort == dispatchPort) { + CFRUNLOOP_WAKEUP_FOR_DISPATCH(); + __CFRunLoopModeUnlock(rlm); + __CFRunLoopUnlock(rl); + _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL); +#if DEPLOYMENT_TARGET_WINDOWS + void *msg = 0; +#endif + __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); + _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL); + __CFRunLoopLock(rl); + __CFRunLoopModeLock(rlm); + sourceHandledThisLoop = true; + didDispatchPortLastTime = true; + } + // 如果一个 Source1 (基于port) 发出事件了,处理这个事件 + else { + CFRUNLOOP_WAKEUP_FOR_SOURCE(); + + // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again. + voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release); + + CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort); + if (rls) { +#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI + mach_msg_header_t *reply = NULL; + sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop; + if (NULL != reply) { + (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL); + CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply); + } +#elif DEPLOYMENT_TARGET_WINDOWS + sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop; +#endif +``` + +第八步:根据当前 RunLoop 状态判断是否需要进入下一个 loop。当被外部强制停止或者 loop 超时,就不继续下一个 loop,否则进入下一个 loop +```Objective-C +if (sourceHandledThisLoop && stopAfterHandle) { + // 进入loop时参数说处理完事件就返回 + retVal = kCFRunLoopRunHandledSource; + } else if (timeout_context->termTSR < mach_absolute_time()) { + // 超出传入参数标记的超时时间了 + retVal = kCFRunLoopRunTimedOut; +} else if (__CFRunLoopIsStopped(rl)) { + __CFRunLoopUnsetStopped(rl); + // 被外部调用者强制停止了 + retVal = kCFRunLoopRunStopped; +} else if (rlm->_stopped) { + rlm->_stopped = false; + retVal = kCFRunLoopRunStopped; +} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) { + // source/timer一个都没有 + retVal = kCFRunLoopRunFinished; +} +``` + +完整且带有注释的 RunLoop 代码见[此处](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFRunLoop.c)。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质还是调用 Source0 事件的处理函数。 + + +![RunLoop 状态](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-05-RunLoop.png) +RunLoop 6个状态 +```Objective-C + +typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { + kCFRunLoopEntry , // 进入 loop + kCFRunLoopBeforeTimers , // 触发 Timer 回调 + kCFRunLoopBeforeSources , // 触发 Source0 回调 + kCFRunLoopBeforeWaiting , // 等待 mach_port 消息 + kCFRunLoopAfterWaiting ), // 接收 mach_port 消息 + kCFRunLoopExit , // 退出 loop + kCFRunLoopAllActivities // loop 所有状态改变 +} +``` + +RunLoop 在进入睡眠前的方法执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步,都会阻塞线程。如果是主线程,则表现为卡顿。 + +一旦发现进入睡眠前的 KCFRunLoopBeforeSources 状态,或者唤醒后 KCFRunLoopAfterWaiting,在设置的时间阈值内没有变化,则可判断为卡顿,此时 dump 堆栈信息,还原案发现场,进而解决卡顿问题。 + + +开启一个子线程,不断进行循环监测是否卡顿了。在 n 次都超过卡顿阈值后则认为卡顿了。卡顿之后进行堆栈 dump 并上报(具有一定的机制,数据处理在下一 part 讲)。 + +WatchDog 在不同状态下具有不同的值。 +- 启动(Launch):20s +- 恢复(Resume):10s +- 挂起(Suspend):10s +- 退出(Quit):6s +- 后台(Background):3min(在 iOS7 之前可以申请 10min;之后改为 3min;可连续申请,最多到 10min) + +卡顿阈值的设置的依据是 WatchDog 的机制。APM 系统里面的阈值需要小于 WatchDog 的值,所以取值范围在 [1, 6] 之间,业界通常选择3秒。 + +通过 `long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)` 方法判断是否阻塞主线程,`Returns zero on success, or non-zero if the timeout occurred.` 返回非0则代表超时阻塞了主线程。 + + + +![RunLoop-ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-APM-RunLoopANR.jpg) + +可能很多人纳闷 RunLoop 状态那么多,为什么选择 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?因为大部分卡顿都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之间。比如 Source0 类型的 App 内部事件等 + +Runloop 检测卡顿流程图如下: + +![RunLoop ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-ANRRunloop.png) + +关键代码如下: + + +```Objective-c +// 设置Runloop observer的运行环境 +CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL}; +// 创建Runloop observer对象 +_observer = CFRunLoopObserverCreate(kCFAllocatorDefault, + kCFRunLoopAllActivities, + YES, + 0, + &runLoopObserverCallBack, + &context); +// 将新建的observer加入到当前thread的runloop +CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes); +// 创建信号 +_semaphore = dispatch_semaphore_create(0); + +__weak __typeof(self) weakSelf = self; +// 在子线程监控时长 +dispatch_async(dispatch_get_global_queue(0, 0), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + while (YES) { + if (strongSelf.isCancel) { + return; + } + // N次卡顿超过阈值T记录为一次卡顿 + long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC)); + if (semaphoreWait != 0) { + if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) { + if (++strongSelf.countTime < strongSelf.standstillCount){ + continue; + } + // 堆栈信息 dump 并结合数据上报机制,按照一定策略上传数据到服务器。堆栈 dump 会在下面讲解。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + } + } + strongSelf.countTime = 0; + } +}); +``` + + + + + +#### 3.2 子线程 ping 主线程监听的方式 + +开启一个子线程,创建一个初始值为0的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为主线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + +```Objective-c +while (self.isCancelled == NO) { + @autoreleasepool { + __block BOOL isMainThreadNoRespond = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + isMainThreadNoRespond = NO; + dispatch_semaphore_signal(semaphore); + }); + + [NSThread sleepForTimeInterval:self.threshold]; + + if (isMainThreadNoRespond) { + if (self.handlerBlock) { + self.handlerBlock(); // 外部在 block 内部 dump 堆栈(下面会讲),数据上报 + } + } + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + } + } +``` + + + +### 4. 堆栈 dump + +方法堆栈的获取是一个麻烦事。理一下思路。`[NSThread callStackSymbols]` 可以获取当前线程的调用栈。但是当监控到卡顿发生,需要拿到主线程的堆栈信息就无能为力了。从任何线程回到主线程这条路走不通。先做个知识回顾。 + +在计算机科学中,调用堆栈是一种栈类型的数据结构,用于存储有关计算机程序的线程信息。这种栈也叫做执行堆栈、程序堆栈、控制堆栈、运行时堆栈、机器堆栈等。调用堆栈用于跟踪每个活动的子例程在完成执行后应该返回控制的点。 + +维基百科搜索到 “Call Stack” 的一张图和例子,如下 +![调用栈](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-StackFrame.png) +上图表示为一个栈。分为若干个栈帧(Frame),每个栈帧对应一个函数调用。下面蓝色部分表示 `DrawSquare` 函数,它在执行的过程中调用了 `DrawLine` 函数,用绿色部分表示。 + +可以看到栈帧由三部分组成:函数参数、返回地址、局部变量。比如在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数需要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。 + +栈指针 Stack Pointer 表示当前栈的顶部,大多部分操作系统都是栈向下生长,所以栈指针是最小值。帧指针 Frame Pointer 指向的地址中,存储了上一次 Stack Pointer 的值,也就是返回地址。 + +大多数操作系统中,每个栈帧还保存了上一个栈帧的帧指针。因此知道当前栈帧的 Stack Pointer 和 Frame Pointer 就可以不断回溯,递归获取栈底的帧。 + +接下来的步骤就是拿到所有线程的 Stack Pointer 和 Frame Pointer。然后不断回溯,还原案发现场。 + + + +### 5. Mach Task 知识 + +**Mach task:** + +App 在运行的时候,会对应一个 Mach Task,而 Task 下可能有多条线程同时执行任务。《OS X and iOS Kernel Programming》 中描述 Mach Task 为:任务(Task)是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。简单概括为:Mack task 是一个机器无关的 thread 的执行环境抽象。 + +作用: task 可以理解为一个进程,包含它的线程列表。 + +结构体:task_threads,将 target_task 任务下的所有线程保存在 act_list 数组中,数组个数为 act_listCnt + +```c++ +kern_return_t task_threads +( + task_t traget_task, + thread_act_array_t *act_list, //线程指针列表 + mach_msg_type_number_t *act_listCnt //线程个数 +) +``` + + + +thread_info: + +```c++ +kern_return_t thread_info +( + thread_act_t target_act, + thread_flavor_t flavor, + thread_info_t thread_info_out, + mach_msg_type_number_t *thread_info_outCnt +); +``` + +如何获取线程的堆栈数据: + +系统方法 `kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);` 可以获取到所有的线程,不过这种方法获取到的线程信息是最底层的 **mach 线程**。 + +对于每个线程,可以用 `kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt);` 方法获取它的所有信息,信息填充在 `_STRUCT_MCONTEXT` 类型的参数中,这个方法中有2个参数随着 CPU 架构不同而不同。所以需要定义宏屏蔽不同 CPU 之间的区别。 + +`_STRUCT_MCONTEXT` 结构体中,存储了当前线程的 Stack Pointer 和最顶部栈帧的 Frame pointer,进而回溯整个线程调用堆栈。 + +但是上述方法拿到的是内核线程,我们需要的信息是 NSThread,所以需要将内核线程转换为 NSThread。 + +pthread 的 p 是 **POSIX** 的缩写,表示「可移植操作系统接口」(Portable Operating System Interface)。设计初衷是每个系统都有自己独特的线程模型,且不同系统对于线程操作的 API 都不一样。所以 POSIX 的目的就是提供抽象的 pthread 以及相关 API。这些 API 在不同的操作系统中有不同的实现,但是完成的功能一致。 + +Unix 系统提供的 `task_threads` 和 `thread_get_state` 操作的都是内核系统,每个内核线程由 thread_t 类型的 id 唯一标识。pthread 的唯一标识是 pthread_t 类型。其中内核线程和 pthread 的转换(即 thread_t 和 pthread_t)很容易,因为 pthread 设计初衷就是「抽象内核线程」。 + + + +`memorystatus_action_neededpthread_create` 方法创建线程的回调函数为 **nsthreadLauncher**。 + +```Objective-c +static void *nsthreadLauncher(void* thread) +{ + NSThread *t = (NSThread*)thread; + [nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil]; + [t _setName: [t name]]; + [t main]; + [NSThread exit]; + return NULL; +} +``` + +NSThreadDidStartNotification 其实就是字符串 @"_NSThreadDidStartNotification"。 + +```Objective-c +{number = 1, name = main} +``` +为了 NSThread 和内核线程对应起来,只能通过 name 一一对应。 pthread 的 API `pthread_getname_np` 也可获取内核线程名字。np 代表 not POSIX,所以不能跨平台使用。 + +思路概括为:将 NSThread 的原始名字存储起来,再将名字改为某个随机数(时间戳),然后遍历内核线程 pthread 的名字,名字匹配则 NSThread 和内核线程对应了起来。找到后将线程的名字还原成原本的名字。对于主线程,由于不能使用 `pthread_getname_np`,所以在当前代码的 load 方法中获取到 thread_t,然后匹配名字。 + + +```Objective-c +static mach_port_t main_thread_id; ++ (void)load { + main_thread_id = mach_thread_self(); +} +``` + + + + +## 二、 App 启动时间监控 + + + +### 1. App 启动时间的监控 + +应用启动时间是影响用户体验的重要因素之一,所以我们需要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。 + +![App 启动时间](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-03-30-APMAppLaunch.png) + +冷启动:App 尚未运行,必须加载并构建整个应用。完成应用的初始化。冷启动存在较大优化空间。冷启动时间从 `application: didFinishLaunchingWithOptions:` 方法开始计算,App 一般在这里进行各种 SDK 和 App 的基础初始化工作。 + +热启动:应用已经在后台运行(常见场景:比如用户使用 App 过程中点击 Home 键,再打开 App),由于某些事件将应用唤醒到前台,App 会在 `applicationWillEnterForeground:` 方法接受应用进入前台的事件 + + + +思路比较简单。如下 + +- 在监控类的 `load` 方法中先拿到当前的时间值 +- 监听 App 启动完成后的通知 `UIApplicationDidFinishLaunchingNotification` +- 收到通知后拿到当前的时间 +- 步骤1和3的时间差就是 App 启动时间。 + +`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后2个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 + +```Objective-c +mach_timebase_info_data_t g_apmmStartupMonitorTimebaseInfoData = 0; +mach_timebase_info(&g_apmmStartupMonitorTimebaseInfoData); +uint64_t timelapse = mach_absolute_time() - g_apmmLoadTime; +double timeSpan = (timelapse * g_apmmStartupMonitorTimebaseInfoData.numer) / (g_apmmStartupMonitorTimebaseInfoData.denom * 1e9); +``` + + + +### 2. 线上监控启动时间就好,但是在开发阶段需要对启动时间做优化。 + +要优化启动时间,就先得知道在启动阶段到底做了什么事情,针对现状作出方案。 + +pre-main 阶段定义为 App 开始启动到系统调用 main 函数这个阶段;main 阶段定义为 main 函数入口到主 UI 框架的 viewDidAppear。 + +App 启动过程: +- 解析 Info.plist:加载相关信息例如闪屏;沙盒建立、权限检查; +- Mach-O 加载:如果是胖二进制文件,寻找合适当前 CPU 架构的部分;加载所有依赖的 Mach-O 文件(递归调用 Mach-O 加载的方法);定义内部、外部指针引用,例如字符串、函数等;加载分类中的方法;c++ 静态对象加载、调用 Objc 的 `+load()` 函数;执行声明为 __attribute_((constructor)) 的 c 函数; +- 程序执行:调用 main();调用 UIApplicationMain();调用 applicationWillFinishLaunching(); + +Pre-Main 阶段 +![Pre-Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-PreMain.png) + +Main 阶段 +![Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-Main.png) + + +#### 2.1 加载 Dylib + +每个动态库的加载,dyld 需要 +- 分析所依赖的动态库 +- 找到动态库的 Mach-O 文件 +- 打开文件 +- 验证文件 +- 在系统核心注册文件签名 +- 对动态库的每一个 segment 调用 mmap() + +优化: +- 减少非系统库的依赖 +- 使用静态库而不是动态库 +- 合并非系统动态库为一个动态库 + + + +#### 2.2 Rebase && Binding + +优化: +- 减少 Objc 类数量,减少 selector 数量,把未使用的类和函数都可以删掉 +- 减少 c++ 虚函数数量 +- 转而使用 Swift struct(本质就是减少符号的数量) + + + +#### 2.3 Initializers + +优化: +- 使用 `+initialize` 代替 `+load` +- 不要使用过 attribute*((constructor)) 将方法显示标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用时才初始化,推迟了一部分工作耗时也尽量不要使用 c++ 的静态对象 + + +#### 2.4 pre-main 阶段影响因素 + +- 动态库加载越多,启动越慢。 +- ObjC 类越多,函数越多,启动越慢。 +- 可执行文件越大启动越慢。 +- C 的 constructor 函数越多,启动越慢。 +- C++ 静态对象越多,启动越慢。 +- ObjC 的 +load 越多,启动越慢。 + +优化手段: +- 减少依赖不必要的库,不管是动态库还是静态库;如果可以的话,把动态库改造成静态库;如果必须依赖动态库,则把多个非系统的动态库合并成一个动态库 +- 检查下 framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查 +- 合并或者删减一些OC类和函数。关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类(也可以用根据linkmap文件来分析,但是准确度不算很高) +有一个叫做[FUI](https://github.com/dblock/fui)的开源项目能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了C++的类模板 +- 删减一些无用的静态变量 +- 删减没有被调用到或者已经废弃的方法 +- 将不必须在 +load 方法中做的事情延迟到 +initialize中,尽量不要用 C++ 虚函数(创建虚函数表有开销) +- 类和方法名不要太长:iOS每个类和方法名都在 __cstring 段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的 +因还是 Object-c 的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,Object-c 对象模型会把类/方法名字符串都保存下来; +- 用 dispatch_once() 代替所有的 attribute((constructor)) 函数、C++ 静态对象初始化、ObjC 的 +load 函数; +- 在设计师可接受的范围内压缩图片的大小,会有意外收获。 +压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的, +图片小了,IO操作量就小了,启动当然就会快了,比较靠谱的压缩算法是 TinyPNG。 + + +#### 2.5 main 阶段优化 + +- 减少启动初始化的流程。能懒加载就懒加载,能放后台初始化就放后台初始化,能延迟初始化的就延迟初始化,不要卡主线程的启动时间,已经下线的业务代码直接删除 +- 优化代码逻辑。去除一些非必要的逻辑和代码,减小每个流程所消耗的时间 +- 启动阶段使用多线程来进行初始化,把 CPU 性能发挥最大 +- 使用纯代码而不是 xib 或者 storyboard 来描述 UI,尤其是主 UI 框架,比如 TabBarController。因为 xib 和 storyboard 还是需要解析成代码来渲染页面,多了一步。 + + + +### 3. 启动时间加速 + +内存缺页异常?在使用中,访问虚拟内存的一个 page 而对应的物理内存缺不存在(没有被加载到物理内存中),则发生缺页异常。影响耗时,在几毫秒之内。 + +什么时候发生大量的缺页异常?一个应用程序刚启动的时候。 + + + +启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。 + +二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。 + + + +一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。 + +其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以hook 肯定不行、fishhook 也不行,用 clang 插桩可以满足需求。 + + + + + + +## 三、 CPU 使用率监控 + + + +### 1. CPU 架构 + +CPU(Central Processing Unit)中央处理器,市场上主流的架构有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。区别在于**不同的 CPU 设计理念和方法**。 + +早期 CPU 全部是 CISC 架构,设计目的是**用最少的机器语言指令来完成所需的计算任务**。比如对于乘法运算,在 CISC 架构的 CPU 上。一条指令 `MUL ADDRA, ADDRB` 就可以将内存 ADDRA 和内存 ADDRB 中的数香乘,并将结果存储在 ADDRA 中。做的事情就是:将 ADDRA、ADDRB 中的数据读入到寄存器,相乘的结果写入到内存的操作依赖于 CPU 设计,所以 **CISC 架构会增加 CPU 的复杂性和对 CPU 工艺的要求。** + +RISC 架构要求软件来指定各个操作步骤。比如上面的乘法,指令实现为 `MOVE A, ADDRA; MOVE B, ADDRB; MUL A, B; STR ADDRA, A;`。这种架构可以降低 CPU 的复杂性以及允许在同样的工艺水平下生产出功能更加强大的 CPU,但是对于编译器的设计要求更高。 + +目前市场是大部分的 iPhone 都是基于 arm64 架构的。且 arm 架构能耗低。 + + + +### 2. 获取线程信息 + +讲完了区别来讲下如何做 CPU 使用率的监控 +- 开启定时器,按照设定的周期不断执行下面的逻辑 +- 获取当前任务 task。从当前 task 中获取所有的线程信息(线程个数、线程数组) +- 遍历所有的线程信息,判断是否有线程的 CPU 使用率超过设置的阈值 +- 假如有线程使用率超过阈值,则 dump 堆栈 +- 组装数据,上报数据 + +线程信息结构体 +```Objective-c +struct thread_basic_info { + time_value_t user_time; /* user run time(用户运行时长) */ + time_value_t system_time; /* system run time(系统运行时长) */ + integer_t cpu_usage; /* scaled cpu usage percentage(CPU使用率,上限1000) */ + policy_t policy; /* scheduling policy in effect(有效调度策略) */ + integer_t run_state; /* run state (运行状态,见下) */ + integer_t flags; /* various flags (各种各样的标记) */ + integer_t suspend_count; /* suspend count for thread(线程挂起次数) */ + integer_t sleep_time; /* number of seconds that thread + * has been sleeping(休眠时间) */ +}; +``` + +代码在讲堆栈还原的时候讲过,忘记的看一下上面的分析 +```Objective-C +thread_act_array_t threads; +mach_msg_type_number_t threadCount = 0; +const task_t thisTask = mach_task_self(); +kern_return_t kr = task_threads(thisTask, &threads, &threadCount); +if (kr != KERN_SUCCESS) { + return ; +} +for (int i = 0; i < threadCount; i++) { + thread_info_data_t threadInfo; + thread_basic_info_t threadBaseInfo; + mach_msg_type_number_t threadInfoCount; + + kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount); + + if (kr == KERN_SUCCESS) { + + threadBaseInfo = (thread_basic_info_t)threadInfo; + // todo:条件判断,看不明白 + if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) { + integer_t cpuUsage = threadBaseInfo->cpu_usage / 10; + if (cpuUsage > CPUMONITORRATE) { + + NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary]; + NSData *CPUPayloadData = [NSData data]; + + NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread]; + // 1. 组装卡顿的 Meta 信息 + CPUMetaDictionary[@"MONITOR_TYPE"] = APMMonitorCPUType; + + // 2. 组装卡顿的 Payload 信息(一个JSON对象,对象的 Key 为约定好的 STACK_TRACE, value 为 base64 后的堆栈信息) + NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding]; + NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0]; + NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)}; + + NSError *error; + // NSJSONWritingOptions 参数一定要传0,因为服务端需要根据 \n 处理逻辑,传递 0 则生成的 json 串不带 \n + NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error]; + if (error) { + APMMLog(@"%@", error); + return; + } + CPUPayloadData = [parsedData copy]; + + // 3. 数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + [[HermesClient sharedInstance] sendWithType:APMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; + } + } + } +} +``` + + + + +## 四、 OOM 问题 + +### 1. 基础知识准备 + +硬盘:也叫做磁盘,用于存储数据。你存储的歌曲、图片、视频都是在硬盘里。 + +内存:由于硬盘读取速度较慢,如果 CPU 运行程序期间,所有的数据都直接从硬盘中读取,则非常影响效率。所以 CPU 会将程序运行所需要的数据从硬盘中读取到内存中。然后 CPU 与内存中的数据进行计算、交换。内存是易失性存储器(断电后,数据消失)。内存条区是计算机内部(在主板上)的一些存储器,用来保存 CPU 运算的中间数据和结果。内存是程序与 CPU 之间的桥梁。从硬盘读取出数据或者运行程序提供给 CPU。 + +**虚拟内存** 是计算机系统内存管理的一种技术。它使得程序认为它拥有连续的可用内存,而实际上,它通常被分割成多个物理内存碎片,可能部分暂时存储在外部磁盘(硬盘)存储器上(当需要使用时则用硬盘中数据交换到内存中)。Windows 系统中称为 “虚拟内存”,Linux/Unix 系统中称为 ”交换空间“。 + +iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手机系统都不支持。因为移动设备的大量存储器是**闪存**,它的读写速度远远小电脑所使用的硬盘,也就是说手机即使使用了**交换空间**技术,也因为闪存慢的问题,不能提升性能,所以索性就没有交换空间技术。 + + + +### 2. iOS 内存知识 + +内存(RAM)与 CPU 一样都是系统中最稀少的资源,也很容易发生竞争,应用内存与性能直接相关。iOS 没有交换空间作为备选资源,所以内存资源尤为重要。 + +什么是 OOM?是 out-of-memory 的缩写,字面意思是超过了内存限制。分为 FOOM(Foreground Out Of Memory,应用在前台运行的过程中崩溃。用户在使用的过程中产生的,这样的崩溃会使得活跃用户流失,业务上是非常不愿意看到的)和 BOOM(Background Out Of Memory,应用在后台运行的过程崩溃)。它是由 iOS 的 `Jetsam` 机制造成的一种非主流 Crash,它不能通过 Signal 这种监控方案所捕获。 + +什么是 Jetsam 机制?Jetsam 机制可以理解为系统为了控制内存资源过度使用而采用的一种管理机制。Jetsam 机制是运行在一个独立的进程中,每个进程都有一个内存阈值,一旦超过这个内存阈值,Jetsam 会立即杀掉这个进程。 + +为什么设计 Jetsam 机制?因为设备的内存是有限的,所以内存资源非常重要。系统进程以及其他使用的 App 都会抢占这个资源。由于 iOS 不支持交换空间,一旦触发低内存事件,Jetsam 就会尽可能多的释放 App 所在内存,这样 iOS 系统上出现内存不足时,App 就会被系统杀掉,变现为 crash。 + +2种情况触发 OOM:系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "**highg water mark**" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。 + +读了源码(xnu/bsd/kern/kern_memorystatus.c)会发现内存被杀也有2种机制,如下 + +highwater 处理 -> 我们的 App 占用内存不能超过单个限制 + +1. 从优先级列表里循环寻找线程 +2. 判断是否满足 p_memstat_memlimit 的限制条件 +3. DiagonoseActive、FREEZE 过滤 +4. 杀进程,成功则 exit,否则循环 + +memorystatus_act_aggressive 处理 -> 内存占用高,按照优先级杀死 + +1. 根据 policy 家在 jld_bucket_count,用来判断是否被杀 +2. 从 JETSAM_PRIORITY_ELEVATED_INACTIVE 开始杀 +3. Old_bucket_count 和 memorystatus_jld_eval_period_msecs 判断是否开杀 +4. 根据优先级从低到高开始杀,直到 memorystatus_avail_pages_below_pressure + + +内存过大的几种情况 + +- App 内存消耗较低,同时其他 App 内存管理也很棒,那么即使切换到其他 App,我们自己的 App 依旧是“活着”的,保留了用户状态。体验好 +- App 内存消耗较低,但其他 App 内存消耗太大(可能是内存管理糟糕,也可能是本身就耗费资源,比如游戏),那么除了在前台的线程,其他 App 都会被系统杀死,回收内存资源,用来给活跃的进程提供内存。 +- App 内存消耗较大,切换到其他 App 后,即使其他 App 向系统申请的内存不大,系统也会因为内存资源紧张,优先把内存消耗大的 App 杀死。表现为用户将 App 退出到后台,过会儿再次打开会发现 App 重新加载启动。 +- App 内存消耗非常大,在前台运行时就被系统杀死,造成闪退。 + +App 内存不足时,系统会按照一定策略来腾出更多的空间供使用。比较常见的做法是将一部分优先级低的数据挪到磁盘上,该操作为称为 **page out**。之后再次访问这块数据的时候,系统会负责将它重新搬回到内存中,该操作被称为 **page in**。 + + + +Memory page** 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。通常它是 16KB 大小,且有3种类型的 page。 + +![内存page种类](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryType.png) + + +- Clean Memory + Clean memory 包括3类:可以 `page out` 的内存、内存映射文件、App 使用到的 framework(每个 framework 都有 _DATA_CONST 段,通常都是 clean 状态,但使用 runtime swizling,那么变为 dirty)。 + + 一开始分配的 page 都是干净的(堆里面的对象分配除外),我们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。 + + ![Clean memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeClean.png) + +- Dirty Memory + + Dirty memory 包括4类:被 App 写入过数据的内存、所有堆区分配的对象、图像解码缓冲区、framework(framework 都有 _DATA 段和 _DATA_DIRTY 段,它们的内存都是 dirty)。 + + 在使用 framework 的过程中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减少 Dirty memory(因为单例一旦创建就不销毁,一直在内存中,系统不认为是 Dirty memory)。 + + ![Dirty memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeDirty.png) + +- Compressed Memory + + 由于闪存容量和读写限制,iOS 没有交换空间机制,而是在 iOS7 引入了 **memory compressor**。它是在内存紧张时候能够将最近一段时间未使用过的内存对象,内存压缩器会把对象压缩,释放出更多的 page。在需要时内存压缩器对其解压复用。在节省内存的同时提高了响应速度。 + + 比如 App 使用某 Framework,内部有个 NSDictionary 属性存储数据,使用了 3 pages 内存,在近期未被访问的时候 memory compressor 将其压缩为 1 page,再次使用的时候还原为 3 pages。 + +App 运行内存 = pageNumbers * pageSize。因为 Compressed Memory 属于 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize + +设备不同,内存占用上限不同,App 上限较高,extension 上限较低,超过上限 crash 到 `EXC_RESOURCE_EXCEPTION`。 +![Memory footprint](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryFootprint.png) + +接下来谈一下如何获取内存上限,以及如何监控 App 因为占用内存过大而被强杀。 + + + +### 3. 获取内存信息 + +#### 3.1 通过 JetsamEvent 日志计算内存限制值 + +当 App 被 Jetsam 机制杀死时,手机会生成系统日志。查看路径:Settings-Privacy-Analytics & Improvements- Analytics Data(设置-隐私- 分析与改进-分析数据),可以看到 `JetsamEvent-2020-03-14-161828.ips` 形式的日志,以 JetsamEvent 开头。这些 JetsamEvent 日志都是 iOS 系统内核强杀掉那些优先级不高(idle、frontmost、suspended)且占用内存超过系统内存限制的 App 留下的。 + +日志包含了 App 的内存信息。可以查看到 日志最顶部有 `pageSize` 字段,查找到 per-process-limit,该节点所在结构里的 `rpages` ,将 rpages * pageSize 即可得到 OOM 的阈值。 + +日志中 largestProcess 字段代表 App 名称;reason 字段代表内存原因;states 字段代表奔溃时 App 的状态( idle、suspended、frontmost...)。 + +为了测试数据的准确性,我将测试2台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码如下 + +```objective-c +- (void)viewDidLoad { + [super viewDidLoad]; + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIImage *image = [UIImage imageNamed:@"AppIcon"]; + imageView.image = image; + [array addObject:imageView]; + } +} +``` + +iPhone 6s plus/13.3.1 数据如下: + +```shell +{"bug_type":"298","timestamp":"2020-03-19 17:23:45.94 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"DA8AF66D-24E8-458C-8734-981866942168"} +{ + "crashReporterKey" : "fc9b659ce486df1ed1b8062d5c7c977a7eb8c851", + "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:10:44 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_S8000", + "product" : "iPhone8,2", + "incident" : "DA8AF66D-24E8-458C-8734-981866942168", + "date" : "2020-03-19 17:23:45.93 +0800", + "build" : "iPhone OS 13.3.1 (17D50)", + "timeDelta" : 332, + "memoryStatus" : { + "compressorSize" : 48499, + "compressions" : 7458651, + "decompressions" : 5190200, + "zoneMapCap" : 744407040, + "largestZone" : "APFS_4K_OBJS", + "largestZoneSize" : 41402368, + "pageSize" : 16384, + "uncompressed" : 104065, + "zoneMapSize" : 141606912, + "memoryPages" : { + "active" : 26214, + "throttled" : 0, + "fileBacked" : 14903, + "wired" : 20019, + "anonymous" : 37140, + "purgeable" : 142, + "inactive" : 23669, + "free" : 2967, + "speculative" : 2160 + } +}, + "largestProcess" : "Test", + "genCounter" : 0, + "processes" : [ + { + "uuid" : "39c5738b-b321-3865-a731-68064c4f7a6f", + "states" : [ + "daemon", + "idle" + ], + "lifetimeMax" : 188, + "age" : 948223699030, + "purgeable" : 0, + "fds" : 25, + "coalition" : 422, + "rpages" : 177, + "pid" : 282, + "idleDelta" : 824711280, + "name" : "com.apple.Safari.SafeBrowsing.Se", + "cpuTime" : 10.275422000000001 + }, + // ... + { + "uuid" : "83dbf121-7c0c-3ab5-9b66-77ee926e1561", + "states" : [ + "frontmost" + ], + "killDelta" : 2592, + "genCount" : 0, + "age" : 1531004794, + "purgeable" : 0, + "fds" : 50, + "coalition" : 1047, + "rpages" : 92806, + "reason" : "per-process-limit", + "pid" : 2384, + "cpuTime" : 59.464373999999999, + "name" : "Test", + "lifetimeMax" : 92806 + }, + // ... + ] +} +``` + +iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450.09375M + + + +iPhone 11 Pro/13.3.1 数据如下: + +```shell +{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"} +{ + "crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276", + "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030", + "product" : "iPhone12,3", + "incident" : "7F111601-BC7A-4BD7-A468-CE3370053057", + "date" : "2020-03-19 17:30:28.39 +0800", + "build" : "iPhone OS 13.3.1 (17D50)", + "timeDelta" : 189, + "memoryStatus" : { + "compressorSize" : 66443, + "compressions" : 25498129, + "decompressions" : 15532621, + "zoneMapCap" : 1395015680, + "largestZone" : "APFS_4K_OBJS", + "largestZoneSize" : 41222144, + "pageSize" : 16384, + "uncompressed" : 127027, + "zoneMapSize" : 169639936, + "memoryPages" : { + "active" : 58652, + "throttled" : 0, + "fileBacked" : 20291, + "wired" : 45838, + "anonymous" : 96445, + "purgeable" : 4, + "inactive" : 54368, + "free" : 5461, + "speculative" : 3716 + } +}, + "largestProcess" : "杭城小刘", + "genCounter" : 0, + "processes" : [ + { + "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7", + "states" : [ + "daemon", + "idle" + ], + "lifetimeMax" : 171, + "age" : 5151034269954, + "purgeable" : 0, + "fds" : 50, + "coalition" : 66, + "rpages" : 164, + "pid" : 11276, + "idleDelta" : 3801132318, + "name" : "wcd", + "cpuTime" : 3.430787 + }, + // ... + { + "uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0", + "states" : [ + "frontmost" + ], + "killDelta" : 4345, + "genCount" : 0, + "age" : 654480778, + "purgeable" : 0, + "fds" : 50, + "coalition" : 1718, + "rpages" : 134278, + "reason" : "per-process-limit", + "pid" : 14206, + "cpuTime" : 23.955463999999999, + "name" : "杭城小刘", + "lifetimeMax" : 134278 + }, + // ... + ] +} +``` + +iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384\*134278)/(1024*1024)=2098.09375M + + + +**iOS 系统如何发现 Jetsam ?** + +MacOS/iOS 是一个 BSD 衍生而来的系统,其内核是 Mach,但是对于上层暴露的接口一般是基于 BSD 层对 Mach 的包装后的。Mach 是一个微内核的架构,真正的虚拟内存管理也是在其中进行的,BSD 对内存管理提供了上层接口。Jetsam 事件也是由 BSD 产生的。`bsd_init` 函数是入口,其中基本都是在初始化各个子系统,比如虚拟内存管理等。 + +```c++ +// 1. Initialize the kernel memory allocator, 初始化 BSD 内存 Zone,这个 Zone 是基于 Mach 内核的zone 构建 +kmeminit(); + +// 2. Initialise background freezing, iOS 上独有的特性,内存和进程的休眠的常驻监控线程 +#if CONFIG_FREEZE +#ifndef CONFIG_MEMORYSTATUS + #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS" +#endif + /* Initialise background freezing */ + bsd_init_kprintf("calling memorystatus_freeze_init\n"); + memorystatus_freeze_init(); +#endif> + +// 3. iOS 独有,JetSAM(即低内存事件的常驻监控线程) +#if CONFIG_MEMORYSTATUS + /* Initialize kernel memory status notifications */ + bsd_init_kprintf("calling memorystatus_init\n"); + memorystatus_init(); +#endif /* CONFIG_MEMORYSTATUS */ +``` + +**主要作用就是开启了2个优先级最高的线程,来监控整个系统的内存情况。** + + + +CONFIG_FREEZE 开启时,内核对进程进行冷冻而不是杀死。冷冻功能是由内核中启动一个 `memorystatus_freeze_thread` 进行,这个进程在收到信号后调用 `memorystatus_freeze_top_process` 进行冷冻。 + +iOS 系统会开启优先级最高的线程 `vm_pressure_monitor` 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 进程。iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。有关 Jetsam 也就是 memorystatus 相关的逻辑,可以在 XNU 项目中的 **kern_memorystatus.h** 和 **kern_memorystatus.c **源码中查看。 + +iOS 系统因内存占用过高会强杀 App 前,至少有 6秒钟可以用来做优先级判断,JetsamEvent 日志也是在这6秒内生成的。 + +上文提到了 iOS 系统没有交换空间,于是引入了 **MemoryStatus 机制(也称为 Jetsam)**。也就是说在 iOS 系统上释放尽可能多的内存供当前 App 使用。这个机制表现在优先级上,就是先强杀后台应用;如果内存还是不够多,就强杀掉当前应用。在 MacOS 中,MemoryStatus 只会强杀掉标记为空闲退出的进程。 + +MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 的线程,它负责强杀 App 和记录日志,不会发送消息,所以内存压力检测线程无法获取到强杀 App 的消息。 + +当监控线程发现某 App 有内存压力时,就发出通知,此时有内存的 App 就去执行 `didReceiveMemoryWarning` 代理方法。在这个时机,我们还有机会做一些内存资源释放的逻辑,也许会避免 App 被系统杀死。 + + + +**源码角度查看问题** + +iOS 系统内核有一个数组,专门维护线程的优先级。数组的每一项是一个包含进程链表的结构体。结构体如下: + +```objective-c +#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1) + +typedef struct memstat_bucket { + TAILQ_HEAD(, proc) list; + int count; +} memstat_bucket_t; + +memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT]; +``` + +在 kern_memorystatus.h 中可以看到进行优先级信息 + +```objective-c +#define JETSAM_PRIORITY_IDLE_HEAD -2 +/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */ +#define JETSAM_PRIORITY_IDLE 0 +#define JETSAM_PRIORITY_IDLE_DEFERRED 1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/ +#define JETSAM_PRIORITY_AGING_BAND1 JETSAM_PRIORITY_IDLE_DEFERRED +#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC 2 +#define JETSAM_PRIORITY_AGING_BAND2 JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC +#define JETSAM_PRIORITY_BACKGROUND 3 +#define JETSAM_PRIORITY_ELEVATED_INACTIVE JETSAM_PRIORITY_BACKGROUND +#define JETSAM_PRIORITY_MAIL 4 +#define JETSAM_PRIORITY_PHONE 5 +#define JETSAM_PRIORITY_UI_SUPPORT 8 +#define JETSAM_PRIORITY_FOREGROUND_SUPPORT 9 +#define JETSAM_PRIORITY_FOREGROUND 10 +#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY 12 +#define JETSAM_PRIORITY_CONDUCTOR 13 +#define JETSAM_PRIORITY_HOME 16 +#define JETSAM_PRIORITY_EXECUTIVE 17 +#define JETSAM_PRIORITY_IMPORTANT 18 +#define JETSAM_PRIORITY_CRITICAL 19 + +#define JETSAM_PRIORITY_MAX 21 +``` + +可以明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为10。 + +优先级规则是:内核线程优先级 > 操作系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被降低。 + +在 kern_memorystatus.c 中可以看到 OOM 可能的原因: + +```shell +/* For logging clarity */ +static const char *memorystatus_kill_cause_name[] = { + "" , /* kMemorystatusInvalid */ + "jettisoned" , /* kMemorystatusKilled */ + "highwater" , /* kMemorystatusKilledHiwat */ + "vnode-limit" , /* kMemorystatusKilledVnodes */ + "vm-pageshortage" , /* kMemorystatusKilledVMPageShortage */ + "proc-thrashing" , /* kMemorystatusKilledProcThrashing */ + "fc-thrashing" , /* kMemorystatusKilledFCThrashing */ + "per-process-limit" , /* kMemorystatusKilledPerProcessLimit */ + "disk-space-shortage" , /* kMemorystatusKilledDiskSpaceShortage */ + "idle-exit" , /* kMemorystatusKilledIdleExit */ + "zone-map-exhaustion" , /* kMemorystatusKilledZoneMapExhaustion */ + "vm-compressor-thrashing" , /* kMemorystatusKilledVMCompressorThrashing */ + "vm-compressor-space-shortage" , /* kMemorystatusKilledVMCompressorSpaceShortage */ +}; +``` + +查看 memorystatus_init 这个函数中初始化 Jetsam 线程的关键代码 + +```c++ +__private_extern__ void +memorystatus_init(void) +{ + // ... + /* Initialize the jetsam_threads state array */ + jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads); + + /* Initialize all the jetsam threads */ + for (i = 0; i < max_jetsam_threads; i++) { + + result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread); + if (result == KERN_SUCCESS) { + jetsam_threads[i].inited = FALSE; + jetsam_threads[i].index = i; + thread_deallocate(jetsam_threads[i].thread); + } else { + panic("Could not create memorystatus_thread %d", i); + } + } +} +``` + +```shell +/* + * High-level priority assignments + * + ************************************************************************* + * 127 Reserved (real-time) + * A + * + + * (32 levels) + * + + * V + * 96 Reserved (real-time) + * 95 Kernel mode only + * A + * + + * (16 levels) + * + + * V + * 80 Kernel mode only + * 79 System high priority + * A + * + + * (16 levels) + * + + * V + * 64 System high priority + * 63 Elevated priorities + * A + * + + * (12 levels) + * + + * V + * 52 Elevated priorities + * 51 Elevated priorities (incl. BSD +nice) + * A + * + + * (20 levels) + * + + * V + * 32 Elevated priorities (incl. BSD +nice) + * 31 Default (default base for threads) + * 30 Lowered priorities (incl. BSD -nice) + * A + * + + * (20 levels) + * + + * V + * 11 Lowered priorities (incl. BSD -nice) + * 10 Lowered priorities (aged pri's) + * A + * + + * (11 levels) + * + + * V + * 0 Lowered priorities (aged pri's / idle) + ************************************************************************* + */ +``` + +可以看出:用户态的应用程序的线程不可能高于操作系统和内核。而且,用户态的应用程序间的线程优先级分配也有区别,比如处于前台的应用程序优先级高于处于后台的应用程序优先级。iOS 上应用程序优先级最高的是 SpringBoard;此外线程的优先级不是一成不变的。Mach 会根据线程的利用率和系统整体负载动态调整线程优先级。如果耗费 CPU 太多就降低线程优先级,如果线程过度挨饿,则会提升线程优先级。但是无论怎么变,程序都不能超过其所在线程的优先级区间范围。 + + + + + +可以看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(一般情况为1,特殊情况下可能为3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0~127。上文的宏定义是进程优先级,区间为:-2~19)。 + + + +紧接着,分析下 memorystatus_thread 函数,主要负责线程启动的初始化 + +```c++ +static void +memorystatus_thread(void *param __unused, wait_result_t wr __unused) +{ + //... + while (memorystatus_action_needed()) { + boolean_t killed; + int32_t priority; + uint32_t cause; + uint64_t jetsam_reason_code = JETSAM_REASON_INVALID; + os_reason_t jetsam_reason = OS_REASON_NULL; + + cause = kill_under_pressure_cause; + switch (cause) { + case kMemorystatusKilledFCThrashing: + jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING; + break; + case kMemorystatusKilledVMCompressorThrashing: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING; + break; + case kMemorystatusKilledVMCompressorSpaceShortage: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE; + break; + case kMemorystatusKilledZoneMapExhaustion: + jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION; + break; + case kMemorystatusKilledVMPageShortage: + /* falls through */ + default: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE; + cause = kMemorystatusKilledVMPageShortage; + break; + } + + /* Highwater */ + boolean_t is_critical = TRUE; + if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) { + if (is_critical == FALSE) { + /* + * For now, don't kill any other processes. + */ + break; + } else { + goto done; + } + } + + jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code); + if (jetsam_reason == OS_REASON_NULL) { + printf("memorystatus_thread: failed to allocate jetsam reason\n"); + } + + if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) { + goto done; + } + + /* + * memorystatus_kill_top_process() drops a reference, + * so take another one so we can continue to use this exit reason + * even after it returns + */ + os_reason_ref(jetsam_reason); + + /* LRU */ + killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors); + sort_flag = FALSE; + + if (killed) { + if (memorystatus_post_snapshot(priority, cause) == TRUE) { + + post_snapshot = TRUE; + } + + /* Jetsam Loop Detection */ + if (memorystatus_jld_enabled == TRUE) { + if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) { + jld_idle_kills++; + } else { + /* + * We've reached into bands beyond idle deferred. + * We make no attempt to monitor them + */ + } + } + + if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) { + /* + * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT + * then we attempt to relieve pressure by purging corpse memory. + */ + task_purge_all_corpses(); + corpse_list_purged = TRUE; + } + goto done; + } + + if (memorystatus_avail_pages_below_critical()) { + /* + * Still under pressure and unable to kill a process - purge corpse memory + */ + if (total_corpses_count() > 0) { + task_purge_all_corpses(); + corpse_list_purged = TRUE; + } + + if (memorystatus_avail_pages_below_critical()) { + /* + * Still under pressure and unable to kill a process - panic + */ + panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages); + } + } + +done: + +} +``` + +可以看到它开启了一个 循环,memorystatus_action_needed() 来作为循环条件,持续释放内存。 + +```c++ +static boolean_t +memorystatus_action_needed(void) +{ +#if CONFIG_EMBEDDED + return (is_reason_thrashing(kill_under_pressure_cause) || + is_reason_zone_map_exhaustion(kill_under_pressure_cause) || + memorystatus_available_pages <= memorystatus_available_pages_pressure); +#else /* CONFIG_EMBEDDED */ + return (is_reason_thrashing(kill_under_pressure_cause) || + is_reason_zone_map_exhaustion(kill_under_pressure_cause)); +#endif /* CONFIG_EMBEDDED */ +} +``` + +它通过 vm_pagepout 发送的内存压力来判断当前内存资源是否紧张。几种情况:频繁的页面换出换进 is_reason_thrashing, Mach Zone 耗尽了 is_reason_zone_map_exhaustion、以及可用的页低于了 memory status_available_pages 这个门槛。 + +继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,如果进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。 + +通常来说单个 App 很难触碰到 high water mark,如果不能结束任何进程,最终走到 memorystatus_act_aggressive,也就是大多数 OOM 发生的地方。 + +```c++ +static boolean_t +memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) +{ + // ... + if ( (jld_bucket_count == 0) || + (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) { + + /* + * Refresh evaluation parameters + */ + jld_timestamp_msecs = jld_now_msecs; + jld_idle_kill_candidates = jld_bucket_count; + *jld_idle_kills = 0; + jld_eval_aggressive_count = 0; + jld_priority_band_max = JETSAM_PRIORITY_UI_SUPPORT; + } + //... +} +``` + +上述代码看到,判断要不要真正执行 kill 是根据一定的时间间判断的,条件是 `jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs`。 也就是在 memorystatus_jld_eval_period_msecs 后才发生条件里面的 kill。 + +```C +/* Jetsam Loop Detection */ +if (max_mem <= (512 * 1024 * 1024)) { + /* 512 MB devices */ +memorystatus_jld_eval_period_msecs = 8000; /* 8000 msecs == 8 second window */ +} else { + /* 1GB and larger devices */ +memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ +} +``` + +其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我们可以在6秒内做些处理。 + + + +#### 3.2 开发者们整理所得 + +[stackoverflow](https://stackoverflow.com/questions/5887248/ios-app-maximum-memory-budget/15200855#15200855) 上有一份数据,整理了各种设备的 OOM 临界值 + +| device | crash amount:MB | total amount:MB | percentage of total | +| :--------------------------------: | :-------------: | :-------------: | :-----------------: | +| iPad1 | 127 | 256 | 49% | +| iPad2 | 275 | 512 | 53% | +| iPad3 | 645 | 1024 | 62% | +| iPad4(iOS 8.1) | 585 | 1024 | 57% | +| Pad Mini 1st Generation | 297 | 512 | 58% | +| iPad Mini retina(iOS 7.1) | 696 | 1024 | 68% | +| iPad Air | 697 | 1024 | 68% | +| iPad Air 2(iOS 10.2.1) | 1383 | 2048 | 68% | +| iPad Pro 9.7"(iOS 10.0.2 (14A456)) | 1395 | 1971 | 71% | +| iPad Pro 10.5”(iOS 11 beta4) | 3057 | 4000 | 76% | +| iPad Pro 12.9” (2015)(iOS 11.2.1) | 3058 | 3999 | 76% | +| iPad 10.2(iOS 13.2.3) | 1844 | 2998 | 62% | +| iPod touch 4th gen(iOS 6.1.1) | 130 | 256 | 51% | +| iPod touch 5th gen | 286 | 512 | 56% | +| iPhone4 | 325 | 512 | 63% | +| iPhone4s | 286 | 512 | 56% | +| iPhone5 | 645 | 1024 | 62% | +| iPhone5s | 646 | 1024 | 63% | +| iPhone6(iOS 8.x) | 645 | 1024 | 62% | +| iPhone6 Plus(iOS 8.x) | 645 | 1024 | 62% | +| iPhone6s(iOS 9.2) | 1396 | 2048 | 68% | +| iPhone6s Plus(iOS 10.2.1) | 1396 | 2048 | 68% | +| iPhoneSE(iOS 9.3) | 1395 | 2048 | 68% | +| iPhone7(iOS 10.2) | 1395 | 2048 | 68% | +| iPhone7 Plus(iOS 10.2.1) | 2040 | 3072 | 66% | +| iPhone8(iOS 12.1) | 1364 | 1990 | 70% | +| iPhoneX(iOS 11.2.1) | 1392 | 2785 | 50% | +| iPhoneXS(iOS 12.1) | 2040 | 3754 | 54% | +| iPhoneXS Max(iOS 12.1) | 2039 | 3735 | 55% | +| iPhoneXR(iOS 12.1) | 1792 | 2813 | 63% | +| iPhone11(iOS 13.1.3) | 2068 | 3844 | 54% | +| iPhone11 Pro Max(iOS 13.2.3) | 2067 | 3740 | 55% | + + + +#### 3.3 触发当前 App 的 high water mark + +我们可以写定时器,不断的申请内存,之后再通过 `phys_footprint` 打印当前占用内存,按道理来说不断申请内存即可触发 Jetsam 机制,强杀 App,那么**最后一次打印的内存占用也就是当前设备的内存上限值**。 + +```objective-c +timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES]; + +- (void)allocateMemory { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIImage *image = [UIImage imageNamed:@"AppIcon"]; + imageView.image = image; + [array addObject:imageView]; + + memoryLimitSizeMB = [self usedSizeOfMemory]; + if (memoryWarningSizeMB && memoryLimitSizeMB) { + NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB); + } +} + +- (int)usedSizeOfMemory { + task_vm_info_data_t taskInfo; + mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; + kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); + + if (kernReturn != KERN_SUCCESS) { + return 0; + } + return (int)(taskInfo.phys_footprint/1024.0/1024.0); +} +``` + + + +#### 3.4 适用于 iOS13 系统的获取方式 + +iOS13 开始 中 `size_t os_proc_available_memory(void); ` 可以查看当前可用内存。 + +> Return Value +> +> The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns `0`. +> +> Discussion +> +> Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device. +> +> Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently. +> +> Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs. +> +> If you need more detailed information about the available memory resources, you can call [`task_info`](apple-reference-documentation://hcPGvbcfam). However, be aware that `task_info` is an expensive call, whereas this function is much more efficient. + +```objective-c +if (@available(iOS 13.0, *)) { + return os_proc_available_memory() / 1024.0 / 1024.0; +} +``` + + + +App 内存信息的 API 可以在 Mach 层找到,`mach_task_basic_info` 结构体存储了 Mach task 的内存使用信息,其中 phys_footprint 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。 + +```Objective-c +#define MACH_TASK_BASIC_INFO 20 /* always 64-bit basic info */ +struct mach_task_basic_info { + mach_vm_size_t virtual_size; /* virtual memory size (bytes) */ + mach_vm_size_t resident_size; /* resident memory size (bytes) */ + mach_vm_size_t resident_size_max; /* maximum resident memory size (bytes) */ + time_value_t user_time; /* total user run time for + terminated threads */ + time_value_t system_time; /* total system run time for + terminated threads */ + policy_t policy; /* default policy for new threads */ + integer_t suspend_count; /* suspend count for task */ +}; +``` + +所以获取代码为 + +```Objective-c +task_vm_info_data_t vmInfo; +mach_msg_type_number_t count = TASK_VM_INFO_COUNT; +kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count); + +if (kr != KERN_SUCCESS) { + return ; +} +CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0); +``` + +可能有人好奇不应该是 `resident_size` 这个字段获取内存的使用情况吗?一开始测试后发现 resident_size 和 Xcode 测量结果差距较大。而使用 phys_footprint 则接近于 Xcode 给出的结果。且可以从 [WebKit 源码](https://github.com/WebKit/webkit/blob/52bc6f0a96a062cb0eb76e9a81497183dc87c268/Source/WTF/wtf/cocoa/MemoryFootprintCocoa.cpp)中得到印证。 + +所以在 iOS13 上,我们可以通过 `os_proc_available_memory` 获取到当前可以用内存,通过 `phys_footprint` 获取到当前 App 占用内存,2者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。 + +```objective-c +- (CGFloat)limitSizeOfMemory { + if (@available(iOS 13.0, *)) { + task_vm_info_data_t taskInfo; + mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; + kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); + + if (kernReturn != KERN_SUCCESS) { + return 0; + } + return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0); + } + return 0; +} +``` + +当前可以使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值一样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450.09375M」。 + + + +#### 3.5 通过 XNU 获取内存限制值 + +在 XNU 中,有专门用于获取内存上限值的函数和宏,可以通过 `memorystatus_priority_entry` 这个结构体得到所有进程的优先级和内存限制值。 + +```objective-c +typedef struct memorystatus_priority_entry { + pid_t pid; + int32_t priority; + uint64_t user_data; + int32_t limit; + uint32_t state; +} memorystatus_priority_entry_t; +``` + +其中,priority 代表进程优先级,limit 代表进程的内存限制值。但是这种方式需要 root 权限,由于没有越狱设备,我没有尝试过。 + +相关代码可查阅 `kern_memorystatus.h` 文件。需要用到函数 `int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);` + +```c +/* Commands */ +#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1 +#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2 +#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3 +#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4 +#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */ +#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */ +#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */ +#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */ +#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */ +#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */ +#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE 11 /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */ +#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12 /* Disable the 'lenient' mode for aggressive jetsam. */ +#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS 13 /* Compute how much a process's phys_footprint exceeds inactive memory limit */ +#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */ +#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 15 /* Reset the inactive jetsam band for a process to the default band (0)*/ +#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED 16 /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */ +#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED 17 /* Return the 'managed' status of a process */ +#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., +``` + + + +伪代码 + +```objective-c +struct memorystatus_priority_entry memStatus[NUM_ENTRIES]; +size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES; +int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count); +if (rc < 0) { + NSLog(@"memorystatus_control"); + return ; +} + +int entry = 0; +for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){ + printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n", + memstatus[entry].pid, + memstatus[entry].priority, + memstatus[entry].user_data, + memstatus[entry].limit, + state_to_text(memstatus[entry].state)); + entry++; +} +``` + +for 循环打印出每个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为10的进程,即我们前台运行的 App。为什么是10? 因为 `#define JETSAM_PRIORITY_FOREGROUND 10` 我们的目的就是获取前台 App 的内存上限值。 + + + +### 4. 如何判定发生了 OOM + +OOM 导致 crash 前,app 一定会收到低内存警告吗? + +做2组对比实验: + +```objective-c +// 实验1 +NSMutableArray *array = [NSMutableArray array]; +for (NSInteger index = 0; index < 10000000; index++) { + NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"]; + NSData *data = [NSData dataWithContentsOfFile:filePath]; + [array addObject:data]; +} +``` + +```objective-c +// 实验2 +// ViewController.m +- (void)viewDidLoad { + [super viewDidLoad]; + dispatch_async(dispatch_get_global_queue(0, 0), ^{ + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"]; + NSData *data = [NSData dataWithContentsOfFile:filePath]; + [array addObject:data]; + } + }); +} +- (void)didReceiveMemoryWarning +{ + NSLog(@"2"); +} + +// AppDelegate.m +- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application +{ + NSLog(@"1"); +} +``` + +现象: + +1. 在 viewDidLoad 也就是主线程中内存消耗过大,系统并不会发出低内存警告,直接 Crash。因为内存增长过快,主线程很忙。 +2. 多线程的情况下,App 因内存增长过快,会收到低内存警告,AppDelegate 中的`applicationDidReceiveMemoryWarning` 先执行,随后是当前 VC 的 `didReceiveMemoryWarning`。 + +结论: + +**收到低内存警告不一定会 Crash,因为有6秒钟的系统判断时间,6秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** + + + +### 5. 内存信息收集 + +要想精确的定位问题,就需要 dump 所有对象及其内存信息。当内存接近系统内存上限的时候,收集并记录所需信息,结合一定的数据上报机制,上传到服务器,分析并修复。 + +还需要知道每个对象具体是在哪个函数里创建出来的,以便还原“案发现场”。 + +源代码(libmalloc/malloc),内存分配函数 malloc 和 calloc 等默认使用 nano_zone,nano_zone 是小于 256B 以下的内存分配,大于 256B 则使用 scalable_zone 来分配。 + +主要针对大内存的分配监控。malloc 函数用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。 + +使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,因为系统为了有个地方专门统计并管理内存分配情况。这样的设计也满足「收口原则」。 + + + +```c++ +void * +malloc(size_t size) +{ + void *retval; + retval = malloc_zone_malloc(default_zone, size); + if (retval == NULL) { + errno = ENOMEM; + } + return retval; +} + +void * +calloc(size_t num_items, size_t size) +{ + void *retval; + retval = malloc_zone_calloc(default_zone, num_items, size); + if (retval == NULL) { + errno = ENOMEM; + } + return retval; +} +``` + +首先来看看这个 `default_zone` 是什么东西, 代码如下 + +```c++ +typedef struct { + malloc_zone_t malloc_zone; + uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)]; +} virtual_default_zone_t; + +static virtual_default_zone_t virtual_default_zone +__attribute__((section("__DATA,__v_zone"))) +__attribute__((aligned(PAGE_MAX_SIZE))) = { + NULL, + NULL, + default_zone_size, + default_zone_malloc, + default_zone_calloc, + default_zone_valloc, + default_zone_free, + default_zone_realloc, + default_zone_destroy, + DEFAULT_MALLOC_ZONE_STRING, + default_zone_batch_malloc, + default_zone_batch_free, + &default_zone_introspect, + 10, + default_zone_memalign, + default_zone_free_definite_size, + default_zone_pressure_relief, + default_zone_malloc_claimed_address, +}; + +static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone; + +static void * +default_zone_malloc(malloc_zone_t *zone, size_t size) +{ + zone = runtime_default_zone(); + + return zone->malloc(zone, size); +} + + +MALLOC_ALWAYS_INLINE +static inline malloc_zone_t * +runtime_default_zone() { + return (lite_zone) ? lite_zone : inline_malloc_default_zone(); +} +``` + +可以看到 `default_zone` 通过这种方式来初始化 + +```c++ +static inline malloc_zone_t * +inline_malloc_default_zone(void) +{ + _malloc_initialize_once(); + // malloc_report(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone); + return malloc_zones[0]; +} +``` + +**随后的调用如下** +`_malloc_initialize` -> `create_scalable_zone` -> `create_scalable_szone` 最终我们创建了 szone_t 类型的对象,通过类型转换,得到了我们的 default_zone。 + +```c++ +malloc_zone_t * +create_scalable_zone(size_t initial_size, unsigned debug_flags) { + return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags); +} +``` + +```c++ +void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) +{ + MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0); + void *ptr; + if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) { + internal_check(); + } + if (size > MALLOC_ABSOLUTE_MAX_SIZE) { + return NULL; + } + ptr = zone->malloc(zone, size); + // 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录 + if (malloc_logger) { + malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0); + } + MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0); + return ptr; +} +``` + +其分配实现是 `zone->malloc` 根据之前的分析,就是szone_t结构体对象中对应的malloc实现。 + +在创建szone之后,做了一系列如下的初始化操作。 + +```c++ +// Initialize the security token. +szone->cookie = (uintptr_t)malloc_entropy[0]; + +szone->basic_zone.version = 12; +szone->basic_zone.size = (void *)szone_size; +szone->basic_zone.malloc = (void *)szone_malloc; +szone->basic_zone.calloc = (void *)szone_calloc; +szone->basic_zone.valloc = (void *)szone_valloc; +szone->basic_zone.free = (void *)szone_free; +szone->basic_zone.realloc = (void *)szone_realloc; +szone->basic_zone.destroy = (void *)szone_destroy; +szone->basic_zone.batch_malloc = (void *)szone_batch_malloc; +szone->basic_zone.batch_free = (void *)szone_batch_free; +szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect; +szone->basic_zone.memalign = (void *)szone_memalign; +szone->basic_zone.free_definite_size = (void *)szone_free_definite_size; +szone->basic_zone.pressure_relief = (void *)szone_pressure_relief; +szone->basic_zone.claimed_address = (void *)szone_claimed_address; +``` + +其他使用 scalable_zone 分配内存的函数的方法也类似,所以大内存的分配,不管外部函数如何封装,最终都会调用到 malloc_logger 函数。所以我们可以用 fishhook 去 hook 这个函数,然后记录内存分配情况,结合一定的数据上报机制,上传到服务器,分析并修复。 + + + +``` +// For logging VM allocation and deallocation, arg1 here +// is the mach_port_name_t of the target task in which the +// alloc or dealloc is occurring. For example, for mmap() +// that would be mach_task_self(), but for a cross-task-capable +// call such as mach_vm_map(), it is the target task. + +typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip); + +extern malloc_logger_t *__syscall_logger; +``` + +当 malloc_logger 和 __syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放通过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,我们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 DSYM 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样 **符号表地址 = 堆栈地址 - slide。** + +小 tips: + +ASLR(Address space layout randomization):常见称呼为位址空间随机载入、位址空间配置随机化、位址空间布局随机化,是一种防止内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的定址空间来放置攻击者能可靠地跳转到内存的特定位置来操作函数。现代作业系统一般都具备该机制。 + +函数地址 add: 函数真实的实现地址; + +函数虚拟地址:`vm_add`; + +ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add`。也就是:`*(base +offset)= imp`。 + + + +由于腾讯也开源了自己的 OOM 定位方案- [OOMDetector](https://github.com/Tencent/OOMDetector) ,有了现成的轮子,那么用好就可以了,所以对于内存的监控思路就是找到系统给 App 的内存上限,然后当接近内存上限值的时候,dump 内存情况,组装基础数据信息成一个合格的上报数据,经过一定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不同工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(情况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 +问题分析处理后要么发布新版本,要么 hot fix。 + + + +### 6. 开发阶段针对内存我们能做些什么 + +1. 图片缩放 + + WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部分内存,还会生成中间位图 bitmap 消耗大量内存。而 **ImageIO** 不存在上述2种弊端,只会占用最终图片大小的内存 + + 做了2组对比实验:给 App 显示一张图片 + + ```objective-c + // 方法1: 19.6M + UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"] newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)]; + self.imageView.image = imageResult; + + // 方法2: 14M + NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]); + UIImage *imageResult = [self scaledImageWithData:data withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp]; + self.imageView.image = imageResult; + + - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize + { + UIGraphicsBeginImageContextWithOptions(newSize, NO, 0); + [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; + UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return newImage; + } + + - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation + { + CGFloat maxPixelSize = MAX(size.width, size.height); + CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil); + NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue, + (__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]}; + CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options); + UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation]; + CGImageRelease(imageRef); + CFRelease(sourceRef); + return resultImage; + } + ``` + + 可以看出使用 ImageIO 比使用 UIImage 直接缩放占用内存更低。 + +2. 合理使用 autoreleasepool + + 我们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,我们如果在不断申请内存,比如各种循环,那么我们就需要手动添加 autoreleasepool,避免短时间内内存猛涨发生 OOM。 + + 对比实验 + + ```objective-c + // 实验1 + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; + NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; + [array addObject:resultString]; + } + + // 实验2 + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + @autoreleasepool { + NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; + NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; + [array addObject:resultString]; + } + } + ``` + + 实验1消耗内存 739.6M,实验2消耗内存 587M。 + +3. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。 + +4. 不管是打开网页,还是执行 js,都应该使用 WKWebView。UIWebView 会占用大量内存,从而导致 App 发生 OOM 的几率增加,而 WKWebView 是一个多进程组件,Network Loading 以及 UI Rendering 在其它进程中执行,比 UIWebView 占用更低的内存开销。 + +5. 在做 SDK 或者 App,如果场景是缓存相关,尽量使用 NSCache 而不是 NSMutableDictionary。它是系统提供的专门处理缓存的类,NSCache 分配的内存是 `Purgeable Memory`,可以由系统自动释放。NSCache 与 NSPureableData 的结合使用可以让系统根据情况回收内存,也可以在内存清理时移除对象。 + + 其他的开发习惯就不一一描述了,良好的开发习惯和代码意识是需要平时注意修炼的。 + + + + + +## 五、 App 网络监控 + +移动网络环境一直很复杂,WIFI、2G、3G、4G、5G 等,用户使用 App 的过程中可能在这几种类型之间切换,这也是移动网络和传统网络间的一个区别,被称为「Connection Migration」。此外还存在 DNS 解析缓慢、失败率高、运营商劫持等问题。用户在使用 App 时因为某些原因导致体验很差,要想针对网络情况进行改善,必须有清晰的监控手段。 + + + +### 1. App 网络请求过程 + +![网络请求各阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-03-NetworkTime.png) + +App 发送一次网络请求一般会经历下面几个关键步骤: + +- DNS 解析 + + Domain Name system,网络域名名称系统,本质上就是将`域名`和`IP 地址` 相互映射的一个分布式数据库,使人们更方便的访问互联网。首先会查询本地的 DNS 缓存,查找失败就去 DNS 服务器查询,这其中可能会经过非常多的节点,涉及到**递归查询和迭代查询**的过程。运营商可能不干人事:一种情况就是出现运营商劫持的现象,表现为你在 App 内访问某个网页的时候会看到和内容不相关的广告;另一种可能的情况就是把你的请求丢给非常远的基站去做 DNS 解析,导致我们 App 的 DNS 解析时间较长,App 网络效率低。一般做 HTTPDNS 方案去自行解决 DNS 的问题。 + +- TCP 3次握手 + + 关于 TCP 握手过程中为什么是3次握手而不是2次、4次,可以查看这篇[文章](https://draveness.me/whys-the-design-tcp-three-way-handshake/)。 + +- TLS 握手 + + 对于 HTTPS 请求还需要做 TLS 握手,也就是密钥协商的过程。 + +- 发送请求 + + 连接建立好之后就可以发送 request,此时可以记录下 request start 时间 + +- 等待回应 + + 等待服务器返回响应。这个时间主要取决于资源大小,也是网络请求过程中最为耗时的一个阶段。 + +- 返回响应 + + 服务端返回响应给客户端,根据 HTTP header 信息中的状态码判断本次请求是否成功、是否走缓存、是否需要重定向。 + + + +### 2. 监控原理 + +| 名称 | 说明 | +| :-------------: | :---------------------: | +| NSURLConnection | 已经被废弃。用法简单 | +| NSURLSession | iOS7.0 推出,功能更强大 | +| CFNetwork | NSURL 的底层,纯 C 实现 | + +iOS 网络框架层级关系如下: + +![Network Level](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-05-NetworkLevel.png) + +iOS 网络现状是由4层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。 + +目前业界对于网络监控主要有2种:一种是通过 NSURLProtocol 监控、一种是通过 Hook 来监控。下面介绍几种办法来监控网络请求,各有优缺点。 + + + +#### 2.1 方案一:NSURLProtocol 监控 App 网络请求 + +NSURLProtocol 作为上层接口,使用较为简单,但 NSURLProtocol 属于 URL Loading System 体系中。应用协议的支持程度有限,支持 FTP、HTTP、HTTPS 等几个应用层协议,对于其他的协议则无法监控,存在一定的局限性。如果监控底层网络库 CFNetwork 则没有这个限制。 + +对于 NSURLProtocol 的具体做法在[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md)中讲过,继承抽象类并实现相应的方法,自定义去发起网络请求来实现监控的目的。 + +iOS 10 之后,NSURLSessionTaskDelegate 中增加了一个新的代理方法: + +```objective-c +/* + * Sent when complete statistics information has been collected for the task. + */ +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)); +``` + +可以从 `NSURLSessionTaskMetrics` 中获取到网络情况的各项指标。各项参数如下 + +```objective-c +@interface NSURLSessionTaskMetrics : NSObject + +/* + * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution. + */ +@property (copy, readonly) NSArray *transactionMetrics; + +/* + * Interval from the task creation time to the task completion time. + * Task creation time is the time when the task was instantiated. + * Task completion time is the time when the task is about to change its internal state to completed. + */ +@property (copy, readonly) NSDateInterval *taskInterval; + +/* + * redirectCount is the number of redirects that were recorded. + */ +@property (assign, readonly) NSUInteger redirectCount; + +- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); ++ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); + +@end +``` + +其中:`taskInterval` 表示任务从创建到完成话费的总时间,任务的创建时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;`redirectCount` 表示被重定向的次数;`transactionMetrics` 数组包含了任务执行过程中每个请求/响应事务中收集的指标,各项参数如下: + +```objective-c +/* + * This class defines the performance metrics collected for a request/response transaction during the task execution. + */ +API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) +@interface NSURLSessionTaskTransactionMetrics : NSObject + +/* + * Represents the transaction request. 请求事务 + */ +@property (copy, readonly) NSURLRequest *request; + +/* + * Represents the transaction response. Can be nil if error occurred and no response was generated. 响应事务 + */ +@property (nullable, copy, readonly) NSURLResponse *response; + +/* + * For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding “EndDate” metric will be nil. + * For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics. + */ + +/* + * 客户端开始请求的时间,无论是从服务器还是从本地缓存中获取 + * fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources. + * + * The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources: + * + * domainLookupStartDate + * domainLookupEndDate + * connectStartDate + * connectEndDate + * secureConnectionStartDate + * secureConnectionEndDate + */ +@property (nullable, copy, readonly) NSDate *fetchStartDate; + +/* + * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 开始解析的时间 + */ +@property (nullable, copy, readonly) NSDate *domainLookupStartDate; + +/* + * domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的时间 + */ +@property (nullable, copy, readonly) NSDate *domainLookupEndDate; + +/* + * connectStartDate is the time immediately before the user agent started establishing the connection to the server. + * + * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客户端与服务端开始建立 TCP 连接的时间 + */ +@property (nullable, copy, readonly) NSDate *connectStartDate; + +/* + * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手开始的时间 + * + * For example, this would correspond to the time immediately before the user agent started the TLS handshake. + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSDate *secureConnectionStartDate; + +/* + * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手结束的时间 + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSDate *secureConnectionEndDate; + +/* + * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客户端与服务器建立 TCP 连接完成的时间,包括 TLS 握手时间 + */ +@property (nullable, copy, readonly) NSDate *connectEndDate; + +/* + * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources. + 客户端请求开始的时间,可以理解为开始传输 HTTP 请求的 header 的第一个字节时间 + * + * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request. + */ +@property (nullable, copy, readonly) NSDate *requestStartDate; + +/* + * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources. + 客户端请求结束的时间,可以理解为 HTTP 请求的最后一个字节传输完成的时间 + * + * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request. + */ +@property (nullable, copy, readonly) NSDate *requestEndDate; + +/* + * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources. + 客户端从服务端接收响应的第一个字节的时间 + * + * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response. + */ +@property (nullable, copy, readonly) NSDate *responseStartDate; + +/* + * responseEndDate is the time immediately after the user agent received the last byte of the resource. 客户端从服务端接收到最后一个请求的时间 + */ +@property (nullable, copy, readonly) NSDate *responseEndDate; + +/* + * The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301]. + * E.g., h2, http/1.1, spdy/3.1. + 网络协议名,比如 http/1.1, spdy/3.1 + * + * When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol. + * + * For example: + * If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned. + * If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned. + * If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned. + * + */ +@property (nullable, copy, readonly) NSString *networkProtocolName; + +/* + * This property is set to YES if a proxy connection was used to fetch the resource. + 该连接是否使用了代理 + */ +@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection; + +/* + * This property is set to YES if a persistent connection was used to fetch the resource. + 是否复用了现有连接 + */ +@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection; + +/* + * Indicates whether the resource was loaded, pushed or retrieved from the local cache. + 获取资源来源 + */ +@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType; + +/* + * countOfRequestHeaderBytesSent is the number of bytes transferred for request header. + 请求头的字节数 + */ +@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfRequestBodyBytesSent is the number of bytes transferred for request body. + 请求体的字节数 + * It includes protocol-specific framing, transfer encoding, and content encoding. + */ +@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream. + 上传体数据、文件、流的大小 + */ +@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseHeaderBytesReceived is the number of bytes transferred for response header. + 响应头的字节数 + */ +@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseBodyBytesReceived is the number of bytes transferred for response body. + 响应体的字节数 + * It includes protocol-specific framing, transfer encoding, and content encoding. + */ +@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler. +给代理方法或者完成后处理的回调的数据大小 + + */ +@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * localAddress is the IP address string of the local interface for the connection. + 当前连接下的本地接口 IP 地址 + * + * For multipath protocols, this is the local address of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * localPort is the port number of the local interface for the connection. + 当前连接下的本地端口号 + + * + * For multipath protocols, this is the local port of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * remoteAddress is the IP address string of the remote interface for the connection. + 当前连接下的远端 IP 地址 + * + * For multipath protocols, this is the remote address of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * remotePort is the port number of the remote interface for the connection. + 当前连接下的远端端口号 + * + * For multipath protocols, this is the remote port of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection. + 连接协商用的 TLS 协议版本号 + * It is a 2-byte sequence in host byte order. + * + * Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection. + 连接协商用的 TLS 密码套件 + * It is a 2-byte sequence in host byte order. + * + * Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over a cellular interface. + 是否是通过蜂窝网络建立的连接 + */ +@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over an expensive interface. + 是否通过昂贵的接口建立的连接 + */ +@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over a constrained interface. + 是否通过受限接口建立的连接 + */ +@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether a multipath protocol is successfully negotiated for the connection. + 是否为了连接成功协商了多路径协议 + */ +@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + + +- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); ++ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); + +@end +``` + + + +网络监控简单代码 + +```objective-c +// 监控基础信息 +@interface NetworkMonitorBaseDataModel : NSObject +// 请求的 URL 地址 +@property (nonatomic, strong) NSString *requestUrl; +//请求头 +@property (nonatomic, strong) NSArray *requestHeaders; +//响应头 +@property (nonatomic, strong) NSArray *responseHeaders; +//GET方法 的请求参数 +@property (nonatomic, strong) NSString *getRequestParams; +//HTTP 方法, 比如 POST +@property (nonatomic, strong) NSString *httpMethod; +//协议名,如http1.0 / http1.1 / http2.0 +@property (nonatomic, strong) NSString *httpProtocol; +//是否使用代理 +@property (nonatomic, assign) BOOL useProxy; +//DNS解析后的 IP 地址 +@property (nonatomic, strong) NSString *ip; +@end + +// 监控信息模型 +@interface NetworkMonitorDataModel : NetworkMonitorBaseDataModel +//客户端发起请求的时间 +@property (nonatomic, assign) UInt64 requestDate; +//客户端开始请求到开始dns解析的等待时间,单位ms +@property (nonatomic, assign) int waitDNSTime; +//DNS 解析耗时 +@property (nonatomic, assign) int dnsLookupTime; +//tcp 三次握手耗时,单位ms +@property (nonatomic, assign) int tcpTime; +//ssl 握手耗时 +@property (nonatomic, assign) int sslTime; +//一个完整请求的耗时,单位ms +@property (nonatomic, assign) int requestTime; +//http 响应码 +@property (nonatomic, assign) NSUInteger httpCode; +//发送的字节数 +@property (nonatomic, assign) UInt64 sendBytes; +//接收的字节数 +@property (nonatomic, assign) UInt64 receiveBytes; + + +// 错误信息模型 +@interface NetworkMonitorErrorModel : NetworkMonitorBaseDataModel +//错误码 +@property (nonatomic, assign) NSInteger errorCode; +//错误次数 +@property (nonatomic, assign) NSUInteger errCount; +//异常名 +@property (nonatomic, strong) NSString *exceptionName; +//异常详情 +@property (nonatomic, strong) NSString *exceptionDetail; +//异常堆栈 +@property (nonatomic, strong) NSString *stackTrace; +@end + + +// 继承自 NSURLProtocol 抽象类,实现响应方法,代理网络请求 +@interface CustomURLProtocol () + +@property (nonatomic, strong) NSURLSessionDataTask *dataTask; +@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue; +@property (nonatomic, strong) NetworkMonitorDataModel *dataModel; +@property (nonatomic, strong) NetworkMonitorErrorModel *errModel; + +@end + +//使用NSURLSessionDataTask请求网络 +- (void)startLoading { + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration + delegate:self + delegateQueue:nil]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; + self.sessionDelegateQueue = [[NSOperationQueue alloc] init]; + self.sessionDelegateQueue.maxConcurrentOperationCount = 1; + self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue"; + self.dataTask = [session dataTaskWithRequest:self.request]; + [self.dataTask resume]; +} + +#pragma mark - NSURLSessionTaskDelegate +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { + if (error) { + [self.client URLProtocol:self didFailWithError:error]; + } else { + [self.client URLProtocolDidFinishLoading:self]; + } + if (error) { + NSURLRequest *request = task.currentRequest; + if (request) { + self.errModel.requestUrl = request.URL.absoluteString; + self.errModel.httpMethod = request.HTTPMethod; + self.errModel.requestParams = request.URL.query; + } + self.errModel.errorCode = error.code; + self.errModel.exceptionName = error.domain; + self.errModel.exceptionDetail = error.description; + // 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + } + self.dataTask = nil; +} + + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics { + if (@available(iOS 10.0, *) && [metrics.transactionMetrics count] > 0) { + [metrics.transactionMetrics enumerateObjectsUsingBlock:^(NSURLSessionTaskTransactionMetrics *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (obj.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad) { + if (obj.fetchStartDate) { + self.dataModel.requestDate = [obj.fetchStartDate timeIntervalSince1970] * 1000; + } + if (obj.domainLookupStartDate && obj.domainLookupEndDate) { + self.dataModel. waitDNSTime = ceil([obj.domainLookupStartDate timeIntervalSinceDate:obj.fetchStartDate] * 1000); + self.dataModel. dnsLookupTime = ceil([obj.domainLookupEndDate timeIntervalSinceDate:obj.domainLookupStartDate] * 1000); + } + if (obj.connectStartDate) { + if (obj.secureConnectionStartDate) { + self.dataModel. waitDNSTime = ceil([obj.secureConnectionStartDate timeIntervalSinceDate:obj.connectStartDate] * 1000); + } else if (obj.connectEndDate) { + self.dataModel.tcpTime = ceil([obj.connectEndDate timeIntervalSinceDate:obj.connectStartDate] * 1000); + } + } + if (obj.secureConnectionEndDate && obj.secureConnectionStartDate) { + self.dataModel.sslTime = ceil([obj.secureConnectionEndDate timeIntervalSinceDate:obj.secureConnectionStartDate] * 1000); + } + + if (obj.fetchStartDate && obj.responseEndDate) { + self.dataModel.requestTime = ceil([obj.responseEndDate timeIntervalSinceDate:obj.fetchStartDate] * 1000); + } + + self.dataModel.httpProtocol = obj.networkProtocolName; + + NSHTTPURLResponse *response = (NSHTTPURLResponse *)obj.response; + if ([response isKindOfClass:NSHTTPURLResponse.class]) { + self.dataModel.receiveBytes = response.expectedContentLength; + } + + if ([obj respondsToSelector:@selector(_remoteAddressAndPort)]) { + self.dataModel.ip = [obj valueForKey:@"_remoteAddressAndPort"]; + } + + if ([obj respondsToSelector:@selector(_requestHeaderBytesSent)]) { + self.dataModel.sendBytes = [[obj valueForKey:@"_requestHeaderBytesSent"] unsignedIntegerValue]; + } + if ([obj respondsToSelector:@selector(_responseHeaderBytesReceived)]) { + self.dataModel.receiveBytes = [[obj valueForKey:@"_responseHeaderBytesReceived"] unsignedIntegerValue]; + } + + self.dataModel.requestUrl = [obj.request.URL absoluteString]; + self.dataModel.httpMethod = obj.request.HTTPMethod; + self.dataModel.useProxy = obj.isProxyConnection; + } + }]; + // 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + } +} +``` + + + +#### 2.2 方案二:NSURLProtocol 监控 App 网络请求之黑魔法篇 + +文章上面 [2.1 ](#network-2.1)分析到了 NSURLSessionTaskMetrics 由于兼容性问题,对于网络监控来说似乎不太完美,但是自后在搜资料的时候看到了一篇[文章](https://www.jianshu.com/p/1c34147030d1)。文章在分析 WebView 的网络监控的时候分析 Webkit 源码的时候发现了下面代码 + +```objective-c +#if !HAVE(TIMINGDATAOPTIONS) +void setCollectsTimingData() +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [NSURLConnection _setCollectsTimingData:YES]; + ... + }); +} +#endif +``` + +也就是说明 NSURLConnection 本身有一套 `TimingData` 的收集 API,只是没有暴露给开发者,苹果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 `_setCollectsTimingData:` 、`_timingData` 2个 api(iOS8 以后可以使用)。 + +NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 TimingData 了。 + +注意: + +- 因为是私有 API,所以在使用的时候注意混淆。比如 `[[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]`。 +- 不推荐私有 API,一般做 APM 的属于公共团队,你想想看虽然你做的 SDK 达到网络监控的目的了,但是万一给业务线的 App 上架造成了问题,那就得不偿失了。一般这种投机取巧,不是百分百确定的事情可以在玩具阶段使用。 + +```objective-c +@interface _NSURLConnectionProxy : DelegateProxy + +@end + +@implementation _NSURLConnectionProxy + +- (BOOL)respondsToSelector:(SEL)aSelector +{ + if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) { + return YES; + } + return [self.target respondsToSelector:aSelector]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation +{ + [super forwardInvocation:invocation]; + if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) { + __unsafe_unretained NSURLConnection *conn; + [invocation getArgument:&conn atIndex:2]; + SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]); + NSDictionary *timingData = [conn performSelector:selector]; + [[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest]; + } +} + +@end + +@implementation NSURLConnection(tracker) + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class class = [self class]; + + SEL originalSelector = @selector(initWithRequest:delegate:); + SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:); + + Method originalMethod = class_getInstanceMethod(class, originalSelector); + Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); + method_exchangeImplementations(originalMethod, swizzledMethod); + + NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]; + SEL selector = NSSelectorFromString(selectorName); + [NSURLConnection performSelector:selector withObject:@(YES)]; + }); +} + +- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id)delegate +{ + if (delegate) { + _NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate]; + objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return [self swizzledInitWithRequest:request delegate:(id)proxy]; + }else{ + return [self swizzledInitWithRequest:request delegate:delegate]; + } +} + +@end +``` + + + +#### 2.3 方案三:Hook + +iOS 中 hook 技术有2类,一种是 NSProxy,一种是 method swizzling(isa swizzling) + +##### 2.3.1 方法一 + +写 SDK 肯定不可能手动侵入业务代码(你没那个权限提交到线上代码 😂),所以不管是 APM 还是无痕埋点都是通过 Hook 的方式。 + +面向切面程序设计(Aspect-oriented Programming,AOP)是计算机科学中的一种程序设计范型,将**横切关注点**与业务主体进一步分离,以提高程序代码的模块化程度。在不修改源代码的情况下给程序动态增加功能。其核心思想是将业务逻辑(核心关注点,系统主要功能)与公共功能(横切关注点,比如日志系统)进行分离,降低复杂性,保持系统模块化程度、可维护性、可重用性。常被用在日志系统、性能统计、安全控制、事务处理、异常处理等场景下。 + +在 iOS 中 AOP 的实现是基于 Runtime 机制,目前由3种方式:Method Swizzling、NSProxy、FishHook(主要用用于 hook c 代码)。 + +文章上面 [2.1 ](#network-2.1)讨论了满足大多数的需求的场景,NSURLProtocol 监控了 NSURLConnection、NSURLSession 的网络请求,自身代理后可以发起网络请求并得到诸如请求开始时间、请求结束时间、header 信息等,但是无法得到非常详细的网络性能数据,比如 DNS 开始解析时间、DNS 解析用了多久、reponse 开始返回的时间、返回了多久等。 iOS10 之后 NSURLSessionTaskDelegate 增加了一个代理方法 `- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));`,可以获取到精确的各项网络数据。但是具有兼容性。文章上面 [2.2 ](#network-2.2)讨论了从 Webkit 源码中得到的信息,通过私有方法 `_setCollectsTimingData:` 、`_timingData` 可以获取到 TimingData。 + +但是如果需要监全部的网络请求就不能满足需求了,查阅资料后发现了阿里百川有 APM 的解决方案,于是有了方案3,对于网络监控需要做如下的处理 + +![network hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-network_monitor.png) + + + +可能对于 CFNetwork 比较陌生,可以看一下 CFNetwork 的层级和简单用法 + +![CFNetwork Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-CFNetworkStructure.png) + + + +CFNetwork 的基础是 CFSocket 和 CFStream。 + +CFSocket:Socket 是网络通信的底层基础,可以让2个 socket 端口互发数据,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了所有的 BSD 功能,此外加入了 RunLoop。 + +CFStream:提供了与设备无关的读写数据方法,使用它可以为内存、文件、网络(使用 socket)的数据建立流,使用 stream 可以不必将所有数据写入到内存中。CFStream 提供 API 对2种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。 + +简单 Demo + +```objective-c +- (void)testCFNetwork +{ + CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL); + CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1); + CFRelease(urlRef); + + CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef); + CFRelease(httpMessageRef); + + CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); + + CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered); + CFStreamClientContext context = { + 0, + NULL, + NULL, + NULL, + NULL + } ; + // Assigns a client to a stream, which receives callbacks when certain events occur. + CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context); + // Opens a stream for reading. + CFReadStreamOpen(readStream); +} +// callback +void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) { + CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0); + CFIndex numberOfBytesRead = 0; + do { + UInt8 buffer[2014]; + numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer)); + if (numberOfBytesRead > 0) { + CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead); + } + } while (numberOfBytesRead > 0); + + + CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader); + if (responseBytes) { + if (response) { + CFHTTPMessageSetBody(response, responseBytes); + } + CFRelease(responseBytes); + } + + // close and cleanup + CFReadStreamClose(stream); + CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); + CFRelease(stream); + + // print response + if (response) { + CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response); + CFRelease(response); + + printResponseData(reponseBodyData); + CFRelease(reponseBodyData); + } +} + +void printResponseData (CFDataRef responseData) { + CFIndex dataLength = CFDataGetLength(responseData); + UInt8 *bytes = (UInt8 *)malloc(dataLength); + CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes); + CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE); + CFShow(responseString); + CFRelease(responseString); + free(bytes); +} +// console +{ + "args": {}, + "headers": { + "Host": "httpbin.org", + "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", + "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564" + }, + "origin": "183.159.122.102", + "url": "https://httpbin.org/get" +} +``` + +我们知道 NSURLSession、NSURLConnection、CFNetwork 的使用都需要调用一堆方法进行设置然后需要设置代理对象,实现代理方法。所以针对这种情况进行监控首先想到的是使用 runtime hook 掉方法层级。但是针对设置的代理对象的代理方法没办法 hook,因为不知道代理对象是哪个类。所以想办法可以 hook 设置代理对象这个步骤,将代理对象替换成我们设计好的某个类,然后让这个类去实现 NSURLConnection、NSURLSession、CFNetwork 相关的代理方法。然后在这些方法的内部都去调用一下原代理对象的方法实现。所以我们的需求得以满足,我们在相应的方法里面可以拿到监控数据,比如请求开始时间、结束时间、状态码、内容大小等。 + +NSURLSession、NSURLConnection hook 如下。 + +![NSURLSession Hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-NSURLSessionHook.jpeg) + +![NSURLConnection Hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-NSURLConnectionHook.jpeg) + + + +业界有 APM 针对 CFNetwork 的方案,整理描述下: + +CFNetwork 是 c 语言实现的,要对 c 代码进行 hook 需要使用 Dynamic Loader Hook 库 - [fishhook](https://github.com/facebook/fishhook)。 + +> **Dynamic Loader**(dyld)通过更新 **Mach-O** 文件中保存的指针的方法来绑定符号。借用它可以在 **Runtime** 修改 **C** 函数调用的函数指针。**fishhook** 的实现原理:遍历 `__DATA segment` 里面 `__nl_symbol_ptr` 、`__la_symbol_ptr` 两个 section 里面的符号,通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到自己要替换的函数,达到 hook 的目的。 + +> /* Returns the number of bytes read, or -1 if an error occurs preventing any +> +> bytes from being read, or 0 if the stream's end was encountered. +> +> It is an error to try and read from a stream that hasn't been opened first. +> +> This call will block until at least one byte is available; it will NOT block +> +> until the entire buffer can be filled. To avoid blocking, either poll using +> +> CFReadStreamHasBytesAvailable() or use the run loop and listen for the +> +> kCFStreamEventHasBytesAvailable event for notification of data available. */ +> +> CF_EXPORT +> +> CFIndex CFReadStreamRead(CFReadStreamRef **_Null_unspecified** stream, UInt8 * **_Null_unspecified** buffer, CFIndex bufferLength); + +CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式来接受服务器的响应。当回调函数受到 + + + +具体步骤及其关键代码如下,以 NSURLConnection 举例 + +- 因为要 Hook 挺多地方,所以写一个 method swizzling 的工具类 + + ```objective-c + #import + + NS_ASSUME_NONNULL_BEGIN + + @interface NSObject (hook) + + /** + hook对象方法 + + @param originalSelector 需要hook的原始对象方法 + @param swizzledSelector 需要替换的对象方法 + */ + + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; + + /** + hook类方法 + + @param originalSelector 需要hook的原始类方法 + @param swizzledSelector 需要替换的类方法 + */ + + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; + + @end + + NS_ASSUME_NONNULL_END + + + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector + { + class_swizzleInstanceMethod(self, originalSelector, swizzledSelector); + } + + + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector + { + //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 + Class class2 = object_getClass(self); + class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); + } + + void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) + { + Method originMethod = class_getInstanceMethod(class, originalSEL); + Method replaceMethod = class_getInstanceMethod(class, replacementSEL); + + if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod))) + { + class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod)); + }else { + method_exchangeImplementations(originMethod, replaceMethod); + } + } + ``` + +- 建立一个继承自 NSProxy 抽象类的类,实现相应方法。 + + ```objective-c + #import + + NS_ASSUME_NONNULL_BEGIN + + // 为 NSURLConnection、NSURLSession、CFNetwork 代理设置代理转发 + @interface NetworkDelegateProxy : NSProxy + + + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate; + + @end + + NS_ASSUME_NONNULL_END + + // .m + @interface NetworkDelegateProxy () { + id _originalTarget; + id _NewDelegate; + } + + @end + + + @implementation NetworkDelegateProxy + + #pragma mark - life cycle + + + (instancetype)sharedInstance { + static NetworkDelegateProxy *_sharedInstance = nil; + + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + _sharedInstance = [NetworkDelegateProxy alloc]; + }); + + return _sharedInstance; + } + + + #pragma mark - public Method + + + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate + { + NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance]; + instance->_originalTarget = originalTarget; + instance->_NewDelegate = newDelegate; + return instance; + } + + - (void)forwardInvocation:(NSInvocation *)invocation + { + if ([_originalTarget respondsToSelector:invocation.selector]) { + [invocation invokeWithTarget:_originalTarget]; + [((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation]; + } + } + + - (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel + { + return [_originalTarget methodSignatureForSelector:sel]; + } + + @end + ``` + +- 创建一个对象,实现 NSURLConnection、NSURLSession、NSIuputStream 代理方法 + + ```objective-c + // NetworkImplementor.m + + #pragma mark-NSURLConnectionDelegate + - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { + NSLog(@"%s", __func__); + } + + - (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response { + NSLog(@"%s", __func__); + return request; + } + + #pragma mark-NSURLConnectionDataDelegate + - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { + NSLog(@"%s", __func__); + } + + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { + NSLog(@"%s", __func__); + } + + - (void)connection:(NSURLConnection *)connection didSendBodyData:(NSInteger)bytesWritten + totalBytesWritten:(NSInteger)totalBytesWritten + totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite { + NSLog(@"%s", __func__); + } + + - (void)connectionDidFinishLoading:(NSURLConnection *)connection { + NSLog(@"%s", __func__); + } + + #pragma mark-NSURLConnectionDownloadDelegate + - (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { + NSLog(@"%s", __func__); + } + + - (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { + NSLog(@"%s", __func__); + } + + - (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL { + NSLog(@"%s", __func__); + } + // 根据需求自己去写需要监控的数据项 + ``` + +- 给 NSURLConnection 添加 Category,专门设置 hook 代理对象、hook NSURLConnection 对象方法 + + ```objective-c + // NSURLConnection+Monitor.m + @implementation NSURLConnection (Monitor) + + + (void)load + { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + @autoreleasepool { + [[self class] apm_swizzleMethod:@selector(apm_initWithRequest:delegate:) swizzledSelector:@selector(initWithRequest: delegate:)]; + } + }); + } + + - (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate + { + /* + 1. 在设置 Delegate 的时候替换 delegate。 + 2. 因为要在每个代理方法里面,监控数据,所以需要将代理方法都 hook 下 + 3. 在原代理方法执行的时候,让新的代理对象里面,去执行方法的转发, + */ + NSString *traceId = @"traceId"; + NSMutableURLRequest *rq = [request mutableCopy]; + NSString *preTraceId = [request.allHTTPHeaderFields valueForKey:@"head_key_traceid"]; + if (preTraceId) { + // 调用 hook 之前的初始化方法,返回 NSURLConnection + return [self apm_initWithRequest:rq delegate:delegate]; + } else { + [rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"]; + + NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new]; + [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; + + [self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; + [self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; + [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; + + [self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"]; + [self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"]; + delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate]; + + // 调用 hook 之前的初始化方法,返回 NSURLConnection + return [self apm_initWithRequest:rq delegate:delegate]; + } + } + + - (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag + { + if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) { + IMP originalMethodImp = class_getMethodImplementation([originalDelegate class], NSSelectorFromString(methodName)); + IMP newMethodImp = class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)); + if (originalMethodImp != newMethodImp) { + [newDelegate registerSelector: methodName]; + NSLog(@""); + } + } else { + class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag); + } + } + + @end + ``` + + + +这样下来就是可以监控到网络信息了,然后将数据交给数据上报 SDK,按照下发的数据上报策略去上报数据。 + +##### 2.3.2 方法二 + +其实,针对上述的需求还有另一种方法一样可以达到目的,那就是 **isa swizzling**。 + +顺道说一句,上面针对 NSURLConnection、NSURLSession、NSInputStream 代理对象的 hook 之后,利用 NSProxy 实现代理对象方法的转发,有另一种方法可以实现,那就是 **isa swizzling**。 + +- Method swizzling 原理 + + ```objective-c + struct old_method { + SEL method_name; + char *method_types; + IMP method_imp; + }; + ``` + + ![method swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-09-methodSwizzling.png) + + method swizzling 改进版如下 + + ```objective-c + Method originalMethod = class_getInstanceMethod(aClass, aSEL); + IMP originalIMP = method_getImplementation(originalMethod); + char *cd = method_getTypeEncoding(originalMethod); + IMP newIMP = imp_implementationWithBlock(^(id self) { + void (*tmp)(id self, SEL _cmd) = originalIMP; + tmp(self, aSEL); + }); + class_replaceMethod(aClass, aSEL, newIMP, cd); + ``` + +- isa swizzling + + ```objective-c + /// Represents an instance of a class. + struct objc_object { + Class _Nonnull isa OBJC_ISA_AVAILABILITY; + }; + + /// A pointer to an instance of a class. + typedef struct objc_object *id; + + ``` + + ![isa swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-13-isaSwizzling.png) + + + +我们来分析一下为什么修改 `isa` 可以实现目的呢? + +1. 写 APM 监控的人没办法确定业务代码 +2. 不可能为了方便监控 APM,写某些类,让业务线开发者别使用系统 NSURLSession、NSURLConnection 类 + +想想 KVO 的实现原理?结合上面的图 + +- 创建监控对象子类 +- 重写子类中属性的 getter、seeter +- 将监控对象的 isa 指针指向新创建的子类 +- 在子类的 getter、setter 中拦截值的变化,通知监控对象值的变化 +- 监控完之后将监控对象的 isa 还原回去 + +按照这个思路,我们也可以对 NSURLConnection、NSURLSession 的 load 方法中动态创建子类,在子类中重写方法,比如 `- (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately;` ,然后将 NSURLSession、NSURLConnection 的 isa 指向动态创建的子类。在这些方法处理完之后还原本身的 isa 指针。 + +不过 isa swizzling 针对的还是 method swizzling,代理对象不确定,还是需要 NSProxy 进行动态处理。 + + + +至于如何修改 isa,我写一个简单的 Demo 来模拟 KVO + +```objective-c +- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context { + //生成自定义的名称 + NSString *className = NSStringFromClass(self.class); + NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className]; + //1. runtime 生成类 + Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0); + // 生成后不能马上使用,必须先注册 + objc_registerClassPair(myclass); + + //2. 重写 setter 方法 + class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@"); + +// class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@"); + //3. 修改 isa + object_setClass(self, myclass); + + //4. 将观察者保存到当前对象里面 + objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN); + + //5. 将传递的上下文绑定到当前对象里面 + objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN); +} + + +void say(id self, SEL _cmd) +{ + // 调用父类方法一 + struct objc_super superclass = {self, [self superclass]}; + ((void(*)(struct objc_super *,SEL))objc_msgSendSuper)(&superclass,@selector(say)); + NSLog(@"%s", __func__); +// 调用父类方法二 +// Class class = [self class]; +// object_setClass(self, class_getSuperclass(class)); +// objc_msgSend(self, @selector(say)); +} + +void setName (id self, SEL _cmd, NSString *name) { + NSLog(@"come here"); + //先切换到当前类的父类,然后发送消息 setName,然后切换当前子类 + //1. 切换到父类 + Class class = [self class]; + object_setClass(self, class_getSuperclass(class)); + //2. 调用父类的 setName 方法 + objc_msgSend(self, @selector(setName:), name); + + //3. 调用观察 + id observer = objc_getAssociatedObject(self, "observer"); + id context = objc_getAssociatedObject(self, "context"); + if (observer) { + objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context); + } + //4. 改回子类 + object_setClass(self, class); +} + +@end +``` + + + +#### 2.4 方案四:监控 App 常见网络请求 + +本着成本的原因,由于现在大多数的项目的网络能力都是通过 [AFNetworking](https://github.com/AFNetworking/AFNetworking) 完成的,所以本文的网络监控可以快速完成。 + +AFNetworking 在发起网络的时候会有相应的通知。`AFNetworkingTaskDidResumeNotification` 和 `AFNetworkingTaskDidCompleteNotification`。通过监听通知携带的参数获取网络情况信息。 + +```Objective-c + self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { + // 开始 + __strong __typeof(weakSelf)strongSelf = weakSelf; + NSURLSessionTask *task = note.object; + NSString *requestId = [[NSUUID UUID] UUIDString]; + task.apm_requestId = requestId; + [strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task]; +}]; + +self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { + + __strong __typeof(weakSelf)strongSelf = weakSelf; + + NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey]; + NSURLSessionTask *task = note.object; + if (!error) { + // 成功 + [strongSelf.networkRecoder recordFinishRequestWithRequestID:task.apmn_requestId task:task]; + } else { + // 失败 + [strongSelf.networkRecoder recordResponseErrorWithRequestID:task.apmn_requestId task:task error:error]; + } +}]; +``` +在 networkRecoder 的方法里面去组装数据,交给数据上报组件,等到合适的时机策略去上报。 + +因为网络是一个异步的过程,所以当网络请求开始的时候需要为每个网络设置唯一标识,等到网络请求完成后再根据每个请求的标识,判断该网络耗时多久、是否成功等。所以措施是为 **NSURLSessionTask** 添加分类,通过 runtime 增加一个属性,也就是唯一标识。 + +这里插一嘴,为 Category 命名、以及内部的属性和方法命名的时候需要注意下。假如不注意会怎么样呢?假如你要为 NSString 类增加身份证号码中间位数隐藏的功能,那么写代码久了的老司机 A,为 NSString 增加了一个方法名,叫做 getMaskedIdCardNumber,但是他的需求是从 [9, 12] 这4位字符串隐藏掉。过了几天同事 B 也遇到了类似的需求,他也是一位老司机,为 NSString 增加了一个也叫 getMaskedIdCardNumber 的方法,但是他的需求是从 [8, 11] 这4位字符串隐藏,但是他引入工程后发现输出并不符合预期,为该方法写的单测没通过,他以为自己写错了截取方法,检查了几遍才发现工程引入了另一个 NSString 分类,里面的方法同名 😂 真坑。 + +下面的例子是 SDK,但是日常开发也是一样。 + +- Category 类名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加当前分类的功能,也就是`类名+SDK名称简写_功能名称`。比如当前 SDK 叫 JuhuaSuanAPM,那么该 NSURLSessionTask Category 名称就叫做 `NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h` +- Category 属性名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加属性名,也就是`SDK名称简写_属性名称`。比如 JuhuaSuanAPM_requestId` +- Category 方法名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加方法名,也就是`SDK名称简写_方法名称`。比如 `-(BOOL)JuhuaSuanAPM__isGzippedData` + +例子如下: +```Objective-c +#import + +@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor) + +@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId; + +@end + +#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h" +#import + +@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor) + +- (NSString*)JuhuaSuanAPM_requestId +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId +{ + objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC); +} +@end +``` + + + +#### 2.5 iOS 流量监控 + + + +##### 2.5.1 HTTP 请求、响应数据结构 + +HTTP 请求报文结构 + +![请求报文结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-16-HTTPRequestStructure.png) + +响应报文的结构 + +![响应报文结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-16-HTTPResponseStructure.png) + +1. HTTP 报文是格式化的数据块,每条报文由三部分组成:对报文进行描述的起始行、包含属性的首部块、以及可选的包含数据的主体部分。 +2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一个由2个字符组成的行终止序列作为结束(包括一个回车符、一个换行符) +3. 实体的主体或者报文的主体是一个可选的数据块。与起始行和首部不同的是,主体中可以包含文本或者二进制数据,也可以为空。 +4. HTTP 首部(也就是 Headers)总是应该以一个空行结束,即使没有实体部分。浏览器发送了一个空白行来通知服务器,它已经结束了该头信息的发送。 + + + +请求报文的格式 + +```powershell + + + + +``` + +响应报文的格式 + +```shell + + + + +``` + + + +下图是打开 Chrome 查看极课时间网页的请求信息。包括响应行、响应头、响应体等信息。 + +![请求数据结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-13-HTTPDataStructure.png) + +下图是在终端使用 `curl` 查看一个完整的请求和响应数据 + +![curl查看HTTP响应](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-14-HTTPRequestStructure.png) + +我们都知道在 HTTP 通信中,响应数据会使用 gzip 或其他压缩方式压缩,用 NSURLProtocol 等方案监听,用 NSData 类型去计算分析流量等会造成数据的不精确,因为正常一个 HTTP 响应体的内容是使用 gzip 或其他压缩方式压缩的,所以使用 NSData 会偏大。 + + + +##### 2.5.2 问题 + +1. Request 和 Response 不一定成对存在 + + 比如网络断开、App 突然 Crash 等,所以 Request 和 Response 监控后不应该记录在一条记录里 + +2. 请求流量计算方式不精确 + + 主要原因有: + + - 监控技术方案忽略了请求头和请求行部分的数据大小 + - 监控技术方案忽略了 Cookie 部分的数据大小 + - 监控技术方案在对请求体大小计算的时候直接使用 `HTTPBody.length`,导致不够精确 + +3. 响应流量计算方式不精确 + + 主要原因有: + + - 监控技术方案忽略了响应头和响应行部分的数据大小 + - 监控技术方案在对 body 部分的字节大小计算,因采用 `exceptedContentLength` 导致不够准确 + - 监控技术方案忽略了响应体使用 gzip 压缩。真正的网络通信过程中,客户端在发起请求的请求头中 `Accept-Encoding` 字段代表客户端支持的数据压缩方式(表明客户端可以正常使用数据时支持的压缩方法),同样服务端根据客户端想要的压缩方式、服务端当前支持的压缩方式,最后处理数据,在响应头中`Content-Encoding` 字段表示当前服务器采用了什么压缩方式。 + + + +##### 2.5.3 技术实现 + +第五部分讲了网络拦截的各种原理和技术方案,这里拿 NSURLProtocol 来说实现流量监控(Hook 的方式)。从上述知道了我们需要什么样的,那么就逐步实现吧。 + +###### 2.5.3.1 Request 部分 + +1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 + +2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层) + + ```objective-c + @property(nonatomic, strong) NSURLConnection *internalConnection; + @property(nonatomic, strong) NSURLResponse *internalResponse; + @property(nonatomic, strong) NSMutableData *responseData; + @property (nonatomic, strong) NSURLRequest *internalRequest; + ``` + + ```objective-c + - (void)startLoading + { + NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; + self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self]; + self.internalRequest = self.request; + } + + - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response + { + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + self.internalResponse = response; + } + + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data + { + [self.responseData appendData:data]; + [self.client URLProtocol:self didLoadData:data]; + } + ``` + +3. Status Line 部分 + + NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,所以要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 可以实现。 + + **思路:将 NSURLResponse 通过 `_CFURLResponse` 转换为 `CFTypeRef`,然后再将 `CFTypeRef` 转换为 `CFHTTPMessageRef`,再通过 `CFHTTPMessageCopyResponseStatusLine` 获取 `CFHTTPMessageRef` 的 Status Line 信息。** + + 将读取 Status Line 的功能添加一个 NSURLResponse 的分类。 + + ```objective-c + // NSURLResponse+apm_FetchStatusLineFromCFNetwork.h + #import + + NS_ASSUME_NONNULL_BEGIN + + @interface NSURLResponse (apm_FetchStatusLineFromCFNetwork) + + - (NSString *)apm_fetchStatusLineFromCFNetwork; + + @end + + NS_ASSUME_NONNULL_END + + // NSURLResponse+apm_FetchStatusLineFromCFNetwork.m + #import "NSURLResponse+apm_FetchStatusLineFromCFNetwork.h" + #import + + + #define SuppressPerformSelectorLeakWarning(Stuff) \ + do { \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \ + Stuff; \ + _Pragma("clang diagnostic pop") \ + } while (0) + + typedef CFHTTPMessageRef (*APMURLResponseFetchHTTPResponse)(CFURLRef response); + + @implementation NSURLResponse (apm_FetchStatusLineFromCFNetwork) + + - (NSString *)apm_fetchStatusLineFromCFNetwork + { + NSString *statusLine = @""; + NSString *funcName = @"CFURLResponseGetHTTPResponse"; + APMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]); + + SEL getSelector = NSSelectorFromString(@"_CFURLResponse"); + if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) { + CFTypeRef cfResponse; + SuppressPerformSelectorLeakWarning( + cfResponse = CFBridgingRetain([self performSelector:getSelector]); + ); + if (NULL != cfResponse) { + CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse); + statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef); + CFRelease(cfResponse); + } + } + return statusLine; + } + + @end + ``` + +4. 将获取到的 Status Line 转换为 NSData,再计算大小 + + ```objective-c + - (NSUInteger)apm_getLineLength { + NSString *statusLineString = @""; + if ([self isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self; + statusLineString = [self apm_fetchStatusLineFromCFNetwork]; + } + NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding]; + return lineData.length; + } + ``` + +5. Header 部分 + + `allHeaderFields` 获取到 NSDictionary,然后按照 `key: value` 拼接成字符串,然后转换成 NSData 计算大小 + + 注意:`key: value` key 后是有空格的,curl 或者 chrome Network 面板可以查看印证下。 + + ```objective-c + - (NSUInteger)apm_getHeadersLength + { + NSUInteger headersLength = 0; + if ([self isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self; + NSDictionary *headerFields = httpResponse.allHeaderFields; + NSString *headerString = @""; + for (NSString *key in headerFields.allKeys) { + headerString = [headerStr stringByAppendingString:key]; + headheaderStringerStr = [headerString stringByAppendingString:@": "]; + if ([headerFields objectForKey:key]) { + headerString = [headerString stringByAppendingString:headerFields[key]]; + } + headerString = [headerString stringByAppendingString:@"\n"]; + } + NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding]; + headersLength = headerData.length; + } + return headersLength; + } + ``` + +6. Body 部分 + + Body 大小的计算不能直接使用 excepectedContentLength,官方文档说明了其不准确性,只可以作为参考。或者 `allHeaderFields` 中的 `Content-Length` 值也是不够准确的。 + + > /*! + > + > **@abstract** Returns the expected content length of the receiver. + > + > **@discussion** Some protocol implementations report a content length + > + > as part of delivering load metadata, but not all protocols + > + > guarantee the amount of data that will be delivered in actuality. + > + > Hence, this method returns an expected amount. Clients should use + > + > this value as an advisory, and should be prepared to deal with + > + > either more or less data. + > + > **@result** The expected content length of the receiver, or -1 if + > + > there is no expectation that can be arrived at regarding expected + > + > content length. + > + > */ + > + > **@property** (**readonly**) **long** **long** expectedContentLength; + + - HTTP 1.1 版本规定,如果存在 `Transfer-Encoding: chunked`,则在 header 中不能有 `Content-Length`,有也会被忽视。 + - 在 HTTP 1.0及之前版本中,`content-length` 字段可有可无 + - 在 HTTP 1.1及之后版本。如果是 `keep alive`,则 `Content-Length` 和 `chunked` 必然是二选一。若是非`keep alive`,则和 HTTP 1.0一样。`Content-Length` 可有可无。 + + 什么是 `Transfer-Encoding: chunked` + + 数据以一系列分块的形式进行发送 `Content-Length` 首部在这种情况下不被发送. 在每一个分块的开头需要添加当前分块的长度, 以十六进制的形式表示,后面紧跟着 `\r\n` , 之后是分块本身, 后面也是 `\r\n` ,终止块是一个常规的分块, 不同之处在于其长度为0. + + 我们之前拿 NSMutableData 记录了数据,所以我们可以在 `stopLoading `方法中计算出 Body 大小。步骤如下: + + - 在 `didReceiveData` 中不断添加 data + + ```objective-c + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data + { + [self.responseData appendData:data]; + [self.client URLProtocol:self didLoadData:data]; + } + ``` + + - 在 `stopLoading` 方法中拿到 `allHeaderFields` 字典,获取 `Content-Encoding` key 的值,如果是 **gzip**,则在 `stopLoading` 中将 NSData 处理为 gzip 压缩后的数据,再计算大小。(gzip 相关功能可以使用这个[工具](https://github.com/nicklockwood/GZIP)) + + 需要额外计算一个空白行的长度 + + ```objective-c + - (void)stopLoadi + { + [self.internalConnection cancel]; + + HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; + model.path = self.request.URL.path; + model.host = self.request.URL.host; + model.type = DMNetworkTrafficDataTypeResponse; + model.lineLength = [self.internalResponse apm_getStatusLineLength]; + model.headerLength = [self.internalResponse apm_getHeadersLength]; + model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength]; + if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response; + NSData *data = self.dm_data; + if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) { + data = [self.dm_data gzippedData]; + } + model.bodyLength = data.length; + } + model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; + NSDictionary *networkTrafficDictionary = [model convertToDictionary]; + [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; + } + ``` + +###### 2.5.3.2 Resquest 部分 + +1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 + +2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层) + + ```objective-c + @property(nonatomic, strong) NSURLConnection *internalConnection; + @property(nonatomic, strong) NSURLResponse *internalResponse; + @property(nonatomic, strong) NSMutableData *responseData; + @property (nonatomic, strong) NSURLRequest *internalRequest; + ``` + + ```objective-c + - (void)startLoading + { + NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; + self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self]; + self.internalRequest = self.request; + } + + - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response + { + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + self.internalResponse = response; + } + + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data + { + [self.responseData appendData:data]; + [self.client URLProtocol:self didLoadData:data]; + } + ``` + +3. Status Line 部分 + + 对于 NSURLRequest 没有像 NSURLResponse 一样的方法找到 StatusLine。所以兜底方案是自己根据 Status Line 的结构,自己手动构造一个。结构为:`协议版本号+空格+状态码+空格+状态文本+换行` + + 为 NSURLRequest 添加一个专门获取 Status Line 的分类。 + + ```objective-c + // NSURLResquest+apm_FetchStatusLineFromCFNetwork.m + - (NSUInteger)apm_fetchStatusLineLength + { + NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"]; + NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding]; + return statusLineData.length; + } + ``` + +4. Header 部分 + + 一个 HTTP 请求会先构建判断是否存在缓存,然后进行 DNS 域名解析以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。 + + 所以一个网络监控不考虑 cookie 😂,借用王多鱼的一句话「那不完犊子了吗」。 + + 看过一些文章说 NSURLRequest 不能完整获取到请求头信息。其实问题不大, 几个信息获取不完全也没办法。衡量监控方案本身就是看接口在不同版本或者某些情况下数据消耗是否异常,WebView 资源请求是否过大,类似于控制变量法的思想。 + + 所以获取到 NSURLRequest 的 `allHeaderFields` 后,加上 cookie 信息,计算完整的 Header 大小 + + ```objective-c + // NSURLResquest+apm_FetchHeaderWithCookies.m + - (NSUInteger)apm_fetchHeaderLengthWithCookie + { + NSDictionary *headerFields = self.allHTTPHeaderFields; + NSDictionary *cookiesHeader = [self apm_fetchCookies]; + + if (cookiesHeader.count) { + NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields]; + [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader]; + headerFields = [headerDictionaryWithCookies copy]; + } + + NSString *headerString = @""; + + for (NSString *key in headerFields.allKeys) { + headerString = [headerString stringByAppendingString:key]; + headerString = [headerString stringByAppendingString:@": "]; + if ([headerFields objectForKey:key]) { + headerString = [headerString stringByAppendingString:headerFields[key]]; + } + headerString = [headerString stringByAppendingString:@"\n"]; + } + NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding]; + headersLength = headerData.length; + return headerString; + } + + - (NSDictionary *)apm_fetchCookies + { + NSDictionary *cookiesHeaderDictionary; + NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; + NSArray *cookies = [cookieStorage cookiesForURL:self.URL]; + if (cookies.count) { + cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; + } + return cookiesHeaderDictionary; + } + ``` + +5. Body 部分 + + NSURLConnection 的 `HTTPBody` 有可能获取不到,问题类似于 WebView 上 ajax 等情况。所以可以通过 `HTTPBodyStream` 读取 stream 来计算 body 大小. + + ```objective-c + - (NSUInteger)apm_fetchRequestBody + { + NSDictionary *headerFields = self.allHTTPHeaderFields; + NSUInteger bodyLength = [self.HTTPBody length]; + + if ([headerFields objectForKey:@"Content-Encoding"]) { + NSData *bodyData; + if (self.HTTPBody == nil) { + uint8_t d[1024] = {0}; + NSInputStream *stream = self.HTTPBodyStream; + NSMutableData *data = [[NSMutableData alloc] init]; + [stream open]; + while ([stream hasBytesAvailable]) { + NSInteger len = [stream read:d maxLength:1024]; + if (len > 0 && stream.streamError == nil) { + [data appendBytes:(void *)d length:len]; + } + } + bodyData = [data copy]; + [stream close]; + } else { + bodyData = self.HTTPBody; + } + bodyLength = [[bodyData gzippedData] length]; + } + return bodyLength; + } + ``` + +6. 在 `- (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response` 方法中将数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 + + ```objective-c + -(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response + { + if (response != nil) { + self.internalResponse = response; + [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; + } + + HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; + model.path = request.URL.path; + model.host = request.URL.host; + model.type = DMNetworkTrafficDataTypeRequest; + model.lineLength = [connection.currentRequest dgm_getLineLength]; + model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie]; + model.bodyLength = [connection.currentRequest dgm_getBodyLength]; + model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength]; + model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; + + NSDictionary *networkTrafficDictionary = [model convertToDictionary]; + [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; + return request; + } + ``` + + + +## 六、 电量消耗 + +移动设备上电量一直是比较敏感的问题,如果用户在某款 App 的时候发现耗电量严重、手机发热严重,那么用户很大可能会马上卸载这款 App。所以需要在开发阶段关心耗电量问题。 + +一般来说遇到耗电量较大,我们立马会想到是不是使用了定位、是不是使用了频繁网络请求、是不是不断循环做某件事情? + +开发阶段基本没啥问题,我们可以结合 `Instrucments` 里的 `Energy Log` 工具来定位问题。但是线上问题就需要代码去监控耗电量,可以作为 APM 的能力之一。 + + + +### 1. 如何获取电量 + +在 iOS 中,`IOKit` 是一个私有框架,用来获取硬件和设备的详细信息,也是硬件和内核服务通信的底层框架。所以我们可以通过 `IOKit `来获取硬件信息,从而获取到电量信息。步骤如下: + +- 首先在苹果开放源代码 opensource 中找到 [IOPowerSources.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPowerSources.h.auto.html)、[IOPSKeys.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPSKeys.h)。在 Xcode 的 `Package Contents` 里面找到 `IOKit.framework`。 路径为 `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework` +- 然后将 IOPowerSources.h、IOPSKeys.h、IOKit.framework 导入项目工程 +- 设置 UIDevice 的 batteryMonitoringEnabled 为 true +- 获取到的耗电量精确度为 1% + +### 2. 定位问题 + +通常我们通过 Instrucments 里的 Energy Log 解决了很多问题后,App 上线了,线上的耗电量解决就需要使用 APM 来解决了。耗电地方可能是二方库、三方库,也可能是某个同事的代码。 + +思路是:在检测到耗电后,先找到有问题的线程,然后堆栈 dump,还原案发现场。 + +在上面部分我们知道了线程信息的结构, `thread_basic_info` 中有个记录 CPU 使用率百分比的字段 `cpu_usage`。所以我们可以通过遍历当前线程,判断哪个线程的 CPU 使用率较高,从而找出有问题的线程。然后再 dump 堆栈,从而定位到发生耗电量的代码。详细请看 [3.2](#threadInfo) 部分。 + +```objective-c +- (double)fetchBatteryCostUsage +{ + // returns a blob of power source information in an opaque CFTypeRef + CFTypeRef blob = IOPSCopyPowerSourcesInfo(); + // returns a CFArray of power source handles, each of type CFTypeRef + CFArrayRef sources = IOPSCopyPowerSourcesList(blob); + CFDictionaryRef pSource = NULL; + const void *psValue; + // returns the number of values currently in an array + int numOfSources = CFArrayGetCount(sources); + // error in CFArrayGetCount + if (numOfSources == 0) { + NSLog(@"Error in CFArrayGetCount"); + return -1.0f; + } + + // calculating the remaining energy + for (int i=0; i, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)()` 并指定 队列的 qos 为 `QOS_CLASS_UTILITY`。将任务提交到这个队列的 block 中,在 QOS_CLASS_UTILITY 模式下,系统针对大量数据的计算,做了电量优化 + +除了 CPU 大量运算,I/O 操作也是耗电主要原因。业界常见方案都是将「碎片化的数据写入磁盘存储」这个操作延后,先在内存中聚合吗,然后再进行磁盘存储。碎片化数据先聚合,在内存中进行存储的机制,iOS 提供 `NSCache` 这个对象。 + +NSCache 是线程安全的,NSCache 会在达到达预设的缓存空间的条件时清理缓存,此时会触发 `- (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj;` 方法回调,在该方法内部对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 次数少了,对电量的消耗也就减少了。 + +NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读取缓存处理时,没直接读取硬盘文件(I/O),而是使用系统的 NSCache。 + +```objective-c +- (nullable UIImage *)imageFromMemoryCacheForKey:(nullable NSString *)key { + return [self.memoryCache objectForKey:key]; +} + +- (nullable UIImage *)imageFromDiskCacheForKey:(nullable NSString *)key { + UIImage *diskImage = [self diskImageForKey:key]; + if (diskImage && self.config.shouldCacheImagesInMemory) { + NSUInteger cost = diskImage.sd_memoryCost; + [self.memoryCache setObject:diskImage forKey:key cost:cost]; + } + + return diskImage; +} +``` + +可以看到主要逻辑是先从磁盘中读取图片,如果配置允许开启内存缓存,则将图片保存到 NSCache 中,使用的时候也是从 NSCache 中读取图片。NSCache 的 `totalCostLimit、countLimit` 属性, + +`- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;` 方法用来设置缓存条件。所以我们写磁盘、内存的文件操作时可以借鉴该策略,以优化耗电量。 + + + + +## 七、 Crash 监控 + +### 1. 异常相关知识回顾 + +#### 1.1 Mach 层对异常的处理 + +Mach 在消息传递基础上实现了一套独特的异常处理方法。Mach 异常处理在设计时考虑到: + +- 带有一致的语义的单一异常处理设施:Mach 只提供一个异常处理机制用于处理所有类型的异常(包括用户定义的异常、平台无关的异常以及平台特定的异常)。根据异常类型进行分组,具体的平台可以定义具体的子类型。 +- 清晰和简洁:异常处理的接口依赖于 Mach 已有的具有良好定义的消息和端口架构,因此非常优雅(不会影响效率)。这就允许调试器和外部处理程序的拓展-甚至在理论上还支持拓展基于网络的异常处理。 + +在 Mach 中,异常是通过内核中的基础设施-消息传递机制处理的。一个异常并不比一条消息复杂多少,异常由出错的线程或者任务(通过 msg_send()) 抛出,然后由一个处理程序通过 msg_recv())捕捉。处理程序可以处理异常,也可以清楚异常(将异常标记为已完成并继续),还可以决定终止线程。 + +Mach 的异常处理模型和其他的异常处理模型不同,其他模型的异常处理程序运行在出错的线程上下文中,而 Mach 的异常处理程序在不同的上下文中运行异常处理程序,出错的线程向预先指定好的异常端口发送消息,然后等待应答。每一个任务都可以注册一个异常处理端口,这个异常处理端口会对该任务中的所有线程生效。此外,每个线程都可以通过 `thread_set_exception_ports(<#thread_act_t thread#>, <#exception_mask_t exception_mask#>, <#mach_port_t new_port#>, <#exception_behavior_t behavior#>, <#thread_state_flavor_t new_flavor#>)` 注册自己的异常处理端口。通常情况下,任务和线程的异常端口都是 NULL,也就是异常不会被处理,而一旦创建异常端口,这些端口就像系统中的其他端口一样,可以转交给其他任务或者其他主机。(有了端口,就可以使用 UDP 协议,通过网络能力让其他的主机上应用程序处理异常)。 + +发生异常时,首先尝试将异常抛给线程的异常端口,然后尝试抛给任务的异常端口,最后再抛给主机的异常端口(即主机注册的默认端口)。如果没有一个端口返回 `KERN_SUCCESS`,那么整个任务将被终止。也就是 Mach 不提供异常处理逻辑,只提供传递异常通知的框架。 + +异常首先是由处理器陷阱引发的。为了处理陷阱,每一个现代的内核都会安插陷阱处理程序。这些底层函数是由内核的汇编部分安插的。 + + + +#### 1.2 BSD 层对异常的处理 + +BSD 层是用户态主要使用的 XUN 接口,这一层展示了一个符合 POSIX 标准的接口。开发者可以使用 UNIX 系统的一切功能,但不需要了解 Mach 层的细节实现。 + +Mach 已经通过异常机制提供了底层的陷进处理,而 BSD 则在异常机制之上构建了信号处理机制。硬件产生的信号被 Mach 层捕捉,然后转换为对应的 UNIX 信号,为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为 Mach 异常,然后再转换为信号。 + +Mach 异常都在 host 层被 `ux_exception` 转换为相应的 unix 信号,并通过 `threadsignal` 将信号投递到出错的线程。 + +![Mach 异常处理以及转换为 Unix 信号的流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-19-BSDCatchSignal.png) + + + +### 2. Crash 收集方式 + +iOS 系统自带的 Apples`s Crash Reporter 在设置中记录 Crash 日志,我们先观察下 Crash 日志 + +```shell +Incident Identifier: 7FA6736D-09E8-47A1-95EC-76C4522BDE1A +CrashReporter Key: 4e2d36419259f14413c3229e8b7235bcc74847f3 +Hardware Model: iPhone7,1 +Process: APMMonitorExample [3608] +Path: /var/containers/Bundle/Application/9518A4F4-59B7-44E9-BDDA-9FBEE8CA18E5/APMMonitorExample.app/APMMonitorExample +Identifier: com.Wacai.APMMonitorExample +Version: 1.0 (1) +Code Type: ARM-64 +Parent Process: ? [1] + +Date/Time: 2017-01-03 11:43:03.000 +0800 +OS Version: iOS 10.2 (14C92) +Report Version: 104 + +Exception Type: EXC_CRASH (SIGABRT) +Exception Codes: 0x00000000 at 0x0000000000000000 +Crashed Thread: 0 + +Application Specific Information: +*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSSingleObjectArrayI objectForKey:]: unrecognized selector sent to instance 0x174015060' + +Thread 0 Crashed: +0 CoreFoundation 0x0000000188f291b8 0x188df9000 + 1245624 ( + 124) +1 libobjc.A.dylib 0x000000018796055c 0x187958000 + 34140 (objc_exception_throw + 56) +2 CoreFoundation 0x0000000188f30268 0x188df9000 + 1274472 ( + 140) +3 CoreFoundation 0x0000000188f2d270 0x188df9000 + 1262192 ( + 916) +4 CoreFoundation 0x0000000188e2680c 0x188df9000 + 186380 (_CF_forwarding_prep_0 + 92) +5 APMMonitorExample 0x000000010004c618 0x100044000 + 34328 (-[MakeCrashHandler throwUncaughtNSException] + 80) +``` + +会发现,Crash 日志中 `Exception Type` 项由2部分组成:Mach 异常 + Unix 信号。 + +所以 `Exception Type: EXC_CRASH (SIGABRT)` 表示:Mach 层发生了 `EXC_CRASH` 异常,在 host 层被转换为 `SIGABRT` 信号投递到出错的线程。 + + + +**问题:** 捕获 Mach 层异常、注册 Unix 信号处理都可以捕获 Crash,这两种方式如何选择? + +**答:** 优选 Mach 层异常拦截。根据上面 1.2 中的描述我们知道 Mach 层异常处理时机更早些,假如 Mach 层异常处理程序让进程退出,这样 Unix 信号永远不会发生了。 + + + +业界关于崩溃日志的收集开源项目很多,著名的有: KSCrash、plcrashreporter,提供一条龙服务的 Bugly、友盟等。我们一般使用开源项目在此基础上开发成符合公司内部需求的 bug 收集工具。一番对比后选择 KSCrash。为什么选择 KSCrash 不在本文重点。 + + + +KSCrash 功能齐全,可以捕获如下类型的 Crash + +- Mach kernel exceptions +- Fatal signals +- C++ exceptions +- Objective-C exceptions +- Main thread deadlock (experimental) +- Custom crashes (e.g. from scripting languages) + +所以分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 监控实现原理。 + +#### 2.1. Mach 层异常处理 + +大体思路是:先创建一个异常处理端口,为该端口申请权限,再设置异常端口、新建一个内核线程,在该线程内循环等待异常。但是为了防止自己注册的 Mach 层异常处理抢占了其他 SDK、或者业务线开发者设置的逻辑,我们需要在最开始保存其他的异常处理端口,等逻辑执行完后将异常处理交给其他的端口内的逻辑处理。收集到 Crash 信息后组装数据,写入 json 文件。 + +流程图如下: + +![KSCrash流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-20-KSCrashStructure.png) + + + +对于 Mach 异常捕获,可以注册一个异常端口,该端口负责对当前任务的所有线程进行监听。 + +下面来看看关键代码: + +注册 Mach 层异常监听代码 + +```objective-c +static bool installExceptionHandler() +{ + KSLOG_DEBUG("Installing mach exception handler."); + + bool attributes_created = false; + pthread_attr_t attr; + + kern_return_t kr; + int error; + // 拿到当前进程 + const task_t thisTask = mach_task_self(); + exception_mask_t mask = EXC_MASK_BAD_ACCESS | + EXC_MASK_BAD_INSTRUCTION | + EXC_MASK_ARITHMETIC | + EXC_MASK_SOFTWARE | + EXC_MASK_BREAKPOINT; + + KSLOG_DEBUG("Backing up original exception ports."); + // 获取该 Task 上的注册好的异常端口 + kr = task_get_exception_ports(thisTask, + mask, + g_previousExceptionPorts.masks, + &g_previousExceptionPorts.count, + g_previousExceptionPorts.ports, + g_previousExceptionPorts.behaviors, + g_previousExceptionPorts.flavors); + // 获取失败走 failed 逻辑 + if(kr != KERN_SUCCESS) + { + KSLOG_ERROR("task_get_exception_ports: %s", mach_error_string(kr)); + goto failed; + } + // KSCrash 的异常为空则走执行逻辑 + if(g_exceptionPort == MACH_PORT_NULL) + { + KSLOG_DEBUG("Allocating new port with receive rights."); + // 申请异常处理端口 + kr = mach_port_allocate(thisTask, + MACH_PORT_RIGHT_RECEIVE, + &g_exceptionPort); + if(kr != KERN_SUCCESS) + { + KSLOG_ERROR("mach_port_allocate: %s", mach_error_string(kr)); + goto failed; + } + + KSLOG_DEBUG("Adding send rights to port."); + // 为异常处理端口申请权限:MACH_MSG_TYPE_MAKE_SEND + kr = mach_port_insert_right(thisTask, + g_exceptionPort, + g_exceptionPort, + MACH_MSG_TYPE_MAKE_SEND); + if(kr != KERN_SUCCESS) + { + KSLOG_ERROR("mach_port_insert_right: %s", mach_error_string(kr)); + goto failed; + } + } + + KSLOG_DEBUG("Installing port as exception handler."); + // 为该 Task 设置异常处理端口 + kr = task_set_exception_ports(thisTask, + mask, + g_exceptionPort, + EXCEPTION_DEFAULT, + THREAD_STATE_NONE); + if(kr != KERN_SUCCESS) + { + KSLOG_ERROR("task_set_exception_ports: %s", mach_error_string(kr)); + goto failed; + } + + KSLOG_DEBUG("Creating secondary exception thread (suspended)."); + pthread_attr_init(&attr); + attributes_created = true; + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + // 设置监控线程 + error = pthread_create(&g_secondaryPThread, + &attr, + &handleExceptions, + kThreadSecondary); + if(error != 0) + { + KSLOG_ERROR("pthread_create_suspended_np: %s", strerror(error)); + goto failed; + } + // 转换为 Mach 内核线程 + g_secondaryMachThread = pthread_mach_thread_np(g_secondaryPThread); + ksmc_addReservedThread(g_secondaryMachThread); + + KSLOG_DEBUG("Creating primary exception thread."); + error = pthread_create(&g_primaryPThread, + &attr, + &handleExceptions, + kThreadPrimary); + if(error != 0) + { + KSLOG_ERROR("pthread_create: %s", strerror(error)); + goto failed; + } + pthread_attr_destroy(&attr); + g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread); + ksmc_addReservedThread(g_primaryMachThread); + + KSLOG_DEBUG("Mach exception handler installed."); + return true; + + +failed: + KSLOG_DEBUG("Failed to install mach exception handler."); + if(attributes_created) + { + pthread_attr_destroy(&attr); + } + // 还原之前的异常注册端口,将控制权还原 + uninstallExceptionHandler(); + return false; +} +``` + +处理异常的逻辑、组装崩溃信息 + +```objective-c +/** Our exception handler thread routine. + * Wait for an exception message, uninstall our exception port, record the + * exception information, and write a report. + */ +static void* handleExceptions(void* const userData) +{ + MachExceptionMessage exceptionMessage = {{0}}; + MachReplyMessage replyMessage = {{0}}; + char* eventID = g_primaryEventID; + + const char* threadName = (const char*) userData; + pthread_setname_np(threadName); + if(threadName == kThreadSecondary) + { + KSLOG_DEBUG("This is the secondary thread. Suspending."); + thread_suspend((thread_t)ksthread_self()); + eventID = g_secondaryEventID; + } + // 循环读取注册好的异常端口信息 + for(;;) + { + KSLOG_DEBUG("Waiting for mach exception"); + + // Wait for a message. + kern_return_t kr = mach_msg(&exceptionMessage.header, + MACH_RCV_MSG, + 0, + sizeof(exceptionMessage), + g_exceptionPort, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL); + // 获取到信息后则代表发生了 Mach 层异常,跳出 for 循环,组装数据 + if(kr == KERN_SUCCESS) + { + break; + } + + // Loop and try again on failure. + KSLOG_ERROR("mach_msg: %s", mach_error_string(kr)); + } + + KSLOG_DEBUG("Trapped mach exception code 0x%x, subcode 0x%x", + exceptionMessage.code[0], exceptionMessage.code[1]); + if(g_isEnabled) + { + // 挂起所有线程 + ksmc_suspendEnvironment(); + g_isHandlingCrash = true; + // 通知发生了异常 + kscm_notifyFatalExceptionCaptured(true); + + KSLOG_DEBUG("Exception handler is installed. Continuing exception handling."); + + + // Switch to the secondary thread if necessary, or uninstall the handler + // to avoid a death loop. + if(ksthread_self() == g_primaryMachThread) + { + KSLOG_DEBUG("This is the primary exception thread. Activating secondary thread."); +// TODO: This was put here to avoid a freeze. Does secondary thread ever fire? + restoreExceptionPorts(); + if(thread_resume(g_secondaryMachThread) != KERN_SUCCESS) + { + KSLOG_DEBUG("Could not activate secondary thread. Restoring original exception ports."); + } + } + else + { + KSLOG_DEBUG("This is the secondary exception thread. Restoring original exception ports."); +// restoreExceptionPorts(); + } + + // Fill out crash information + // 组装异常所需要的方案现场信息 + KSLOG_DEBUG("Fetching machine state."); + KSMC_NEW_CONTEXT(machineContext); + KSCrash_MonitorContext* crashContext = &g_monitorContext; + crashContext->offendingMachineContext = machineContext; + kssc_initCursor(&g_stackCursor, NULL, NULL); + if(ksmc_getContextForThread(exceptionMessage.thread.name, machineContext, true)) + { + kssc_initWithMachineContext(&g_stackCursor, 100, machineContext); + KSLOG_TRACE("Fault address 0x%x, instruction address 0x%x", kscpu_faultAddress(machineContext), kscpu_instructionAddress(machineContext)); + if(exceptionMessage.exception == EXC_BAD_ACCESS) + { + crashContext->faultAddress = kscpu_faultAddress(machineContext); + } + else + { + crashContext->faultAddress = kscpu_instructionAddress(machineContext); + } + } + + KSLOG_DEBUG("Filling out context."); + crashContext->crashType = KSCrashMonitorTypeMachException; + crashContext->eventID = eventID; + crashContext->registersAreValid = true; + crashContext->mach.type = exceptionMessage.exception; + crashContext->mach.code = exceptionMessage.code[0]; + crashContext->mach.subcode = exceptionMessage.code[1]; + if(crashContext->mach.code == KERN_PROTECTION_FAILURE && crashContext->isStackOverflow) + { + // A stack overflow should return KERN_INVALID_ADDRESS, but + // when a stack blasts through the guard pages at the top of the stack, + // it generates KERN_PROTECTION_FAILURE. Correct for this. + crashContext->mach.code = KERN_INVALID_ADDRESS; + } + crashContext->signal.signum = signalForMachException(crashContext->mach.type, crashContext->mach.code); + crashContext->stackCursor = &g_stackCursor; + + kscm_handleException(crashContext); + + KSLOG_DEBUG("Crash handling complete. Restoring original handlers."); + g_isHandlingCrash = false; + ksmc_resumeEnvironment(); + } + + KSLOG_DEBUG("Replying to mach exception message."); + // Send a reply saying "I didn't handle this exception". + replyMessage.header = exceptionMessage.header; + replyMessage.NDR = exceptionMessage.NDR; + replyMessage.returnCode = KERN_FAILURE; + + mach_msg(&replyMessage.header, + MACH_SEND_MSG, + sizeof(replyMessage), + 0, + MACH_PORT_NULL, + MACH_MSG_TIMEOUT_NONE, + MACH_PORT_NULL); + + return NULL; +} +``` + +还原异常处理端口,转移控制权 + +```objective-c +/** Restore the original mach exception ports. + */ +static void restoreExceptionPorts(void) +{ + KSLOG_DEBUG("Restoring original exception ports."); + if(g_previousExceptionPorts.count == 0) + { + KSLOG_DEBUG("Original exception ports were already restored."); + return; + } + + const task_t thisTask = mach_task_self(); + kern_return_t kr; + + // Reinstall old exception ports. + // for 循环去除保存好的在 KSCrash 之前注册好的异常端口,将每个端口注册回去 + for(mach_msg_type_number_t i = 0; i < g_previousExceptionPorts.count; i++) + { + KSLOG_TRACE("Restoring port index %d", i); + kr = task_set_exception_ports(thisTask, + g_previousExceptionPorts.masks[i], + g_previousExceptionPorts.ports[i], + g_previousExceptionPorts.behaviors[i], + g_previousExceptionPorts.flavors[i]); + if(kr != KERN_SUCCESS) + { + KSLOG_ERROR("task_set_exception_ports: %s", + mach_error_string(kr)); + } + } + KSLOG_DEBUG("Exception ports restored."); + g_previousExceptionPorts.count = 0; +} +``` + + + +#### 2.2. Signal 异常处理 + +对于 Mach 异常,操作系统会将其转换为对应的 `Unix 信号`,所以开发者可以通过注册 `signanHandler` 的方式来处理。 + +KSCrash 在这里的处理逻辑如下图: + +![signal 处理步骤](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-20-signalCrash.png) + +看一下关键代码: + +设置信号处理函数 + +```objective-c +static bool installSignalHandler() +{ + KSLOG_DEBUG("Installing signal handler."); + +#if KSCRASH_HAS_SIGNAL_STACK + // 在堆上分配一块内存, + if(g_signalStack.ss_size == 0) + { + KSLOG_DEBUG("Allocating signal stack area."); + g_signalStack.ss_size = SIGSTKSZ; + g_signalStack.ss_sp = malloc(g_signalStack.ss_size); + } + // 信号处理函数的栈挪到堆中,而不和进程共用一块栈区 + // sigaltstack() 函数,该函数的第 1 个参数 sigstack 是一个 stack_t 结构的指针,该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数 old_sigstack 也是一个 stack_t 类型指针,它用来返回上一次建立的“可替换信号栈”的信息(如果有的话) + KSLOG_DEBUG("Setting signal stack area."); + // sigaltstack 第一个参数为创建的新的可替换信号栈,第二个参数可以设置为NULL,如果不为NULL的话,将会将旧的可替换信号栈的信息保存在里面。函数成功返回0,失败返回-1. + if(sigaltstack(&g_signalStack, NULL) != 0) + { + KSLOG_ERROR("signalstack: %s", strerror(errno)); + goto failed; + } +#endif + + const int* fatalSignals = kssignal_fatalSignals(); + int fatalSignalsCount = kssignal_numFatalSignals(); + + if(g_previousSignalHandlers == NULL) + { + KSLOG_DEBUG("Allocating memory to store previous signal handlers."); + g_previousSignalHandlers = malloc(sizeof(*g_previousSignalHandlers) + * (unsigned)fatalSignalsCount); + } + + // 设置信号处理函数 sigaction 的第二个参数,类型为 sigaction 结构体 + struct sigaction action = {{0}}; + // sa_flags 成员设立 SA_ONSTACK 标志,该标志告诉内核信号处理函数的栈帧就在“可替换信号栈”上建立。 + action.sa_flags = SA_SIGINFO | SA_ONSTACK; +#if KSCRASH_HOST_APPLE && defined(__LP64__) + action.sa_flags |= SA_64REGSET; +#endif + sigemptyset(&action.sa_mask); + action.sa_sigaction = &handleSignal; + + // 遍历需要处理的信号数组 + for(int i = 0; i < fatalSignalsCount; i++) + { + // 将每个信号的处理函数绑定到上面声明的 action 去,另外用 g_previousSignalHandlers 保存当前信号的处理函数 + KSLOG_DEBUG("Assigning handler for signal %d", fatalSignals[i]); + if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0) + { + char sigNameBuff[30]; + const char* sigName = kssignal_signalName(fatalSignals[i]); + if(sigName == NULL) + { + snprintf(sigNameBuff, sizeof(sigNameBuff), "%d", fatalSignals[i]); + sigName = sigNameBuff; + } + KSLOG_ERROR("sigaction (%s): %s", sigName, strerror(errno)); + // Try to reverse the damage + for(i--;i >= 0; i--) + { + sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL); + } + goto failed; + } + } + KSLOG_DEBUG("Signal handlers installed."); + return true; + +failed: + KSLOG_DEBUG("Failed to install signal handlers."); + return false; +} +``` + +信号处理时记录线程等上下文信息 + +```objective-c +static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext) +{ + KSLOG_DEBUG("Trapped signal %d", sigNum); + if(g_isEnabled) + { + ksmc_suspendEnvironment(); + kscm_notifyFatalExceptionCaptured(false); + + KSLOG_DEBUG("Filling out context."); + KSMC_NEW_CONTEXT(machineContext); + ksmc_getContextForSignal(userContext, machineContext); + kssc_initWithMachineContext(&g_stackCursor, 100, machineContext); + // 记录信号处理时的上下文信息 + KSCrash_MonitorContext* crashContext = &g_monitorContext; + memset(crashContext, 0, sizeof(*crashContext)); + crashContext->crashType = KSCrashMonitorTypeSignal; + crashContext->eventID = g_eventID; + crashContext->offendingMachineContext = machineContext; + crashContext->registersAreValid = true; + crashContext->faultAddress = (uintptr_t)signalInfo->si_addr; + crashContext->signal.userContext = userContext; + crashContext->signal.signum = signalInfo->si_signo; + crashContext->signal.sigcode = signalInfo->si_code; + crashContext->stackCursor = &g_stackCursor; + + kscm_handleException(crashContext); + ksmc_resumeEnvironment(); + } + + KSLOG_DEBUG("Re-raising signal for regular handlers to catch."); + // This is technically not allowed, but it works in OSX and iOS. + raise(sigNum); +} +``` + +KSCrash 信号处理后还原之前的信号处理权限 + +```objective-c +static void uninstallSignalHandler(void) +{ + KSLOG_DEBUG("Uninstalling signal handlers."); + + const int* fatalSignals = kssignal_fatalSignals(); + int fatalSignalsCount = kssignal_numFatalSignals(); + // 遍历需要处理信号数组,将之前的信号处理函数还原 + for(int i = 0; i < fatalSignalsCount; i++) + { + KSLOG_DEBUG("Restoring original handler for signal %d", fatalSignals[i]); + sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL); + } + + KSLOG_DEBUG("Signal handlers uninstalled."); +} +``` + +说明: + +1. 先从堆上分配一块内存区域,被称为“可替换信号栈”,目的是将信号处理函数的栈干掉,用堆上的内存区域代替,而不和进程共用一块栈区。 + + 为什么这么做?一个进程可能有 n 个线程,每个线程都有自己的任务,假如某个线程执行出错,这样就会导致整个进程的崩溃。所以为了信号处理函数正常运行,需要为信号处理函数设置单独的运行空间。另一种情况是递归函数将系统默认的栈空间用尽了,但是信号处理函数使用的栈是它实现在堆中分配的空间,而不是系统默认的栈,所以它仍旧可以正常工作。 + +2. `int sigaltstack(const stack_t * __restrict, stack_t * __restrict)` 函数的二个参数都是 `stack_t` 结构的指针,存储了可替换信号栈的信息(栈的起始地址、栈的长度、状态)。第1个参数该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数用来返回上一次建立的“可替换信号栈”的信息(如果有的话)。 + + ```c + _STRUCT_SIGALTSTACK + { + void *ss_sp; /* signal stack base */ + __darwin_size_t ss_size; /* signal stack length */ + int ss_flags; /* SA_DISABLE and/or SA_ONSTACK */ + }; + typedef _STRUCT_SIGALTSTACK stack_t; /* [???] signal stack */ + ``` + + 新创建的可替换信号栈,`ss_flags` 必须设置为 0。系统定义了 `SIGSTKSZ` 常量,可满足绝大多可替换信号栈的需求。 + + ```c + /* + * Structure used in sigaltstack call. + */ + + #define SS_ONSTACK 0x0001 /* take signal on signal stack */ + #define SS_DISABLE 0x0004 /* disable taking signals on alternate stack */ + #define MINSIGSTKSZ 32768 /* (32K)minimum allowable stack */ + #define SIGSTKSZ 131072 /* (128K)recommended stack size */ + ``` + + `sigaltstack` 系统调用通知内核“可替换信号栈”已经建立。 + + `ss_flags` 为 `SS_ONSTACK` 时,表示进程当前正在“可替换信号栈”中执行,如果此时试图去建立一个新的“可替换信号栈”,那么会遇到 `EPERM` (禁止该动作) 的错误;为 `SS_DISABLE` 说明当前没有已建立的“可替换信号栈”,禁止建立“可替换信号栈”。 + +3. `int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);` + + 第一个函数表示需要处理的信号值,但不能是 `SIGKILL` 和 `SIGSTOP` ,这两个信号的处理函数不允许用户重写,因为它们给超级用户提供了终止程序的方法( `SIGKILL` and `SIGSTOP` cannot be caught, blocked, or ignored); + + 第二个和第三个参数是一个 `sigaction` 结构体。如果第二个参数不为空则代表将其指向信号处理函数,第三个参数不为空,则将之前的信号处理函数保存到该指针中。如果第二个参数为空,第三个参数不为空,则可以获取当前的信号处理函数。 + + ```c + /* + * Signal vector "template" used in sigaction call. + */ + struct sigaction { + union __sigaction_u __sigaction_u; /* signal handler */ + sigset_t sa_mask; /* signal mask to apply */ + int sa_flags; /* see signal options below */ + }; + ``` + + `sigaction` 函数的 `sa_flags` 参数需要设置 `SA_ONSTACK` 标志,告诉内核信号处理函数的栈帧就在“可替换信号栈”上建立。 + + + +#### 2.3. C++ 异常处理 + +c++ 异常处理的实现是依靠了标准库的 `std::set_terminate(CPPExceptionTerminate)` 函数。 + +iOS 工程中某些功能的实现可能使用了C、C++等。假如抛出 C++ 异常,如果该异常可以被转换为 NSException,则走 OC 异常捕获机制,如果不能转换,则继续走 C++ 异常流程,也就是 `default_terminate_handler`。这个 C++ 异常的默认 terminate 函数内部调用 `abort_message` 函数,最后触发了一个 `abort` 调用,系统产生一个 `SIGABRT` 信号。 + +在系统抛出 C++ 异常后,加一层 `try...catch...` 来判断该异常是否可以转换为 `NSException`,再重新抛出的C++异常。此时异常的现场堆栈已经消失,所以上层通过捕获 `SIGABRT` 信号是无法还原发生异常时的场景,即异常堆栈缺失。 + +为什么?`try...catch...` 语句内部会调用 `__cxa_rethrow()` 抛出异常,`__cxa_rethrow()` 内部又会调用 `unwind`,`unwind` 可以简单理解为函数调用的逆调用,主要用来清理函数调用过程中每个函数生成的局部变量,一直到最外层的 catch 语句所在的函数,并把控制移交给 catch 语句,这就是C++异常的堆栈消失原因。 + +```c++ +static void setEnabled(bool isEnabled) +{ + if(isEnabled != g_isEnabled) + { + g_isEnabled = isEnabled; + if(isEnabled) + { + initialize(); + + ksid_generate(g_eventID); + g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate); + } + else + { + std::set_terminate(g_originalTerminateHandler); + } + g_captureNextStackTrace = isEnabled; + } +} + +static void initialize() +{ + static bool isInitialized = false; + if(!isInitialized) + { + isInitialized = true; + kssc_initCursor(&g_stackCursor, NULL, NULL); + } +} + +void kssc_initCursor(KSStackCursor *cursor, + void (*resetCursor)(KSStackCursor*), + bool (*advanceCursor)(KSStackCursor*)) +{ + cursor->symbolicate = kssymbolicator_symbolicate; + cursor->advanceCursor = advanceCursor != NULL ? advanceCursor : g_advanceCursor; + cursor->resetCursor = resetCursor != NULL ? resetCursor : kssc_resetCursor; + cursor->resetCursor(cursor); +} +``` + + + +```c++ +static void CPPExceptionTerminate(void) +{ + ksmc_suspendEnvironment(); + KSLOG_DEBUG("Trapped c++ exception"); + const char* name = NULL; + std::type_info* tinfo = __cxxabiv1::__cxa_current_exception_type(); + if(tinfo != NULL) + { + name = tinfo->name(); + } + + if(name == NULL || strcmp(name, "NSException") != 0) + { + kscm_notifyFatalExceptionCaptured(false); + KSCrash_MonitorContext* crashContext = &g_monitorContext; + memset(crashContext, 0, sizeof(*crashContext)); + + char descriptionBuff[DESCRIPTION_BUFFER_LENGTH]; + const char* description = descriptionBuff; + descriptionBuff[0] = 0; + + KSLOG_DEBUG("Discovering what kind of exception was thrown."); + g_captureNextStackTrace = false; + try + { + throw; + } + catch(std::exception& exc) + { + strncpy(descriptionBuff, exc.what(), sizeof(descriptionBuff)); + } +#define CATCH_VALUE(TYPE, PRINTFTYPE) \ +catch(TYPE value)\ +{ \ + snprintf(descriptionBuff, sizeof(descriptionBuff), "%" #PRINTFTYPE, value); \ +} + CATCH_VALUE(char, d) + CATCH_VALUE(short, d) + CATCH_VALUE(int, d) + CATCH_VALUE(long, ld) + CATCH_VALUE(long long, lld) + CATCH_VALUE(unsigned char, u) + CATCH_VALUE(unsigned short, u) + CATCH_VALUE(unsigned int, u) + CATCH_VALUE(unsigned long, lu) + CATCH_VALUE(unsigned long long, llu) + CATCH_VALUE(float, f) + CATCH_VALUE(double, f) + CATCH_VALUE(long double, Lf) + CATCH_VALUE(char*, s) + catch(...) + { + description = NULL; + } + g_captureNextStackTrace = g_isEnabled; + + // TODO: Should this be done here? Maybe better in the exception handler? + KSMC_NEW_CONTEXT(machineContext); + ksmc_getContextForThread(ksthread_self(), machineContext, true); + + KSLOG_DEBUG("Filling out context."); + crashContext->crashType = KSCrashMonitorTypeCPPException; + crashContext->eventID = g_eventID; + crashContext->registersAreValid = false; + crashContext->stackCursor = &g_stackCursor; + crashContext->CPPException.name = name; + crashContext->exceptionName = name; + crashContext->crashReason = description; + crashContext->offendingMachineContext = machineContext; + + kscm_handleException(crashContext); + } + else + { + KSLOG_DEBUG("Detected NSException. Letting the current NSException handler deal with it."); + } + ksmc_resumeEnvironment(); + + KSLOG_DEBUG("Calling original terminate handler."); + g_originalTerminateHandler(); +} +``` + + + +#### 2.4. Objective-C 异常处理 + +对于 OC 层面的 NSException 异常处理较为容易,可以通过注册 `NSUncaughtExceptionHandler` 来捕获异常信息,通过 NSException 参数来做 Crash 信息的收集,交给数据上报组件。 + +```c++ +static void setEnabled(bool isEnabled) +{ + if(isEnabled != g_isEnabled) + { + g_isEnabled = isEnabled; + if(isEnabled) + { + KSLOG_DEBUG(@"Backing up original handler."); + // 记录之前的 OC 异常处理函数 + g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler(); + + KSLOG_DEBUG(@"Setting new handler."); + // 设置新的 OC 异常处理函数 + NSSetUncaughtExceptionHandler(&handleException); + KSCrash.sharedInstance.uncaughtExceptionHandler = &handleException; + } + else + { + KSLOG_DEBUG(@"Restoring original handler."); + NSSetUncaughtExceptionHandler(g_previousUncaughtExceptionHandler); + } + } +} +``` + + + +#### 2.5. 主线程死锁 + +主线程死锁的检测和 ANR 的检测有些类似 + +- 创建一个线程,在线程运行方法中用 `do...while...` 循环处理逻辑,加了 autorelease 避免内存过高 + +- 有一个 `awaitingResponse` 属性和 `watchdogPulse` 方法。watchdogPulse 主要逻辑为设置 `awaitingResponse` 为 YES,切换到主线程中,设置 `awaitingResponse` 为 NO, + + ```objective-c + - (void) watchdogPulse + { + __block id blockSelf = self; + self.awaitingResponse = YES; + dispatch_async(dispatch_get_main_queue(), ^ + { + [blockSelf watchdogAnswer]; + }); + } + ``` + +- 线程的执行方法里面不断循环,等待设置的 `g_watchdogInterval` 后判断 `awaitingResponse` 的属性值是不是初始状态的值,否则判断为死锁 + + ```objective-c + - (void) runMonitor + { + BOOL cancelled = NO; + do + { + // Only do a watchdog check if the watchdog interval is > 0. + // If the interval is <= 0, just idle until the user changes it. + @autoreleasepool { + NSTimeInterval sleepInterval = g_watchdogInterval; + BOOL runWatchdogCheck = sleepInterval > 0; + if(!runWatchdogCheck) + { + sleepInterval = kIdleInterval; + } + [NSThread sleepForTimeInterval:sleepInterval]; + cancelled = self.monitorThread.isCancelled; + if(!cancelled && runWatchdogCheck) + { + if(self.awaitingResponse) + { + [self handleDeadlock]; + } + else + { + [self watchdogPulse]; + } + } + } + } while (!cancelled); + } + ``` + +#### 2.6 Crash 的生成与保存 + +##### 2.6.1 Crash 日志的生成逻辑 + +上面的部分讲过了 iOS 应用开发中的各种 crash 监控逻辑,接下来就应该分析下 crash 捕获后如何将 crash 信息记录下来,也就是保存到应用沙盒中。 + +拿主线程死锁这种 crash 举例子,看看 KSCrash 是如何记录 crash 信息的。 + +```c +// KSCrashMonitor_Deadlock.m +- (void) handleDeadlock +{ + ksmc_suspendEnvironment(); + kscm_notifyFatalExceptionCaptured(false); + + KSMC_NEW_CONTEXT(machineContext); + ksmc_getContextForThread(g_mainQueueThread, machineContext, false); + KSStackCursor stackCursor; + kssc_initWithMachineContext(&stackCursor, 100, machineContext); + char eventID[37]; + ksid_generate(eventID); + + KSLOG_DEBUG(@"Filling out context."); + KSCrash_MonitorContext* crashContext = &g_monitorContext; + memset(crashContext, 0, sizeof(*crashContext)); + crashContext->crashType = KSCrashMonitorTypeMainThreadDeadlock; + crashContext->eventID = eventID; + crashContext->registersAreValid = false; + crashContext->offendingMachineContext = machineContext; + crashContext->stackCursor = &stackCursor; + + kscm_handleException(crashContext); + ksmc_resumeEnvironment(); + + KSLOG_DEBUG(@"Calling abort()"); + abort(); +} +``` + +其他几个 crash 也是一样,异常信息经过包装交给 `kscm_handleException()` 函数处理。可以看到这个函数被其他几种 crash 捕获后所调用。 + +![caller](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-03-KSCrashCaller.png) + +```c + +/** Start general exception processing. + * + * @oaram context Contextual information about the exception. + */ +void kscm_handleException(struct KSCrash_MonitorContext* context) +{ + context->requiresAsyncSafety = g_requiresAsyncSafety; + if(g_crashedDuringExceptionHandling) + { + context->crashedDuringCrashHandling = true; + } + for(int i = 0; i < g_monitorsCount; i++) + { + Monitor* monitor = &g_monitors[i]; + // 判断当前的 crash 监控是开启状态 + if(isMonitorEnabled(monitor)) + { + // 针对每种 crash 类型做一些额外的补充信息 + addContextualInfoToEvent(monitor, context); + } + } + // 真正处理 crash 信息,保存 json 格式的 crash 信息 + g_onExceptionEvent(context); + + + if(g_handlingFatalException && !g_crashedDuringExceptionHandling) + { + KSLOG_DEBUG("Exception is fatal. Restoring original handlers."); + kscm_setActiveMonitors(KSCrashMonitorTypeNone); + } +} +``` + +`g_onExceptionEvent` 是一个 block,声明为 `static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);` 在 `KSCrashMonitor.c` 中被赋值 + +```c +void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monitorContext)) +{ + g_onExceptionEvent = onEvent; +} +``` + +`kscm_setEventCallback()` 函数在 `KSCrashC.c` 文件中被调用 + +```c +KSCrashMonitorType kscrash_install(const char* appName, const char* const installPath) +{ + KSLOG_DEBUG("Installing crash reporter."); + + if(g_installed) + { + KSLOG_DEBUG("Crash reporter already installed."); + return g_monitoring; + } + g_installed = 1; + + char path[KSFU_MAX_PATH_LENGTH]; + snprintf(path, sizeof(path), "%s/Reports", installPath); + ksfu_makePath(path); + kscrs_initialize(appName, path); + + snprintf(path, sizeof(path), "%s/Data", installPath); + ksfu_makePath(path); + snprintf(path, sizeof(path), "%s/Data/CrashState.json", installPath); + kscrashstate_initialize(path); + + snprintf(g_consoleLogPath, sizeof(g_consoleLogPath), "%s/Data/ConsoleLog.txt", installPath); + if(g_shouldPrintPreviousLog) + { + printPreviousLog(g_consoleLogPath); + } + kslog_setLogFilename(g_consoleLogPath, true); + + ksccd_init(60); + // 设置 crash 发生时的 callback 函数 + kscm_setEventCallback(onCrash); + KSCrashMonitorType monitors = kscrash_setMonitoring(g_monitoring); + + KSLOG_DEBUG("Installation complete."); + return monitors; +} + +/** Called when a crash occurs. + * + * This function gets passed as a callback to a crash handler. + */ +static void onCrash(struct KSCrash_MonitorContext* monitorContext) +{ + KSLOG_DEBUG("Updating application state to note crash."); + kscrashstate_notifyAppCrash(); + monitorContext->consoleLogPath = g_shouldAddConsoleLogToReport ? g_consoleLogPath : NULL; + + // 正在处理 crash 的时候,发生了再次 crash + if(monitorContext->crashedDuringCrashHandling) + { + kscrashreport_writeRecrashReport(monitorContext, g_lastCrashReportFilePath); + } + else + { + // 1. 先根据当前时间创建新的 crash 的文件路径 + char crashReportFilePath[KSFU_MAX_PATH_LENGTH]; + kscrs_getNextCrashReportPath(crashReportFilePath); + // 2. 将新生成的文件路径保存到 g_lastCrashReportFilePath + strncpy(g_lastCrashReportFilePath, crashReportFilePath, sizeof(g_lastCrashReportFilePath)); + // 3. 将新生成的文件路径传入函数进行 crash 写入 + kscrashreport_writeStandardReport(monitorContext, crashReportFilePath); + } +} +``` + +接下来的函数就是具体的日志写入文件的实现。2个函数做的事情相似,都是格式化为 json 形式并写入文件。区别在于 crash 写入时如果再次发生 crash, 则走简易版的写入逻辑 `kscrashreport_writeRecrashReport()`,否则走标准的写入逻辑 `kscrashreport_writeStandardReport()`。 + +```c +bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, char* writeBuffer, int writeBufferLength) +{ + writer->buffer = writeBuffer; + writer->bufferLength = writeBufferLength; + writer->position = 0; + /* + open() 的第二个参数描述的是文件操作的权限 + #define O_RDONLY 0x0000 open for reading only + #define O_WRONLY 0x0001 open for writing only + #define O_RDWR 0x0002 open for reading and writing + #define O_ACCMODE 0x0003 mask for above mode + + #define O_CREAT 0x0200 create if nonexistant + #define O_TRUNC 0x0400 truncate to zero length + #define O_EXCL 0x0800 error if already exists + + 0755:即用户具有读/写/执行权限,组用户和其它用户具有读写权限; + 0644:即用户具有读写权限,组用户和其它用户具有只读权限; + 成功则返回文件描述符,若出现则返回 -1 + */ + writer->fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0644); + if(writer->fd < 0) + { + KSLOG_ERROR("Could not open crash report file %s: %s", path, strerror(errno)); + return false; + } + return true; +} +``` + + + +```c +/** + * Write a standard crash report to a file. + * + * @param monitorContext Contextual information about the crash and environment. + * The caller must fill this out before passing it in. + * + * @param path The file to write to. + */ +void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* const monitorContext, + const char* path) +{ + KSLOG_INFO("Writing crash report to %s", path); + char writeBuffer[1024]; + KSBufferedWriter bufferedWriter; + + if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer))) + { + return; + } + + ksccd_freeze(); + + KSJSONEncodeContext jsonContext; + jsonContext.userData = &bufferedWriter; + KSCrashReportWriter concreteWriter; + KSCrashReportWriter* writer = &concreteWriter; + prepareReportWriter(writer, &jsonContext); + + ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter); + + writer->beginObject(writer, KSCrashField_Report); + { + writeReportInfo(writer, + KSCrashField_Report, + KSCrashReportType_Standard, + monitorContext->eventID, + monitorContext->System.processName); + ksfu_flushBufferedWriter(&bufferedWriter); + + writeBinaryImages(writer, KSCrashField_BinaryImages); + ksfu_flushBufferedWriter(&bufferedWriter); + + writeProcessState(writer, KSCrashField_ProcessState, monitorContext); + ksfu_flushBufferedWriter(&bufferedWriter); + + writeSystemInfo(writer, KSCrashField_System, monitorContext); + ksfu_flushBufferedWriter(&bufferedWriter); + + writer->beginObject(writer, KSCrashField_Crash); + { + writeError(writer, KSCrashField_Error, monitorContext); + ksfu_flushBufferedWriter(&bufferedWriter); + writeAllThreads(writer, + KSCrashField_Threads, + monitorContext, + g_introspectionRules.enabled); + ksfu_flushBufferedWriter(&bufferedWriter); + } + writer->endContainer(writer); + + if(g_userInfoJSON != NULL) + { + addJSONElement(writer, KSCrashField_User, g_userInfoJSON, false); + ksfu_flushBufferedWriter(&bufferedWriter); + } + else + { + writer->beginObject(writer, KSCrashField_User); + } + if(g_userSectionWriteCallback != NULL) + { + ksfu_flushBufferedWriter(&bufferedWriter); + g_userSectionWriteCallback(writer); + } + writer->endContainer(writer); + ksfu_flushBufferedWriter(&bufferedWriter); + + writeDebugInfo(writer, KSCrashField_Debug, monitorContext); + } + writer->endContainer(writer); + + ksjson_endEncode(getJsonContext(writer)); + ksfu_closeBufferedWriter(&bufferedWriter); + ksccd_unfreeze(); +} + +/** Write a minimal crash report to a file. + * + * @param monitorContext Contextual information about the crash and environment. + * The caller must fill this out before passing it in. + * + * @param path The file to write to. + */ +void kscrashreport_writeRecrashReport(const struct KSCrash_MonitorContext* const monitorContext, + const char* path) +{ + char writeBuffer[1024]; + KSBufferedWriter bufferedWriter; + static char tempPath[KSFU_MAX_PATH_LENGTH]; + // 将传递过来的上份 crash report 文件名路径(/var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.json)修改为去掉 .json ,加上 .old 成为新的文件路径 /var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.old + + strncpy(tempPath, path, sizeof(tempPath) - 10); + strncpy(tempPath + strlen(tempPath) - 5, ".old", 5); + KSLOG_INFO("Writing recrash report to %s", path); + + if(rename(path, tempPath) < 0) + { + KSLOG_ERROR("Could not rename %s to %s: %s", path, tempPath, strerror(errno)); + } + // 根据传入路径来打开内存写入需要的文件 + if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer))) + { + return; + } + + ksccd_freeze(); + // json 解析的 c 代码 + KSJSONEncodeContext jsonContext; + jsonContext.userData = &bufferedWriter; + KSCrashReportWriter concreteWriter; + KSCrashReportWriter* writer = &concreteWriter; + prepareReportWriter(writer, &jsonContext); + + ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter); + + writer->beginObject(writer, KSCrashField_Report); + { + writeRecrash(writer, KSCrashField_RecrashReport, tempPath); + ksfu_flushBufferedWriter(&bufferedWriter); + if(remove(tempPath) < 0) + { + KSLOG_ERROR("Could not remove %s: %s", tempPath, strerror(errno)); + } + writeReportInfo(writer, + KSCrashField_Report, + KSCrashReportType_Minimal, + monitorContext->eventID, + monitorContext->System.processName); + ksfu_flushBufferedWriter(&bufferedWriter); + + writer->beginObject(writer, KSCrashField_Crash); + { + writeError(writer, KSCrashField_Error, monitorContext); + ksfu_flushBufferedWriter(&bufferedWriter); + int threadIndex = ksmc_indexOfThread(monitorContext->offendingMachineContext, + ksmc_getThreadFromContext(monitorContext->offendingMachineContext)); + writeThread(writer, + KSCrashField_CrashedThread, + monitorContext, + monitorContext->offendingMachineContext, + threadIndex, + false); + ksfu_flushBufferedWriter(&bufferedWriter); + } + writer->endContainer(writer); + } + writer->endContainer(writer); + + ksjson_endEncode(getJsonContext(writer)); + ksfu_closeBufferedWriter(&bufferedWriter); + ksccd_unfreeze(); +} +``` + + + +##### 2.6.2 Crash 日志的读取逻辑 + +当前 App 在 Crash 之后,KSCrash 将数据保存到 App 沙盒目录下,App 下次启动后我们读取存储的 crash 文件,然后处理数据并上传。 + +App 启动后函数调用: + +` [KSCrashInstallation sendAllReportsWithCompletion:]` -> `[KSCrash sendAllReportsWithCompletion:]` -> `[KSCrash allReports]` -> `[KSCrash reportWithIntID:]` ->`[KSCrash loadCrashReportJSONWithID:]` -> `kscrs_readReport ` + +在 `sendAllReportsWithCompletion` 里读取沙盒里的Crash 数据。 + +```objective-c +// 先通过读取文件夹,遍历文件夹内的文件数量来判断 crash 报告的个数 +static int getReportCount() +{ + int count = 0; + DIR* dir = opendir(g_reportsPath); + if(dir == NULL) + { + KSLOG_ERROR("Could not open directory %s", g_reportsPath); + goto done; + } + struct dirent* ent; + while((ent = readdir(dir)) != NULL) + { + if(getReportIDFromFilename(ent->d_name) > 0) + { + count++; + } + } + +done: + if(dir != NULL) + { + closedir(dir); + } + return count; +} + +// 通过 crash 文件个数、文件夹信息去遍历,一次获取到文件名(文件名的最后一部分就是 reportID),拿到 reportID 再去读取 crash 报告内的文件内容,写入数组 +- (NSArray*) allReports +{ + int reportCount = kscrash_getReportCount(); + int64_t reportIDs[reportCount]; + reportCount = kscrash_getReportIDs(reportIDs, reportCount); + NSMutableArray* reports = [NSMutableArray arrayWithCapacity:(NSUInteger)reportCount]; + for(int i = 0; i < reportCount; i++) + { + NSDictionary* report = [self reportWithIntID:reportIDs[i]]; + if(report != nil) + { + [reports addObject:report]; + } + } + + return reports; +} + +// 根据 reportID 找到 crash 信息 +- (NSDictionary*) reportWithIntID:(int64_t) reportID +{ + NSData* jsonData = [self loadCrashReportJSONWithID:reportID]; + if(jsonData == nil) + { + return nil; + } + + NSError* error = nil; + NSMutableDictionary* crashReport = [KSJSONCodec decode:jsonData + options:KSJSONDecodeOptionIgnoreNullInArray | + KSJSONDecodeOptionIgnoreNullInObject | + KSJSONDecodeOptionKeepPartialObject + error:&error]; + if(error != nil) + { + KSLOG_ERROR(@"Encountered error loading crash report %" PRIx64 ": %@", reportID, error); + } + if(crashReport == nil) + { + KSLOG_ERROR(@"Could not load crash report"); + return nil; + } + [self doctorReport:crashReport]; + + return crashReport; +} + +// reportID 读取 crash 内容并转换为 NSData 类型 +- (NSData*) loadCrashReportJSONWithID:(int64_t) reportID +{ + char* report = kscrash_readReport(reportID); + if(report != NULL) + { + return [NSData dataWithBytesNoCopy:report length:strlen(report) freeWhenDone:YES]; + } + return nil; +} + +// reportID 读取 crash 数据到 char 类型 +char* kscrash_readReport(int64_t reportID) +{ + if(reportID <= 0) + { + KSLOG_ERROR("Report ID was %" PRIx64, reportID); + return NULL; + } + + char* rawReport = kscrs_readReport(reportID); + if(rawReport == NULL) + { + KSLOG_ERROR("Failed to load report ID %" PRIx64, reportID); + return NULL; + } + + char* fixedReport = kscrf_fixupCrashReport(rawReport); + if(fixedReport == NULL) + { + KSLOG_ERROR("Failed to fixup report ID %" PRIx64, reportID); + } + + free(rawReport); + return fixedReport; +} + +// 多线程加锁,通过 reportID 执行 c 函数 getCrashReportPathByID,将路径设置到 path 上。然后执行 ksfu_readEntireFile 读取 crash 信息到 result +char* kscrs_readReport(int64_t reportID) +{ + pthread_mutex_lock(&g_mutex); + char path[KSCRS_MAX_PATH_LENGTH]; + getCrashReportPathByID(reportID, path); + char* result; + ksfu_readEntireFile(path, &result, NULL, 2000000); + pthread_mutex_unlock(&g_mutex); + return result; +} + +int kscrash_getReportIDs(int64_t* reportIDs, int count) +{ + return kscrs_getReportIDs(reportIDs, count); +} + +int kscrs_getReportIDs(int64_t* reportIDs, int count) +{ + pthread_mutex_lock(&g_mutex); + count = getReportIDs(reportIDs, count); + pthread_mutex_unlock(&g_mutex); + return count; +} +// 循环读取文件夹内容,根据 ent->d_name 调用 getReportIDFromFilename 函数,来获取 reportID,循环内部填充数组 +static int getReportIDs(int64_t* reportIDs, int count) +{ + int index = 0; + DIR* dir = opendir(g_reportsPath); + if(dir == NULL) + { + KSLOG_ERROR("Could not open directory %s", g_reportsPath); + goto done; + } + + struct dirent* ent; + while((ent = readdir(dir)) != NULL && index < count) + { + int64_t reportID = getReportIDFromFilename(ent->d_name); + if(reportID > 0) + { + reportIDs[index++] = reportID; + } + } + + qsort(reportIDs, (unsigned)count, sizeof(reportIDs[0]), compareInt64); + +done: + if(dir != NULL) + { + closedir(dir); + } + return index; +} + +// sprintf(参数1, 格式2) 函数将格式2的值返回到参数1上,然后执行 sscanf(参数1, 参数2, 参数3),函数将字符串参数1的内容,按照参数2的格式,写入到参数3上。crash 文件命名为 "App名称-report-reportID.json" +static int64_t getReportIDFromFilename(const char* filename) +{ + char scanFormat[100]; + sprintf(scanFormat, "%s-report-%%" PRIx64 ".json", g_appName); + + int64_t reportID = 0; + sscanf(filename, scanFormat, &reportID); + return reportID; +} +``` + +![KSCrash 存储 Crash 数据位置](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-31-KSCrashStoreCrashData.png) + + + +#### 2.7 前端 js 相关的 Crash 的监控 + +##### 2.7.1 JavascriptCore 异常监控 + +这部分简单粗暴,直接通过 JSContext 对象的 exceptionHandler 属性来监控,比如下面的代码 + +```objective-c +jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) { + // 处理 jscore 相关的异常信息 +}; +``` + +##### 2.7.2 h5 页面异常监控 + +当 h5 页面内的 Javascript 运行异常时会 window 对象会触发 `ErrorEvent` 接口的 error 事件,并执行 `window.onerror()`。 + +```js +window.onerror = function (msg, url, lineNumber, columnNumber, error) { + // 处理异常信息 +}; +``` + +![h5 异常监控](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-19-JSErrorCatch.png) + + + +##### 2.7.3 React Native 异常监控 + +小实验:下图是写了一个 RN Demo 工程,在 Debug Text 控件上加了事件监听代码,内部人为触发 crash + +```jsx +{1+qw;}}>Debug +``` + +对比组1: + +条件: iOS 项目 debug 模式。在 RN 端增加了异常处理的代码。 + +模拟器点击 `command + d` 调出面板,选择 Debug,打开 Chrome 浏览器, Mac 下快捷键 `Command + Option + J` 打开调试面板,就可以像调试 React 一样调试 RN 代码了。 + +![React Native Crash Monitor](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-19-ReactNativeCrashMonitor.png) + +查看到 crash stack 后点击可以跳转到 sourceMap 的地方。 + + + +Tips:RN 项目打 Release 包 + +- 在项目根目录下创建文件夹( release_iOS),作为资源的输出文件夹 + +- 在终端切换到工程目录,然后执行下面的代码 + + ```shell + react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map; + ``` + +- 将 release_iOS 文件夹内的 `.jsbundle` 和 `assets` 文件夹内容拖入到 iOS 工程中即可 + + + +对比组2: + +条件:iOS 项目 release 模式。在 RN 端不增加异常处理代码 + +操作:运行 iOS 工程,点击按钮模拟 crash + +现象:iOS 项目奔溃。截图以及日志如下 + +![RN crash](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-22-RNUncaughtCrash.png) + +```shell +2020-06-22 22:26:03.318 [info][tid:main][RCTRootView.m:294] Running application todos ({ + initialProps = { + }; + rootTag = 1; +}) +2020-06-22 22:26:03.490 [info][tid:com.facebook.react.JavaScript] Running "todos" with {"rootTag":1,"initialProps":{}} +2020-06-22 22:27:38.673 [error][tid:com.facebook.react.JavaScript] ReferenceError: Can't find variable: qw +2020-06-22 22:27:38.675 [fatal][tid:com.facebook.react.ExceptionsManagerQueue] Unhandled JS Exception: ReferenceError: Can't find variable: qw +2020-06-22 22:27:38.691300+0800 todos[16790:314161] *** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: ReferenceError: Can't find variable: qw', reason: 'Unhandled JS Exception: ReferenceError: Can't find variable: qw, stack: +onPress@397:1821 +@203:3896 +_performSideEffectsForTransition@210:9689 +_performSideEffectsForTransition@(null):(null) +_receiveSignal@210:8425 +_receiveSignal@(null):(null) +touchableHandleResponderRelease@210:5671 +touchableHandleResponderRelease@(null):(null) +onResponderRelease@203:3006 +b@97:1125 +S@97:1268 +w@97:1322 +R@97:1617 +M@97:2401 +forEach@(null):(null) +U@97:2201 +@97:13818 +Pe@97:90199 +Re@97:13478 +Ie@97:13664 +receiveTouches@97:14448 +value@27:3544 +@27:840 +value@27:2798 +value@27:812 +value@(null):(null) +' +*** First throw call stack: +( + 0 CoreFoundation 0x00007fff23e3cf0e __exceptionPreprocess + 350 + 1 libobjc.A.dylib 0x00007fff50ba89b2 objc_exception_throw + 48 + 2 todos 0x00000001017b0510 RCTFormatError + 0 + 3 todos 0x000000010182d8ca -[RCTExceptionsManager reportFatal:stack:exceptionId:suppressRedBox:] + 503 + 4 todos 0x000000010182e34e -[RCTExceptionsManager reportException:] + 1658 + 5 CoreFoundation 0x00007fff23e43e8c __invoking___ + 140 + 6 CoreFoundation 0x00007fff23e41071 -[NSInvocation invoke] + 321 + 7 CoreFoundation 0x00007fff23e41344 -[NSInvocation invokeWithTarget:] + 68 + 8 todos 0x00000001017e07fa -[RCTModuleMethod invokeWithBridge:module:arguments:] + 578 + 9 todos 0x00000001017e2a84 _ZN8facebook5reactL11invokeInnerEP9RCTBridgeP13RCTModuleDatajRKN5folly7dynamicE + 246 + 10 todos 0x00000001017e280c ___ZN8facebook5react15RCTNativeModule6invokeEjON5folly7dynamicEi_block_invoke + 78 + 11 libdispatch.dylib 0x00000001025b5f11 _dispatch_call_block_and_release + 12 + 12 libdispatch.dylib 0x00000001025b6e8e _dispatch_client_callout + 8 + 13 libdispatch.dylib 0x00000001025bd6fd _dispatch_lane_serial_drain + 788 + 14 libdispatch.dylib 0x00000001025be28f _dispatch_lane_invoke + 422 + 15 libdispatch.dylib 0x00000001025c9b65 _dispatch_workloop_worker_thread + 719 + 16 libsystem_pthread.dylib 0x00007fff51c08a3d _pthread_wqthread + 290 + 17 libsystem_pthread.dylib 0x00007fff51c07b77 start_wqthread + 15 +) +libc++abi.dylib: terminating with uncaught exception of type NSException +(lldb) +``` + + + +Tips:如何在 RN release 模式下调试(看到 js 侧的 console 信息) + +- 在 `AppDelegate.m` 中引入 `#import ` +- 在 `- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions` 中加入 `RCTSetLogThreshold(RCTLogLevelTrace);` + + + +对比组3: + +条件:iOS 项目 release 模式。在 RN 端增加异常处理代码。 + +```js +global.ErrorUtils.setGlobalHandler((e) => { + console.log(e); + let message = { name: e.name, + message: e.message, + stack: e.stack + }; + axios.get('http://192.168.1.100:8888/test.php', { + params: { 'message': JSON.stringify(message) } + }).then(function (response) { + console.log(response) + }).catch(function (error) { + console.log(error) + }); +}, true) +``` + +操作:运行 iOS 工程,点击按钮模拟 crash。 + +现象:iOS 项目不奔溃。日志信息如下,对比 bundle 包中的 js。 + +![RN release log](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-RNReleaseLog.png) + + + +结论: + +在 RN 项目中,如果发生了 crash 则会在 Native 侧有相应体现。如果 RN 侧写了 crash 捕获的代码,则 Native 侧不会奔溃。如果 RN 侧的 crash 没有捕获,则 Native 直接奔溃。 + +RN 项目写了 crash 监控,监控后将堆栈信息打印出来发现对应的 js 信息是经过 webpack 处理的,crash 分析难度很大。所以我们针对 RN 的 crash 需要在 RN 侧写监控代码,监控后需要上报,此外针对监控后的信息需要写专门的 crash 信息还原给你,也就是 sourceMap 解析。 + + + +###### 2.7.3.1 js 逻辑错误 + +写过 RN 的人都知道在 DEBUG 模式下 js 代码有问题则会产生红屏,在 RELEASE 模式下则会白屏或者闪退,为了体验和质量把控需要做异常监控。 + +在看 RN 源码时候发现了 `ErrorUtils`,看代码可以设置处理错误信息。 + +```js +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + * @polyfill + */ + +let _inGuard = 0; + +type ErrorHandler = (error: mixed, isFatal: boolean) => void; +type Fn = (...Args) => Return; + +/** + * This is the error handler that is called when we encounter an exception + * when loading a module. This will report any errors encountered before + * ExceptionsManager is configured. + */ +let _globalHandler: ErrorHandler = function onError( + e: mixed, + isFatal: boolean, +) { + throw e; +}; + +/** + * The particular require runtime that we are using looks for a global + * `ErrorUtils` object and if it exists, then it requires modules with the + * error handler specified via ErrorUtils.setGlobalHandler by calling the + * require function with applyWithGuard. Since the require module is loaded + * before any of the modules, this ErrorUtils must be defined (and the handler + * set) globally before requiring anything. + */ +const ErrorUtils = { + setGlobalHandler(fun: ErrorHandler): void { + _globalHandler = fun; + }, + getGlobalHandler(): ErrorHandler { + return _globalHandler; + }, + reportError(error: mixed): void { + _globalHandler && _globalHandler(error, false); + }, + reportFatalError(error: mixed): void { + // NOTE: This has an untyped call site in Metro. + _globalHandler && _globalHandler(error, true); + }, + applyWithGuard, TOut>( + fun: Fn, + context?: ?mixed, + args?: ?TArgs, + // Unused, but some code synced from www sets it to null. + unused_onError?: null, + // Some callers pass a name here, which we ignore. + unused_name?: ?string, + ): ?TOut { + try { + _inGuard++; + // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work + return fun.apply(context, args); + } catch (e) { + ErrorUtils.reportError(e); + } finally { + _inGuard--; + } + return null; + }, + applyWithGuardIfNeeded, TOut>( + fun: Fn, + context?: ?mixed, + args?: ?TArgs, + ): ?TOut { + if (ErrorUtils.inGuard()) { + // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work + return fun.apply(context, args); + } else { + ErrorUtils.applyWithGuard(fun, context, args); + } + return null; + }, + inGuard(): boolean { + return !!_inGuard; + }, + guard, TOut>( + fun: Fn, + name?: ?string, + context?: ?mixed, + ): ?(...TArgs) => ?TOut { + // TODO: (moti) T48204753 Make sure this warning is never hit and remove it - types + // should be sufficient. + if (typeof fun !== 'function') { + console.warn('A function must be passed to ErrorUtils.guard, got ', fun); + return null; + } + const guardName = name ?? fun.name ?? ''; + function guarded(...args: TArgs): ?TOut { + return ErrorUtils.applyWithGuard( + fun, + context ?? this, + args, + null, + guardName, + ); + } + + return guarded; + }, +}; + +global.ErrorUtils = ErrorUtils; + +export type ErrorUtilsT = typeof ErrorUtils; +``` + +所以 RN 的异常可以使用 `global.ErrorUtils` 来设置错误处理。举个例子 + +``` +global.ErrorUtils.setGlobalHandler(e => { + // e.name e.message e.stack +}, true); +``` + + + +###### 2.7.3.2 组件问题 + +其实对于 RN 的 crash 处理还有个需要注意的就是 **React Error Boundaries**。[详细资料](https://zh-hans.reactjs.org/docs/error-boundaries.html) + +> 过去,组件内的 JavaScript 错误会导致 React 的内部状态被破坏,并且在下一次渲染时 [产生](https://github.com/facebook/react/issues/4026) [可能无法追踪的](https://github.com/facebook/react/issues/6895) [错误](https://github.com/facebook/react/issues/8579)。这些错误基本上是由较早的其他代码(非 React 组件代码)错误引起的,但 React 并没有提供一种在组件中优雅处理这些错误的方式,也无法从错误中恢复。 +> +> 部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。 +> +> 错误边界是一种 React 组件,这种组件**可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI**,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。 + +它能捕获子组件生命周期函数中的异常,包括构造函数(constructor)和 render 函数 + +而不能捕获以下异常: + +- Event handlers(事件处理函数) +- Asynchronous code(异步代码,如setTimeout、promise等) +- Server side rendering(服务端渲染) +- Errors thrown in the error boundary itself (rather than its children)(异常边界组件本身抛出的异常) + +所以可以通过异常边界组件捕获组件生命周期内的所有异常然后渲染兜底组件 ,防止 App crash,提高用户体验。也可引导用户反馈问题,方便问题的排查和修复 + +至此 RN 的 crash 分为2种,分别是 js 逻辑错误、组件 js 错误,都已经被监控处理了。接下来就看看如何从工程化层面解决这些问题 + + + +##### 2.7.4 RN Crash 还原 + +SourceMap 文件对于前端日志的解析至关重要,SourceMap 文件中各个参数和如何计算的步骤都在里面有写,可以查看[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/2.41.md)。 + +有了 SourceMap 文件,借助于 [mozilla ](https://github.com/mozilla) 的 [source-map](https://github.com/mozilla/source-map) 项目,可以很好的还原 RN 的 crash 日志。 + +我写了个 NodeJS 脚本,代码如下 + +```js +var fs = require('fs'); +var sourceMap = require('source-map'); +var arguments = process.argv.splice(2); + +function parseJSError(aLine, aColumn) { + fs.readFile('./index.ios.map', 'utf8', function (err, data) { + const whatever = sourceMap.SourceMapConsumer.with(data, null, consumer => { + // 读取 crash 日志的行号、列号 + let parseData = consumer.originalPositionFor({ + line: parseInt(aLine), + column: parseInt(aColumn) + }); + // 输出到控制台 + console.log(parseData); + // 输出到文件中 + fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) { + if(err) { + console.log(err); + } + }); + }); + }); +} + +var line = arguments[0]; +var column = arguments[1]; +parseJSError(line, column); +``` + +接下来做个实验,还是上述的 todos 项目。 + +1. 在 Text 的点击事件上模拟 crash + + ```jsx + {1+qw;}}>Debug + ``` + +2. 将 RN 项目打 bundle 包、产出 sourceMap 文件。执行命令, + + ```shell + react-native bundle --entry-file index.js --platform android --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.android.map; + ``` + + 因为高频使用,所以给 iterm2 增加 alias 别名设置,修改 `.zshrc` 文件 + + ```shell + alias RNRelease='react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;' # RN 打 Release 包 + ``` + +3. 将 js bundle 和图片资源拷贝到 Xcode 工程中 + +4. 点击模拟 crash,将日志下面的行号和列号拷贝,在 Node 项目下,执行下面命令 + + ```shell + node index.js 397 1822 + ``` + +5. 拿脚本解析好的行号、列号、文件信息去和源代码文件比较,结果很正确。 + +![RN Log analysis](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-RNCrashLogAnalysis.png) + + + +##### 2.7.5 SourceMap 解析系统设计 + +目的:通过平台可以将 RN 项目线上 crash 可以还原到具体的文件、代码行数、代码列数。可以看到具体的代码,可以看到 RN stack trace、提供源文件下载功能。 + +1. 打包系统下管理的服务器: + - 生产环境下打包才生成 source map 文件 + - 存储打包前的所有文件(install) +2. 开发产品侧 RN 分析界面。点击收集到的 RN crash,在详情页可以看到具体的文件、代码行数、代码列数。可以看到具体的代码,可以看到 RN stack trace、Native stack trace。(具体技术实现上面讲过了) +3. 由于 souece map 文件较大,RN 解析过长虽然不久,但是是对计算资源的消耗,所以需要设计高效读取方式 +4. SourceMap 在 iOS、Android 模式下不一样,所以 SoureceMap 存储需要区分 os。 + +### 3. KSCrash 的使用包装 + +然后再封装自己的 Crash 处理逻辑。比如要做的事情就是: + +- 继承自 KSCrashInstallation 这个抽象类,设置初始化工作(抽象类比如 NSURLProtocol 必须继承后使用),实现抽象类中的 `sink` 方法。 + + ```c++ + /** + * Crash system installation which handles backend-specific details. + * + * Only one installation can be installed at a time. + * + * This is an abstract class. + */ + @interface KSCrashInstallation : NSObject + ``` + + ```objective-c + #import "APMCrashInstallation.h" + #import + #import "APMCrashReporterSink.h" + + @implementation APMCrashInstallation + + + (instancetype)sharedInstance { + static APMCrashInstallation *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[APMCrashInstallation alloc] init]; + }); + return sharedInstance; + } + + - (id)init { + return [super initWithRequiredProperties: nil]; + } + + - (id)sink { + APMCrashReporterSink *sink = [[APMCrashReporterSink alloc] init]; + return [sink defaultCrashReportFilterSetAppleFmt]; + } + + @end + ``` + +- `sink` 方法内部的 `APMCrashReporterSink` 类,遵循了 **KSCrashReportFilter** 协议,声明了公有方法 `defaultCrashReportFilterSetAppleFmt` + + ```objective-c + // .h + #import + #import + + @interface APMCrashReporterSink : NSObject + + - (id ) defaultCrashReportFilterSetAppleFmt; + + @end + + // .m + #pragma mark - public Method + + - (id ) defaultCrashReportFilterSetAppleFmt + { + return [KSCrashReportFilterPipeline filterWithFilters: + [APMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], + self, + nil]; + } + ``` + + 其中 `defaultCrashReportFilterSetAppleFmt` 方法内部返回了一个 `KSCrashReportFilterPipeline` 类方法 `filterWithFilters` 的结果。 + + `APMCrashReportFilterAppleFmt` 是一个继承自 `KSCrashReportFilterAppleFmt` 的类,遵循了 `KSCrashReportFilter` 协议。协议方法允许开发者处理 Crash 的数据格式。 + + ```objective-c + /** Filter the specified reports. + * + * @param reports The reports to process. + * @param onCompletion Block to call when processing is complete. + */ + - (void) filterReports:(NSArray*) reports + onCompletion:(KSCrashReportFilterCompletion) onCompletion; + ``` + + + + ```objective-c + #import + + @interface APMCrashReportFilterAppleFmt : KSCrashReportFilterAppleFmt + + @end + + // .m + - (void) filterReports:(NSArray*)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion + { + NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; + for(NSDictionary *report in reports){ + if([self majorVersion:report] == kExpectedMajorVersion){ + id monitorInfo = [self generateMonitorInfoFromCrashReport:report]; + if(monitorInfo != nil){ + [filteredReports addObject:monitorInfo]; + } + } + } + kscrash_callCompletion(onCompletion, filteredReports, YES, nil); + } + + /** + @brief 获取Crash JSON中的crash时间、mach name、signal name和apple report + */ + - (NSDictionary *)generateMonitorInfoFromCrashReport:(NSDictionary *)crashReport + { + NSDictionary *infoReport = [crashReport objectForKey:@"report"]; + // ... + id appleReport = [self toAppleFormat:crashReport]; + + NSMutableDictionary *info = [NSMutableDictionary dictionary]; + [info setValue:crashTime forKey:@"crashTime"]; + [info setValue:appleReport forKey:@"appleReport"]; + [info setValue:userException forKey:@"userException"]; + [info setValue:userInfo forKey:@"custom"]; + + return [info copy]; + } + ``` + + ```objective-c + /** + * A pipeline of filters. Reports get passed through each subfilter in order. + * + * Input: Depends on what's in the pipeline. + * Output: Depends on what's in the pipeline. + */ + @interface KSCrashReportFilterPipeline : NSObject + ``` + +- APM 能力中为 Crash 模块设置一个启动器。启动器内部设置 KSCrash 的初始化工作,以及触发 Crash 时候监控所需数据的组装。比如:SESSION_ID、App 启动时间、App 名称、崩溃时间、App 版本号、当前页面信息等基础信息。 + + ```objective-c + /** C Function to call during a crash report to give the callee an opportunity to + * add to the report. NULL = ignore. + * + * WARNING: Only call async-safe functions from this function! DO NOT call + * Objective-C methods!!! + */ + @property(atomic,readwrite,assign) KSReportWriteCallback onCrash; + ``` + + + + ```objective-c + + (instancetype)sharedInstance + { + static APMCrashMonitor *_sharedManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedManager = [[APMCrashMonitor alloc] init]; + }); + return _sharedManager; + } + + + #pragma mark - public Method + + - (void)startMonitor + { + APMMLog(@"crash monitor started"); + + #ifdef DEBUG + BOOL _trackingCrashOnDebug = [APMMonitorConfig sharedInstance].trackingCrashOnDebug; + if (_trackingCrashOnDebug) { + [self installKSCrash]; + } + #else + [self installKSCrash]; + #endif + } + + #pragma mark - private method + + static void onCrash(const KSCrashReportWriter* writer) + { + NSString *sessionId = [NSString stringWithFormat:@"\"%@\"", ***]]; + writer->addJSONElement(writer, "SESSION_ID", [sessionId UTF8String], true); + + NSString *appLaunchTime = ***; + writer->addJSONElement(writer, "USER_APP_START_DATE", [[NSString stringWithFormat:@"\"%@\"", appLaunchTime] UTF8String], true); + // ... + } + + - (void)installKSCrash + { + [[APMCrashInstallation sharedInstance] install]; + [[APMCrashInstallation sharedInstance] sendAllReportsWithCompletion:nil]; + [APMCrashInstallation sharedInstance].onCrash = onCrash; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + _isCanAddCrashCount = NO; + }); + } + ``` + + 在 `installKSCrash` 方法中调用了 `[[APMCrashInstallation sharedInstance] sendAllReportsWithCompletion: nil]`,内部实现如下 + + ```objective-c + - (void) sendAllReportsWithCompletion:(KSCrashReportFilterCompletion) onCompletion + { + NSError* error = [self validateProperties]; + if(error != nil) + { + if(onCompletion != nil) + { + onCompletion(nil, NO, error); + } + return; + } + + id sink = [self sink]; + if(sink == nil) + { + onCompletion(nil, NO, [NSError errorWithDomain:[[self class] description] + code:0 + description:@"Sink was nil (subclasses must implement method \"sink\")"]); + return; + } + + sink = [KSCrashReportFilterPipeline filterWithFilters:self.prependedFilters, sink, nil]; + + KSCrash* handler = [KSCrash sharedInstance]; + handler.sink = sink; + [handler sendAllReportsWithCompletion:onCompletion]; + } + ``` + + 方法内部将 `KSCrashInstallation` 的 `sink` 赋值给 `KSCrash` 对象。 内部还是调用了 `KSCrash` 的 `sendAllReportsWithCompletion` 方法,实现如下 + + ```objective-c + - (void) sendAllReportsWithCompletion:(KSCrashReportFilterCompletion) onCompletion + { + NSArray* reports = [self allReports]; + + KSLOG_INFO(@"Sending %d crash reports", [reports count]); + + [self sendReports:reports + onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) + { + KSLOG_DEBUG(@"Process finished with completion: %d", completed); + if(error != nil) + { + KSLOG_ERROR(@"Failed to send reports: %@", error); + } + if((self.deleteBehaviorAfterSendAll == KSCDeleteOnSucess && completed) || + self.deleteBehaviorAfterSendAll == KSCDeleteAlways) + { + kscrash_deleteAllReports(); + } + kscrash_callCompletion(onCompletion, filteredReports, completed, error); + }]; + } + ``` + + 该方法内部调用了对象方法 `sendReports: onCompletion:`,如下所示 + + ```objective-c + - (void) sendReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion + { + if([reports count] == 0) + { + kscrash_callCompletion(onCompletion, reports, YES, nil); + return; + } + + if(self.sink == nil) + { + kscrash_callCompletion(onCompletion, reports, NO, + [NSError errorWithDomain:[[self class] description] + code:0 + description:@"No sink set. Crash reports not sent."]); + return; + } + + [self.sink filterReports:reports + onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) + { + kscrash_callCompletion(onCompletion, filteredReports, completed, error); + }]; + } + ``` + + 方法内部的 `[self.sink filterReports: onCompletion: ]` 实现其实就是 `APMCrashInstallation` 中设置的 `sink` getter 方法,内部返回了 `APMCrashReporterSink` 对象的 `defaultCrashReportFilterSetAppleFmt` 方法的返回值。内部实现如下 + + ```objective-c + - (id ) defaultCrashReportFilterSetAppleFmt + { + return [KSCrashReportFilterPipeline filterWithFilters: + [APMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], + self, + nil]; + } + ``` + + 可以看到这个函数内部设置了多个 **filters**,其中一个就是 **self**,也就是 `APMCrashReporterSink` 对象,所以上面的 ` [self.sink filterReports: onCompletion:]` ,也就是调用 `APMCrashReporterSink` 内的数据处理方法。完了之后通过 `kscrash_callCompletion(onCompletion, reports, YES, nil);` 告诉 `KSCrash` 本地保存的 Crash 日志已经处理完毕,可以删除了。 + + ```objective-c + - (void)filterReports:(NSArray *)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion + { + for (NSDictionary *report in reports) { + // 处理 Crash 数据,将数据交给统一的数据上报组件处理... + } + kscrash_callCompletion(onCompletion, reports, YES, nil); + } + ``` + + 至此,概括下 KSCrash 做的事情,提供各种 crash 的监控能力,在 crash 后将进程信息、基本信息、异常信息、线程信息等用 c 高效转换为 json 写入文件,App 下次启动后读取本地的 crash 文件夹中的 crash 日志,让开发者可以自定义 key、value 然后去上报日志到 APM 系统,然后删除本地 crash 文件夹中的日志。 + + + +### 4. 符号化 + +应用 crash 之后,系统会生成一份崩溃日志,存储在设置中,应用的运行状态、调用堆栈、所处线程等信息会记录在日志中。但是这些日志是地址,并不可读,所以需要进行符号化还原。 + +#### 4.1 .DSYM 文件 + +`.DSYM` (debugging symbol)文件是保存十六进制函数地址映射信息的中转文件,调试信息(symbols)都包含在该文件中。Xcode 工程每次编译运行都会生成新的 `.DSYM` 文���。默认情况下 debug 模式时不生成 `.DSYM` ,可以在 Build Settings -> Build Options -> Debug Information Format 后将值 `DWARF` 修改为 `DWARF with DSYM File`,这样再次编译运行就可以生成 `.DSYM` 文件。 + +所以每次 App 打包的时候都需要保存每个版本的 `.DSYM` 文件。 + +`.DSYM` 文件中包含 DWARF 信息,打开文件的包内容 `Test.app.DSYM/Contents/Resources/DWARF/Test` 保存的就是 `DWARF` 文件。 + +`.DSYM` 文件是从 Mach-O 文件中抽取调试信息而得到的文件目录,发布的时候为了安全,会把调试信息存储在单独的文件,`.DSYM` 其实是一个文件目录,结构如下: + +![.DSYM文件结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-29-DYSMStructure.png) + + + +#### 4.2 DWARF 文件 + +> DWARF is a debugging file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments. + +**DWARF 是一种调试文件格式,它被许多编译器和调试器所广泛使用以支持源代码级别的调试**。它满足许多过程语言(C、C++、Fortran)的需求,它被设计为支持拓展到其他语言。DWARF 是架构独立的,适用于其他任何的处理器和操作系统。被广泛使用在 Unix、Linux 和其他的操作系统上,以及独立环境上。 + +DWARF 全称是 Debugging With Arbitrary Record Formats,是一种使用属性化记录格式的调试文件。 + +DWARF 是可执行程序与源代码关系的一个紧凑表示。 + +大多数现代编程语言都是块结构:每个实体(一个类、一个函数)被包含在另一个实体中。一个 c 程序,每个文件可能包含多个数据定义、多个变量、多个函数,所以 DWARF 遵循这个模型,也是块结构。DWARF 里基本的描述项是调试信息项 DIE(Debugging Information Entry)。一个 DIE 有一个标签,表示这个 DIE 描述了什么以及一个填入了细节并进一步描述该项的属性列表(类比 html、xml 结构)。一个 DIE(除了最顶层的)被一个父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个DIE的引用(比如一个函数的返回值类型)。 + +DWARF 文件中的数据如下: + +| 数据列 | 信息说明 | +| --------------- | -------------------------------------- | +| .debug_loc | 在 DW_AT_location 属性中使用的位置列表 | +| .debug_macinfo | 宏信息 | +| .debug_pubnames | 全局对象和函数的查找表 | +| .debug_pubtypes | 全局类型的查找表 | +| .debug_ranges | 在 DW_AT_ranges 属性中使用的地址范围 | +| .debug_str | 在 .debug_info 中使用的字符串表 | +| .debug_types | 类型描述 | + +常用的标记与属性如下: + +| 数据列 | 信息说明 | +| --------------------------- | ----------------------------- | +| DW_TAG_class_type | 表示类名称和类型信息 | +| DW_TAG_structure_type | 表示结构名称和类型信息 | +| DW_TAG_union_type | 表示联合名称和类型信息 | +| DW_TAG_enumeration_type | 表示枚举名称和类型信息 | +| DW_TAG_typedef | 表示 typedef 的名称和类型信息 | +| DW_TAG_array_type | 表示数组名称和类型信息 | +| DW_TAG_subrange_type | 表示数组的大小信息 | +| DW_TAG_inheritance | 表示继承的类名称和类型信息 | +| DW_TAG_member | 表示类的成员 | +| DW_TAG_subprogram | 表示函数的名称信息 | +| DW_TAG_formal_parameter | 表示函数的参数信息 | +| DW_TAG_name | 表示名称字符串 | +| DW_TAG_type | 表示类型信息 | +| DW_TAG_artifical | 在创建时由编译程序设置 | +| DW_TAG_sibling | 表示兄弟位置信息 | +| DW_TAG_data_memver_location | 表示位置信息 | +| DW_TAG_virtuality | 在虚拟时设置 | + +简单看一个 DWARF 的例子:将测试工程的 `.DSYM` 文件夹下的 DWARF 文件用下面命令解析 + +```shell +dwarfdump -F --debug-info Test.app.DSYM/Contents/Resources/DWARF/Test > debug-info.txt +``` + +打开如下 + +```shell +Test.app.DSYM/Contents/Resources/DWARF/Test: file format Mach-O arm64 + +.debug_info contents: +0x00000000: Compile Unit: length = 0x0000004f version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00000053) + +0x0000000b: DW_TAG_compile_unit + DW_AT_producer [DW_FORM_strp] ("Apple clang version 11.0.3 (clang-1103.0.32.62)") + DW_AT_language [DW_FORM_data2] (DW_LANG_ObjC) + DW_AT_name [DW_FORM_strp] ("_Builtin_stddef_max_align_t") + DW_AT_stmt_list [DW_FORM_sec_offset] (0x00000000) + DW_AT_comp_dir [DW_FORM_strp] ("/Users/lbp/Desktop/Test") + DW_AT_APPLE_major_runtime_vers [DW_FORM_data1] (0x02) + DW_AT_GNU_dwo_id [DW_FORM_data8] (0x392b5344d415340c) + +0x00000027: DW_TAG_module + DW_AT_name [DW_FORM_strp] ("_Builtin_stddef_max_align_t") + DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") + DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include") + DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") + +0x00000038: DW_TAG_typedef + DW_AT_type [DW_FORM_ref4] (0x0000004b "long double") + DW_AT_name [DW_FORM_strp] ("max_align_t") + DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h") + DW_AT_decl_line [DW_FORM_data1] (16) + +0x00000043: DW_TAG_imported_declaration + DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h") + DW_AT_decl_line [DW_FORM_data1] (27) + DW_AT_import [DW_FORM_ref_addr] (0x0000000000000027) + +0x0000004a: NULL + +0x0000004b: DW_TAG_base_type + DW_AT_name [DW_FORM_strp] ("long double") + DW_AT_encoding [DW_FORM_data1] (DW_ATE_float) + DW_AT_byte_size [DW_FORM_data1] (0x08) + +0x00000052: NULL +0x00000053: Compile Unit: length = 0x000183dc version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00018433) + +0x0000005e: DW_TAG_compile_unit + DW_AT_producer [DW_FORM_strp] ("Apple clang version 11.0.3 (clang-1103.0.32.62)") + DW_AT_language [DW_FORM_data2] (DW_LANG_ObjC) + DW_AT_name [DW_FORM_strp] ("Darwin") + DW_AT_stmt_list [DW_FORM_sec_offset] (0x000000a7) + DW_AT_comp_dir [DW_FORM_strp] ("/Users/lbp/Desktop/Test") + DW_AT_APPLE_major_runtime_vers [DW_FORM_data1] (0x02) + DW_AT_GNU_dwo_id [DW_FORM_data8] (0xa4a1d339379e18a5) + +0x0000007a: DW_TAG_module + DW_AT_name [DW_FORM_strp] ("Darwin") + DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") + DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") + DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") + +0x0000008b: DW_TAG_module + DW_AT_name [DW_FORM_strp] ("C") + DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") + DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") + DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") + +0x0000009c: DW_TAG_module + DW_AT_name [DW_FORM_strp] ("fenv") + DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") + DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") + DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") + +0x000000ad: DW_TAG_enumeration_type + DW_AT_type [DW_FORM_ref4] (0x00017276 "unsigned int") + DW_AT_byte_size [DW_FORM_data1] (0x04) + DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/fenv.h") + DW_AT_decl_line [DW_FORM_data1] (154) + +0x000000b5: DW_TAG_enumerator + DW_AT_name [DW_FORM_strp] ("__fpcr_trap_invalid") + DW_AT_const_value [DW_FORM_udata] (256) + +0x000000bc: DW_TAG_enumerator + DW_AT_name [DW_FORM_strp] ("__fpcr_trap_divbyzero") + DW_AT_const_value [DW_FORM_udata] (512) + +0x000000c3: DW_TAG_enumerator + DW_AT_name [DW_FORM_strp] ("__fpcr_trap_overflow") + DW_AT_const_value [DW_FORM_udata] (1024) + +0x000000ca: DW_TAG_enumerator + DW_AT_name [DW_FORM_strp] ("__fpcr_trap_underflow") +// ...... +0x000466ee: DW_TAG_subprogram + DW_AT_name [DW_FORM_strp] ("CFBridgingRetain") + DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h") + DW_AT_decl_line [DW_FORM_data1] (105) + DW_AT_prototyped [DW_FORM_flag_present] (true) + DW_AT_type [DW_FORM_ref_addr] (0x0000000000019155 "CFTypeRef") + DW_AT_inline [DW_FORM_data1] (DW_INL_inlined) + +0x000466fa: DW_TAG_formal_parameter + DW_AT_name [DW_FORM_strp] ("X") + DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h") + DW_AT_decl_line [DW_FORM_data1] (105) + DW_AT_type [DW_FORM_ref4] (0x00046706 "id") + +0x00046705: NULL + +0x00046706: DW_TAG_typedef + DW_AT_type [DW_FORM_ref4] (0x00046711 "objc_object*") + DW_AT_name [DW_FORM_strp] ("id") + DW_AT_decl_file [DW_FORM_data1] ("/Users/lbp/Desktop/Test/Test/NetworkAPM/NSURLResponse+apm_FetchStatusLineFromCFNetwork.m") + DW_AT_decl_line [DW_FORM_data1] (44) + +0x00046711: DW_TAG_pointer_type + DW_AT_type [DW_FORM_ref4] (0x00046716 "objc_object") + +0x00046716: DW_TAG_structure_type + DW_AT_name [DW_FORM_strp] ("objc_object") + DW_AT_byte_size [DW_FORM_data1] (0x00) + +0x0004671c: DW_TAG_member + DW_AT_name [DW_FORM_strp] ("isa") + DW_AT_type [DW_FORM_ref4] (0x00046727 "objc_class*") + DW_AT_data_member_location [DW_FORM_data1] (0x00) +// ...... +``` + +这里就不粘贴全部内容了(太长了)。可以看到 DIE 包含了函数开始地址、结束地址、函数名、文件名、所在行数,对于给定的地址,找到函数开始地址、结束地址之间包含该地址的 DIE,则可以还原函数名和文件名信息。 + + + +debug_line 可以还原文件行数等信息 + +```shell +dwarfdump -F --debug-line Test.app.DSYM/Contents/Resources/DWARF/Test > debug-inline.txt +``` + +贴部分信息 + +```shell +Test.app.DSYM/Contents/Resources/DWARF/Test: file format Mach-O arm64 + +.debug_line contents: +debug_line[0x00000000] +Line table prologue: + total_length: 0x000000a3 + version: 4 + prologue_length: 0x0000009a + min_inst_length: 1 +max_ops_per_inst: 1 + default_is_stmt: 1 + line_base: -5 + line_range: 14 + opcode_base: 13 +standard_opcode_lengths[DW_LNS_copy] = 0 +standard_opcode_lengths[DW_LNS_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_advance_line] = 1 +standard_opcode_lengths[DW_LNS_set_file] = 1 +standard_opcode_lengths[DW_LNS_set_column] = 1 +standard_opcode_lengths[DW_LNS_negate_stmt] = 0 +standard_opcode_lengths[DW_LNS_set_basic_block] = 0 +standard_opcode_lengths[DW_LNS_const_add_pc] = 0 +standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 +standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 +standard_opcode_lengths[DW_LNS_set_isa] = 1 +include_directories[ 1] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include" +file_names[ 1]: + name: "__stddef_max_align_t.h" + dir_index: 1 + mod_time: 0x00000000 + length: 0x00000000 + +Address Line Column File ISA Discriminator Flags +------------------ ------ ------ ------ --- ------------- ------------- +0x0000000000000000 1 0 1 0 0 is_stmt end_sequence +debug_line[0x000000a7] +Line table prologue: + total_length: 0x0000230a + version: 4 + prologue_length: 0x00002301 + min_inst_length: 1 +max_ops_per_inst: 1 + default_is_stmt: 1 + line_base: -5 + line_range: 14 + opcode_base: 13 +standard_opcode_lengths[DW_LNS_copy] = 0 +standard_opcode_lengths[DW_LNS_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_advance_line] = 1 +standard_opcode_lengths[DW_LNS_set_file] = 1 +standard_opcode_lengths[DW_LNS_set_column] = 1 +standard_opcode_lengths[DW_LNS_negate_stmt] = 0 +standard_opcode_lengths[DW_LNS_set_basic_block] = 0 +standard_opcode_lengths[DW_LNS_const_add_pc] = 0 +standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 +standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 +standard_opcode_lengths[DW_LNS_set_isa] = 1 +include_directories[ 1] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include" +include_directories[ 2] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include" +include_directories[ 3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys" +include_directories[ 4] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach" +include_directories[ 5] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern" +include_directories[ 6] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/architecture" +include_directories[ 7] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_types" +include_directories[ 8] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/_types" +include_directories[ 9] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arm" +include_directories[ 10] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_pthread" +include_directories[ 11] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/arm" +include_directories[ 12] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern/arm" +include_directories[ 13] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/uuid" +include_directories[ 14] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet" +include_directories[ 15] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet6" +include_directories[ 16] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/net" +include_directories[ 17] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/pthread" +include_directories[ 18] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach_debug" +include_directories[ 19] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/os" +include_directories[ 20] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/malloc" +include_directories[ 21] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/bsm" +include_directories[ 22] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/machine" +include_directories[ 23] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/machine" +include_directories[ 24] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/secure" +include_directories[ 25] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/xlocale" +include_directories[ 26] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arpa" +file_names[ 1]: + name: "fenv.h" + dir_index: 1 + mod_time: 0x00000000 + length: 0x00000000 +file_names[ 2]: + name: "stdatomic.h" + dir_index: 2 + mod_time: 0x00000000 + length: 0x00000000 +file_names[ 3]: + name: "wait.h" + dir_index: 3 + mod_time: 0x00000000 + length: 0x00000000 +// ...... +Address Line Column File ISA Discriminator Flags +------------------ ------ ------ ------ --- ------------- ------------- +0x000000010000b588 14 0 2 0 0 is_stmt +0x000000010000b5b4 16 5 2 0 0 is_stmt prologue_end +0x000000010000b5d0 17 11 2 0 0 is_stmt +0x000000010000b5d4 0 0 2 0 0 +0x000000010000b5d8 17 5 2 0 0 +0x000000010000b5dc 17 11 2 0 0 +0x000000010000b5e8 18 1 2 0 0 is_stmt +0x000000010000b608 20 0 2 0 0 is_stmt +0x000000010000b61c 22 5 2 0 0 is_stmt prologue_end +0x000000010000b628 23 5 2 0 0 is_stmt +0x000000010000b644 24 1 2 0 0 is_stmt +0x000000010000b650 15 0 1 0 0 is_stmt +0x000000010000b65c 15 41 1 0 0 is_stmt prologue_end +0x000000010000b66c 11 0 2 0 0 is_stmt +0x000000010000b680 11 17 2 0 0 is_stmt prologue_end +0x000000010000b6a4 11 17 2 0 0 is_stmt end_sequence +debug_line[0x0000def9] +Line table prologue: + total_length: 0x0000015a + version: 4 + prologue_length: 0x000000eb + min_inst_length: 1 +max_ops_per_inst: 1 + default_is_stmt: 1 + line_base: -5 + line_range: 14 + opcode_base: 13 +standard_opcode_lengths[DW_LNS_copy] = 0 +standard_opcode_lengths[DW_LNS_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_advance_line] = 1 +standard_opcode_lengths[DW_LNS_set_file] = 1 +standard_opcode_lengths[DW_LNS_set_column] = 1 +standard_opcode_lengths[DW_LNS_negate_stmt] = 0 +standard_opcode_lengths[DW_LNS_set_basic_block] = 0 +standard_opcode_lengths[DW_LNS_const_add_pc] = 0 +standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 +standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 +standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 +standard_opcode_lengths[DW_LNS_set_isa] = 1 +include_directories[ 1] = "Test" +include_directories[ 2] = "Test/NetworkAPM" +include_directories[ 3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/objc" +file_names[ 1]: + name: "AppDelegate.h" + dir_index: 1 + mod_time: 0x00000000 + length: 0x00000000 +file_names[ 2]: + name: "JMWebResourceURLProtocol.h" + dir_index: 2 + mod_time: 0x00000000 + length: 0x00000000 +file_names[ 3]: + name: "AppDelegate.m" + dir_index: 1 + mod_time: 0x00000000 + length: 0x00000000 +file_names[ 4]: + name: "objc.h" + dir_index: 3 + mod_time: 0x00000000 + length: 0x00000000 +// ...... +``` + +可以看到 debug_line 里包含了每个代码地址对应的行数。上面贴了 AppDelegate 的部分。 + + + +#### 4.3 symbols + +> 在链接中,我们将函数和变量统称为符合(Symbol),函数名或变量名就是符号名(Symbol Name),我们可以将符号看成是链接中的粘合剂,整个链接过程正是基于符号才能正确完成的。 + +上述文字来自《程序员的自我修养》。所以符号就是函数、变量、类的统称。 + +按照类型划分,符号可以分为三类: + +- 全局符号:目标文件外可见的符号,可以被其他目标文件所引用,或者需要其他目标文件定义 +- 局部符号:只在目标文件内可见的符号,指只在目标文件内可见的函数和变量 +- 调试符号:包括行号信息的调试符号信息,行号信息记录了函数和变量对应的文件和文件行号。 + +**符号表(Symbol Table)**:是内存地址与函数名、文件名、行号的映射表。每个定义的符号都有一个对应的值得,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是地址,符号表组成如下 + +```shell +<起始地址> <结束地址> <函数> [<文件名:行号>] +``` + + + + + +#### 4.4 **如何获取地址?** + +image 加载的时候会进行相对基地址进行重定位,并且每次加载的基地址都不一样,函数栈 frame 的地址是重定位后的绝对地址,我们要的是重定位前的相对地址。 + +Binary Images + +拿测试工程的 crash 日志举例子,打开贴部分 Binary Images 内容 + +```shell +// ... +Binary Images: +0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test +0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib +0x103204000 - 0x103267fff dyld arm64 <6f1c86b640a3352a8529bca213946dd5> /usr/lib/dyld +0x189a78000 - 0x189a8efff libsystem_trace.dylib arm64 /usr/lib/system/libsystem_trace.dylib +// ... +``` + +可以看到 Crash 日志的 Binary Images 包含每个 Image 的加载开始地址、结束地址、image 名称、arm 架构、uuid、image 路径。 + +crash 日志中的信息 + +```shell +Last Exception Backtrace: +// ... +5 Test 0x102fe592c -[ViewController testMonitorCrash] + 22828 (ViewController.mm:58) +``` + +```sh +Binary Images: +0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test +``` + +所以 frame 5 的相对地址为 `0x102fe592c - 0x102fe0000 `。再使用 命令可以还原符号信息。 + +使用 atos 来解析,`0x102fe0000` 为 image 加载的开始地址,`0x102fe592c` 为 frame 需要还原的地址。 + +```shell +atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x102fe592c +``` + + + +#### 4.5 UUID + +- crash 文件的 UUID + + ```shell + grep --after-context=2 "Binary Images:" *.crash + ``` + + ```shell + Test 5-28-20, 7-47 PM.crash:Binary Images: + Test 5-28-20, 7-47 PM.crash-0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test + Test 5-28-20, 7-47 PM.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib + -- + Test.crash:Binary Images: + Test.crash-0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test + Test.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib + ``` + + Test App 的 UUID 为 `37eaa57df2523d95969e47a9a1d69ce5`. + +- .DSYM 文件的 UUID + + ```shell + dwarfdump --uuid Test.app.DSYM + ``` + + 结果为 + + ```shell + UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app.DSYM/Contents/Resources/DWARF/Test + ``` + +- app 的 UUID + + ```shell + dwarfdump --uuid Test.app/Test + ``` + + 结果为 + + ```shell + UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app/Test + ``` + + + +#### 4.6 符号化(解析 Crash 日志) + +上述篇幅分析了如何捕获各种类型的 crash,App 在用户手中我们通过技术手段可以获取 crash 案发现场信息并结合一定的机制去上报,但是这种堆栈是十六进制的地址,无法定位问题,所以需要做符号化处理。 + +上面也说明了[.DSYM 文件](#DSYM) 的作用,**通过符号地址结合 DSYM 文件来还原文件名、所在行、函数名,这个过程叫符号化**。但是 .DSYM 文件必须和 crash log 文件的 bundle id、version 严格对应。 + +获取 Crash 日志可以通过 Xcode -> Window -> Devices and Simulators 选择对应设备,找到 Crash 日志文件,根据时间和 App 名称定位。 + +app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Developer/Xcode/Archives`。 + +解析方法一般有2种: + +- 使用 **symbolicatecrash** + + symbolicatecrash 是 Xcode 自带的 crash 日志分析工具,先确定所在路径,在终端执行下面的命令 + + ````shell + find /Applications/Xcode.app -name symbolicatecrash -type f + ```` + + 会返回几个路径,找到 `iPhoneSimulator.platform` 所在那一行 + + ``` + /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash + ``` + + 将 symbolicatecrash 拷贝到指定文件夹下(保存了 app、DSYM、crash 文件的文件夹) + + 执行命令 + + ```shell + ./symbolicatecrash Test.crash Test.DSYM > Test.crash + ``` + + 第一次做这事儿应该会报错 `Error: "DEVELOPER_DIR" is not defined at ./symbolicatecrash line 69.`,解决方案:在终端执行下面命令 + + ```shell + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + ``` + +- 使用 atos + + 区别于 symbolicatecrash,atos 较为灵活,只要 `.crash` 和 `.DSYM` 或者 `.crash` 和 `.app` 文件对应即可。 + + 用法如下,-l 最后跟得是符号地址 + + ```shell + xcrun atos -o Test.app.DSYM/Contents/Resources/DWARF/Test -arch armv7 -l 0x1023c592c + ``` + + 也可以解析 .app 文件(不存在 .DSYM 文件),其中xxx为段地址,xx为偏移地址 + + ```shell + atos -arch architecture -o binary -l xxx xx + ``` + +因为我们的 App 可能有很多,每个 App 在用户手中可能是不同的版本,所以在 APM 拦截之后需要符号化的时候需要将 crash 文件和 `.DSYM` 文件一一对应,才能正确符号化,对应的原则就是 **UUID** 一致。 + + + +#### 4.7 系统库符号化解析 + +我们每次真机连接 Xcode 运行程序,会提示等待,其实系统为了堆栈解析,都会把当前版本的系统符号库自动导入到 `/Users/你自己的用户名/Library/Developer/Xcode/iOS DeviceSupport` 目录下安装了一大堆系统库的符号化文件。你可以访问下面目录看看 + +```shell +/Users/你自己的用户名/Library/Developer/Xcode/iOS DeviceSupport/ +``` + +![系统符号化文件](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-28-SymbolicateLib.png) + + + +### 5. 服务端处理 + +##### 5.1 ELK 日志系统 + +业界设计日志监控系统一般会采用基于 ELK 技术。ELK 是 Elasticsearch、Logstash、Kibana 三个开源框架缩写。Elasticsearch 是一个分布式、通过 Restful 方式进行交互的近实时搜索的平台框架。Logstash 是一个中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集不同格式的数据,经过过滤后支持输出到不同目的地(文件/MQ/Redis/ElasticsSearch/Kafka)。Kibana 可以将 Elasticserarch 的数据通过友好的页面展示出来,提供可视化分析功能。所以 ELK 可以搭建一个高效、企业级的日志分析系统。 + +早期单体应用时代,几乎应用的所有功能都在一台机器上运行,出了问题,运维人员打开终端输入命令直接查看系统日志,进而定位问题、解决问题。随着系统的功能越来越复杂,用户体量越来越大,单体应用几乎很难满足需求,所以技术架构迭代了,通过水平拓展来支持庞大的用户量,将单体应用进行拆分为多个应用,每个应用采用集群方式部署,负载均衡控制调度,假如某个子模块发生问题,去找这台服务器上终端找日志分析吗?显然台落后,所以日志管理平台便应运而生。通过 Logstash 去收集分析每台服务器的日志文件,然后按照定义的正则模版过滤后传输到 Kafka 或 Redis,然后由另一个 Logstash 从 Kafka 或 Redis 上读取日志存储到 ES 中创建索引,最后通过 Kibana 进行可视化分析。此外可以将收集到的数据进行数据分析,做更进一步的维护和决策。 + +![ELK架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-14-ELK.png) + +上图展示了一个 ELK 的日志架构图。简单说明下: + +- Logstash 和 ES 之前存在一个 Kafka 层,因为 Logstash 是架设在数据资源服务器上,将收集到的数据进行实时过滤,过滤需要消耗时间和内存,所以存在 Kafka,起到了数据缓冲存储作用,因为 Kafka 具备非常出色的读写性能。 +- 再一步就是 Logstash 从 Kafka 里面进行读取数据,将数据过滤、处理,将结果传输到 ES +- 这个设计不但性能好、耦合低,还具备可拓展性。比如可以从 n 个不同的 Logstash 上读取传输到 n 个 Kafka 上,再由 n 个 Logstash 过滤处理。日志来源可以是 m 个,比如 App 日志、Tomcat 日志、Nginx 日志等等 + +下图贴一个 Elasticsearch 社区分享的一个 “Elastic APM 动手实战”[主题](https://elasticsearch.cn/slides/257#page=3)的内容截图。 + +![Elasticsearch & APM](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-ElastiicsearchAPM.png) + + + +##### 5.2 服务侧 + +Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化处理,以方便定位问题、crash 产生报表和后续处理。 + +![crash log 处理流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-14-CrashLogSymbolicate.png) + + + +所以整个流程就是:客户端 APM SDK 收集 crash log -> Kafka 存储 -> Mac 机执行定时任务符号化 -> 数据回传 Kafka -> 产品侧(显示端)对数据进行分类、报表、报警等操作。 + +因为公司的产品线有多条,相应的 App 有多个,用户使用的 App 版本也各不相同,所以 crash 日志分析必须要有正确的 .DSYM 文件,那么多 App 的不同版本,自动化就变得非常重要了。 + +自动化有2种手段,规模小一点的公司或者图省事,可以在 Xcode中 添加 runScript 脚本代码来自动在 release 模式下上传DSYM)。 + +因为我们大前端有一套体系,可以同时管理 iOS SDK、iOS App、Android SDK、Android App、Node、React、React Native 工程项目的初始化、依赖管理、构建(持续集成、Unit Test、Lint、统跳检测)、测试、打包、部署、动态能力(热更新、统跳路由下发)等能力于一身。可以基于各个阶段做能力的插入,所以可以在打包系统中,当调用打包后在打包机上传 `.DSYM` 文件到七牛云存储(规则可以是以 AppName + Version 为 key,value 为 .DSYM 文件)。 + +现在很多架构设计都是微服务,至于为什么选微服务,不在本文范畴。所以 crash 日志的符号化被设计为一个微服务。架构图如下 + +![crash 符号化流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMServerArch.png) + +说明: + +- Symbolication Service 作为整个监控系统的一个组成部分,是专注于 crash report 符号化的微服务。 + +- 接收来自任务调度框架的包含预处理过的 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对 crash report 做符号化解析,计算 hash,并将 hash 响应给「数据处理和任务调度框架」。 +- 接收来自 APM 管理系统的包含原始 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对crash report 做符号化解析,并将符号化的 crash report 响应给 APM 管理系统。 +- 脚手架 cli 有个能力就是调用打包系统的打包构建能力,会根据项目的特点,选择合适的打包机(打包平台是维护了多个打包任务,不同任务根据特点被派发到不同的打包机上,任务详情页可以看到依赖的下载、编译、运行过程等,打包好的产物包括二进制包、下载二维码等等) + +![符号化流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-symolication_flow.png) + +其中符号化服务是大前端背景下大前端团队的产物,所以是 NodeJS 实现的(单线程,所以为了提高机器利用率,就要开启多进程能力)。iOS 的符号化机器是 双核的 Mac mini,这就需要做实验测评到底需要开启几个 worker 进程做符号化服务。结果是双进程处理 crash log,比单进程效率高近一倍,而四进程比双进程效率提升不明显,符合双核 mac mini 的特点。所以开启两个 worker 进程做符号化处理。 + +下图是完整设计图 + +![符号化技术设计图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMServerWorker.png) + +简单说明下,符号化流程是一个主从模式,一台 master 机,多个 slave 机,master 机读取 .DSYM 和 crash 结果的 cache。「数据处理和任务调度框架」调度符号化服务(内部2个 symbolocate worker)同时从七牛云上获取 .DSYM 文件。 + +系统架构图如下 + +![符号化服务架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-SymbolicateServerArch.png) + + + + + +## 八、 APM 小结 + +1. 通常来说各个端的监控能力是不太一致的,技术实现细节也不统一。所以在技术方案评审的时候需要将监控能力对齐统一。每个能力在各个端的数据字段必须对齐(字段个数、名称、数据类型和精度),因为 APM 本身是一个闭环,监控了之后需符号化解析、数据整理,进行产品化开发、最后需要监控大盘展示等 + +2. 一些 crash 或者 ANR 等根据等级需要邮件、短信、企业内容通信工具告知干系人,之后快速发布版本、hot fix 等。 + +3. 监控的各个能力需要做成可配置,灵活开启关闭。 + +4. 监控数据需要做内存到文件的写入处理,需要注意策略。监控数据需要存储数据库,数据库大小、设计规则等。存入数据库后如何上报,上报机制等会在另一篇文章讲:[打造一个通用、可配置的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) + +5. 尽量在技术评审后,将各端的技术实现写进文档中,同步给相关人员。比如 ANR 的实现 + + ```objective-c + /* + android 端 + + 根据设备分级,一般超过 300ms 视为一次卡顿 + hook 系统 loop,在消息处理前后插桩,用以计算每条消息的时长 + 开启另外线程 dump 堆栈,处理结束后关闭 + */ + new ExceptionProcessor().init(this, new Runnable() { + @Override + public void run() { + //监测卡顿 + try { + ProxyPrinter proxyPrinter = new ProxyPrinter(PerformanceMonitor.this); + Looper.getMainLooper().setMessageLogging(proxyPrinter); + mWeakPrinter = new WeakReference(proxyPrinter); + } catch (FileNotFoundException e) { + } + } + }) + + /* + iOS 端 + + 子线程通过 ping 主线程来确认主线程当前是否卡顿。 + 卡顿阈值设置为 300ms,超过阈值时认为卡顿。 + 卡顿时获取主线程的堆栈,并存储上传。 + */ + - (void) main() { + while (self.cancle == NO) { + self.isMainThreadBlocked = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + self.isMainThreadBlocked = YES; + [self.semaphore singal]; + }); + [Thread sleep:300]; + if (self.isMainThreadBlocked) { + [self handleMainThreadBlock]; + } + [self.semaphore wait]; + } + } + ``` + +6. 整个 APM 的架构图如下 + + ![APM Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMStructure.jpg) + + 说明: + + - 埋点 SDK,通过 sessionId 来关联日志数据 + +7. APM 技术方案本身是随着技术手段、分析需求不断调整升级的。上图的几个结构示意图是早期几个版本的,目前使用的是在此基础上进行了升级和结构调整,提几个关键词:Hermes、Flink SQL、InfluxDB。 + + + + + +## 参考资料 + +- [iOS 保持界面流畅的技巧](https://blog.ibireme.com/2015/11/12/smooth_user_interfaces_for_ios/) +- [Call Stack](https://en.wikipedia.org/wiki/Call_stack) +- [关于函数调用栈(call stack)的个人理解](https://blog.csdn.net/VarusK/article/details/83031643) +- [获取任意线程调用栈的那些事](https://bestswifter.com/callstack/) +- [iOS启动时间优化](https://www.zoomfeng.com/blog/launch-time.html) +- [WWDC2019之启动时间与Dyld3](https://www.zoomfeng.com/blog/launch-optimize-from-wwdc2019.html) +- [Apple-libmalloc](https://opensource.apple.com/tarballs/libmalloc/) +- [Apple-XNU](https://opensource.apple.com/tarballs/xnu/) +- [OOM探究:XNU 内存状态管理](https://www.jianshu.com/p/4458700a8ba8) +- [Reducing FOOMs in the Facebook iOS app](https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/) +- [iOS内存abort(Jetsam) 原理探究](https://satanwoo.github.io/2017/10/18/abort/) +- [iOS微信内存监控](https://wetest.qq.com/lab/view/367.html?from=coop_gad) +- [iOS堆栈信息解析(函数地址与符号关联)](https://www.jianshu.com/p/df5b08330afd) +- [Apple-CFNetwork Programming Guide](https://developer.apple.com/library/archive/documentation/Networking/Conceptual/CFNetwork/Introduction/Introduction.html#//apple_ref/doc/uid/TP30001132-CH1-DontLinkElementID_30) +- [MDN-HTTP Messages](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) +- [DWARF 和符号化](https://junyixie.github.io/2018/09/30/dwarf和符号化/) + + diff --git a/Chapter1 - iOS/1.75.md b/Chapter1 - iOS/1.75.md index 93a4d83..dc7e6af 100644 --- a/Chapter1 - iOS/1.75.md +++ b/Chapter1 - iOS/1.75.md @@ -1,3 +1,1153 @@ # 写好测试,提升应用质量 -内容脱敏中... \ No newline at end of file +> 相信在国内一些中小型公司,开发者很少会去写软件测试相关的代码。当然这背后有一些原因在。本文就讲讲 iOS 开发中的软件测试相关的内容。 + + + +## 一、 测试的重要性 + +测试很重要!测试很重要!测试很重要!重要的事情说三遍。 + +场景1:每次我们写完代码后都需要编译运行,以查看应用程序的表现是否符合预期。假如改动点、代码量小,那验证成本低一些,假如不符合预期,则说明我们的代码有问,人工去排查问题花费的时间也少一些。假如改动点很多、受影响的地方较多,我们首先要大概猜测受影响的功能,然后去定位问题、排查问题的成本就很高。 + +场景2:你新接手的 SDK 某个子功能需要做一次技术重构。但是你只有在公司内部的代码托管平台上可以看到一些 Readme、接入文档、系统设计文档、技术方案评估文档等一堆文档。可能你会看完再去动手重构。当你重构完了,找了公司某条业务线的 App 接入测试,点了几下发现发生了奔溃。😂 心想,本地测试、debug 都正常可是为什么接入后就 Crash 了。其实想想也好理解,你本地重构只是确保了你开发的那个功能运行正常,你很难确保你写的代码没有影响其他类、其他功能。假如之前的 SDK 针对每个类都有单元测试代码,那你在新功能开发完毕后完整跑一次单元测试代码就好了,保证每个 Unit Test 都通过、分支覆盖率达到约定的线,那么基本上是没问题的。 + +场景3:在版本迭代的时候,计划功能 A,从开发、联调、测试、上线共2周时间。老司机做事很自信,这么简单的 UI、动画、交互,代码风骚,参考服务端的「领域驱动」在该 feature 开发阶段落地试验了下。联调、本地测试都通过了,还剩3天时间,本以为测试1天,bug fix 一天,最后一天提交审核。代码跟你开了个玩笑,测试完 n 个 bug(大大超出预期)。为了不影响 App 的发布上架,不得不熬夜修 bug。将所有的测试都通过测试工程师去处理,这个阶段理论上质量应该很稳定,不然该阶段发现代码异常、技术设计有漏洞就来不及了,你需要协调各个团队的资源(可能接口要改动、产品侧要改动),这个阶段造成改动的成本非常大。 + +相信大多数开发者都遇到过上述场景的问题。其实上述这几个问题都有解,那就是“软件测试”。 + + + + + +## 二、软件测试 + +### 1. 分类 + +软件测试就是在规定的条件下对应用程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。 + +合理应用软件测试技术,就可以规避掉第一部分的3个场景下的问题。 + + + +软件测试强调开发、测试同步进行,甚至是测试先行,从需求评审阶段就先考虑好软件测试方案,随后才进行技术方案评审、开发编码、单元测试、集成测试、系统测试、回归测试、验收测试等。 + +软件测试从测试范围分为:单元测试、集成测试、系统测试、回归测试、验收测试(有些公司会谈到“冒烟测试“,这个词的精确定义不知道,但是学软件测试课的时候按照范围就只有上述几个分类)。工程师自己负责的是单元测试。测试工程师、QA 负责的是集成测试、系统测试。 + +单元测试(Unit Testing):又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。「单元」的概念会比较抽象,它不仅仅是我们所编写的某个方法、函数,也可能是某个类、对象等。 + +软件测试从开发模式分为:面向测试驱动开发 TDD (Test-driven development)、面向行为驱动开发 BDD (Behavior-driven development)。 + + + +### 2. TDD + +TDD 的思想是:先编写测试用例,再快速开发代码,然后在测试用例的保证下,可以方便安全地进行代码重构,提升应用程序的质量。一言以蔽之就是通过测试来推动开发的进行。正是由于这个特点,TDD 被广泛使用于敏捷开发。 + +也就是说 TDD 模式下,首先考虑如何针对功能进行测试,然后去编写代码实现,再不断迭代,在测试用例的保证下,不断进行代码优化。 + +优点:目标明确、架构分层清晰。可保证开发代码不会偏离需求。每个阶段持续测试 + +缺点:技术方案需要先评审结束、架构需要提前搭建好。假如需求变动,则前面步骤需要重新执行,灵活性较差。 + + + +### 3. BDD + +BDD 即行为驱动开发,是敏捷开发**技术**之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。 + +BDD 的思想是跳出单一的函数,针对的是行为而展开的测试。BDD 关心的是业务领域、行为方式,而不是具体的函数、方法,通过对行为的描述来验证功能的可用性。BDD 使用 DSL (Domin Specific Language)领域特定语言来描述测试用例,这样编写的测试用例非常易读,看起来跟文档一样易读,BDD 的代码结构是 `Given->When->Then`。 + +优点:各团队的成员可以集中在一起,设计基于行为的计测试用例。 + + + +### 4. 对比 + +根据特点也就是找到了各自的使用场景,TDD 主要针对开发中的最小单元进行测试,适合单元测试。而 BDD 针对的是行为,所以测试范围可以再大一些,在集成测试、系统测试中都可以使用 + + + +TDD 编写的测试用例一般针对的是开发中的最小单元(比如某个类、函数、方法)而展开,适合单元测试。 + +BDD 编写的测试用例针对的是行为,测试范围更大一些,适合集成测试、系统测试阶段。 + + + + + +## 三、 单元测试编码规范 + +本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。 + +编写功能、业务代码的时候一般会遵循 `kiss 原则` ,所以类、方法、函数往往不会太大,分层设计越好、职责越单一、耦合度越低的代码越适合做单元测试,单元测试也倒逼开发过程中代码分层、解耦。 + +可能某个功能的实现代码有30行,测试代码有50行。单元测试的代码如何编写才更合理、整洁、规范呢? + +### 1. 编码分模块展开 + +先贴一段代码。 + +```objective-c +- (void)testInsertDataInOneSpecifiedTable +{ + XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"]; + // given + [dbInstance removeAllLogsInTableType:PCTLogTableTypeMeta]; + NSMutableArray *insertModels = [NSMutableArray array]; + for (NSInteger index = 1; index <= 10000; index++) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = index; + // ... + [insertModels addObject:model]; + } + // when + [dbInstance add:insertModels inTableType:PCTLogTableTypeMeta]; + // then + [dbInstance recordsCountInTableType:PCTLogTableTypeMeta completion:^(NSInteger count) { + XCTAssert(count == insertModels.count, @"「数据增加」功能:异常"); + [exception fulfill]; + }]; + [self waitForExpectationsWithCommonTimeout]; +} + +``` + +可以看到这个方法的名称为 testInsertDataInOneSpecifiedTable,这段代码做的事情通过函数名可以看出来:测试插入数据到某个特定的表。这个测试用例分为3部分:测试环境所需的先决条件准备;调用所要测试的某个方法、函数;验证输出和行为是否符合预期。 + +其实,每个测试用例的编写也要按照该种方式去组织代码。步骤分为3个阶段:Given->When->Then。 + +所以单元测试的代码规范也就出来了。此外单元测试代码规范统一后,每个人的测试代码都按照这个标准展开,那其他人的阅读起来就更加容易、方便。按照这3个步骤去阅读、理解测试代码,就可以清晰明了的知道在做什么。 + + + +### 2. 一个测试用例只测试一个分支 + +我们写的代码有很多语句组成,有各种逻辑判断、分支(if...else、swicth)等等,因此一个程序从一个单一入口进去,过程可能产生 n 个不同的分支,但是程序的出口总是一个。所以由于这样的特性,我们的测试也需要针对这样的现状走完尽可能多的分支。相应的指标叫做「分支覆盖率」。 + +假如某个方法内部有 if...else...,我们在测试的时候尽量将每种情况写成一个单独的测试用例,单独的输入、输出,判断是否符合预期。这样每个 case 都单一的测试某个分支,可读性也很高。 + +比如对下面的函数做单元测试,测试用例设计如下 + +```objective-c +- (void)shouldIEatSomething +{ + BOOL shouldEat = [self getAteWeight] < self.dailyFoodSupport; + if (shouldEat) { + [self eatSomemuchFood]; + } else { + [self doSomeExercise]; + } +} +``` + +```objective-c +- (void)testShouldIEatSomethingWhenHungry +{ + // .... +} + +- (void)testShouldIEatSomethingWhenFull +{ + // ... +} +``` + + + +### 3. 明确标识被测试类 + +这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道,可能某个函数本来就10行代码,可是为了测试它,测试代码写了30行。一个方法这样写问题不大,多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大,测试代码很大的情况下,不管是作者自身还是多年后负责维护的其他同事,看这个代码阅读成本会很大,需要先看测试文件名 `代码类名 + Test` 才知道是测试的是哪个类,看测试方法名 `test + 方法名` 才知道是测试的是哪个方法。 + +这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 `_sut` 用来标记当前被测试类(sut,System under Test,软件测试领域有个词叫做**被测系统**,用来表示正在被测试的系统)。 + +```objective-c +#import +#import "PCTLogPayloadModel.h" + +@interface PCTLogPayloadModelTest : PCTTestCase +{ + PCTLogPayloadModel *_sut; +} + +@end + +@implementation PCTLogPayloadModelTest + +- (void)setUp +{ + [super setUp]; + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = 1; + // ... + _sut = model; +} + +- (void)tearDown +{ + _sut = nil; + [super tearDown]; +} + +- (void)testGetDictionary +{ + NSDictionary *payloadDictionary = [_sut getDictionary]; + XCTAssert([(NSString *)payloadDictionary[@"report_id"] isEqualToString:@"001"] && + [payloadDictionary[@"size"] integerValue] == 102 && + [(NSString *)payloadDictionary[@"meta"] containsString:@"meiying"], + @"PCTLogPayloadModel 的 「getDictionary」功能异常"); +} + +@end +``` + + + +### 4. 使用分类来暴露私有方法、私有变量 + +某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 `Category` 可以实现这样的需求。 + +为测试类添加一个分类,后缀名为 `UnitTest`。如下所示 + + `HermesClient` 类有私有属性 `@property (nonatomic, strong) NSString *name;`,私有方法 `- (void)hello`。为了在测试用例中访问私有属性和私有方法,写了如下分类 + +```objective-c +// HermesClientTest.m + +@interface HermesClient (UnitTest) + +- (NSString *)name; + +- (void)hello; + +@end + +@implementation HermesClientTest + +- (void)testPrivatePropertyAndMethod +{ + NSLog(@"%@",[HermesClient sharedInstance].name); + [[HermesClient sharedInstance] hello]; +} +@end +``` + + + +## 四、 单元测试下开发模式、技术框架选择 + +单元测试是按照测试范围来划分的。TDD、BDD 是按照开发模式来划分的。因此就有各种排列组合,这里我们只关心单元测试下的 TDD、BDD 方案。 + +在单元测试阶段,TDD 和 BDD 都可以适用。 + +### 1. TDD + +TDD 强调不断的测试推动代码的开发,这样`简化了`代码,保证了代码质量。 + +思想是在拿到一个新的功能时,首先思考该功能如何测试,各种测试用例、各种边界 case;然后完成测试代码的开发;最后编写相应的代码以满足、通过这些测试用例。 + +TDD 开发过程类似下图: + +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStructure.png) + +- 先编写该功能的测试用例,实现测试代码。这时候去跑测试,是不通过的,也就是到了红色的状态 +- 然后编写真正的功能实现代码。这时候去跑测试,测试通过,也就是到了绿色的状态 +- 在测试用例的保证下,可以重构、优化代码 + + + +**抛出一个问题:TDD 看上去很好,应该用它吗?** + +这个问题不用着急回答,回答了也不会有对错之分。开发中经常是这样一个流程,新的需求出来后,先经过技术评审会议,确定宏观层面的技术方案、确定各个端的技术实现、使用的技术等,整理出开发文档、会议文档。工期评估后开始编码。事情这么简单吗?前期即使想的再充分、再细致,可能还是存在特殊 case 漏掉的情况,导致技术方案或者是技术实现的改变。如果采用 TDD,那么之前新功能给到后,就要考虑测试用例的设计、编写了测试代码,在测试用例的保证下再去实现功能。如果遇到了技术方案的变更,之前的测试用例要改变、测试代码实现要改变。可能新增的某个 case 导致大部分的测试代码和实现代码都要改变。 + + + +如何开展 TDD** + +1. 新建一个工程,确保 “Include Unit Tests” 选项是选中的状态 + + ![TDD Step 1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep1.png) + +2. 创建后的工程目录如下 + + ![TDD step2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep2.png) + +3. 删除 Xcode 创建的测试模版文件 `TDDDemoTests.m` + +4. 假如我们需要设计一个人类,它具有吃饭的功能,且当他吃完后会说一句“好饱啊”。 + +5. 那么按照 TDD 我们先设计测试用例。假设有个 Person 类,有个对象方法叫做吃饭,吃完饭后会返回一个“好饱啊”的字符串。那测试用例就是 + + | 步骤 | 期望 | 结果 | + | --------------------------------------- | ------------------ | ---- | + | 实例化 Person 对象,调用对象的 eat 方法 | 调用后返回“好饱啊” | ? | + +6. 实现测试用例代码。创建继承自 Unit Test Case class 的测试类,命名为 `工程前缀+测试类名+Test`,也就是 `TDDPersonTest.m`。 + + ![TDD step 3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep3.png) + +7. 因为要测试 Person 类,所以在主工程中创建 Person 类 + +8. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 + + ```objective-c + - (void)testReturnStatusStringWhenPersonAte + { + // Given + Person *somebody = [[Person alloc] init]; + + // When + NSString *statusMessage = [somebody performSelector:@selector(eat)]; + + // Then + XCTAssert([statusMessage isEqualToString:@"好饱啊"], @"Person 「吃饭后返回“好饱啊”」功能异常"); + } + ``` + +9. Xcode 下按快捷键 `Command + U`,跑测试代码发现是失败的。因为我们的 Person 类根本没实现相应的方法 + +10. 从 [TDD 开发过程](#TDDStructure)可以看到,我们现在是红色的 “Fail” 状态。所以需要去 Person 类中实现功能代码。Person 类如下 + + ```objective-c + #import "Person.h" + + @implementation Person + + - (NSString *)eat + { + [NSThread sleepForTimeInterval:1]; + return @"好饱啊";; + } + + @end + ``` + +11. 再次运行,跑一下测试用例(Command + U 快捷键)。发现测试通过,也就是[TDD 开发过程](#TDDStructure)中的绿色 “Success” 状态。 + +12. 例子比较简单,假如情况需要,可以在 `-(void)setUp` 方法里面做一些测试的前置准备工作,在 `-(void)tearDown` 方法里做资源释放的操作 + +13. 假如 eat 方法实现的不够漂亮。现在在测试用例的保证下,大胆重构,最后确保所有的 Unit Test case 通过即可。 + + + +### 2. BDD + +相比 TDD,BDD 关注的是行为方式的设计,拿上述“人吃饭”举例说明。 + +和 TDD 相比第1~4步骤相同。 + +5. BDD 则需要先实现功能代码。创建 Person 类,实现 `-(void)eat;`方法。代码和上面的相同 + +6. BDD 需要引入好用的框架 `Kiwi`,使用 Pod 的方式引入 + +7. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 + + ```objective-c + #import "kiwi.h" + #import "Person.h" + + SPEC_BEGIN(BDDPersonTest) + + describe(@"Person", ^{ + context(@"when someone ate", ^{ + it(@"should get a string",^{ + Person *someone = [[Person alloc] init]; + NSString *statusMessage = [someone eat]; + [[statusMessage shouldNot] beNil]; + [[statusMessage should] equal:@"好饱啊"]; + }); + }); + }); + + SPEC_END + ``` + + + +### 3. XCTest + +**开发步骤** + +Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 + +- 在 `Tests` 目录下为被测的类创建一个继承自 `XCTestCase` 的测试类。 + +- 删除新建的测试代码模版里面的无用方法 `- (void)testPerformanceExample`、`- (void)testExample`。 + +- 跟普通类一样,可以继承,可以写私有属性、私有方法。所以可以在新建的类里面,根据需求写一些私有属性等 + +- 在 `- (void)setUp` 方法里面写一些初始化、启动设置相关的代码。比如测试数据库功能的时候,写一些数据库连接池相关代码 + +- 为被测类里面的每个方法写测试方法。被测类里面可能是 n 个方法,测试类里面可能是 m 个方法(m >= n),根据我们在[第三部分:单元测试编码规范](#codeRules)里讲过的 **一个测试用例只测试一个分支**,方法内部有 if、switch 语句时,需要为每个分支写测试用例 + +- 为测试类每个方法写的测试方法有一定的规范。命名必须是 `test+被测方法名`。函数无参数、无返回值。比如 `- (void)testSharedInstance`。 + +- 测试方法里面的代码按照 `Given->When->Then` 的顺序展开。测试环境所需的先决条件准备;调用所要测试的某个方法、函数;使用断言验证输出和行为是否符合预期。 + +- 在 `- (void)tearDown` 方法里面写一些释放掉资源或者关闭的代码。比如测试数据库功能的时候,写一些数据库连接池关闭的代码 + + + +**断言相关宏** + +```c++ +/*! + * @function XCTFail(...) + * Generates a failure unconditionally. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTFail(...) \ + _XCTPrimitiveFail(self, __VA_ARGS__) + +/*! + * @define XCTAssertNil(expression, ...) + * Generates a failure when ((\a expression) != nil). + * @param expression An expression of id type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNil(expression, ...) \ + _XCTPrimitiveAssertNil(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertNotNil(expression, ...) + * Generates a failure when ((\a expression) == nil). + * @param expression An expression of id type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNotNil(expression, ...) \ + _XCTPrimitiveAssertNotNil(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssert(expression, ...) + * Generates a failure when ((\a expression) == false). + * @param expression An expression of boolean type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssert(expression, ...) \ + _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertTrue(expression, ...) + * Generates a failure when ((\a expression) == false). + * @param expression An expression of boolean type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertTrue(expression, ...) \ + _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertFalse(expression, ...) + * Generates a failure when ((\a expression) != false). + * @param expression An expression of boolean type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertFalse(expression, ...) \ + _XCTPrimitiveAssertFalse(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertEqualObjects(expression1, expression2, ...) + * Generates a failure when ((\a expression1) not equal to (\a expression2)). + * @param expression1 An expression of id type. + * @param expression2 An expression of id type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertEqualObjects(expression1, expression2, ...) \ + _XCTPrimitiveAssertEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertNotEqualObjects(expression1, expression2, ...) + * Generates a failure when ((\a expression1) equal to (\a expression2)). + * @param expression1 An expression of id type. + * @param expression2 An expression of id type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNotEqualObjects(expression1, expression2, ...) \ + _XCTPrimitiveAssertNotEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertEqual(expression1, expression2, ...) + * Generates a failure when ((\a expression1) != (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertEqual(expression1, expression2, ...) \ + _XCTPrimitiveAssertEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertNotEqual(expression1, expression2, ...) + * Generates a failure when ((\a expression1) == (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNotEqual(expression1, expression2, ...) \ + _XCTPrimitiveAssertNotEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...) + * Generates a failure when (difference between (\a expression1) and (\a expression2) is > (\a accuracy))). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...) \ + _XCTPrimitiveAssertEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__) + +/*! + * @define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...) + * Generates a failure when (difference between (\a expression1) and (\a expression2) is <= (\a accuracy)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...) \ + _XCTPrimitiveAssertNotEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__) + +/*! + * @define XCTAssertGreaterThan(expression1, expression2, ...) + * Generates a failure when ((\a expression1) <= (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertGreaterThan(expression1, expression2, ...) \ + _XCTPrimitiveAssertGreaterThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertGreaterThanOrEqual(expression1, expression2, ...) + * Generates a failure when ((\a expression1) < (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertGreaterThanOrEqual(expression1, expression2, ...) \ + _XCTPrimitiveAssertGreaterThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertLessThan(expression1, expression2, ...) + * Generates a failure when ((\a expression1) >= (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertLessThan(expression1, expression2, ...) \ + _XCTPrimitiveAssertLessThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertLessThanOrEqual(expression1, expression2, ...) + * Generates a failure when ((\a expression1) > (\a expression2)). + * @param expression1 An expression of C scalar type. + * @param expression2 An expression of C scalar type. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertLessThanOrEqual(expression1, expression2, ...) \ + _XCTPrimitiveAssertLessThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) + +/*! + * @define XCTAssertThrows(expression, ...) + * Generates a failure when ((\a expression) does not throw). + * @param expression An expression. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertThrows(expression, ...) \ + _XCTPrimitiveAssertThrows(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertThrowsSpecific(expression, exception_class, ...) + * Generates a failure when ((\a expression) does not throw \a exception_class). + * @param expression An expression. + * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertThrowsSpecific(expression, exception_class, ...) \ + _XCTPrimitiveAssertThrowsSpecific(self, expression, @#expression, exception_class, __VA_ARGS__) + +/*! + * @define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...) + * Generates a failure when ((\a expression) does not throw \a exception_class with \a exception_name). + * @param expression An expression. + * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. + * @param exception_name The name of the exception. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...) \ + _XCTPrimitiveAssertThrowsSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__) + +/*! + * @define XCTAssertNoThrow(expression, ...) + * Generates a failure when ((\a expression) throws). + * @param expression An expression. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNoThrow(expression, ...) \ + _XCTPrimitiveAssertNoThrow(self, expression, @#expression, __VA_ARGS__) + +/*! + * @define XCTAssertNoThrowSpecific(expression, exception_class, ...) + * Generates a failure when ((\a expression) throws \a exception_class). + * @param expression An expression. + * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNoThrowSpecific(expression, exception_class, ...) \ + _XCTPrimitiveAssertNoThrowSpecific(self, expression, @#expression, exception_class, __VA_ARGS__) + +/*! + * @define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...) + * Generates a failure when ((\a expression) throws \a exception_class with \a exception_name). + * @param expression An expression. + * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. + * @param exception_name The name of the exception. + * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. +*/ +#define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...) \ + _XCTPrimitiveAssertNoThrowSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__) +``` + + + +**经验小结** + +1. XCTestCase 类和其他类一样,你可以定义基类,这里面封装一些常用的方法。 + + ``` + // PCTTestCase.h + #import + + NS_ASSUME_NONNULL_BEGIN + + @interface PCTTestCase : XCTestCase + + @property (nonatomic, assign) NSTimeInterval networkTimeout; + + + /** + 用一个默认时间设置异步测试 XCTestExpectation 的超时处理 + */ + - (void)waitForExpectationsWithCommonTimeout; + + /** + 用一个默认时间设置异步测试的 + + @param handler 超时的处理逻辑 + */ + - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler; + + + /** + 生成 Crash 类型的 meta 数据 + + @return meta 类型的字典 + */ + - (NSDictionary *)generateCrashMetaDataFromReport; + + @end + + NS_ASSUME_NONNULL_END + + // PCTTestCase.m + #import "PCTTestCase.h" + #import ... + + @implementation PCTTestCase + + #pragma mark - life cycle + + - (void)setUp + { + [super setUp]; + self.networkTimeout = 20.0; + // 1. 设置平台信息 + [self setupAppProfile]; + // 2. 设置 Mget 配置 + [[TITrinityInitManager sharedInstance] setup]; + // .... + // 3. 设置 HermesClient + [[HermesClient sharedInstance] setup]; + } + + - (void)tearDown + { + [super tearDown]; + } + + + #pragma mark - public Method + + - (void)waitForExpectationsWithCommonTimeout + { + [self waitForExpectationsWithCommonTimeoutUsingHandler:nil]; + } + + - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler + { + [self waitForExpectationsWithTimeout:self.networkTimeout handler:handler]; + } + + + - (NSDictionary *)generateCrashMetaDataFromReport + { + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDate *crashTime = [NSDate date]; + metaDictionary[@"MONITOR_TYPE"] = @"appCrash"; + // ... + metaDictionary[@"USER_CRASH_DATE"] = @([crashTime timeIntervalSince1970] * 1000); + return [metaDictionary copy]; + } + + + #pragma mark - private method + + - (void)setupAppProfile + { + [[CMAppProfile sharedInstance] setMPlatform:@"70"]; + // ... + } + + @end + ``` + +2. 上述说的基本是开发规范相关。测试方法内部如果调用了其他类的方法,则在测试方法内部必须 Mock 一个外部对象,限制好返回值等。 + +3. 在 XCTest 内难以使用 mock 或 stub,这些是测试中非常常见且重要的功能 + + + +**例子** + +这里举个例子,是测试一个数据库操作类 `PCTDatabase`,代码只放某个方法的测试代码。 + +```objective-c +- (void)testRemoveLatestRecordsByCount +{ + XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库删除最新数据功能"]; + // 1. 先清空数据表 + [dbInstance removeAllLogsInTableType:PCTLogTableTypeMeta]; + // 2. 再插入一批数据 + NSMutableArray *insertModels = [NSMutableArray array]; + NSMutableArray *reportIDS = [NSMutableArray array]; + + for (NSInteger index = 1; index <= 100; index++) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = index; + // ... + if (index > 90 && index <= 100) { + [reportIDS addObject:model.report_id]; + } + [insertModels addObject:model]; + } + [dbInstance add:insertModels inTableType:PCTLogTableTypeMeta]; + + // 3. 将早期的数据删除掉(id > 90 && id <= 100) + [dbInstance removeLatestRecordsByCount:10 inTableType:PCTLogTableTypeMeta]; + + // 4. 拿到当前的前10条数据和之前存起来的前10条 id 做比较。再判断当前表中的总记录条数是否等于 90 + [dbInstance getLatestRecoreds:10 inTableType:PCTLogTableTypeMeta completion:^(NSArray * _Nonnull records) { + NSArray *latestRTentRecords = records; + + [dbInstance getOldestRecoreds:100 inTableType:PCTLogTableTypeMeta completion:^(NSArray * _Nonnull records) { + NSArray *currentRecords = records; + + __block BOOL isEarlyData = NO; + [latestRTentRecords enumerateObjectsUsingBlock:^(PCTLogModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if ([reportIDS containsObject:obj.report_id]) { + isEarlyData = YES; + } + }]; + + XCTAssert(!isEarlyData && currentRecords.count == 90, @"***Database「删除最新n条数据」功能:异常"); + [exception fulfill]; + }]; + + }]; + [self waitForExpectationsWithCommonTimeout]; +} +``` + + + + + +### 3. 测试框架 + +#### 1. Kiwi + +BDD 框架里的 Kiwi 可圈可点。使用 CocoaPods 引入 `pod 'Kiwi'`。看下面的例子 + +被测类(Planck 项目是一个基于 WebView 的 SDK,根据业务场景,发现针对 WebView 的大部分功能定制都是基于 WebView 的生命周期内发生的,所以参考 NodeJS 的中间件思想,设计了基于生命周期的 WebView 中间件) + +```objective-c +#import + +@interface TPKTrustListHelper : NSObject + ++(void)fetchRemoteTrustList; + ++(BOOL)isHostInTrustlist:(NSString *)scheme; + ++(NSArray *)trustList; + +@end + +``` + +测试类 + +```objective-c +SPEC_BEGIN(TPKTrustListHelperTest) +describe(@"Middleware Wrapper", ^{ + + context(@"when get trustlist", ^{ + it(@"should get a array of string",^{ + NSArray *array = [TPKTrustListHelper trustList]; + [[array shouldNot] beNil]; + NSString *first = [array firstObject]; + [[first shouldNot] beNil]; + [[NSStringFromClass([first class]) should] equal:@"__NSCFString"]; + }); + }); + + context(@"when check a string wether contained in trustlist ", ^{ + it(@"first string should contained in trustlist",^{ + NSArray *array = [TPKTrustListHelper trustList]; + NSString *first = [array firstObject]; + [[theValue([TPKTrustListHelper isHostInTrustlist:first]) should] equal:@(YES)]; + }); + }); +}); +SPEC_END +``` + +例子包含 Kiwi 的最基础元素。`SPEC_BEGIN` 和 `SPEC_END` 表示测试类;`describe` 描述需要被测试的类;`context` 表示一个测试场景,也就是 `Given->When->Then` 里的 `Given`;`it` 表示要测试的内容,也就是也就是 `Given->When->Then` 里的 `When` 和 `Then`。1个 `describe` 下可以包含多个 `context`,1个 `context` 下可以包含多个 `it`。 + +Kiwi 的使用分为:[Specs](https://github.com/kiwi-bdd/Kiwi/wiki/Specs)、 [Expectations](https://github.com/kiwi-bdd/Kiwi/wiki/Expectations) 、 [Mocks and Stubs](https://github.com/kiwi-bdd/Kiwi/wiki/Mocks-and-Stubs) 、[Asynchronous Testing](https://github.com/kiwi-bdd/Kiwi/wiki/Asynchronous-Testing) 四部分。点击可以访问详细的说明文档。 + +`it` 里面的代码块是真正的测试代码,使用链式调用的方式,简单上手。 + +**测试领域中 Mock 和 Stub 非常重要。Mock 模拟对象可以降低对象之间的依赖,模拟出一个纯净的测试环境(类似初中物理课上“控制变量法”的思想)。Kiwi 也支持的非常好,可以模拟对象、模拟空对象、模拟遵循协议的对象等等,点击 [Mocks and Stubs](https://github.com/kiwi-bdd/Kiwi/wiki/Mocks-and-Stubs) 查看。Stub 存根可以控制某个方法的返回值,这对于方法内调用别的对象的方法返回值很有帮助。减少对于外部的依赖,单一测试当前行为是否符合预期。** + +针对异步测试,XCTest 则需要创建一个 `XCTestExpectation` 对象,在异步实现里面调用该对象的 `fulfill` 方法,最后设置最大等待时间和完成的回调 `- (void)waitForExpectationsWithTimeout:(NSTimeInterval)timeout handler:(nullable XCWaitCompletionHandler)handler;` 如下例子 + +``` +XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"]; + [dbInstance removeAllLogsInTableType:PCTLogTableTypeMeta]; + NSMutableArray *insertModels = [NSMutableArray array]; + for (NSInteger index = 1; index <= 10000; index++) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = index; + // 。。。 + [insertModels addObject:model]; + } + [dbInstance add:insertModels inTableType:PCTLogTableTypeMeta]; + [dbInstance recordsCountInTableType:PCTLogTableTypeMeta completion:^(NSInteger count) { + XCTAssert(count == insertModels.count, @"**Database「数据增加」功能:异常"); + [exception fulfill]; + }]; + [self waitForExpectationsWithCommonTimeout]; +``` + + + + + +#### 2. expecta、Specta + +expecta 和 Specta 都出自 [orta](https://github.com/orta) 之手,他也是 Cocoapods 的开发者之一。太牛逼了,工程化、质量保证领域的大佬。 + +Specta 是一个轻量级的 BDD 测试框架,采用 DSL 模式,让测试更接近于自然语言,因此更易读。 + +特点: + +- 易于集成到项目中。在 Xcode 中勾选 `Include Unit Tests` ,和 XCTest 搭配使用 +- 语法很规范,对比 Kiwi 和 Specta 的文档,发现很多东西都是相同的,也就是很规范,所以学习成本低、后期迁移到其他框架很平滑。 + + + +Expecta 是一个匹配(断言)框架,相比 Xcode 的断言 XCAssert,Excepta 提供更加丰富的断言。 + +特点: + +- Eepecta 没有数据类型限制,比如 1,并不关心是 NSInteger 还是 CGFloat +- 链式编程,写起来很舒服 +- 反向匹配,很灵活。断言匹配用 `except(...).to.equal(...)`,断言不匹配则使用 `.notTo` 或者 `.toNot` +- 延时匹配,可以在链式表达式后加入 `.will`、`.willNot`、`.after(interval)` 等 + + + +### 4. 小结 + +Xcode 自带的 `XCTestCase` 比较适合 TDD,不影响源代码,系统独立且不影响 App 包大小。适合简单场景下的测试。且每个函数在最左侧又个测试按钮,点击后可以单独测试某个函数。 + +Kiwi 是一个强大的 BDD 框架,适合稍微复杂写的项目,写法舒服、功能强大,模拟对象、存根语法、异步测试等满足几乎所有的测试场景。不能和 XCTest 继承。 + +Specta 也是一个 BDD 框架,基于 XCTest 开发,可以和 XCTest 模版集合使用。相比 Kiwi,Specta 轻量一些。开发中一般搭配 Excepta 使用。如果需要使用 Mock 和 Stud 可以搭配 OCMock。 + +Excepta 是一个匹配框架,比 XCTest 的断言则更加全面一些。 + +没办法说哪个最好、最合理,根据项目需求选择合适的组合。 + + + +## 五、网络测试 + +我们在测试某个方法的时候可能会遇到方法内部调用了网络通信能力,网络请求成功,可能刷新 UI 或者给出一些成功的提示;网络失败或者网络不可用则给出一些失败的提示。所以需要对网络通信去看进行模拟。 + +iOS 中很多网络都是基于 NSURL 系统下的类实现的。所以我们可以利用 `NSURLProtocol` 的能力来监控网络并 mock 网络数据。如果感兴趣可以查看[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md#五-app-网络监控)。 + +开源项目 [OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs) 就是一个对网络模拟的库。它可以拦截 HTTP 请求,返回 json 数据,定制各种头信息。 + +> Stub your network requests easily! Test your apps with fake network data and custom response time, response code and headers! + + + +几个主要类及其功能:`HTTPStubsProtocol` 拦截网络请求;`HTTPStubs` 单例管理 `HTTPStubsDescriptor` 实例对象;`HTTPStubsResponse` 伪造 HTTP 请求。 + +`HTTPStubsProtocol` 继承自 `NSURLProtocol`,可以在 HTTP 请求发送之前对 request 进行过滤处理 + +```objective-c ++ (BOOL)canInitWithRequest:(NSURLRequest *)request +{ + BOOL found = ([HTTPStubs.sharedInstance firstStubPassingTestForRequest:request] != nil); + if (!found && HTTPStubs.sharedInstance.onStubMissingBlock) { + HTTPStubs.sharedInstance.onStubMissingBlock(request); + } + return found; +} +``` + +`firstStubPassingTestForRequest` 方法内部会判断请求是否需要被当前对象处理 + +紧接着开始发送网络请求。实际上在 `- (void)startLoading` 方法中可以用任何网络能力去完成请求,比如 NSURLSession、NSURLConnection、AFNetworking 或其他网络框架。OHHTTPStubs 的做法是获取 request、client 对象。如果 HTTPStubs 单例中包含 `onStubActivationBlock` 对象,则执行该 block,然后利用 responseBlock 对象返回一个 HTTPStubsResponse 响应对象。 + +OHHTTPStubs 的具体 API 可以查看[文档](https://github.com/AliSoftware/OHHTTPStubs/wiki/Usage-Examples)。 + +举个例子,利用 Kiwi、OHHTTPStubs 测试离线包功能。代码如下 + +```objective-c +@interface HORouterManager (Unittest) + +- (void)fetchOfflineInfoIfNeeded; + +@end + +SPEC_BEGIN(HORouterTests) + +describe(@"routerTests", ^{ + context(@"criticalPath", ^{ + __block HORouterManager *routerManager = nil; + beforeAll(^{ + routerManager = [[HORouterManager alloc] init]; + }); + it(@"getLocalPath", ^{ + __block NSString *pagePath = nil; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + pagePath = [routerManager filePathOfUrl:@"http://***/resource1"]; + }); + [[expectFutureValue(pagePath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil]; + + __block NSString *rescPath = nil; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + rescPath = [routerManager filePathOfUrl:@"http://***/resource1"]; + }); + [[expectFutureValue(rescPath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil]; + }); + it(@"fetchOffline", ^{ + [HOOfflineManager sharedInstance].offlineInfoInterval = 0; + [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { + return [request.URL.absoluteString containsString:@"h5-offline-pkg"]; + } withStubResponse:^OHHTTPStubsResponse*(NSURLRequest *request) { + NSMutableDictionary *dict = [NSMutableDictionary dictionary]; + dict[@"code"] = @(0); + dict[@"data"] = @"f722fc3efce547897819e9449d2ac562cee9075adda79ed74829f9d948e2f6d542a92e969e39dfbbd70aa2a7240d6fa3e51156c067e8685402727b6c13328092ecc0cbc773d95f9e0603b551e9447211b0e3e72648603e3d18e529b128470fa86aeb45d16af967d1a21b3e04361cfc767b7811aec6f19c274d388ddae4c8c68e857c14122a44c92a455051ae001fa7f2b177704bdebf8a2e3277faf0053460e0ecf178549e034a086470fa3bf287abbdd0f79867741293860b8a29590d2c2bb72b749402fb53dfcac95a7744ad21fe7b9e188881d1c24047d58c9fa46b3ebf4bc42a1defc50748758b5624c6c439c182fe21d4190920197628210160cf279187444bd1cb8707362cc4c3ab7486051af088d7851846bea21b64d4a5c73bd69aafc4bb34eb0862d1525c4f9a62ce64308289e2ecbc19ea105aa2bf99af6dd5a3ff653bbe7893adbec37b44a088b0b74b80532c720c79b7bb59fda3daf85b34ef35"; + NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; + return [OHHTTPStubsResponse responseWithData:data + statusCode:200 + headers:@{@"Content-Type":@"application/json"}]; + }]; + [routerManager fetchOfflineInfoIfNeeded]; + [[HOOfflineInfo shouldEventually] receive:@selector(saveToLocal:)]; + }); + }); +}); + +SPEC_END +``` + +😂 插一嘴,我贴的代码已经好几次可以看到不同的测试框架组合了,所以不是说选了框架 A 就完事,根据场景选择最优解。 + + + +## 六、UI 测试 + +上面文章大篇幅的讲了单元测试相关的话题,单元测试十分适合代码质量、逻辑、网络等内容的测试,但是针对最终产物 App 来说单元测试就不太适合了,如果测试 UI 界面的正确性、功能是否正确显然就不太适合了。Apple 在 Xcode 7 开始推出的 `UI Testing` 就是苹果自己的 UI 测试框架。 + +很多 UI 自动化测试框架的底层实现都依赖于 `Accessibility`,也就是 App 可用性。`UI Accessibility` 是 iOS 3.0 引入的一个人性化功能,帮助身体不便的人士方便使用 App。 + +Accessibility 通过对 UI 元素进行分类和标记。分类成类似按钮、文本框、文本等类型,使用 identifier 来区分不同 UI 元素。[无痕埋点的设计与实现](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.55.md)里面也使用 `accessibilityIdentifier` 来绑定业务数据。 + +1. 使用 Xcode 自带的 UI测试则在创建工程的时候需要勾选 “Include UI Tests”。 +2. 像单元测试意义,UI 测试方法命名以 test 开头。将鼠标光标移到方法内,点击 Xcode 左下方的红色按钮,开始录制 UI 脚本。 + +![UI 脚本录制](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-15-XcodeUITesting.png) + +解释说明: + +```objective-c +/*! Proxy for an application that may or may not be running. */ +@interface XCUIApplication : XCUIElement +// ... +@end +``` + +- `XCUIApplication launch` 来启动测试。`XCUIApplication` 是 UIApplication 在测试进程中的代理,用来和 App 进行一些交互。 + +- 使用 `staticTexts`来获取当前屏幕上的静态文本(UILabel)元素的代理。等价于 `[app descendantsMatchingType:XCUIElementTypeStaticText]`。XCUIElementTypeStaticText 参数是枚举类型。 + + ```objective-c + typedef NS_ENUM(NSUInteger, XCUIElementType) { + XCUIElementTypeAny = 0, + XCUIElementTypeOther = 1, + XCUIElementTypeApplication = 2, + XCUIElementTypeGroup = 3, + XCUIElementTypeWindow = 4, + XCUIElementTypeSheet = 5, + XCUIElementTypeDrawer = 6, + XCUIElementTypeAlert = 7, + XCUIElementTypeDialog = 8, + XCUIElementTypeButton = 9, + XCUIElementTypeRadioButton = 10, + XCUIElementTypeRadioGroup = 11, + XCUIElementTypeCheckBox = 12, + XCUIElementTypeDisclosureTriangle = 13, + XCUIElementTypePopUpButton = 14, + XCUIElementTypeComboBox = 15, + XCUIElementTypeMenuButton = 16, + XCUIElementTypeToolbarButton = 17, + XCUIElementTypePopover = 18, + XCUIElementTypeKeyboard = 19, + XCUIElementTypeKey = 20, + XCUIElementTypeNavigationBar = 21, + XCUIElementTypeTabBar = 22, + XCUIElementTypeTabGroup = 23, + XCUIElementTypeToolbar = 24, + XCUIElementTypeStatusBar = 25, + XCUIElementTypeTable = 26, + XCUIElementTypeTableRow = 27, + XCUIElementTypeTableColumn = 28, + XCUIElementTypeOutline = 29, + XCUIElementTypeOutlineRow = 30, + XCUIElementTypeBrowser = 31, + XCUIElementTypeCollectionView = 32, + XCUIElementTypeSlider = 33, + XCUIElementTypePageIndicator = 34, + XCUIElementTypeProgressIndicator = 35, + XCUIElementTypeActivityIndicator = 36, + XCUIElementTypeSegmentedControl = 37, + XCUIElementTypePicker = 38, + XCUIElementTypePickerWheel = 39, + XCUIElementTypeSwitch = 40, + XCUIElementTypeToggle = 41, + XCUIElementTypeLink = 42, + XCUIElementTypeImage = 43, + XCUIElementTypeIcon = 44, + XCUIElementTypeSearchField = 45, + XCUIElementTypeScrollView = 46, + XCUIElementTypeScrollBar = 47, + XCUIElementTypeStaticText = 48, + XCUIElementTypeTextField = 49, + XCUIElementTypeSecureTextField = 50, + XCUIElementTypeDatePicker = 51, + XCUIElementTypeTextView = 52, + XCUIElementTypeMenu = 53, + XCUIElementTypeMenuItem = 54, + XCUIElementTypeMenuBar = 55, + XCUIElementTypeMenuBarItem = 56, + XCUIElementTypeMap = 57, + XCUIElementTypeWebView = 58, + XCUIElementTypeIncrementArrow = 59, + XCUIElementTypeDecrementArrow = 60, + XCUIElementTypeTimeline = 61, + XCUIElementTypeRatingIndicator = 62, + XCUIElementTypeValueIndicator = 63, + XCUIElementTypeSplitGroup = 64, + XCUIElementTypeSplitter = 65, + XCUIElementTypeRelevanceIndicator = 66, + XCUIElementTypeColorWell = 67, + XCUIElementTypeHelpTag = 68, + XCUIElementTypeMatte = 69, + XCUIElementTypeDockItem = 70, + XCUIElementTypeRuler = 71, + XCUIElementTypeRulerMarker = 72, + XCUIElementTypeGrid = 73, + XCUIElementTypeLevelIndicator = 74, + XCUIElementTypeCell = 75, + XCUIElementTypeLayoutArea = 76, + XCUIElementTypeLayoutItem = 77, + XCUIElementTypeHandle = 78, + XCUIElementTypeStepper = 79, + XCUIElementTypeTab = 80, + XCUIElementTypeTouchBar = 81, + XCUIElementTypeStatusItem = 82, + }; + ``` + +- 通过 `XCUIApplication` 实例化对象调用 `descendantsMatchingType:` 方法得到的是 `XCUIElementQuery` 类型。比如 `@property (readonly, copy*) XCUIElementQuery *staticTexts;` + + ```objective-c + /*! Returns a query for all descendants of the element matching the specified type. */ + - (XCUIElementQuery *)descendantsMatchingType:(XCUIElementType)type; + ``` + +- `descendantsMatchingType` 返回所有后代的类型匹配对象。`childrenMatchingType` 返回当前层级子元素的类型匹配对象 + + ```objective-c + /*! Returns a query for direct children of the element matching the specified type. */ + - (XCUIElementQuery *)childrenMatchingType:(XCUIElementType)type; + + ``` + +- 拿到 `XCUIElementQuery` 后不能直接拿到 `XCUIElement`。和 `XCUIApplication` 类似,`XCUIElement` 不能直接访问 UI 元素,它是 UI 元素在测试框架中的代理。可以通过 `Accessibility` 中的 `frame`、`identifier` 来获取。 + + + +对比很多自动化测试框架都需要找出 UI 元素,也就是借助于 `Accessibility` 的 `identifier`。这里的唯一标识生成对比[为 UIAutomation 添加自动化测试标签的探索](http://yulingtianxia.com/blog/2016/03/28/Add-UITest-Label-for-UIAutomation/)] + + + +第三方 UI 自动化测试框架挺多的,可以查看下典型的 [appium](https://github.com/appium/appium)、[macaca](https://github.com/alibaba/macaca)。 + + + +## 七、 测试经验总结 + +TDD 写好测试再写业务代码,BDD 先写实现代码,再写基于行为的测试代码。另一种思路是没必要针对每个类的私有方法或者每个方法进行测试,因为等全部功能做完后针对每个类的接口测试,一般会覆盖据大多数的方法。等测试完看如果方法未被覆盖,则针对性的补充 `Unit Test`。 + +目前,UI 测试(appium) 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归了,目前来看价值是方便后续的迭代和维护上有一些便利性。其他的功能性测试还是走 BDD。 + +对于类、函数、方法的走 TDD,老老实实写 UT、走 UT 覆盖率的把控。 + +UITesting 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归,目前来看价值是方便后续的迭代和维护上有一些便利性。例如用户中心 SDK 升级后,当时有了UITesing,基本上免去了测试人员介入。 + +如果是一些活动页和逻辑经常变动的,老老实实走测试黑盒... + +我觉得一直有个误区,就是觉得自动测试是为了质量,其实质量都是附送的,测试先行是让开发更快更爽的 + +![测试占比](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-14-TestingPercentage.png) + +WWDC 这张图也很清楚,UI 其实需要的占比较小,还是要靠单测驱动。 + + + + + +## 参考资料 + +- [维基百科:测试驱动开发](https://zh.wikipedia.org/wiki/测试驱动开发) + + + + + diff --git a/Chapter1 - iOS/1.80.md b/Chapter1 - iOS/1.80.md index a16ac4c..ebd73e8 100644 --- a/Chapter1 - iOS/1.80.md +++ b/Chapter1 - iOS/1.80.md @@ -1,3 +1,2512 @@ # 打造一个通用、可配置、多句柄的数据上报 SDK -内容脱敏中... \ No newline at end of file +> 一个 App 一般会存在很多场景去上传 App 中产生的数据,比如 APM、埋点统计、开发者自定义的数据等等。所以本篇文章就讲讲如何设计一个通用的、可配置的、多句柄的数据上报 SDK。 +> + + + +## 前置说明 + +因为这篇文章和 APM 是属于姊妹篇,所以看这篇文章的时候有些东西不知道活着好奇的时候可以看[带你打造一套 APM 监控系统](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md)。 + +另外看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。 + +- 数据上报 SDK 叫 `HermesClient`,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 `PCT` +- 给 Category 命名,规则为 `类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 `NSDate+pct_TimeStamp` +- 给 Category 的方法命名,规则为 `SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 `+ (long long)pct_currentTimestamp;` + + + +## 一、 首先定义需要做什么 + +我们要做的是「一个通用可配置、多句柄的数据上报 SDK」,也就是说这个 SDK 具有这么几个功能: + +- 具有从服务端拉取配置信息的能力,这些配置用来控制 SDK 的上报行为(需不需要默认行为?) +- SDK 具有多句柄特性,也就是拥有多个对象,每个对象具有自己的控制行为,彼此之间的运行、操作互相隔离 +- APM 监控作为非常特殊的能力存在,它也使用数据上报 SDK。它的能力是 App 质量监控的保障,所以针对 APM 的数据上报通道是需要特殊处理的。 +- 数据先根据配置决定要不要存,存下来之后再根据配置决定如何上报 + +明白我们需要做什么,接下来的步骤就是分析设计怎么做。 + + + +## 二、 拉取配置信息 + +### 1. 需要哪些配置信息 + +首先明确几个原则: + +- 因为监控数据上报作为数据上报的一个特殊 case,那么监控的配置信息也应该特殊处理。 +- 监控能力包含很多,比如卡顿、网络、奔溃、内存、电量、启动时间、CPU 使用率。每个监控能力都需要一份配置信息,比如监控类型、是否仅 WI-FI 环境下上报、是否实时上报、是否需要携带 Payload 数据。(注:Payload 其实就是经过 gZip 压缩、AES-CBC 加密后的数据) +- 多句柄,所以需要一个字段标识每份配置信息,也就是一个 namespace 的概念 +- 每个 namespace 下都有自己的配置,比如数据上传后的服务器地址、上报开关、App 升级后是否需要清除掉之前版本保存的数据、单次上传数据包的最大体积限制、数据记录的最大条数、在非 WI-FI 环境下每天上报的最大流量、数据过期天数、上报开关等 +- 针对 APM 的数据配置,还需要一个是否需要采集的开关。 + +所以数据字段基本如下 + +```objective-c +@interface PCTItemModel : NSObject + +@property (nonatomic, copy) NSString *type; /<上报数据类型*/ +@property (nonatomic, assign) BOOL onlyWifi; /<是否仅 Wi-Fi 上报*/ +@property (nonatomic, assign) BOOL isRealtime; /<是否实时上报*/ +@property (nonatomic, assign) BOOL isUploadPayload; /<是否需要上报 Payload*/ + +@end + +@interface PCTConfigurationModel : NSObject + +@property (nonatomic, copy) NSString *url; /<当前 namespace 对应的上报地址 */ +@property (nonatomic, assign) BOOL isUpload; /<全局上报开关*/ +@property (nonatomic, assign) BOOL isGather; /<全局采集开关*/ +@property (nonatomic, assign) BOOL isUpdateClear; /<升级后是否清除数据*/ +@property (nonatomic, assign) NSInteger maxBodyMByte; /<最大包体积单位 M (范围 < 3M)*/ +@property (nonatomic, assign) NSInteger periodicTimerSecond; /<定时上报时间单位秒 (范围1 ~ 30秒)*/ +@property (nonatomic, assign) NSInteger maxItem; /<最大条数 (范围 < 100)*/ +@property (nonatomic, assign) NSInteger maxFlowMByte; /<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ +@property (nonatomic, assign) NSInteger expirationDay; /<数据过期时间单位 天 (范围 < 30)*/ +@property (nonatomic, copy) NSArray *monitorList; /<配置项目*/ + +@end +``` + +因为数据需要持久化保存,所以需要实现 `NSCoding` 协议。 + +一个小窍门,每个属性写 `encode`、`decode` 会很麻烦,可以借助于宏来实现快速编写。 + +```objective-c +#define PCT_DECODE(decoder, dataType, keyName) \ +{ \ +_##keyName = [decoder decode##dataType##ForKey:NSStringFromSelector(@selector(keyName))]; \ +}; + +#define PCT_ENCODE(aCoder, dataType, key) \ +{ \ +[aCoder encode##dataType:_##key forKey:NSStringFromSelector(@selector(key))]; \ +}; + +- (instancetype)initWithCoder:(NSCoder *)aDecoder { + if (self = [super init]) { + PCT_DECODE(aDecoder, Object, type) + PCT_DECODE(aDecoder, Bool, onlyWifi) + PCT_DECODE(aDecoder, Bool, isRealtime) + PCT_DECODE(aDecoder, Bool, isUploadPayload) + } + return self; +} + +- (void)encodeWithCoder:(NSCoder *)aCoder { + PCT_ENCODE(aCoder, Object, type) + PCT_ENCODE(aCoder, Bool, onlyWifi) + PCT_ENCODE(aCoder, Bool, isRealtime) + PCT_ENCODE(aCoder, Bool, isUploadPayload) +} +``` + + + + + +抛出一个问题:既然监控很重要,那别要配置了,直接全部上传。 + +我们想一想这个问题,监控数据都是不直接上传的,监控 SDK 的责任就是收集监控数据,而且监控后的数据非常多,App 运行期间的网络请求可能都有 n 次,App 启动时间、卡顿、奔溃、内存等可能不多,但是这些数据直接上传后期拓展性非常差,比如根据 APM 监控大盘分析出某个监控能力暂时先关闭掉。这时候就无力回天了,必须等下次 SDK 发布新版本。监控数据必须先存储,假如 crash 了,则必须保存了数据等下次启动再去组装数据、上传。而且数据在消费、新数据在不断生产,假如上传失败了还需要对失败数据的处理,所以这些逻辑还是挺多的,对于监控 SDK 来做这个事情,不是很合适。答案就显而易见了,必须要配置(监控开关的配置、数据上报的行为配置)。 + + + +### 2. 默认配置 + +因为监控真的很特殊,App 一启动就需要去收集 App 的性能、质量相关数据,所以需要一份默认的配置信息。 + +```objective-c +// 初始化一份默认配置 +- (void)setDefaultConfigurationModel { + PCTConfigurationModel *configurationModel = [[PCTConfigurationModel alloc] init]; + configurationModel.url = @"https://***DomainName.com"; + configurationModel.isUpload = YES; + configurationModel.isGather = YES; + configurationModel.isUpdateClear = YES; + configurationModel.periodicTimerSecond = 5; + configurationModel.maxBodyMByte = 1; + configurationModel.maxItem = 100; + configurationModel.maxFlowMByte = 20; + configurationModel.expirationDay = 15; + + PCTItemModel *appCrashItem = [[PCTItemModel alloc] init]; + appCrashItem.type = @"appCrash"; + appCrashItem.onlyWifi = NO; + appCrashItem.isRealtime = YES; + appCrashItem.isUploadPayload = YES; + + PCTItemModel *appLagItem = [[PCTItemModel alloc] init]; + appLagItem.type = @"appLag"; + appLagItem.onlyWifi = NO; + appLagItem.isRealtime = NO; + appLagItem.isUploadPayload = NO; + + PCTItemModel *appBootItem = [[PCTItemModel alloc] init]; + appBootItem.type = @"appBoot"; + appBootItem.onlyWifi = NO; + appBootItem.isRealtime = NO; + appBootItem.isUploadPayload = NO; + + PCTItemModel *netItem = [[PCTItemModel alloc] init]; + netItem.type = @"net"; + netItem.onlyWifi = NO; + netItem.isRealtime = NO; + netItem.isUploadPayload = NO; + + PCTItemModel *netErrorItem = [[PCTItemModel alloc] init]; + netErrorItem.type = @"netError"; + netErrorItem.onlyWifi = NO; + netErrorItem.isRealtime = NO; + netErrorItem.isUploadPayload = NO; + configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; + self.configurationModel = configurationModel; +} +``` + +上面的例子是一份默认配置信息 + + + +### 3. 拉取策略 + +网络拉取使用了基础 SDK (非网络 SDK)的能力 mGet,根据 key 注册网络服务。这些 key 一般是 SDK 内部的定义好的,比如统跳路由表等。 + +这类 key 的共性是 App 在打包阶段会内置一份默认配置,App 启动后会去拉取最新数据,然后完成数据的缓存,缓存会在 `NSDocumentDirectory` 目录下按照 SDK 名称、 App 版本号、打包平台上分配的打包任务 id、 key 建立缓存文件夹。 + +此外它的特点是等 App 启动完成后才去请求网络,获取数据,不会影响 App 的启动。 + +流程图如下 + +![数据上报配置信息获取流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-29-DataUploadConfigurationStructure.png) + +下面是一个截取代码,对比上面图看看。 + +```objective-c +@synthesize configurationDictionary = _configurationDictionary; + +#pragma mark - Initial Methods + ++ (instancetype)sharedInstance { + static PCTConfigurationService *_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedInstance = [[self alloc] init]; + }); + return _sharedInstance; +} + +- (instancetype)init { + if (self = [super init]) { + [self setUp]; + } + return self; +} + +#pragma mark - public Method + +- (void)registerAndFetchConfigurationInfo { + __weak typeof(self) weakself = self; + NSDictionary *params = @{@"deviceId": [[HermesClient sharedInstance] getCommon].SYS_DEVICE_ID}; + + [self.requester fetchUploadConfigurationWithParams:params success:^(NSDictionary * _Nonnull configurationDictionary) { + weakself.configurationDictionary = configurationDictionary; + [NSKeyedArchiver archiveRootObject:configurationDictionary toFile:[self savedFilePath]]; + } failure:^(NSError * _Nonnull error) { + + }]; +} + +- (PCTConfigurationModel *)getConfigurationWithNamespace:(NSString *)namespace { + if (!PCT_IS_CLASS(namespace, NSString)) { + NSAssert(PCT_IS_CLASS(namespace, NSString), @"需要根据 namespace 参数获取对应的配置信息,所以必须是 NSString 类型"); + return nil; + } + if (namespace.length == 0) { + NSAssert(namespace.length > 0, @"需要根据 namespace 参数获取对应的配置信息,所以必须是非空的 NSString"); + return nil; + } + id configurationData = [self.configurationDictionary objectForKey:namespace]; + if (!configurationData) { + return nil; + } + if (!PCT_IS_CLASS(configurationData, NSDictionary)) { + return nil; + } + NSDictionary *configurationDictionary = (NSDictionary *)configurationData; + return [PCTConfigurationModel modelWithDictionary:configurationDictionary]; +} + + +#pragma mark - private method + +- (void)setUp { + // 创建数据保存的文件夹 + [[NSFileManager defaultManager] createDirectoryAtPath:[self configurationDataFilePath] withIntermediateDirectories:YES attributes:nil error:nil]; + [self setDefaultConfigurationModel]; + [self getConfigurationModelFromLocal]; +} + +- (NSString *)savedFilePath { + return [NSString stringWithFormat:@"%@/%@", [self configurationDataFilePath], PCT_CONFIGURATION_FILEPATH]; +} + +// 初始化一份默认配置 +- (void)setDefaultConfigurationModel { + PCTConfigurationModel *configurationModel = [[PCTConfigurationModel alloc] init]; + configurationModel.url = @"https://.com"; + configurationModel.isUpload = YES; + configurationModel.isGather = YES; + configurationModel.isUpdateClear = YES; + configurationModel.periodicTimerSecond = 5; + configurationModel.maxBodyMByte = 1; + configurationModel.maxItem = 100; + configurationModel.maxFlowMByte = 20; + configurationModel.expirationDay = 15; + + PCTItemModel *appCrashItem = [[PCTItemModel alloc] init]; + appCrashItem.type = @"appCrash"; + appCrashItem.onlyWifi = NO; + appCrashItem.isRealtime = YES; + appCrashItem.isUploadPayload = YES; + + PCTItemModel *appLagItem = [[PCTItemModel alloc] init]; + appLagItem.type = @"appLag"; + appLagItem.onlyWifi = NO; + appLagItem.isRealtime = NO; + appLagItem.isUploadPayload = NO; + + PCTItemModel *appBootItem = [[PCTItemModel alloc] init]; + appBootItem.type = @"appBoot"; + appBootItem.onlyWifi = NO; + appBootItem.isRealtime = NO; + appBootItem.isUploadPayload = NO; + + PCTItemModel *netItem = [[PCTItemModel alloc] init]; + netItem.type = @"net"; + netItem.onlyWifi = NO; + netItem.isRealtime = NO; + netItem.isUploadPayload = NO; + + PCTItemModel *netErrorItem = [[PCTItemModel alloc] init]; + netErrorItem.type = @"netError"; + netErrorItem.onlyWifi = NO; + netErrorItem.isRealtime = NO; + netErrorItem.isUploadPayload = NO; + configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; + self.configurationModel = configurationModel; +} + +- (void)getConfigurationModelFromLocal { + id unarchiveObject = [NSKeyedUnarchiver unarchiveObjectWithFile:[self savedFilePath]]; + if (unarchiveObject) { + if (PCT_IS_CLASS(unarchiveObject, NSDictionary)) { + self.configurationDictionary = (NSDictionary *)unarchiveObject; + [self.configurationDictionary enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { + if ([key isEqualToString:HermesNAMESPACE]) { + if (PCT_IS_CLASS(obj, NSDictionary)) { + NSDictionary *configurationDictionary = (NSDictionary *)obj; + self.configurationModel = [PCTConfigurationModel modelWithDictionary:configurationDictionary]; + } + } + }]; + } + } +} + + +#pragma mark - getters and setters + +- (NSString *)configurationDataFilePath { + NSString *filePath = [NSString stringWithFormat:@"%@/%@/%@/%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject, @"hermes", [CMAppProfile sharedInstance].mAppVersion, [[HermesClient sharedInstance] getCommon].WAX_CANDLE_TASK_ID]; + return filePath; +} + +- (PCTRequestFactory *)requester { + if (!_requester) { + _requester = [[PCTRequestFactory alloc] init]; + } + return _requester; +} + +- (void)setConfigurationDictionary:(NSDictionary *)configurationDictionary +{ + @synchronized (self) { + _configurationDictionary = configurationDictionary; + } +} + +- (NSDictionary *)configurationDictionary +{ + @synchronized (self) { + if (_configurationDictionary == nil) { + NSDictionary *hermesDictionary = [self.configurationModel getDictionary]; + _configurationDictionary = @{HermesNAMESPACE: hermesDictionary}; + } + return _configurationDictionary; + } +} + +@end +``` + + + +## 三、数据存储 + +### 1. 数据存储技术选型 + +记得在做数据上报技术的评审会议上,Android 同事说用 [WCDB](https://github.com/Tencent/wcdb),特色是 ORM、多线程安全、高性能。然后就被质疑了。因为上个版本使用的技术是基于系统自带的 sqlite2,单纯为了 ORM、多线程问题就额外引入一个三方库,是不太能说服人的。有这样几个疑问 + +- ORM 并不是核心诉求,利用 Runtime 可以在基础上进行修改,也可支持 ORM 功能 + +- 线程安全。WCDB 在线程安全的实现主要是基于`Handle`,`HandlePool` 和 `Database` 三个类完成的。`Handle` 是 sqlite3 指针,`HandlePool` 用来处理连接。 + + ```c++ + RecyclableHandle HandlePool::flowOut(Error &error) + { + m_rwlock.lockRead(); + std::shared_ptr handleWrap = m_handles.popBack(); + if (handleWrap == nullptr) { + if (m_aliveHandleCount < s_maxConcurrency) { + handleWrap = generate(error); + if (handleWrap) { + ++m_aliveHandleCount; + if (m_aliveHandleCount > s_hardwareConcurrency) { + WCDB::Error::Warning( + ("The concurrency of database:" + + std::to_string(tag.load()) + " with " + + std::to_string(m_aliveHandleCount) + + " exceeds the concurrency of hardware:" + + std::to_string(s_hardwareConcurrency)) + .c_str()); + } + } + } else { + Error::ReportCore( + tag.load(), path, Error::CoreOperation::FlowOut, + Error::CoreCode::Exceed, + "The concurrency of database exceeds the max concurrency", + &error); + } + } + if (handleWrap) { + handleWrap->handle->setTag(tag.load()); + if (invoke(handleWrap, error)) { + return RecyclableHandle( + handleWrap, [this](std::shared_ptr &handleWrap) { + flowBack(handleWrap); + }); + } + } + + handleWrap = nullptr; + m_rwlock.unlockRead(); + return RecyclableHandle(nullptr, nullptr); + } + + void HandlePool::flowBack(const std::shared_ptr &handleWrap) + { + if (handleWrap) { + bool inserted = m_handles.pushBack(handleWrap); + m_rwlock.unlockRead(); + if (!inserted) { + --m_aliveHandleCount; + } + } + } + ``` + + 所以 WCDB 连接池通过读写锁保证线程安全。所以之前版本的地方要实现线程安全修改下缺陷就可以。增加了 sqlite3,虽然看起来就是几兆大小,但是这对于公共团队是致命的。业务线开发者每次接入 SDK 会注意App 包体积的变化,为了数据上报增加好几兆,这是不可以接受的。 + +- 高性能的背后是 WCDB 自带的 sqlite3 开启了 `WAL模式` (Write-Ahead Logging)。当 WAL 文件超过 1000 个页大小时,SQLite3 会将 WAL 文件写会数据库文件。也就是 checkpointing。当大批量的数据写入场景时,如果不停提交文件到数据库事务,效率肯定低下,WCDB 的策略就是在触发 checkpoint 时,通过延时队列去处理,避免不停的触发 WalCheckpoint 调用。通过 `TimedQueue` 将同个数据库的 `WalCheckpoint` 合并延迟到2秒后执行 + + ```c++ + { + Database::defaultCheckpointConfigName, + [](std::shared_ptr &handle, Error &error) -> bool { + handle->registerCommittedHook( + [](Handle *handle, int pages, void *) { + static TimedQueue s_timedQueue(2); + if (pages > 1000) { + s_timedQueue.reQueue(handle->path); + } + static std::thread s_checkpointThread([]() { + pthread_setname_np( + ("WCDB-" + Database::defaultCheckpointConfigName) + .c_str()); + while (true) { + s_timedQueue.waitUntilExpired( + [](const std::string &path) { + Database database(path); + WCDB::Error innerError; + database.exec(StatementPragma().pragma( + Pragma::WalCheckpoint), + innerError); + }); + } + }); + static std::once_flag s_flag; + std::call_once(s_flag, + []() { s_checkpointThread.detach(); }); + }, + nullptr); + return true; + }, + (Configs::Order) Database::ConfigOrder::Checkpoint, + }, + ``` + + + + + +一般来说公共组做事情,SDK 命名、接口名称、接口个数、参数个数、参数名称、参数数据类型是严格一致的,差异是语言而已。实在万不得已,能力不能堆砌的情况下是可以不一致的,但是需要在技术评审会议上说明原因,需要在发布文档、接入文档都有所体现。 + + + +所以最后的结论是在之前的版本基础上进行修改,之前的版本是 FMDB。 + + + +### 2. 数据库维护队列 + +#### 1. FMDB 队列 + +`FMDB` 使用主要是通过 `FMDatabaseQueue` 的 `- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block` 和 `- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block`。这2个方法的实现如下 + +```objective-c +- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block { +#ifndef NDEBUG + /* Get the currently executing queue (which should probably be nil, but in theory could be another DB queue + * and then check it against self to make sure we're not about to deadlock. */ + FMDatabaseQueue *currentSyncQueue = (__bridge id)dispatch_get_specific(kDispatchQueueSpecificKey); + assert(currentSyncQueue != self && "inDatabase: was called reentrantly on the same queue, which would lead to a deadlock"); +#endif + + FMDBRetain(self); + + dispatch_sync(_queue, ^() { + + FMDatabase *db = [self database]; + + block(db); + + if ([db hasOpenResultSets]) { + NSLog(@"Warning: there is at least one open result set around after performing [FMDatabaseQueue inDatabase:]"); + +#if defined(DEBUG) && DEBUG + NSSet *openSetCopy = FMDBReturnAutoreleased([[db valueForKey:@"_openResultSets"] copy]); + for (NSValue *rsInWrappedInATastyValueMeal in openSetCopy) { + FMResultSet *rs = (FMResultSet *)[rsInWrappedInATastyValueMeal pointerValue]; + NSLog(@"query: '%@'", [rs query]); + } +#endif + } + }); + + FMDBRelease(self); +} +``` + +```objective-c +- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block { + [self beginTransaction:FMDBTransactionExclusive withBlock:block]; +} + +- (void)beginTransaction:(FMDBTransaction)transaction withBlock:(void (^)(FMDatabase *db, BOOL *rollback))block { + FMDBRetain(self); + dispatch_sync(_queue, ^() { + + BOOL shouldRollback = NO; + + switch (transaction) { + case FMDBTransactionExclusive: + [[self database] beginTransaction]; + break; + case FMDBTransactionDeferred: + [[self database] beginDeferredTransaction]; + break; + case FMDBTransactionImmediate: + [[self database] beginImmediateTransaction]; + break; + } + + block([self database], &shouldRollback); + + if (shouldRollback) { + [[self database] rollback]; + } + else { + [[self database] commit]; + } + }); + + FMDBRelease(self); +} +``` + +上面的 `_queue` 其实是一个串行队列,通过 `_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL);` 创建。所以,`FMDB` 的核心就是以同步的形式向串行队列提交任务,来保证多线程操作下的读写问题(比每个操作加锁效率高很多)。只有一个任务执行完毕,才可以执行下一个任务。 + +上一个版本的数据上报 SDK 功能比较简单,就是上报 APM 监控后的数据,所以数据量不会很大,之前的人封装超级简单,仅以事务的形式封装了一层 FMDB 的增删改查操作。那么就会有一个问题。假如 SDK 被业务线接入,业务线开发者不知道数据上报 SDK 的内部实现,直接调用接口去写入大量数据,结果 App 发生了卡顿,那不得反馈你这个 SDK 超级难用啊。 + + + +#### 2. 针对 FMDB 的改进 + +改法也比较简单,我们先弄清楚 `FMDB` 这样设计的原因。数据库操作的环境可能是主线程、子线程等不同环境去修改数据,主线程、子线程去读取数据,所以创建了一个串行队列去执行真正的数据增删改查。 + +目的就是让不同线程去使用 `FMDB` 的时候不会阻塞当前线程。既然 `FMDB` 内部维护了一个串行队列去处理多线程情况下的数据操作,那么改法也比较简单,那就是创建一个并发队列,然后以异步的方式提交任务到 `FMDB` 中去,`FMDB` 内部的串行队列去执行真正的任务。 + +代码如下 + +```objective-c +// 创建队列 +self.dbOperationQueue = dispatch_queue_create(PCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); +self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + +// 以删除数据为例,以异步任务的方式向并发队列提交任务,任务内部调用 FMDatabaseQueue 去串行执行每个任务 +- (void)removeAllLogsInTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeAllLogsInTable:tableName]; + }); +} + +- (void)removeAllLogsInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} +``` + +小实验模拟下流程 + +```objective-c +sleep(1); +NSLog(@"1"); +dispatch_queue_t concurrentQueue = dispatch_queue_create("PCT_DATABASE_OPERATION_QUEUE", DISPATCH_QUEUE_CONCURRENT); +dispatch_async(concurrentQueue, ^{ + sleep(2); + NSLog(@"2"); +}); +sleep(1); +NSLog(@"3"); +dispatch_async(concurrentQueue, ^{ + sleep(3); + NSLog(@"4"); +}); +sleep(1); +NSLog(@"5"); + +2020-07-01 13:28:13.610575+0800 Test[54460:1557233] 1 +2020-07-01 13:28:14.611937+0800 Test[54460:1557233] 3 +2020-07-01 13:28:15.613347+0800 Test[54460:1557233] 5 +2020-07-01 13:28:15.613372+0800 Test[54460:1557280] 2 +2020-07-01 13:28:17.616837+0800 Test[54460:1557277] 4 +``` + + + +![MainThread Dispatch Async Task To ConcurrentQueue](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png) + + + +### 3. 数据表设计 + +通用的数据上报 SDK 的功能是数据的保存和上报。从数据的角度来划分,数据可以分为 APM 监控数据和业务线的业务数据。 + +数据各有什么特点呢?APM 监控数据一般可以划分为:基本信息、异常信息、线程信息,也就是最大程度的还原案发线程的数据。业务线数据基本上不会有所谓的大量数据,最多就是数据条数非常多。鉴于此现状,可以将数据表设计为 **meta 表**、**payload 表**。meta 表用来存放 APM 的基础数据和业务线的数据,payload 表用来存放 APM 的线程堆栈数据。 + +数据表的设计是基于业务情况的。那有这样几个背景 + +- APM 监控数据需要报警(具体可以查看 APM 文章,地址在开头 ),所以数据上报 SDK 上报后的数据需要实时解析 +- 产品侧比如监控大盘可以慢,所以符号化系统是异步的 +- 监控数据实在太大了,如果同步解析会因为压力较大造成性能瓶颈 + +所以把监控数据拆分为2块,即 meta 表、payload 表。meta 表相当于记录索引信息,服务端只需要关心这个。而 payload 数据在服务端是不会处理的,会有一个异步服务单独处理。 + +meta 表、payload 表结构如下: + +```sql +create table if not exists ***_meta (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL); + +create table if not exists ***_payload (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL); +``` + + + +### 4. 数据库表的封装 + +```objective-c +#import "PCTDatabase.h" +#import + +static NSString *const PCT_LOG_DATABASE_NAME = @"***.db"; +static NSString *const PCT_LOG_TABLE_META = @"***_hermes_meta"; +static NSString *const PCT_LOG_TABLE_PAYLOAD = @"***_hermes_payload"; +const char *PCT_DATABASE_OPERATION_QUEUE = "com.***.pct_database_operation_QUEUE"; + +@interface PCTDatabase () + +@property (nonatomic, strong) dispatch_queue_t dbOperationQueue; +@property (nonatomic, strong) FMDatabaseQueue *dbQueue; +@property (nonatomic, strong) NSDateFormatter *dateFormatter; + +@end + +@implementation PCTDatabase + +#pragma mark - life cycle ++ (instancetype)sharedInstance { + static PCTDatabase *_sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedInstance = [[self alloc] init]; + }); + return _sharedInstance; +} + +- (instancetype)init { + self = [super init]; + self.dateFormatter = [[NSDateFormatter alloc] init]; + [self.dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss A Z"]; + self.dbOperationQueue = dispatch_queue_create(PCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); + self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [self createLogMetaTableIfNotExist:db]; + [self createLogPayloadTableIfNotExist:db]; + }]; + return self; +} + +#pragma mark - public Method + +- (void)add:(NSArray *)logs inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself add:logs inTable:tableName]; + }); +} + +- (void)remove:(NSArray *)logs inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself remove:logs inTable:tableName]; + }); +} + +- (void)removeAllLogsInTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeAllLogsInTable:tableName]; + }); +} + +- (void)removeOldestRecordsByCount:(NSInteger)count inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeOldestRecordsByCount:count inTable:tableName]; + }); +} + +- (void)removeLatestRecordsByCount:(NSInteger)count inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeLatestRecordsByCount:count inTable:tableName]; + }); +} + +- (void)removeRecordsBeforeDays:(NSInteger)day inTableType:(PCTLogTableType)tableType { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeRecordsBeforeDays:day inTable:tableName]; + }); + [self rebuildDatabaseFileInTableType:tableType]; +} + +- (void)removeDataUseCondition:(NSString *)condition inTableType:(PCTLogTableType)tableType { + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"自定义删除条件必须是字符串类型"); + return; + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"自定义删除条件不能为空"); + return; + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself removeDataUseCondition:condition inTable:tableName]; + }); +} + +- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTableType:(PCTLogTableType)tableType { + if (!PCT_IS_CLASS(state, NSString)) { + NSAssert(PCT_IS_CLASS(state, NSString), @"数据表字段更改命令必须是合法字符串"); + return; + } + if (state.length == 0) { + NSAssert(!(state.length == 0), @"数据表字段更改命令必须是合法字符串"); + return; + } + + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"数据表字段更改条件必须是字符串类型"); + return; + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"数据表字段更改条件不能为空"); + return; + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself updateData:state useCondition:condition inTable:tableName]; + }); +} + +- (void)recordsCountInTableType:(PCTLogTableType)tableType completion:(void (^)(NSInteger count))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSInteger recordsCount = [weakself recordsCountInTable:tableName]; + if (completion) { + completion(recordsCount); + } + }); +} + +- (void)getLatestRecoreds:(NSInteger)count inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getLatestRecoreds:count inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)getOldestRecoreds:(NSInteger)count inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getOldestRecoreds:count inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTableType:(PCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { + if (!PCT_IS_CLASS(condition, NSString)) { + NSAssert(PCT_IS_CLASS(condition, NSString), @"自定义查询条件必须是字符串类型"); + if (completion) { + completion(nil); + } + } + if (condition.length == 0) { + NSAssert(!(condition.length == 0), @"自定义查询条件不能为空"); + if (completion) { + completion(nil); + } + } + [self isExistInTable:tableType]; + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + NSArray *records = [weakself getRecordsByCount:count condtion:condition inTable:tableName]; + if (completion) { + completion(records); + } + }); +} + +- (void)rebuildDatabaseFileInTableType:(PCTLogTableType)tableType { + __weak typeof(self) weakself = self; + dispatch_async(self.dbOperationQueue, ^{ + NSString *tableName = PCTGetTableNameFromType(tableType); + [weakself rebuildDatabaseFileInTable:tableName]; + }); +} + +#pragma mark - CMDatabaseDelegate + +- (void)add:(NSArray *)logs inTable:(NSString *)tableName { + if (logs.count == 0) { + return; + } + __weak typeof(self) weakself = self; + [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { + [db setDateFormat:weakself.dateFormatter]; + for (NSInteger index = 0; index < logs.count; index++) { + id obj = logs[index]; + // meta 类型数据的处理逻辑 + if (PCT_IS_CLASS(obj, PCTLogMetaModel)) { + PCTLogMetaModel *model = (PCTLogMetaModel *)obj; + if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { + PCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); + return; + } + + NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?)", tableName]; + [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.namespace, @(model.is_used), @(model.size)]]; + } + + // payload 类型数据的处理逻辑 + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *model = (PCTLogPayloadModel *)obj; + if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { + PCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); + return; + } + + NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, payload, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", tableName]; + [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.payload ?: [NSData data], model.namespace, @(model.is_used), @(model.size)]]; + } + } + }]; +} + +- (NSInteger)remove:(NSArray *)logs inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where report_id = ?", tableName]; + [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { + [logs enumerateObjectsUsingBlock:^(PCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + [db executeUpdate:sqlString withArgumentsInArray:@[obj.report_id]]; + }]; + }]; + return 0; +} + +- (void)removeAllLogsInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)removeOldestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time asc limit ? )", tableName, tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; + }]; +} + +- (void)removeLatestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time desc limit ?)", tableName, tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; + }]; +} + +- (void)removeRecordsBeforeDays:(NSInteger)day inTable:(NSString *)tableName { + // 找出从create到现在已经超过最大 day 天的数据,然后删除 :delete from ***_hermes_meta where strftime('%s', date('now', '-2 day')) >= created_time; + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) >= created_time", tableName, day]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)removeDataUseCondition:(NSString *)condition inTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where %@", tableName, condition]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTable:(NSString *)tableName +{ + NSString *sqlString = [NSString stringWithFormat:@"update %@ set %@ where %@", tableName, state, condition]; + [self.dbQueue inDatabase:^(FMDatabase * _Nonnull db) { + BOOL res = [db executeUpdate:sqlString]; + PCTLOG(res ? @"更新成功" : @"更新失败"); + }]; +} + +- (NSInteger)recordsCountInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"select count(*) as count from %@", tableName]; + __block NSInteger recordsCount = 0; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + FMResultSet *resultSet = [db executeQuery:sqlString]; + [resultSet next]; + recordsCount = [resultSet intForColumn:@"count"]; + [resultSet close]; + }]; + return recordsCount; +} + +- (NSArray *)getLatestRecoreds:(NSInteger)count inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray new]; + NSString *sql = [NSString stringWithFormat:@"select * from %@ order by created_time desc limit ?", tableName]; + + __weak typeof(self) weakself = self; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + FMResultSet *resultSet = [db executeQuery:sql withArgumentsInArray:@[@(count)]]; + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (NSArray *)getOldestRecoreds:(NSInteger)count inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray array]; + NSString *sqlString = [NSString stringWithFormat:@"select * from %@ order by created_time asc limit ?", tableName]; + + __weak typeof(self) weakself = self; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + + FMResultSet *resultSet = [db executeQuery:sqlString withArgumentsInArray:@[@(count)]]; + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (NSArray *)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTable:(NSString *)tableName { + __block NSMutableArray *records = [NSMutableArray array]; + __weak typeof(self) weakself = self; + NSString *sqlString = [NSString stringWithFormat:@"select * from %@ where %@ order by created_time desc limit %zd", tableName, condition, count]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db setDateFormat:weakself.dateFormatter]; + + FMResultSet *resultSet = [db executeQuery:sqlString]; + + while ([resultSet next]) { + if ([tableName isEqualToString:PCT_LOG_TABLE_META]) { + PCTLogMetaModel *model = [[PCTLogMetaModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + + } else if ([tableName isEqualToString:PCT_LOG_TABLE_PAYLOAD]) { + PCTLogPayloadModel *model = [[PCTLogPayloadModel alloc] init]; + model.log_id = [resultSet intForColumn:@"id"]; + model.report_id = [resultSet stringForColumn:@"report_id"]; + model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; + model.created_time = [resultSet stringForColumn:@"created_time"]; + model.meta = [resultSet stringForColumn:@"meta"]; + model.payload = [resultSet dataForColumn:@"payload"]; + model.namespace = [resultSet stringForColumn:@"namespace"]; + model.size = [resultSet intForColumn:@"size"]; + model.is_biz = [resultSet boolForColumn:@"is_biz"]; + model.is_used = [resultSet boolForColumn:@"is_used"]; + [records addObject:model]; + } + } + [resultSet close]; + }]; + return records; +} + +- (void)rebuildDatabaseFileInTable:(NSString *)tableName { + NSString *sqlString = [NSString stringWithFormat:@"vacuum %@", tableName]; + [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { + [db executeUpdate:sqlString]; + }]; +} + +#pragma mark - private method + ++ (NSString *)databaseFilePath { + NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; + NSString *dbPath = [docsPath stringByAppendingPathComponent:PCT_LOG_DATABASE_NAME]; + PCTLOG(@"上报系统数据库文件位置 -> %@", dbPath); + return dbPath; +} + +- (void)createLogMetaTableIfNotExist:(FMDatabase *)db { + NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL)", PCT_LOG_TABLE_META]; + BOOL result = [db executeStatements:createMetaTableSQL]; + PCTLOG(@"确认日志Meta表是否存在 -> %@", result ? @"成功" : @"失败"); +} + +- (void)createLogPayloadTableIfNotExist:(FMDatabase *)db { + NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL)", PCT_LOG_TABLE_PAYLOAD]; + BOOL result = [db executeStatements:createMetaTableSQL]; + PCTLOG(@"确认日志Payload表是否存在 -> %@", result ? @"成功" : @"失败"); +} + +NS_INLINE NSString *PCTGetTableNameFromType(PCTLogTableType type) { + if (type == PCTLogTableTypeMeta) { + return PCT_LOG_TABLE_META; + } + if (type == PCTLogTableTypePayload) { + return PCT_LOG_TABLE_PAYLOAD; + } + return @""; +} + +// 每次操作前检查数据库以及数据表是否存在,不存在则创建数据库和数据表 +- (void)isExistInTable:(PCTLogTableType)tableType { + NSString *databaseFilePath = [PCTDatabase databaseFilePath]; + BOOL isExist = [[NSFileManager defaultManager] fileExistsAtPath:databaseFilePath]; + if (!isExist) { + self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[PCTDatabase databaseFilePath]]; + } + [self.dbQueue inDatabase:^(FMDatabase *db) { + NSString *tableName = PCTGetTableNameFromType(tableType); + BOOL res = [db tableExists:tableName]; + if (!res) { + if (tableType == PCTLogTableTypeMeta) { + [self createLogMetaTableIfNotExist:db]; + } + if (tableType == PCTLogTableTypeMeta) { + [self createLogPayloadTableIfNotExist:db]; + } + } + }]; +} + +@end +``` + +上面有个地方需要注意下,因为经常需要根据类型来判读操作那个数据表,使用频次很高,所以写成内联函数的形式 + +```objective-c +NS_INLINE NSString *PCTGetTableNameFromType(PCTLogTableType type) { + if (type == PCTLogTableTypeMeta) { + return PCT_LOG_TABLE_META; + } + if (type == PCTLogTableTypePayload) { + return PCT_LOG_TABLE_PAYLOAD; + } + return @""; +} +``` + + + +### 5. 数据存储流程 + + APM 监控数据会比较特殊点,比如 iOS 当发生 crash 后是没办法上报的,只有将 crash 信息保存到文件中,下次 App 启动后读取 crash 日志文件夹再去交给数据上报 SDK。Android 在发生 crash 后由于机制不一样,可以马上将 crash 信息交给数据上报 SDK。 + +由于 payload 数据,也就是堆栈数据非常大,所以上报的接口也有限制,一次上传接口中报文最大包体积的限制等等。 + +可以看一下 Model 信息, + +```objective-c +@interface PCTItemModel : NSObject + +@property (nonatomic, copy) NSString *type; /**<上报数据类型*/ +@property (nonatomic, assign) BOOL onlyWifi; /**<是否仅 Wi-Fi 上报*/ +@property (nonatomic, assign) BOOL isRealtime; /**<是否实时上报*/ +@property (nonatomic, assign) BOOL isUploadPayload; /**<是否需要上报 Payload*/ + +@end + +@interface PCTConfigurationModel : NSObject + +@property (nonatomic, copy) NSString *url; /**<当前 namespace 对应的上报地址 */ +@property (nonatomic, assign) BOOL isUpload; /**<全局上报开关*/ +@property (nonatomic, assign) BOOL isGather; /**<全局采集开关*/ +@property (nonatomic, assign) BOOL isUpdateClear; /**<升级后是否清除数据*/ +@property (nonatomic, assign) NSInteger maxBodyMByte; /**<最大包体积单位 M (范围 < 3M)*/ +@property (nonatomic, assign) NSInteger periodicTimerSecond; /**<定时上报时间单位秒 (范围1 ~ 30秒)*/ +@property (nonatomic, assign) NSInteger maxItem; /**<最大条数 (范围 < 100)*/ +@property (nonatomic, assign) NSInteger maxFlowMByte; /**<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ +@property (nonatomic, assign) NSInteger expirationDay; /**<数据过期时间单位 天 (范围 < 30)*/ +@property (nonatomic, copy) NSArray *monitorList; /**<配置项目*/ + +@end +``` + +监控数据存储流程: + +1. 每个数据(监控数据、业务线数据)过来先判断该数据所在的 namespace 是否开启了收集开关 + +2. 判断数据是否可以落库,根据数据接口中 type 能否命中上报配置数据中的 monitorList 中的任何一项的 type + +3. 监控数据先写入 meta 表,然后判断是否写入 payload 表。判断标准是计算监控数据的 payload 大小是否超过了上报配置数据的 `maxBodyMByte`。超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限 + +4. 走监控接口过来的数据,在方法内部会为监控数据增加基础信息(比如 App 名称、App 版本号、打包任务 id、设备类型等等) + + ```objective-c + @property (nonatomic, copy) NSString *xxx_APP_NAME; /** 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 先写入 meta 表 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; + + // 4. 如果 payload 不存在则退出当前执行 + if (!PCT_IS_CLASS(payload, NSData) && !payload) { + return; + } + + // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) + CGFloat payloadSize = [self calculateDataSize:payload]; + if (payloadSize > self.configureModel.maxBodyMByte) { + NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; + NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); + return; + } + + // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 7. 计算上报时 payload 这条数据的大小(meta+payload) + NSMutableData *totalData = [NSMutableData data]; + [totalData appendData:metaData]; + [totalData appendData:payload]; + + // 8. 再写入 payload 表 + PCTLogPayloadModel *payloadModel = [[PCTLogPayloadModel alloc] init]; + payloadModel.is_used = NO; + payloadModel.namespace = HermesNAMESPACE; + payloadModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + payloadModel.monitor_type = PCT_SAFE_STRING(type); + payloadModel.is_biz = NO; + payloadModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + payloadModel.meta = PCT_SAFE_STRING(metaContentString); + payloadModel.payload = payload; + payloadModel.size = totalData.length; + [PCT_DATABASE add:@[payloadModel] inTableType:PCTLogTableTypePayload]; + + // 9. 判断是否触发实时上报 + [self handleUploadDataWithtype:type]; +} +``` + +业务线数据存储流程基本和监控数据的存储差不多,有差别的是某些字段的标示,用来区分业务线数据。 + + + +## 四、数据上报机制 + +### 1. 数据上报流程和机制设计 + +数据上报机制需要结合数据特点进行设计,数据分为 APM 监控数据和业务线上传数据。先分析下2部分数据的特点。 + +- 业务线数据可能会要求实时上报,需要有根据上报配置数据控制的能力 + +- 整个数据聚合上报过程需要有根据上报配置数据控制的能力定时器周期的能力,隔一段时间去触发上报 + +- 整个数据(业务数据、APM 监控数据)的上报与否需要有通过配置数据控制的能力 + +- 因为 App 在某个版本下收集的数据可能会对下个版本的时候无效,所以上报 SDK 启动后需要有删除之前版本数据的能力(上报配置数据中删除开关打开的情况下) + +- 同样,需要删除过期数据的能力(删除距今多少个自然天前的数据,同样走下发而来的上报配置项) + +- 因为 APM 监控数据非常大,且数据上报 SDK 肯定数据比较大,所以一个网络通信方式的设计好坏会影响 SDK 的质量,为了网络性能不采用传统的 `key/value` 传输。采用**自定义报文结构** + +- 数据的上报流程触发方式有3种:App 启动后触发(APM 监控到 crash 的时候写入本地,启动后处理上次 crash 的数据,是一个特殊 case );定时器触发;数据调用数据上报 SDK 接口后命中实时上报逻辑 + +- 数据落库后会触发一次完整的上报流程 + +- 上报流程的第一步会先判断该数据的 type 能否名字上报配置的 type,命中后如果实时上报配置项为 true,则马上执行后续真正的数据聚合过程;否则中断(只落库,不触发上报) + +- 由于频率会比较高,所以需要做节流的逻辑 + + 很多人会搞不清楚防抖和节流的区别。一言以蔽之:“函数防抖关注一定时间连续触发的事件只在最后执行一次,而函数节流侧重于一段时间内只执行一次”。此处不是本文重点,感兴趣的的可以查看[这篇文章](https://segmentfault.com/a/1190000018445196) + +- 上报流程会首先判断(为了节约用户流量) + + - 判断当前网络环境为 WI-FI 则实时上报 + - 判断当前网络环境不可用,则实时中断后续 + - 判断当前网络环境为蜂窝网络, 则做是否超过**1个自然天内使用流量是否超标**的判断 + - T(当前时间戳) - T(上次保存时间戳) > 24h,则清零已使用的流量,记录当前时间戳到上次上报时间的变量中 + - T(当前时间戳) - T(上次保存时间戳) <= 24h,则判断一个自然天内已使用流量大小是否超过下发的数据上报配置中的流量上限字段,超过则 exit;否则执行后续流程 + +- 数据聚合分表进行,且会有一定的规则 + + - 优先获取 crash 数据 + - 单次网络上报中,整体数据条数不能数据上报配置中的条数限制;数据大小不能超过数据配置中的数据大小 + +- 数据取出后将这批数据标记为 dirty 状态 + +- meta 表数据需要先 `gZip` 压缩,再使用 `AES 128` 加密 + +- payload 表数据需组装自定义格式的报文。格式如下 + + Header 部分: + + ```shell + 2字节大小、数据类型 unsigned short 表示 meta 数据大小 + n 条 payload 数据结构(2字节大小、数据类型为 unsigned int 表示单条 payload 数据大小) + ``` + + ```shell + header + meta 数据 + payload 数据 + ``` + +- 发起数据上报网络请求 + + - 成功回调:删除标记为`dirty` 的数据。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 + - 失败回调:更新标记为`dirty` 的数据为正常状态。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 + +整个上报流程图如下: + +![数据上报流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-04-DataUploaderSDKStructure.png) + + + +### 2. 踩过的坑 && 做得好的地方 + +- 之前做针对网络接口基本上都是使用现有协议的 `key/value` 协议上开发的,它的优点是使用简单,缺点是协议体太大。在设计方案的时候分析道数据上报 SDK 网络上报肯定是非常高频的所以我们需要设计自定义的报文协议,这部分的设计上可以参考 `TCP 报文头结构`。 + +- 当时和后端对接接口的时候发现数据上报过去,服务端解析不了。断点调试发现数据聚合后的大小、条数、压缩、加密都是正常的,在本地 Mock 后完全可以反向解析出来。但为什么到服务端就解析不了,联调后发现是**字节端序**(Big-Endian)的问题。简单介绍如下,关于大小端序的详细介绍请查看我的[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter5%20-%20Network/5.3.md) + + 主机字节顺序HBO(Host Byte Order):与 CPU 类型有关。Big-Endian: PowerPC、IBM、Sun。Little-Endian:x86、DEC + + 网络字节顺序 NBO(Network Byte Order):网络默认为大端序。 + +- 上面的逻辑有一步是当网络上报成功后需要删除标记为 dirty 的数据。但是测试了一下发现,大量数据删除后数据库文件的大小不变,理论上需要腾出内存数据大小的空间。 + + sqlite 采用的是变长记录存储,当数据被删除后,未使用的磁盘空间被添加到一个内在的“空闲列表”中,用于下次插入数据,这属于优化机制之一,sqlite 提供 `vacuum` 命令来释放。 + + 这个问题类似于 Linux 中的文件引用计数的意思,虽然不一样,但是提出来做一下参考。实验是这样的 + + 1. 先看一下当前各个挂载目录的空间大小:`df -h` + + 2. 首先我们产生一个50M大小的文件 + + 3. 写一段代码读取文件 + + ```c++ + #include + #include + int main(void) + {    FILE *fp = NULL;    + fp = fopen("/boot/test.txt", "rw+");    + if(NULL == fp){       + perror("open file failed");    + return -1;    + }     + while(1){       + //do nothing       sleep(1);    + }    + fclose(fp);   + return 0; + } + ``` + + 4. 命令行模式下使用 `rm` 删除文件 + + 5. 查看文件大小: `df -h`,发现文件被删除了,但是该目录下的可用空间并未变多 + + 解释:实际上,只有当一个文件的引用计数为0(包括硬链接数)的时候,才可能调用 unlink 删除,只要它不是0,那么就不会被删除。所谓的删除,也不过是文件名到 inode 的链接删除,只要不被重新写入新的数据,磁盘上的 block 数据块不会被删除,因此,你会看到,即便删库跑路了,某些数据还是可以恢复的。换句话说,当一个程序打开一个文件的时候(获取到文件描述符),它的引用计数会被+1,rm虽然看似删除了文件,实际上只是会将引用计数减1,但由于引用计数不为0,因此文件不会被删除。 + +- 在数据聚合的时候优先获取 crash 数据,总数据条数需要小于上报配置数据的条数限制、总数据大小需要小于上报配置数据的大小限制。这里的处理使用了递归,改变了函数参数 + + ```objective-c + - (void)assembleDataInTable:(PCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { + // 1. 获取到合适的 Crash 类型的数据 + [self fetchCrashDataByCount:self.configureModel.maxFlowMByte + inTable:tableType + upperBound:self.configureModel.maxBodyMByte + completion:^(NSArray *records) { + NSArray *crashData = records; + // 2. 计算剩余需要的数据条数和剩余需要的数据大小 + NSInteger remainingCount = self.configureModel.maxItem - crashData.count; + float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; + // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 + BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); + [self fetchDataExceptCrash:remainingCount + inTable:tableType + upperBound:remainingSize + isWiFI:isWifi + completion:^(NSArray *records) { + NSArray *dataExceptCrash = records; + + NSMutableArray *dataSource = [NSMutableArray array]; + [dataSource addObjectsFromArray:crashData]; + [dataSource addObjectsFromArray:dataExceptCrash]; + if (completion) { + completion([dataSource copy]); + } + }]; + }]; + } + + - (void)fetchDataExceptCrash:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { + // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 + __block NSMutableArray *conditions = [NSMutableArray array]; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (isWifi) { + if (![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; + } + } else { + if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", PCT_SAFE_STRING(obj.type)]]; + } + } + }]; + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; + + // 2. 根据是否有 Wifi 查找对应的数据 + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 + float dataSize = [self calculateDataSize:records]; + + // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; + } + ``` + +- 整个 SDK 的 Unit Test 通过率 100%,代码分支覆盖率为 93%。测试基于 TDD 和 BDD。测试框架:系统自带的 `XCTest`,第三方的 `OCMock`、`Kiwi`、`Expecta`、`Specta`。测试使用了基础类,后续每个文件都设计继承自测试基类的类。 + + Xcode 可以看到整个 SDK 的测试覆盖率和单个文件的测试覆盖率 + + ![Xcode 测试覆盖率](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-06-XcodeTestCoverage.png) + + 也可以使用 [slather](https://github.com/SlatherOrg/slather)。在项目终端环境下新建 `.slather.yml` 配置文件,然后执行语句 `slather coverage -s --scheme hermes-client-Example --workspace hermes-client.xcworkspace hermes-client.xcodeproj`。 + + 关于质量保证的最基础、可靠的方案之一软件测试,在各个端都有一些需要注意的地方,还需要结合工程化,我会写专门的[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.75.md)谈谈经验心得。 + + + +## 五、 接口设计及核心实现 + +### 1. 接口设计 + +```objective-c +@interface HermesClient : NSObject + +- (instancetype)init NS_UNAVAILABLE; + ++ (instancetype)new NS_UNAVAILABLE; + +/** + 单例方式初始化全局唯一对象。单例之后必须马上 setUp + + @return 单例对象 + */ ++ (instancetype)sharedInstance; + +/** + 当前 SDK 初始化。当前功能:注册配置下发服务。 + */ +- (void)setup; + +/** + 上报 payload 类型的数据 + + @param type 监控类型 + @param meta 元数据 + @param payload payload类型的数据 + */ +- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload; + +/** + 上报 meta 类型的数据,需要传递三个参数。type 表明是什么类型的数据;prefix 代表前缀,上报到后台会拼接 prefix+type;meta 是字典类型的元数据 + + @param type 数据类型 + @param prefix 数据类型的前缀。一般是业务线名称首字母简写。比如记账:JZ + @param meta description元数据 + */ +- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta; + +/** + 获取上报相关的通用信息 + + @return 上报基础信息 + */ +- (PCTCommonModel *)getCommon; + +/** + 是否需要采集上报 + + @return 上报开关 + */ +- (BOOL)isGather:(NSString *)namespace; + +@end +``` + +`HermesClient` 类是整个 SDK 的入口,也是接口的提供者。其中 `- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta;` 接口给业务方使用。 + +`- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload;` 给监控数据使用。 + +`setup` 方法内部开启多个 namespace 下的处理 handler。 + +```objective-c +- (void)setup { + // 注册 mget 获取监控和各业务线的配置信息,会产生多个 namespace,彼此平行、隔离 + [[PCTConfigurationService sharedInstance] registerAndFetchConfigurationInfo]; + + [self.configutations enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + PCTService *service = [[PCTService alloc] initWithNamespace:obj]; + [self.services setObject:service forKey:obj]; + }]; + PCTService *hermesService = [self.services objectForKey:HermesNAMESPACE]; + if (!hermesService) { + hermesService = [[PCTService alloc] initWithNamespace:HermesNAMESPACE]; + [self.services setObject:hermesService forKey:HermesNAMESPACE]; + } +} +``` + + + +### 2. 核心实现 + +真正处理逻辑的是 `PCTService` 类。 + +```objective-c +#define PCT_SAVED_FLOW @"PCT_SAVED_FLOW" +#define PCT_SAVED_TIMESTAMP @"PCT_SAVED_TIMESTAMP" + +@interface PCTService () + +@property (nonatomic, copy) NSString *requestBaseUrl; /**<需要配置的baseUrl*/ +@property (nonatomic, copy) PCTConfigurationModel *configureModel; /**<当前 namespace 下的配置信息*/ +@property (nonatomic, copy) NSString *metaURL; /** 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 先写入 meta 表 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; + + // 4. 如果 payload 不存在则退出当前执行 + if (!PCT_IS_CLASS(payload, NSData) && !payload) { + return; + } + + // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) + CGFloat payloadSize = [self calculateDataSize:payload]; + if (payloadSize > self.configureModel.maxBodyMByte) { + NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; + NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); + return; + } + + // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 7. 计算上报时 payload 这条数据的大小(meta+payload) + NSMutableData *totalData = [NSMutableData data]; + [totalData appendData:metaData]; + [totalData appendData:payload]; + + // 8. 再写入 payload 表 + PCTLogPayloadModel *payloadModel = [[PCTLogPayloadModel alloc] init]; + payloadModel.is_used = NO; + payloadModel.namespace = HermesNAMESPACE; + payloadModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + payloadModel.monitor_type = PCT_SAFE_STRING(type); + payloadModel.is_biz = NO; + payloadModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + payloadModel.meta = PCT_SAFE_STRING(metaContentString); + payloadModel.payload = payload; + payloadModel.size = totalData.length; + [PCT_DATABASE add:@[payloadModel] inTableType:PCTLogTableTypePayload]; + + // 9. 判断是否触发实时上报 + [self handleUploadDataWithtype:type]; +} + +- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta { + // 1. 校验参数合法性 + NSString *prefixWarning = [NSString stringWithFormat:@"%@不能是空字符串", prefix]; + if (!PCT_IS_CLASS(prefix, NSString)) { + NSAssert1(PCT_IS_CLASS(prefix, NSString), prefixWarning, prefix); + return; + } + if (prefix.length == 0) { + NSAssert1(prefix.length > 0, prefixWarning, prefix); + return; + } + + NSString *typeWarning = [NSString stringWithFormat:@"%@不能是空字符串", type]; + if (!PCT_IS_CLASS(type, NSString)) { + NSAssert1(PCT_IS_CLASS(type, NSString), typeWarning, type); + return; + } + if (type.length == 0) { + NSAssert1(type.length > 0, typeWarning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 私有接口处理 is_biz 逻辑 + PCTCommonModel *commonModel = [[PCTCommonModel alloc] init]; + [self sendWithType:type namespace:prefix meta:meta isBiz:YES commonModel:commonModel]; +} + + +#pragma mark - private method + +// 基础配置 +- (void)setupConfig { + _requestBaseUrl = @"https://***DomainName.com"; + _metaURL = @"hermes/***"; + _payloadURL = @"hermes/***"; +} + +- (void)executeHandlerWhenAppLaunched +{ + // 1. 删除非法数据 + [self handleInvalidateData]; + // 2. 回收数据库磁盘碎片空间 + [self rebuildDatabase]; + // 3. 开启定时器去定时上报数据 + [self executeTimedTask]; +} + +/* + 1. 当 App 版本变化的时候删除数据 + 2. 删除过期数据 + 3. 删除 Payload 表里面超过限制的数据 + 4. 删除上传接口网络成功,但是突发 crash 造成没有删除这批数据的情况,所以启动完成后删除 is_used = YES 的数据 + */ +- (void)handleInvalidateData +{ + NSString *currentVersion = [[HermesClient sharedInstance] getCommon].APP_VERSION; + NSString *savedVersion = [[NSUserDefaults standardUserDefaults] stringForKey:PCT_SAVED_APP_VERSION] ?: [currentVersion copy]; + + NSInteger threshold = [NSDate pct_currentTimestamp]; + if (![currentVersion isEqualToString:savedVersion] && self.configureModel.isUpdateClear) { + [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:PCT_SAVED_APP_VERSION]; + } else { + threshold = [NSDate pct_currentTimestamp] - self.configureModel.expirationDay * 24 * 60 * 60 *1000; + } + NSInteger sizeUpperLimit = self.configureModel.maxBodyMByte * 1024 * 1024; + NSString *sqlString = [NSString stringWithFormat:@"(created_time < %zd and namespace = '%@') or size > %zd or is_used = 1", threshold, self.namespace, sizeUpperLimit]; + [PCT_DATABASE removeDataUseCondition:sqlString inTableType:PCTLogTableTypeMeta]; + [PCT_DATABASE removeDataUseCondition:sqlString inTableType:PCTLogTableTypePayload]; +} + +// 启动时刻清理数据表空间碎片,回收磁盘大小 +- (void)rebuildDatabase { + [PCT_DATABASE rebuildDatabaseFileInTableType:PCTLogTableTypeMeta]; + [PCT_DATABASE rebuildDatabaseFileInTableType:PCTLogTableTypePayload]; +} + +// 判断数据是否可以落库 +- (BOOL)validateLogData:(NSString *)dataType { + NSArray *monitors = self.configureModel.monitorList; + __block BOOL isValidate = NO; + [monitors enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([obj.type isEqualToString:dataType]) { + isValidate = YES; + *stop = YES; + } + }]; + return isValidate; +} + +- (void)executeTimedTask { + __weak typeof(self) weakself = self; + self.taskExecutor = [[TMLoopTaskExecutor alloc] init]; + TMTaskOption *dataUploadOption = [[TMTaskOption alloc] init]; + dataUploadOption.option = TMTaskRunOptionRuntime; + dataUploadOption.interval = self.configureModel.periodicTimerSecond; + TMTask *dataUploadTask = [[TMTask alloc] init]; + dataUploadTask.runBlock = ^{ + [weakself upload]; + }; + [self.taskExecutor addTask:dataUploadTask option:dataUploadOption]; +} + +- (void)handleUploadDataWithtype:(NSString *)type { + __block BOOL canUploadInTime = NO; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([type isEqualToString:obj.type]) { + if (obj.isRealtime) { + canUploadInTime = YES; + *stop = YES; + } + } + }]; + if (canUploadInTime) { + // 节流 + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self upload]; + }); + } +} + +// 对内和对外的存储都走这个流程。通过这个接口设置 is_biz 信息 +- (void)sendWithType:(NSString *)type namespace:(NSString *)namespace meta:(NSDictionary *)meta isBiz:(BOOL)is_biz commonModel:(PCTCommonModel *)commonModel { + // 0. 判断当前 namespace 是否开启了收集 + if (!self.configureModel.isGather) { + PCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); + return ; + } + + // 1. 检查参数合法性 + NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type]; + if (!PCT_IS_CLASS(type, NSString)) { + NSAssert1(PCT_IS_CLASS(type, NSString), warning, type); + return; + } + if (type.length == 0) { + NSAssert1(type.length > 0, warning, type); + return; + } + + if (!PCT_IS_CLASS(meta, NSDictionary)) { + return; + } + if (meta.allKeys.count == 0) { + return; + } + + // 2. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) + BOOL isValidate = [self validateLogData:type]; + if (!isValidate) { + return; + } + + // 3. 合并 meta 与 Common 基础数据 + NSMutableDictionary *mutableMeta = [NSMutableDictionary dictionaryWithDictionary:meta]; + mutableMeta[@"MONITOR_TYPE"] = is_biz ? [NSString stringWithFormat:@"%@-%@", namespace, type] : type; + meta = [mutableMeta copy]; + + commonModel.IS_BIZ = is_biz; + NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; + NSDictionary *commonDictionary = [commonModel getDictionary]; + + // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 + if ([type isEqualToString:@"appCrash"]) { + [metaDictionary addEntriesFromDictionary:commonDictionary]; + [metaDictionary addEntriesFromDictionary:meta]; + } else { + [metaDictionary addEntriesFromDictionary:meta]; + [metaDictionary addEntriesFromDictionary:commonDictionary]; + } + + // 4. 转换为 NSData + NSError *error; + NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; + if (error) { + PCTLOG(@"%@", error); + return; + } + + // 5. 添加限制(超过 10K 的数据就不能入库,因为这是服务端消耗 meta 的一个上限) + CGFloat metaSize = [self calculateDataSize:metaData]; + if (metaSize > 10 / 1024.0) { + NSAssert(metaSize <= 10 / 1024.0, @"meta 数据的大小超过临界值 10KB"); + return; + } + + NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; + + // 6. 构造 MetaModel 模型 + PCTLogMetaModel *metaModel = [[PCTLogMetaModel alloc] init]; + metaModel.namespace = namespace; + metaModel.report_id = PCT_SAFE_STRING(commonModel.REPORT_ID); + metaModel.monitor_type = PCT_SAFE_STRING(type); + metaModel.created_time = PCT_SAFE_STRING(commonModel.CREATE_TIME); + metaModel.meta = PCT_SAFE_STRING(metaContentString); + metaModel.size = metaData.length; + metaModel.is_biz = is_biz; + + // 7. 写入数据库 + [PCT_DATABASE add:@[metaModel] inTableType:PCTLogTableTypeMeta]; + + // 8. 判断是否触发实时上报(对内的接口则在函数内部判断,如果是对外的则在这里判断) + if (is_biz) { + [self handleUploadDataWithtype:type]; + } +} + +- (BOOL)needUploadPayload:(PCTLogPayloadModel *)model { + __block BOOL needed = NO; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if ([obj.type isEqualToString:model.monitor_type] && obj.isUploadPayload) { + needed = YES; + *stop = YES; + } + }]; + return needed; +} + +/* + 计算 数据包大小,分为2种情况。 + 1. 上传前使用数据表中的 size 字段去判断大小 + 2. 上报完成后则根据真实网络通信中组装的 payload 进行大小计算 + */ +- (float)calculateDataSize:(id)data { + if (PCT_IS_CLASS(data, NSArray)) { + __block NSInteger dataLength = 0; + NSArray *uploadDatasource = (NSArray *)data; + [uploadDatasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogModel)) { + PCTLogModel *uploadModel = (PCTLogModel *)obj; + dataLength += uploadModel.size; + } + }]; + return dataLength / (1024 * 1024.0); + } else if (PCT_IS_CLASS(data, NSData)) { + NSData *rawData = (NSData *)data; + return rawData.length / (1024 * 1024.0); + } else { + return 0; + } +} + +// 上报流程的主函数 +- (void)upload { + /* + 1. 判断能否上报 + 2. 数据聚合 + 3. 加密压缩 + 4. 1分钟内的网络请求合并为1次 + 5. 上报(全局上报开关是开着的情况) + - 成功:删除本地数据、调用更新策略的接口 + - 失败:不删除本地数据 + */ + [self judgeCanUploadCompletionBlock:^(BOOL canUpload, NetworkingManagerStatusType networkType) { + if (canUpload && self.configureModel.isUpload) { + [self handleUploadTask:networkType]; + } + }]; +} + +/** + 上报前的校验 + - 判断网络情况,分为 wifi 和 非 Wi-Fi 、网络不通的情况。 + - 从配置下发的 monitorList 找出 onlyWifi 字段为 true 的 type,组成数组 [appCrash、appLag...] + - 网络不通,则不能上报 + - 网络通,则判断上报校验 + 1. 当前GMT时间戳-保存的时间戳超过24h。则认为是一个新的自然天 + - 清除 currentFlow + - 触发上报流程 + 2. 当前GMT时间戳-保存的时间戳不超过24h + - 当前的流量是否超过配置信息里面的最大流量,未超过(<):触发上报流程 + - 当前的流量是否超过配置信息里面的最大流量,超过:结束流程 + */ +- (void)judgeCanUploadCompletionBlock:(void (^)(BOOL canUpload, NetworkingManagerStatusType networkType))completionBlock { + // WIFI 的情况下不判断直接上传;不是 WIFI 的情况需要判断「当日最大限制流量」 + [self.requester networkStatusWithBlock:^(NetworkingManagerStatusType status) { + switch (status) { + case NetworkingManagerStatusUnknown: { + PCTLOG(@"没有网络权限哦"); + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusUnknown); + } + break; + } + case NetworkingManagerStatusNotReachable: { + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusNotReachable); + } + break; + } + case NetworkingManagerStatusReachableViaWiFi: { + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWiFi); + } + break; + } + case NetworkingManagerStatusReachableViaWWAN: { + if ([self currentGMTStyleTimeStamp] - self.currentTimestamp.integerValue > 24 * 60 * 60 * 1000) { + self.currentFlow = [NSNumber numberWithFloat:0]; + self.currentTimestamp = [NSNumber numberWithInteger:[self currentGMTStyleTimeStamp]]; + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); + } + } else { + if (self.currentFlow.floatValue < self.configureModel.maxFlowMByte) { + if (completionBlock) { + completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); + } + } else { + if (completionBlock) { + completionBlock(NO, NetworkingManagerStatusReachableViaWWAN); + } + } + } + break; + } + } + }]; +} + +- (void)handleUploadTask:(NetworkingManagerStatusType)networkType { + // 数据聚合(2张表分别扫描) -> 压缩 -> 上报 + [self handleUploadTaskInMetaTable:networkType]; + [self handleUploadTaskInPayloadTable:networkType]; +} + +- (void)handleUploadTaskInMetaTable:(NetworkingManagerStatusType)networkType { + __weak typeof(self) weakself = self; + // 1. 数据聚合 + [self assembleDataInTable:PCTLogTableTypeMeta + networkType:networkType + completion:^(NSArray *records) { + if (records.count == 0) { + return; + } + // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) + __block NSMutableString *metaStrings = [NSMutableString string]; + __block NSMutableArray *usedReportIds = [NSMutableArray array]; + + // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 + [records enumerateObjectsUsingBlock:^(PCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogMetaModel)) { + PCTLogMetaModel *metaModel = (PCTLogMetaModel *)obj; + BOOL shouldAppendLineBreakSymbol = idx < (records.count - 1); + [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", metaModel.report_id]]; + [metaStrings appendString:[NSString stringWithFormat:@"%@%@", metaModel.meta, shouldAppendLineBreakSymbol ? @"\n" : @""]]; + } + }]; + if (metaStrings.length == 0) { + return; + } + // 2.2 拼接后的内容先压缩再加密 + NSData *data = [PCTDataSerializer compressAndEncryptWithString:metaStrings]; + + // 3. 将取出来用于接口请求的数据标记为 dirty + NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; + [[PCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:PCTLogTableTypeMeta]; + + // 4. 请求网络 + NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.metaURL]; + + [weakself.requester postDataWithRequestURL:requestURL + bodyData:data + success:^{ + [weakself deleteInvalidateData:records inTableType:PCTLogTableTypeMeta]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:data]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + } + failure:^(NSError *_Nonnull error) { + [[PCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:PCTLogTableTypeMeta]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:data]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + }]; + }]; +} + +- (NSData *)handlePayloadData:(NSArray *)rawArray { + // 1. 数据校验 + if (rawArray.count == 0) { + return nil; + } + // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) + __block NSMutableString *metaStrings = [NSMutableString string]; + __block NSMutableArray *payloads = [NSMutableArray array]; + + + // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 + [rawArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *payloadModel = (PCTLogPayloadModel *)obj; + BOOL shouldAppendLineBreakSymbol = idx < (rawArray.count - 1); + + [metaStrings appendString:[NSString stringWithFormat:@"%@%@", PCT_SAFE_STRING(payloadModel.meta), shouldAppendLineBreakSymbol ? @"\n" : @""]]; + + // 2.2 判断是否需要上传 payload 信息。如果需要则将 payload 取出。 + if ([self needUploadPayload:payloadModel]) { + if (payloadModel.payload) { + NSData *payloadData = [PCTDataSerializer compressAndEncryptWithData:payloadModel.payload]; + if (payloadData) { + [payloads addObject:payloadData]; + } + } + } + } + }]; + + NSData *metaData = [PCTDataSerializer compressAndEncryptWithString:metaStrings]; + + __block NSMutableData *headerData = [NSMutableData data]; + unsigned short metaLength = (unsigned short)metaData.length; + HTONS(metaLength); // 处理2字节的大端序 + [headerData appendData:[NSData dataWithBytes:&metaLength length:sizeof(metaLength)]]; + + Byte payloadCountbytes[] = {payloads.count}; + NSData *payloadCountData = [[NSData alloc] initWithBytes:payloadCountbytes length:sizeof(payloadCountbytes)]; + [headerData appendData:payloadCountData]; + + [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + unsigned int payloadLength = (unsigned int)obj.length; + HTONL(payloadLength); // 处理4字节的大端序 + [headerData appendData:[NSData dataWithBytes:&payloadLength length:sizeof(payloadLength)]]; + }]; + + __block NSMutableData *uploadData = [NSMutableData data]; + // 先添加 header 基础信息,不需要加密压缩 + [uploadData appendData:[headerData copy]]; + // 再添加 meta 信息,meta 信息需要先压缩再加密 + [uploadData appendData:metaData]; + // 再添加 payload 信息 + [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + [uploadData appendData:obj]; + }]; + return [uploadData copy]; +} + +- (void)handleUploadTaskInPayloadTable:(NetworkingManagerStatusType)networkType { + __weak typeof(self) weakself = self; + // 1. 数据聚合 + [self assembleDataInTable:PCTLogTableTypePayload + networkType:networkType + completion:^(NSArray *records) { + if (records.count == 0) { + return; + } + // 2. 取出可以上传的 payload 数据 + NSArray *canUploadPayloadData = [self fetchDataCanUploadPayload:records]; + + if (canUploadPayloadData.count == 0) { + return; + } + + // 3. 将取出来用于接口请求的数据标记为 dirty + __block NSMutableArray *usedReportIds = [NSMutableArray array]; + [canUploadPayloadData enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogModel)) { + PCTLogModel *model = (PCTLogModel *)obj; + [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", model.report_id]]; + } + }]; + NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; + + [[PCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:PCTLogTableTypePayload]; + + // 4. 将取出的数据聚合,组成报文 + NSData *uploadData = [self handlePayloadData:canUploadPayloadData]; + + // 5. 请求网络 + NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.payloadURL]; + + [weakself.requester postDataWithRequestURL:requestURL + bodyData:uploadData + success:^{ + [weakself deleteInvalidateData:canUploadPayloadData inTableType:PCTLogTableTypePayload]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:uploadData]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + } + failure:^(NSError *_Nonnull error) { + [[PCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:PCTLogTableTypePayload]; + if (networkType == NetworkingManagerStatusReachableViaWWAN) { + float currentFlow = [weakself.currentFlow floatValue]; + currentFlow += [weakself calculateDataSize:uploadData]; + weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; + } + }]; + }]; +} + +// 清除过期数据 +- (void)deleteInvalidateData:(NSArray *)data inTableType:(PCTLogTableType)tableType { + [PCT_DATABASE remove:data inTableType:tableType]; +} + +// 以秒为单位的时间戳 +- (NSInteger)currentGMTStyleTimeStamp { + return [NSDate pct_currentTimestamp]/1000; +} + +#pragma mark-- 数据库操作 + +/** + 根据接口配置信息中的条件获取表中的上报数据 + - Wi-Fi 的时候都上报 + - 不为 Wi-Fi 的时候:onlyWifi 为 false 的类型进行上报 + */ +- (void)assembleDataInTable:(PCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { + // 1. 获取到合适的 Crash 类型的数据 + [self fetchCrashDataByCount:self.configureModel.maxFlowMByte + inTable:tableType + upperBound:self.configureModel.maxBodyMByte + completion:^(NSArray *records) { + NSArray *crashData = records; + // 2. 计算剩余需要的数据条数和剩余需要的数据大小 + NSInteger remainingCount = self.configureModel.maxItem - crashData.count; + float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; + // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 + BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); + [self fetchDataExceptCrash:remainingCount + inTable:tableType + upperBound:remainingSize + isWiFI:isWifi + completion:^(NSArray *records) { + NSArray *dataExceptCrash = records; + + NSMutableArray *dataSource = [NSMutableArray array]; + [dataSource addObjectsFromArray:crashData]; + [dataSource addObjectsFromArray:dataExceptCrash]; + if (completion) { + completion([dataSource copy]); + } + }]; + }]; +} + + +- (NSArray *)fetchDataCanUploadPayload:(NSArray *)datasource { + __weak typeof(self) weakself = self; + __block NSMutableArray *array = [NSMutableArray array]; + if (!PCT_IS_CLASS(datasource, NSArray)) { + NSAssert(PCT_IS_CLASS(datasource, NSArray), @"参数必须是数组"); + return nil; + } + [datasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { + if (PCT_IS_CLASS(obj, PCTLogPayloadModel)) { + PCTLogPayloadModel *payloadModel = (PCTLogPayloadModel *)obj; + // 判断是否需要上传 payload 信息 + if ([weakself needUploadPayload:payloadModel]) { + [array addObject:payloadModel]; + } + } + }]; + return [array copy]; +} + +// 递归获取符合条件的 Crash 数据集合(count < maxItem && size < maxBodySize) +- (void)fetchCrashDataByCount:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(NSInteger)size completion:(void (^)(NSArray *records))completion { + // 1. 先通过接口拿到的 maxItem 数去查询表中的 Crash 数据集合 + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type = 'appCrash' and is_used = 0 and namespace = '%@'", self.namespace]; + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 2. Crash 数据集合大小是否超过配置接口拿到的最大包体积(单位M) maxBodySize + float dataSize = [self calculateDataSize:records]; + + // 3. 大于最大包体积则递归获取 maxItem-- 条 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + [self fetchCrashDataByCount:currentCount inTable:tableType upperBound:size completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; +} + +- (void)fetchDataExceptCrash:(NSInteger)count inTable:(PCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { + // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 + __block NSMutableArray *conditions = [NSMutableArray array]; + [self.configureModel.monitorList enumerateObjectsUsingBlock:^(PCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (isWifi) { + if (![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; + } + } else { + if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { + [conditions addObject:[NSString stringWithFormat:@"'%@'", PCT_SAFE_STRING(obj.type)]]; + } + } + }]; + NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; + + // 2. 根据是否有 Wifi 查找对应的数据 + [PCT_DATABASE getRecordsByCount:count + condtion:queryCrashDataCondition + inTableType:tableType + completion:^(NSArray *_Nonnull records) { + // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 + float dataSize = [self calculateDataSize:records]; + + // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 + if (size == 0) { + if (completion) { + completion(records); + } + } else if (dataSize > size) { + NSInteger currentCount = count - 1; + return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; + } else { + if (completion) { + completion(records); + } + } + }]; +} + + +#pragma mark - getters and setters + +- (PCTRequestFactory *)requester { + if (!_requester) { + _requester = [[PCTRequestFactory alloc] init]; + } + return _requester; +} + +- (NSNumber *)currentTimestamp { + if (!_currentTimestamp) { + NSInteger currentTimestampValue = [[NSUserDefaults standardUserDefaults] integerForKey:PCT_SAVED_TIMESTAMP]; + _currentTimestamp = [NSNumber numberWithInteger:currentTimestampValue]; + } + return _currentTimestamp; +} + +- (void)setCurrentTimestamp:(NSNumber *)currentTimestamp { + [[NSUserDefaults standardUserDefaults] setInteger:[currentTimestamp integerValue] forKey:PCT_SAVED_TIMESTAMP]; + _currentTimestamp = currentTimestamp; +} + +- (NSNumber *)currentFlow { + if (!_currentFlow) { + float currentFlowValue = [[NSUserDefaults standardUserDefaults] floatForKey:PCT_SAVED_FLOW]; + _currentFlow = [NSNumber numberWithFloat:currentFlowValue]; + } + return _currentFlow; +} + +- (void)setCurrentFlow:(NSNumber *)currentFlow { + [[NSUserDefaults standardUserDefaults] setFloat:[currentFlow floatValue] forKey:PCT_SAVED_FLOW]; + _currentFlow = currentFlow; +} + +- (PCTConfigurationModel *)configureModel +{ + return [[PCTConfigurationService sharedInstance] getConfigurationWithNamespace:self.namespace]; +} + +- (NSString *)requestBaseUrl +{ + return self.configureModel.url ? self.configureModel.url : @"https://common.***.com"; +} + +- (BOOL)isAppLaunched +{ + id isAppLaunched = [[HermesClient sharedInstance] valueForKey:@"isAppLaunched"]; + return [isAppLaunched boolValue]; +} + +@end +``` + + + +## 六、 总结与思考 + +### 1. 技术方面 + +多线程技术很强大,但是很容易出问题。普通做业务的时候用一些简单的 GCD、NSOperation 等就可以满足基本需求了,但是做 SDK 就不一样,你需要考虑各种场景。比如 FMDB 在多线程读写的时候,设计了 FMDatabaseQueue 以串行队列的方式同步执行任务。但是这样一来假如使用者在主线程插入 n 次数据到数据库,这样会发生 ANR,所以我们还得维护一个任务派发队列,用来维护业务方提交的任务,是一个并发队列,以异步任务的方式提交给 FMDB 以同步任务的方式在串行队列上执行。 + +AFNetworking 2.0 使用了 NSURLConnection,同时维护了一个常驻线程,去处理网络成功后的回调。AF 存在一个常驻线程,假如其他 n 个 SDK 的其中 m 个 SDK 也开启了常驻线程,那你的 App 集成后就有 1+m 个常驻线程。 + +AFNetworking 3.0 使用 NSURLSession 替换 NSURLConnection,取消了常驻线程。为什么换了? 😂 逼不得已呀,Apple 官方出了 NSURLSession,那就不需要 NSURLConnection,并为之创建常驻线程了。至于为什么 NSURLSession 不需要常驻线程?它比 NSURLConnecction 多做了什么,以后再聊 + +创建线程的过程,需要用到物理内存,CPU 也会消耗时间。新建一个线程,系统会在该进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。此外线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程有 CPU 消耗。线程过多时内存、CPU 都会有大量的消耗,出现 ANR 甚至被强杀。 + +举了 🌰 是 FMDB 和 AFNetworking 的作者那么厉害,设计的 FMDB 不包装会 ANR,AFNetworking 必须使用常驻线程,为什么?正是由于多线程太强大、灵活了,开发者骚操作太多,所以 FMDB 设计最简单保证数据库操作线程安全,具体使用可以自己维护队列去包一层。AFNetworking 内的多线程也严格基于系统特点来设计。 + +所以有必要再研究下多线程,建议读 GCD 源码,也就是 [libdispatch](https://opensource.apple.com/tarballs/libdispatch/) + + + +### 2. 规范方面 + +很多开发都不做测试,我们公司都严格约定测试。写基础 SDK 更是如此,一个 App 基础功能必须质量稳定,所以测试是保证手段之一。一定要写好 Unit Test。这样子不断版本迭代,对于 UT,输入恒定,输出恒定,这样内部实现如何变动不需要关心,只需要判断恒定输入,恒定输出就足够了。(针对每个函数单一原则的基础上也是满足 UT)。还有一个好处就是当和别人讨论的的时候,你画个技术流程图、技术架构图、测试的 case、测试输入、输出表述清楚,听的人再看看边界情况是否都考虑全,基本上很快沟通完毕,效率考高。 + +在做 SDK 的接口设计的时候,方法名、参数个数、参数类型、参数名称、返回值名称、类型、数据结构,尽量要做到 iOS 和 Android 端一致,除非某些特殊情况,无法保证一致的输出。别问为什么?好处太多了,成熟 SDK 都这么做。 + +比如一个数据上报 SDK。需要考虑数据来源是什么,我设计的接口需要暴露什么信息,数据如何高效存储、数据如何校验、数据如何高效及时上报。 假如我做的数据上报 SDK 可以上报 APM 监控数据、同时也开放能力给业务线使用,业务线自己将感兴趣的数据并写入保存,保证不丢失的情况下如何高效上报。因为数据实时上报,所以需要考虑上传的网络环境、Wi-Fi 环境和 4G 环境下的逻辑不一样的、数据聚合组装成自定义报文并上报、一个自然天内数据上传需要做流量限制等等、App 版本升级一些数据可能会失去意义、当然存储的数据也存在时效性。种种这些东西就是在开发前需要考虑清楚的。所以基础平台做事情基本是 **设计思考时间:编码时间 = 7:3**。 + + 为什么?假设你一个需求,预期10天时间;前期架构设计、类的设计、Uint Test 设计估计7天,到时候编码开发2天完成。 这么做的好处很多,比如: + +1. 除非是非常优秀,不然脑子想的再前面到真正开发的时候发现有出入,coding 完发现和前期方案设计不一样。所以建议用流程图、UML图、技术架构图、UT 也一样,设计个表格,这样等到时候编码也就是 coding 的工作了,将图翻译成代码 + +2. 后期和别人讨论或者沟通或者 CTO 进行 code review 的时候不需要一行行看代码。你将相关的架构图、流程图、UML 图给他看看。他再看看一些关键逻辑的 UT,保证输入输出正确,一般来说这样就够了 + + + +### 3. 质量保证 + +UT 是质量保证的一个方面,另一个就是 MR 机制。我们团队 MR 采用 `+1` 机制。每个 merge request 必须有团队内至少3个人 +1,且其中一人必须为同技术栈且比你资深一些的同事 +1,一人为和你参加同一个项目的同事。 + +当有人评论或者有疑问时,你必须解答清楚,别人提出的修改点要么修改好,要么解释清楚,才可以 +1。当 +1 数大于3,则合并分支代码。 + +连带责任制。当你的线上代码存在 bug 时,为你该次 MR +1 的同事具有连带责任。 + + + + + + + +## 参考资料 + +- [WAL](https://en.wikipedia.org/wiki/Write-ahead_logging) +- [WCDB 的 WAL 模式和异步 Checkpoint](https://cloud.tencent.com/developer/article/1031030) +- [sqlite vacuum](https://www.sqlite.org/lang_vacuum.html) +- [彻底弄懂函数防抖和函数节流](https://segmentfault.com/a/1190000018445196) + + + + + + + diff --git a/Chapter1 - iOS/1.90.md b/Chapter1 - iOS/1.90.md index 37b2b28..d535614 100644 --- a/Chapter1 - iOS/1.90.md +++ b/Chapter1 - iOS/1.90.md @@ -2,7 +2,7 @@ ## 图片显示流程 -![image-20200813130942777](./../assets/2020-08-13-ImageRenderProcess.png) +![image-20200813130942777](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-ImageRenderProcess.png) ```objective-c UIImage *image = [UIImage imageNamed:@"test"]; @@ -19,7 +19,7 @@ _imageView.image = image; ## YYImage 源码 -![image-20200813131944130](./../assets/2020-08-13-YYImageClassLevel.png) +![image-20200813131944130](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-YYImageClassLevel.png) 很多框架使用锁都是 pthread_mutex_lock,分析原因 diff --git a/Chapter1 - iOS/1.93.md b/Chapter1 - iOS/1.93.md new file mode 100644 index 0000000..8c4f013 --- /dev/null +++ b/Chapter1 - iOS/1.93.md @@ -0,0 +1 @@ +# flutter 新功能引导 diff --git a/Chapter1 - iOS/chapter1.md b/Chapter1 - iOS/chapter1.md index 2025c59..1171f36 100644 --- a/Chapter1 - iOS/chapter1.md +++ b/Chapter1 - iOS/chapter1.md @@ -96,3 +96,4 @@ * [90、YYImage 框架原理,探索图片高效加载原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.90.md) * [91、二进制重排](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.91.md) * [92、flutter 无痕埋点](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.92.md) + * [93、flutter 新功能引导](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.93.md) diff --git a/Chapter2 - Web FrontEnd/2.41.md b/Chapter2 - Web FrontEnd/2.41.md index d02799e..76a94aa 100644 --- a/Chapter2 - Web FrontEnd/2.41.md +++ b/Chapter2 - Web FrontEnd/2.41.md @@ -2,7 +2,7 @@ [jQuery 1.9](http://blog.jquery.com/2013/01/15/jquery-1-9-final-jquery-2-0-beta-migrate-final-released/)发布。 -![jQuery](./../assets/2020-06-23-Jquery.png) +![jQuery](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-Jquery.png) 这是2.0版之前的最后一个新版本,有很多新功能,其中一个就是支持Source Map。 @@ -42,13 +42,13 @@ JavaScript脚本正变得越来越复杂。大部分源码(尤其是各种函 有了它,出错的时候,除错工具将直接显示原始代码,而不是转换后的代码。这无疑给开发者带来了很大方便。 -![jQuery Mapping](./../assets/2020-06-23-JquerySourceMap.png) +![jQuery Mapping](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-JquerySourceMap.png) Chrome 浏览器支持这个功能。在 Developer Tools 的 Setting 设置中,确认选中 "Enable source maps"。 -![设置页](./../assets/2020-06-21-ChromeSourceMapEnableSwitch.png) +![设置页](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-21-ChromeSourceMapEnableSwitch.png) -![i开启 sourceMap](./../assets/2020-06-21-ChromeSourceMapENable.png) +![i开启 sourceMap](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-21-ChromeSourceMapENable.png) @@ -168,11 +168,11 @@ mappings:"AAAAA,BBBBB;CCCCC" VLQ 编码是变长的。如果(整)数值在-15到+15之间(含两个端点),用一个字符表示;超出这个范围,就需要用多个字符表示。它规定,每个字符使用6个两进制位,正好可以借用 [Base 64](http://en.wikipedia.org/wiki/Base_64) 编码的字符表。 -![VLA](./../assets/2020-06-23-VLAMapping.png) +![VLA](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-VLAMapping.png) 在这6个位中,左边的第一位(最高位)表示是否"连续"(continuation)。如果是1,代表这6个位后面的6个位也属于同一个数;如果是0,表示该数值到这6个位结束。 -![VLQ特殊位](./../assets/2020-06-03-VLQSymbol.png) +![VLQ特殊位](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-03-VLQSymbol.png) 这6个位中的右边最后一位(最低位)的含义,取决于这6个位是否是某个数值的VLQ编码的第一个字符。如果是的,这个位代表"符号"(sign),0为正,1为负(Source map的符号固定为0);如果不是,这个位没有特殊含义,被算作数值的一部分。 diff --git a/Chapter9 - Ragdoll/9.1.md b/Chapter9 - Ragdoll/9.1.md index ba6e027..7b3fe67 100644 --- a/Chapter9 - Ragdoll/9.1.md +++ b/Chapter9 - Ragdoll/9.1.md @@ -10,7 +10,7 @@ 先看一张 CFA 证书的图片,是我家 Simba 的证书。 -![CFA-Certificate](./../assets/CFA-Certificate.png) +![CFA-Certificate](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFA-Certificate.png) 不难发现 CFA 证书就像我们日常的身份证一样,虽然没有猫的照片,但猫咪的其他信息一应俱全,花色精细到色号,父母和主人的信息也都在上面。具体每个字段的意思我已经在图上打好圈圈了,每个圆圈的信息都是一个独立的信息点。 @@ -103,7 +103,7 @@ RAGDOOL 2. 访问 [CFA 官网](https://ecat.cfa.org/public/hermanonline.aspx),在 Registration Code 的地方输入证书编号 - ![示意图](./../assets/CFAWebsite.png) + ![示意图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFAWebsite.png) 3. 点击 “Show Report” 按钮,查看 “**Grand Scoring show records for**" 右边的猫咪信息是否匹配。 diff --git a/Chapter9 - Ragdoll/9.2.md b/Chapter9 - Ragdoll/9.2.md index 631984b..30717ed 100644 --- a/Chapter9 - Ragdoll/9.2.md +++ b/Chapter9 - Ragdoll/9.2.md @@ -10,11 +10,11 @@ 先说一下个人赞成生骨肉喂养。 -![多种肉](./../assets/Rawmeat1.jpg) +![多种肉](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Rawmeat1.jpg) -![三文鱼](./../assets/Rawmeat2.JPG) +![三文鱼](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Rawmeat2.JPG) -![全牛肉](./../assets/Rawmeat3.jpg) +![全牛肉](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Rawmeat3.jpg) 群里面经常看到讨论生骨肉的话题,哎呀猫猫这么小,能不能给喂肉吃啊,是不是等半岁/一岁等等各种神奇的时间节点后再喂比较好呢,也不知道这个时间节点从哪来的。 @@ -215,7 +215,7 @@ emmmmmmmmmm,对于这个说法无力吐槽,是否主食主要看搭配配比 6. 可选)一口不粘锅 7. 一个砧板 -![猫饭工具](./../assets/CatsFoodTools.jpeg) +![猫饭工具](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CatsFoodTools.jpeg) @@ -263,7 +263,7 @@ emmmmmmmmmm,对于这个说法无力吐槽,是否主食主要看搭配配比 菜谱的拟定可以复杂异常,也可以用逻辑简单思考得出结果:猫野外怎么吃,我们怎么模仿其比例。大原则永远不变:新鲜的食材,符合肉类、骨骼、内脏比例(83:7:10),足够多样的动物种类(就靠鸡鸭?万万不可)。 -![生骨肉](./../assets/rawMeat-recipe.jpeg) +![生骨肉](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/rawMeat-recipe.jpeg) diff --git a/Chapter9 - Ragdoll/9.3.md b/Chapter9 - Ragdoll/9.3.md index d68d54a..16ad6b8 100644 --- a/Chapter9 - Ragdoll/9.3.md +++ b/Chapter9 - Ragdoll/9.3.md @@ -20,13 +20,13 @@ **Simba,中文名叫辛巴,大家应该看过狮子王希望像狮子王一样成为一个勇敢、坚强、健康的猫。** -![Simba](./../assets/Simba.png) +![Simba](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Simba.png) -![Simba](./../assets/Simba2.png) +![Simba](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Simba2.png) -![Simba](./../assets/Simba3.jpg) +![Simba](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Simba3.jpg) -![Simba](./../assets/Simba4.jpg) +![Simba](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Simba4.jpg) @@ -34,15 +34,15 @@ -![Bella](./../assets/Bella2.jpg) +![Bella](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Bella2.jpg) -![Bella](./../assets/Bella3.jpg) +![Bella](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Bella3.jpg) -![Bella](./../assets/Bella1.jpg) +![Bella](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Bella1.jpg) -![Bella](./../assets/Bella4.jpg) +![Bella](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Bella4.jpg) @@ -50,15 +50,15 @@ **碎星,眼眸中的光芒星星点点,好似撒下的一把星。正好和“遂心”同音,是对毛孩子的美好祈愿** -![碎星](./../assets/SuperStar1.jpg) +![碎星](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/SuperStar1.jpg) -![碎星](./../assets/SuperStar2.jpg) +![碎星](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/SuperStar2.jpg) -![碎星](./../assets/SuperStar3.jpg) +![碎星](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/SuperStar3.jpg) -![碎星](./../assets/SuperStar4.jpg) +![碎星](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/SuperStar4.jpg) diff --git a/README.md b/README.md index a13e1a0..2d6b239 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 技术点: iOS、Web 前端、后端、Hybrid、Node 的应用、爬虫、反爬虫、后端、数据库、算法等领域。 -偶尔记录自学经济学遇到的概念或者有趣的生活现象解读。 +后面的2个小章节主要记录自学经济学遇和养布偶猫的一些心得和经验。 @@ -20,4 +20,6 @@ ## 反馈 -定期更新博文。如果在查看文章的时候发现了问题可以提出 issue。(95年小双鱼,关注大前端领域,有事情可以通过[微博](http://weibo.com/u/3194053975)联系) +定期更新博文,如果在查看文章的时候发现了问题可以提出 issue。 +关注大前端领域,喜欢乒乓球、布偶猫,杭州的小伙伴可以约乒乓球或者交流养猫心得 +有事情可以通过[微博](http://weibo.com/u/3194053975)联系 \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md index 17f3e73..9420760 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -95,6 +95,7 @@ * [90、YYImage 框架原理,探索图片高效加载原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.90.md) * [91、二进制重排](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.91.md) * [92、flutter 无痕埋点](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.92.md) + * [93、flutter 新功能引导](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.93.md) * [Chapter2 - Web FrontEnd](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/chapter2.md) diff --git a/assets/2020-06-17-APMServerArch.png b/assets/2020-06-17-APMServerArch.png index 957dbb546f076a4ce96ee345121b5a4cb17a028e..af1f4372f18223bfefed8f15e67434f36bafdf2e 100644 GIT binary patch literal 59754 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 diff --git a/assets/2020-06-17-APMServerWorker.png b/assets/2020-06-17-APMServerWorker.png index 44d6c4dfe011921e6eaee3ff277dac9096a673b0..58aeed4ad284e92a0010b44b70efd973f98d9446 100644 GIT binary patch literal 36167 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%0Di9ySj#!r8Gk=uSsUUxY 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 K9ZA0*zVts7kDc%U literal 40582 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 P_2k$}Ku0=~A}nySmiu%y;YpfzE?%u;vi? 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 diff --git a/assets/2020-06-17-symolication_flow.png b/assets/2020-06-17-symolication_flow.png index b1738c2a43b340f8e1751d0b9b266989db871cde..9e6c2895c1af52045efb50a568ceacc87bcdc32e 100644 GIT binary patch literal 58518 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 b<^3!&N1 literal 64204 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