diff --git a/Chapter1 - iOS/1.62.md b/Chapter1 - iOS/1.62.md index a7b35d5..27cd8a5 100644 --- a/Chapter1 - iOS/1.62.md +++ b/Chapter1 - iOS/1.62.md @@ -529,6 +529,6 @@ oclintForProject ``` 同类型的文章: -- [如何打造团队的代码风格统一以及开发效率的提升](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.53.md) +- [如何打造团队的代码风格统一以及开发效率的提升](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.52.md) - [oclint介绍](https://github.com/hdw09/CIHexoBlog/blob/master/source/_posts/OClint学习笔记.md) - [自定义oclint规则](https://github.com/hdw09/CIHexoBlog/blob/master/source/_posts/OCLint-自定义规则101.md) \ No newline at end of file diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index 5648f2c..668c303 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -1,19 +1,10 @@ -# APM +# 带你打造一套 APM 监控系统 -> Application Performance Management 应用性能管理对一个应用的持续稳定运行至关重要。所以这篇文章就从一个 iOS App 的性能管理的纬度谈谈如何精确监控以及数据如何上报等技术点 +> APM 是 Application Performance Monitoring 的缩写,监视和管理软件应用程序的性能和可用性。应用性能管理对一个应用的持续稳定运行至关重要。所以这篇文章就从一个 iOS App 的性能管理的纬度谈谈如何精确监控以及数据如何上报等技术点 App 的性能问题是影响用户体验的重要因素之一。性能问题主要包含:Crash、网络请求错误或者超时、UI 响应速度慢、主线程卡顿、CPU 和内存使用率高、耗电量大等等。大多数的问题原因在于开发者错误地使用了线程锁、系统函数、编程规范问题、数据结构等等。解决问题的关键在于尽早的发现和定位问题。 -本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](./1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 - - - -## 监控项目 - - -- OOM -- crash -- 网络 +本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 @@ -31,7 +22,7 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; ``` -代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 +代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 @@ -303,13 +294,15 @@ RunLoop 在进入睡眠前的方法执行时间过长而导致无法进入睡眠 开启一个子线程,不断进行循环监测是否卡顿了。在 n 次都超过卡顿阈值后则认为卡顿了。卡顿之后进行堆栈 dump 并上报(具有一定的机制,数据处理在下一 part 讲)。 -卡顿阈值的设置的依据是 WatchDog 的机制。APM 系统里面的阈值需要小于 WatchDog 的值,不需要非常小,一般认为启动时间在 5s,其他状态下都是 3s。WatchDog 在不同状态下具有不同的值。 +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则代表超时阻塞了主线程。 @@ -512,7 +505,7 @@ static mach_port_t main_thread_id; 应用启动时间是影响用户体验的重要因素之一,所以我们需要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。 -![App 启动时间](/Users/lbp/Downloads/2020-03-30-APMAppLaunch.png) +![App 启动时间](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-03-30-APMAppLaunch.png) 冷启动:App 尚未运行,必须加载并构建整个应用。完成应用的初始化。冷启动存在较大优化空间。冷启动时间从 `application: didFinishLaunchingWithOptions:` 方法开始计算,App 一般在这里进行各种 SDK 和 App 的基础初始化工作。 @@ -589,7 +582,7 @@ Main 阶段 - 不要使用过 attribute*((constructor)) 将方法显示标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用时才初始化,推迟了一部分工作耗时也尽量不要使用 c++ 的静态对象 -##### pre-main 阶段影响因素 +##### 2.4 pre-main 阶段影响因素 - 动态库加载越多,启动越慢。 - ObjC 类越多,函数越多,启动越慢。 @@ -614,7 +607,7 @@ Main 阶段 图片小了,IO操作量就小了,启动当然就会快了,比较靠谱的压缩算法是 TinyPNG。 -##### main 阶段优化 +##### 2.5 main 阶段优化 - 减少启动初始化的流程。能懒加载就懒加载,能放后台初始化就放后台初始化,能延迟初始化的就延迟初始化,不要卡主线程的启动时间,已经下线的业务代码直接删除 - 优化代码逻辑。去除一些非必要的逻辑和代码,减小每个流程所消耗的时间 @@ -632,7 +625,7 @@ Main 阶段 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 工艺的要求。** +早期 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,但是对于编译器的设计要求更高。 @@ -742,12 +735,28 @@ iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手 什么是 OOM?是 out-of-memory 的缩写,字面意思是超过了内存限制。分为 FOOM(foreground OOM)和 BOOM(background OOM)。它是由 iOS 的 `Jetsam` 机制造成的一种非主流 Crash,它不能通过 Signal 这种监控方案所捕获。 -什么是 Jetsam 机制?Jetsam 机制可以理解为系统为了控制内存资源过度使用而采用的一种管理机制。Jetsam 是一个独立的进程,每个进程都有一个内存阈值,一旦超过这个内存阈值,Jetsam 会立即杀掉这个进程。 +什么是 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 + 内存过大的几种情况 @@ -760,7 +769,7 @@ App 内存不足时,系统会按照一定策略来腾出更多的空间供使 -**Memory page** 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。通常它是 16KB 大小,且有3种类型的 page。 +Memory page** 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。通常它是 16KB 大小,且有3种类型的 page。 ![内存page种类](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryType.png) @@ -822,7 +831,7 @@ App 运行内存 = pageNumbers * pageSize。因为 Compressed Memory 属于 Dirt iPhone 6s plus/13.3.1 数据如下: -```html +```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", @@ -904,7 +913,7 @@ iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450. iPhone 11 Pro/13.3.1 数据如下: -```html +```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", @@ -1076,7 +1085,7 @@ memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT]; 在 kern_memorystatus.c 中可以看到 OOM 可能的原因: -``` +```shell /* For logging clarity */ static const char *memorystatus_kill_cause_name[] = { "" , /* kMemorystatusInvalid */ @@ -1366,7 +1375,7 @@ memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ } ``` -其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我们可以在6秒内做些处理。**业界所说「当系统收到内存警告时会有6秒钟,再发生 Crash」。本人实测,是4秒钟后发生 Crash** +其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我们可以在6秒内做些处理。 @@ -1589,7 +1598,7 @@ for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){ } ``` -for 循环打印出每个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为10的进程,即我们前台运行的 App。为什么是10?😂 因为 `#define JETSAM_PRIORITY_FOREGROUND 10` 我们的目的就是获取前台 App 的内存上限值。 +for 循环打印出每个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为10的进程,即我们前台运行的 App。为什么是10? 因为 `#define JETSAM_PRIORITY_FOREGROUND 10` 我们的目的就是获取前台 App 的内存上限值。 @@ -1642,9 +1651,7 @@ for (NSInteger index = 0; index < 10000000; index++) { 结论: -**1. 收到低内存警告不一定会 Crash,因为有6秒钟的系统判断时间,6秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** - -**2. 业界所说「当系统收到内存警告时会有6秒钟,再发生 Crash」。本人实测,是4秒钟后发生 Crash** +**收到低内存警告不一定会 Crash,因为有6秒钟的系统判断时间,6秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** @@ -1654,13 +1661,110 @@ for (NSInteger index = 0; index < 10000000; index++) { 还需要知道每个对象具体是在哪个函数里创建出来的,以便还原“案发现场”。 -内存分配函数 malloc 和 calloc 等默认使用 nano_zone,nano_zone 是小于 256B 以下的内存分配,大于 256B 则使用 scalable_zone 来分配。 +源代码(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); @@ -1681,6 +1785,31 @@ void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) } ``` +其分配实现是 `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 这个函数,然后记录内存分配情况,结合一定的数据上报机制,上传到服务器,分析并修复。 @@ -1699,8 +1828,6 @@ 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):常见称呼为位址空间随机载入、位址空间配置随机化、位址空间布局随机化,是一种防止内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的定址空间来放置攻击者能可靠地跳转到内存的特定位置来操作函数。现代作业系统一般都具备该机制。 @@ -1709,7 +1836,7 @@ ASLR(Address space layout randomization):常见称呼为位址空间随机 函数虚拟地址:`vm_add`; -ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add` +ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add`。也就是:`*(base +offset)= imp`。 @@ -2871,11 +2998,11 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 ``` - ![method swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-09-methodSwizzling.png) + ![method swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-09-methodSwizzling.png) -![isa swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-isaSwizzling.png) +![isa swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-13-isaSwizzling.png) @@ -3035,9 +3162,471 @@ self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForN #### 2.5 iOS 流量监控 -简易版本。 -​ `45r + +##### 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` 查看一个完整的请求和响应数据 + +![qing](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+cm_FetchStatusLineFromCFNetwork.h + #import + + NS_ASSUME_NONNULL_BEGIN + + @interface NSURLResponse (cm_FetchStatusLineFromCFNetwork) + + - (NSString *)cm_fetchStatusLineFromCFNetwork; + + @end + + NS_ASSUME_NONNULL_END + + // NSURLResponse+cm_FetchStatusLineFromCFNetwork.m + #import "NSURLResponse+cm_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 (*CMURLResponseFetchHTTPResponse)(CFURLRef response); + + @implementation NSURLResponse (cm_FetchStatusLineFromCFNetwork) + + - (NSString *)cm_fetchStatusLineFromCFNetwork + { + NSString *statusLine = @""; + NSString *funcName = @"CFURLResponseGetHTTPResponse"; + CMURLResponseFetchHTTPResponse 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)cm_getLineLength { + NSString *statusLineString = @""; + if ([self isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self; + statusLineString = [self cm_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)cm_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]; + + PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init]; + model.path = self.request.URL.path; + model.host = self.request.URL.host; + model.type = DMNetworkTrafficDataTypeResponse; + model.lineLength = [self.internalResponse cm_getStatusLineLength]; + model.headerLength = [self.internalResponse cm_getHeadersLength]; + model.emptyLineLength = [self.internalResponse cm_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]; + [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType 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+cm_FetchStatusLineFromCFNetwork.m + - (NSUInteger)cm_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+cm_FetchHeaderWithCookies.m + - (NSUInteger)cm_fetchHeaderLengthWithCookie + { + NSDictionary *headerFields = self.allHTTPHeaderFields; + NSDictionary *cookiesHeader = [self cm_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 *)cm_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)cm_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` 方法中将数据上报会在 [打造功能强大、灵活可配置的数据上报组件](./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]; + } + + PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel 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 cm_getEmptyLineLength]; + model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; + + NSDictionary *networkTrafficDictionary = [model convertToDictionary]; + [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; + return request; + } + ``` +``` + + + + + + ## 六、 电量消耗 @@ -3058,7 +3647,7 @@ self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForN - 设置 UIDevice 的 batteryMonitoringEnabled 为 true - 获取到的耗电量精确度为 1% -```objective-c +​```objective-c - (double)fetchBatteryCostUsage { // returns a blob of power source information in an opaque CFTypeRef @@ -3148,15 +3737,2837 @@ NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读 -## 七、 Crash 监控 +## 六、 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: CMMonitorExample [3608] +Path: /var/containers/Bundle/Application/9518A4F4-59B7-44E9-BDDA-9FBEE8CA18E5/CMMonitorExample.app/CMMonitorExample +Identifier: com.Wacai.CMMonitorExample +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 CMMonitorExample 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 文件。 + +流程图如下: + +![KSCrasg流程图](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 "CMCrashInstallation.h" + #import + #import "CMCrashReporterSink.h" + + @implementation CMCrashInstallation + + + (instancetype)sharedInstance { + static CMCrashInstallation *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[CMCrashInstallation alloc] init]; + }); + return sharedInstance; + } + + - (id)init { + return [super initWithRequiredProperties: nil]; + } + + - (id)sink { + CMCrashReporterSink *sink = [[CMCrashReporterSink alloc] init]; + return [sink defaultCrashReportFilterSetAppleFmt]; + } + + @end + ``` + +- `sink` 方法内部的 `CMCrashReporterSink` 类,遵循了 **KSCrashReportFilter** 协议,声明了公有方法 `defaultCrashReportFilterSetAppleFmt` + + ```objective-c + // .h + #import + #import + + @interface CMCrashReporterSink : NSObject + + - (id ) defaultCrashReportFilterSetAppleFmt; + + @end + + // .m + #pragma mark - public Method + + - (id ) defaultCrashReportFilterSetAppleFmt + { + return [KSCrashReportFilterPipeline filterWithFilters: + [CMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], + self, + nil]; + } + ``` + + 其中 `defaultCrashReportFilterSetAppleFmt` 方法内部返回了一个 `KSCrashReportFilterPipeline` 类方法 `filterWithFilters` 的结果。 + + `CMCrashReportFilterAppleFmt` 是一个继承自 `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 CMCrashReportFilterAppleFmt : 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 CMCrashMonitor *_sharedManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedManager = [[CMCrashMonitor alloc] init]; + }); + return _sharedManager; + } + + + #pragma mark - public Method + + - (void)startMonitor + { + CMMLog(@"crash monitor started"); + + #ifdef DEBUG + BOOL _trackingCrashOnDebug = [CMMonitorConfig 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 + { + [[CMCrashInstallation sharedInstance] install]; + [[CMCrashInstallation sharedInstance] sendAllReportsWithCompletion:nil]; + [CMCrashInstallation sharedInstance].onCrash = onCrash; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + _isCanAddCrashCount = NO; + }); + } + ``` + + 在 `installKSCrash` 方法中调用了 `[[CMCrashInstallation 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: ]` 实现其实就是 `CMCrashInstallation` 中设置的 `sink` getter 方法,内部返回了 `CMCrashReporterSink` 对象的 `defaultCrashReportFilterSetAppleFmt` 方法的返回值。内部实现如下 + + ```objective-c + - (id ) defaultCrashReportFilterSetAppleFmt + { + return [KSCrashReportFilterPipeline filterWithFilters: + [CMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], + self, + nil]; + } + ``` + + 可以看到这个函数内部设置了多个 **filters**,其中一个就是 **self**,也就是 `CMCrashReporterSink` 对象,所以上面的 ` [self.sink filterReports: onCompletion:]` ,也就是调用 `CMCrashReporterSink` 内的数据处理方法。完了之后通过 `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+cm_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)。 + +因为我们公司有自己的一套体系,wax-cli,可以同时管理 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 作为整个监控系统 Prism 的一个组成部分,是专注于 crash report 符号化的微服务。 + +- 接收来自 mass 的包含预处理过的 crash report 和 dsym index 的请求,从七牛拉取对应的 dsym,对 crash report 做符号化解析,计算 hash,并将 hash 响应给 mass。 +- 接收来自 Prism 管理系统的包含原始 crash report 和 dsym index 的请求,从七牛拉取对应的 dsym,对crash report 做符号化解析,并将符号化的 crash report 响应给 Prism 管理系统。 +- Mass 是一个通用的数据处理(流式/批式)和任务调度框架 +- candle 是一个打包系统,上面说的 wax-cli 有个能力就是打包,其实就是调用的 candle 系统的打包构建能力。会根据项目的特点,选择合适的打包机(打包平台是维护了多个打包任务,不同任务根据特点被派发到不同的打包机上,任务详情页可以看到依赖的下载、编译、运行过程等,打包好的产物包括二进制包、下载二维码等等) + +![符号化流程图](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。mass 调度符号化服务(内部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](./1.80.m) + +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 来关联日志数据 + - wax 上面介绍过了,是一种多端项目管理模式,每个 wax 项目都具有基础信息 + +7. APM 技术方案本身是随着技术手段、分析需求不断调整升级的。上图的几个结构示意图是早期几个版本的,目前使用的是在此基础上进行了升级和结构调整,提几个关键词:Hermes、Flink SQL、InfluxDB。 -*(base +offset)= 你imp ## 参考资料 @@ -3175,5 +6586,6 @@ NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读 - [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和符号化/) - \ No newline at end of file diff --git a/Chapter1 - iOS/1.75.md b/Chapter1 - iOS/1.75.md index 3ce45c9..9249d7c 100644 --- a/Chapter1 - iOS/1.75.md +++ b/Chapter1 - iOS/1.75.md @@ -27,3 +27,28 @@ ``` 另一种思路是没必要针对每个类的私有方法或者每个方法进行测试,因为等全部功能做完后针对每个类的接口测试,一般会覆盖据大多数的方法。等测试完看如果方法未被覆盖,则针对性的补充 Unit Test + +目前,UI 测试(appium) 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归了,目前来看价值是方便后续的迭代和维护上有一些便利性。其他的功能性测试还是走 BDD。 + +对于类、函数、方法的走 TDD,老老实实写 UT、走 UT 覆盖率的把控 + +> 目前,UITesting 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归了,目前来看价值是方便后续的迭代和维护上有一些便利性。例如之前我们分享SDK升级微信和QQSDK,以便支持 universal link 功能,当时有了UITesing,基本上免去了测试人员介入。 + +> 如果是一些活动页和逻辑经常变动的,老老实实走测试黑盒... + +> 我觉得一直有个误区,就是觉得自动测试是为了质量,其实质量都是附送的,测试先行是让开发更快更爽的 + +> appium 但是后来他自己也说 在iOS平台好像不是很好搞 能做的事情挺少的 + +> 投入产出比是不高 那你试试monkey test + +> wwdc上这张图也很清楚,UI其实需要的占比很小的,还是要靠单测驱动 + +> 当时我找个UITest的bug都花了不少力气 + +> UTTest主要测逻辑的 + +> UITest可以测你页面渲染的对不对、按钮点击是否有问题 + + +不过 macaca appium 都可以做到iOS自动化 \ No newline at end of file diff --git a/Chapter1 - iOS/1.80.md b/Chapter1 - iOS/1.80.md index 372af69..d1ea651 100644 --- a/Chapter1 - iOS/1.80.md +++ b/Chapter1 - iOS/1.80.md @@ -3,4 +3,5 @@ 1. https://mp.weixin.qq.com/s?__biz=MzI4MTY5NTk4Ng==&mid=2247489516&idx=1&sn=97c8b0fd84e5fc5b214539c135919884&source=41#wechat_redirect -2. \ No newline at end of file +2. + diff --git a/Chapter1 - iOS/chapter1.md b/Chapter1 - iOS/chapter1.md index 97623bd..2f1b844 100644 --- a/Chapter1 - iOS/chapter1.md +++ b/Chapter1 - iOS/chapter1.md @@ -77,7 +77,7 @@ * [71、Flutter初体验-安装](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.71.md) * [72、架构设计心得](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.72.md) * [73、Ruby学习](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.73.md) - * [74、APM](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md) + * [74、带你打造一套 APM 监控系统](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md) * [75、如何写好测试](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.75.md) * [76、iOS Crash分析](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.76.md) * [77、iOS 打包系统构建加速](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.77.md) diff --git a/assets/2020-05-13-HTTPDataStructure.png b/assets/2020-05-13-HTTPDataStructure.png new file mode 100644 index 0000000..2e266d0 Binary files /dev/null and b/assets/2020-05-13-HTTPDataStructure.png differ diff --git a/assets/2020-05-14-HTTPRequestStructure.png b/assets/2020-05-14-HTTPRequestStructure.png new file mode 100644 index 0000000..ba73b9d Binary files /dev/null and b/assets/2020-05-14-HTTPRequestStructure.png differ diff --git a/assets/2020-05-16-HTTPRequestStructure.png b/assets/2020-05-16-HTTPRequestStructure.png new file mode 100644 index 0000000..4f7591e Binary files /dev/null and b/assets/2020-05-16-HTTPRequestStructure.png differ diff --git a/assets/2020-05-16-HTTPResponseStructure.png b/assets/2020-05-16-HTTPResponseStructure.png new file mode 100644 index 0000000..b087536 Binary files /dev/null and b/assets/2020-05-16-HTTPResponseStructure.png differ diff --git a/assets/2020-05-19-BSDCatchSignal.png b/assets/2020-05-19-BSDCatchSignal.png new file mode 100644 index 0000000..0699a00 Binary files /dev/null and b/assets/2020-05-19-BSDCatchSignal.png differ diff --git a/assets/2020-05-20-KSCrashStructure.png b/assets/2020-05-20-KSCrashStructure.png new file mode 100644 index 0000000..d2acb65 Binary files /dev/null and b/assets/2020-05-20-KSCrashStructure.png differ diff --git a/assets/2020-05-20-signalCrash.png b/assets/2020-05-20-signalCrash.png new file mode 100644 index 0000000..43a57b4 Binary files /dev/null and b/assets/2020-05-20-signalCrash.png differ diff --git a/assets/2020-05-28-SymbolicateLib.png b/assets/2020-05-28-SymbolicateLib.png new file mode 100644 index 0000000..db8e14f Binary files /dev/null and b/assets/2020-05-28-SymbolicateLib.png differ diff --git a/assets/2020-05-29-DYSMStructure.png b/assets/2020-05-29-DYSMStructure.png new file mode 100644 index 0000000..e829fd0 Binary files /dev/null and b/assets/2020-05-29-DYSMStructure.png differ diff --git a/assets/2020-05-31-KSCrashStoreCrashData.png b/assets/2020-05-31-KSCrashStoreCrashData.png new file mode 100644 index 0000000..5cb5dc0 Binary files /dev/null and b/assets/2020-05-31-KSCrashStoreCrashData.png differ diff --git a/assets/2020-06-03-KSCrashCaller.png b/assets/2020-06-03-KSCrashCaller.png new file mode 100644 index 0000000..d8744a2 Binary files /dev/null and b/assets/2020-06-03-KSCrashCaller.png differ diff --git a/assets/2020-06-03-VLQSymbol.png b/assets/2020-06-03-VLQSymbol.png new file mode 100644 index 0000000..75eaa30 Binary files /dev/null and b/assets/2020-06-03-VLQSymbol.png differ diff --git a/assets/2020-06-14-CrashLogSymbolicate.png b/assets/2020-06-14-CrashLogSymbolicate.png new file mode 100644 index 0000000..ca8f38e Binary files /dev/null and b/assets/2020-06-14-CrashLogSymbolicate.png differ diff --git a/assets/2020-06-14-ELK.png b/assets/2020-06-14-ELK.png new file mode 100644 index 0000000..7b7d7b8 Binary files /dev/null and b/assets/2020-06-14-ELK.png differ diff --git a/assets/2020-06-17-APMServerArch.png b/assets/2020-06-17-APMServerArch.png new file mode 100644 index 0000000..957dbb5 Binary files /dev/null and b/assets/2020-06-17-APMServerArch.png differ diff --git a/assets/2020-06-17-APMServerWorker.png b/assets/2020-06-17-APMServerWorker.png new file mode 100644 index 0000000..44d6c4d Binary files /dev/null and b/assets/2020-06-17-APMServerWorker.png differ diff --git a/assets/2020-06-17-APMStructure.jpg b/assets/2020-06-17-APMStructure.jpg new file mode 100644 index 0000000..64aa1bc Binary files /dev/null and b/assets/2020-06-17-APMStructure.jpg differ diff --git a/assets/2020-06-17-ElastiicsearchAPM.png b/assets/2020-06-17-ElastiicsearchAPM.png new file mode 100644 index 0000000..f16b0d7 Binary files /dev/null and b/assets/2020-06-17-ElastiicsearchAPM.png differ diff --git a/assets/2020-06-17-SymbolicateServerArch.png b/assets/2020-06-17-SymbolicateServerArch.png new file mode 100644 index 0000000..6e9cd7b Binary files /dev/null and b/assets/2020-06-17-SymbolicateServerArch.png differ diff --git a/assets/2020-06-17-symolication_flow.png b/assets/2020-06-17-symolication_flow.png new file mode 100644 index 0000000..b1738c2 Binary files /dev/null and b/assets/2020-06-17-symolication_flow.png differ diff --git a/assets/2020-06-19-JSErrorCatch.png b/assets/2020-06-19-JSErrorCatch.png new file mode 100644 index 0000000..019a87a Binary files /dev/null and b/assets/2020-06-19-JSErrorCatch.png differ diff --git a/assets/2020-06-19-ReactNativeCrashMonitor.png b/assets/2020-06-19-ReactNativeCrashMonitor.png new file mode 100644 index 0000000..97d0439 Binary files /dev/null and b/assets/2020-06-19-ReactNativeCrashMonitor.png differ diff --git a/assets/2020-06-20-ReactNativeCrashStack.png b/assets/2020-06-20-ReactNativeCrashStack.png new file mode 100644 index 0000000..554931c Binary files /dev/null and b/assets/2020-06-20-ReactNativeCrashStack.png differ diff --git a/assets/2020-06-21-ChromeSourceMapENable.png b/assets/2020-06-21-ChromeSourceMapENable.png new file mode 100644 index 0000000..c97785a Binary files /dev/null and b/assets/2020-06-21-ChromeSourceMapENable.png differ diff --git a/assets/2020-06-21-ChromeSourceMapEnableSwitch.png b/assets/2020-06-21-ChromeSourceMapEnableSwitch.png new file mode 100644 index 0000000..549c750 Binary files /dev/null and b/assets/2020-06-21-ChromeSourceMapEnableSwitch.png differ diff --git a/assets/2020-06-22-RNReleaseBundle.png b/assets/2020-06-22-RNReleaseBundle.png new file mode 100644 index 0000000..08802e1 Binary files /dev/null and b/assets/2020-06-22-RNReleaseBundle.png differ diff --git a/assets/2020-06-22-RNUncaughtCrash.png b/assets/2020-06-22-RNUncaughtCrash.png new file mode 100644 index 0000000..7f4c292 Binary files /dev/null and b/assets/2020-06-22-RNUncaughtCrash.png differ diff --git a/assets/2020-06-23-Jquery.png b/assets/2020-06-23-Jquery.png new file mode 100644 index 0000000..e3cbfb3 Binary files /dev/null and b/assets/2020-06-23-Jquery.png differ diff --git a/assets/2020-06-23-JquerySourceMap.png b/assets/2020-06-23-JquerySourceMap.png new file mode 100644 index 0000000..d49ff55 Binary files /dev/null and b/assets/2020-06-23-JquerySourceMap.png differ diff --git a/assets/2020-06-23-RNCrashLogAnalysis.png b/assets/2020-06-23-RNCrashLogAnalysis.png new file mode 100644 index 0000000..fe2a257 Binary files /dev/null and b/assets/2020-06-23-RNCrashLogAnalysis.png differ diff --git a/assets/2020-06-23-RNReleaseLog.png b/assets/2020-06-23-RNReleaseLog.png new file mode 100644 index 0000000..03fdbf0 Binary files /dev/null and b/assets/2020-06-23-RNReleaseLog.png differ diff --git a/assets/2020-06-23-VLAMapping.png b/assets/2020-06-23-VLAMapping.png new file mode 100644 index 0000000..3628f9b Binary files /dev/null and b/assets/2020-06-23-VLAMapping.png differ diff --git a/assets/WX20180806-101917@2x.png b/assets/WX20180806-101917@2x.png index 495fa24..a67b58b 100644 Binary files a/assets/WX20180806-101917@2x.png and b/assets/WX20180806-101917@2x.png differ diff --git a/assets/WX20180810-095006@2x.png b/assets/WX20180810-095006@2x.png index 61ac244..e264908 100644 Binary files a/assets/WX20180810-095006@2x.png and b/assets/WX20180810-095006@2x.png differ