diff --git a/.DS_Store b/.DS_Store index a728db4..5693f8e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..faecaee --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Dart & Flutter", + "request": "launch", + "type": "dart" + } + ] +} \ No newline at end of file diff --git a/Chapter1 - iOS/1.44..md b/Chapter1 - iOS/1.44..md index 3f48425..fde36f3 100644 --- a/Chapter1 - iOS/1.44..md +++ b/Chapter1 - iOS/1.44..md @@ -792,3 +792,20 @@ https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&mid=2247488218&idx=1&sn=21afe0 NSURLProtocol能够让你去重新定义苹果的URL加载系统(URL Loading System)的行为,URL Loading System里有许多类用于处理URL请求,比如NSURL,NSURLRequest,NSURLConnection和NSURLSession等。当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,你不应该直接实例化一个NSURLProtocol,NSURLProtocol看起来像是一个协议,但其实这是一个类,而且必须使用该类的子类,并且需要被注册。                                        +4. WKWebView 网络请求拦截 +原生 WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此在 WKWebView 上直接使用 NSURLProtocol 是无法拦截请求的。 + +但是由于 mPaas 的离线包机制强依赖网络拦截,所以基于此,mPaaS 利用了 WKWebview 的隐藏 api,去注册拦截网络请求去满足离线包的业务场景需求,参考代码如下: + +```Objective-c +[WKBrowsingContextController registerSchemeForCustomProtocol:@"https"] +``` +但是因为出于性能的原因,WKWebView 的网络请求在给主进程传递数据的时候会把请求的 body 去掉,导致拦截后请求的 body 参数丢失。 + +在离线包场景,由于页面的资源不需要 body 数据,所以离线包可以正常使用不受影响。但是在 H5 页面内的其他 post 请求会丢失 data 参数。 + +为了解决 post 参数丢失的问题,mPaas 通过在 js 注入代码,hook 了 js 上下文里的 XMLHTTPRequest 对象解决。 + +通过在 JS 层把方法内容组装好,然后通过 WKWebView 的 messageHandler 机制把内容传到主进程,把对应 HTTPBody 然后存起来,随后通知 JS 端继续这个请求,网络请求到主进程后,在将 post 请求对应的 HttpBody 添加上,这样就完成了一次 post 请求的处理。整体流程可以参考如下: +![ajax-时序图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-05-28-WKWebViewRequestHook) +通过上面的机制,既满足了离线包的资源拦截诉求,也解决了 post 请求 body 丢失的问题。但是在一些场景还是存在一些问题,需要开发者进行适配。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index 19a9428..71013c0 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -4,10 +4,7 @@ App 的性能问题是影响用户体验的重要因素之一。性能问题主要包含:Crash、网络请求错误或者超时、UI 响应速度慢、主线程卡顿、CPU 和内存使用率高、耗电量大等等。大多数的问题原因在于开发者错误地使用了线程锁、系统函数、编程规范问题、数据结构等等。解决问题的关键在于尽早的发现和定位问题。 -xf本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 - - - +xf 本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 ## 一、卡顿监控 @@ -15,7 +12,6 @@ xf本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据 FPS(frame per second)每秒钟的帧刷新次数,iPhone 手机以 60 为最佳,iPad 某些型号是 120,也是作为卡顿监控的一项参考参数,为什么说是参考参数?因为它不准确。先说说怎么获取到 FPS。CADisplayLink 是一个系统定时器,会以帧刷新频率一样的速率来刷新视图。 `[CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]`。至于为什么不准我们来看看下面的示例代码 - ```Objective-C _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)]; [_displayLink setPaused:YES]; @@ -24,28 +20,22 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di 代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 - - - ### 1. 屏幕绘制原理 ![老式 CRT 显示器原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_screen_scan.png) 讲讲老式的 CRT 显示器的原理。 CRT 电子枪按照上面方式,从上到下一行行扫描,扫面完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;当一帧画面绘制完成后,电子枪恢复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(Vertical synchronization),简称 VSync。显示器通常以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏,但是原理保持不变。 - - ![显示器和 CPU、GPU 关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-02-screen_display_gpu.png) 通常,屏幕上一张画面的显示是由 CPU、GPU 和显示器是按照上图的方式协同工作的。CPU 根据工程师写的代码计算好需要现实的内容(比如视图创建、布局计算、图片解码、文本绘制等),然后把计算结果提交到 GPU,GPU 负责图层合成、纹理渲染,随后 GPU 将渲染结果提交到帧缓冲区。随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过数模转换传递给显示器显示。 -在帧缓冲区只有一个的情况下,帧缓冲区的读取和刷新都存在效率问题,为了解决效率问题,显示系统会引入2个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入帧缓冲区,让视频控制器来读取,当下一帧渲染好后,GPU 直接把视频控制器的指针指向第二个缓冲区。提升了效率。 +在帧缓冲区只有一个的情况下,帧缓冲区的读取和刷新都存在效率问题,为了解决效率问题,显示系统会引入 2 个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入帧缓冲区,让视频控制器来读取,当下一帧渲染好后,GPU 直接把视频控制器的指针指向第二个缓冲区。提升了效率。 目前来看,双缓冲区提高了效率,但是带来了新的问题:当视频控制器还未读取完成时,即屏幕内容显示了部分,GPU 将新渲染好的一帧提交到另一个帧缓冲区并把视频控制器的指针指向新的帧缓冲区,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂的情况。 为了解决这个问题,GPU 通常有一个机制叫垂直同步信号(V-Sync),当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新。这样的几个机制解决了画面撕裂的情况,也增加了画面流畅度。但需要更多的计算资源 - ![IPC唤醒 RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-ios_vsync_runloop.png) 答疑 @@ -56,56 +46,39 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di **看上去第二帧图像是在等第一帧显示后的视频控制器发送 V-Sync 信号。是吗?真是这样的吗? 😭 想啥呢,当然不是。 🐷 不然双缓冲区就没有存在的意义了** - - 揭秘。请看下图 ![多缓冲区显示原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-Comparison_double_triple_buffering.png) - - 当第一次 V-Sync 信号到来时,先渲染好一帧图像放到帧缓冲区,但是不展示,当收到第二个 V-Sync 信号后读取第一次渲染好的结果(视频控制器的指针指向第一个帧缓冲区),并同时渲染新的一帧图像并将结果存入第二个帧缓冲区,等收到第三个 V-Sync 信号后,读取第二个帧缓冲区的内容(视频控制器的指针指向第二个帧缓冲区),并开始第三帧图像的渲染并送入第一个帧缓冲区,依次不断循环往复。 请查看资料,需要梯子:[Multiple buffering](https://en.m.wikipedia.org/wiki/Multiple_buffering) - - ### 2. 卡顿产生的原因 - - ![卡顿原因](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_frame_drop.png) - - VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图创建、布局计算、图片解码、文本绘制等)。然后将计算的内容提交到 GPU,GPU 经过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示之前渲染好的结果。在垂直同步机制的情况下,如果在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会造成该帧的丢弃,等待下一次机会再显示,这时候屏幕上还是之前渲染的图像,所以这就是 CPU、GPU 层面界面卡顿的原因。 - 目前 iOS 设备有双缓存机制,也有三缓冲机制,Android 现在主流是三缓冲机制,在早期是单缓冲机制。 [iOS 三缓冲机制例子](https://ios.developreference.com/article/12261072/Metal+newBufferWithBytes+usage) - CPU 和 GPU 资源消耗原因很多,比如对象的频繁创建、属性调整、文件读取、视图层级的调整、布局的计算(AutoLayout 视图个数多了就是线性方程求解难度变大)、图片解码(大图的读取优化)、图像绘制、文本渲染、数据库读取(多读还是多写乐观锁、悲观锁的场景)、锁的使用(举例:自旋锁使用不当会浪费 CPU)等方面。开发者根据自身经验寻找最优解(这里不是本文重点)。 - - - ### 3. APM 如何监控卡顿并上报 - -CADisplayLink 肯定不用了,这个 FPS 仅作为参考。一般来讲,卡顿的监测有2种方案:**监听 RunLoop 状态回调、子线程 ping 主线程** - - +CADisplayLink 肯定不用了,这个 FPS 仅作为参考。一般来讲,卡顿的监测有 2 种方案:**监听 RunLoop 状态回调、子线程 ping 主线程** #### 3.1 RunLoop 状态监听的方式 -RunLoop 负责监听输入源进行调度处理。比如网络、输入设备、周期性或者延迟事件、异步回调等。RunLoop 会接收2种类型的输入源:一种是来自另一个线程或者来自不同应用的异步消息(source0事件)、另一种是来自预定或者重复间隔的事件。 +RunLoop 负责监听输入源进行调度处理。比如网络、输入设备、周期性或者延迟事件、异步回调等。RunLoop 会接收 2 种类型的输入源:一种是来自另一个线程或者来自不同应用的异步消息(source0 事件)、另一种是来自预定或者重复间隔的事件。 RunLoop 状态如下图 ![RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/4.png) 第一步:通知 Observers,RunLoop 要开始进入 loop,紧接着进入 loop + ```Objective-c if (currentMode->_observerMask & kCFRunLoopEntry ) // 通知 Observers: RunLoop 即将进入 loop @@ -115,6 +88,7 @@ result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, prev ``` 第二步:开启 do while 循环保活线程,通知 Observers,RunLoop 触发 Timer 回调、Source0 回调,接着执行被加入的 block + ```Objective-c if (rlm->_observerMask & kCFRunLoopBeforeTimers) // 通知 Observers: RunLoop 即将触发 Timer 回调 @@ -127,12 +101,13 @@ __CFRunLoopDoBlocks(rl, rlm); ``` 第三步:RunLoop 在触发 Source0 回调后,如果 Source1 是 ready 状态,就会跳转到 handle_msg 去处理消息。 + ```Objective-c // 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息 if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI msg = (mach_msg_header_t *)msg_buffer; - + if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) { goto handle_msg; } @@ -145,6 +120,7 @@ if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { ``` 第四步:回调触发后,通知 Observers 即将进入休眠状态 + ```Objective-c Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR); // 通知 Observers: RunLoop 的线程即将进入休眠(sleep) @@ -152,11 +128,13 @@ if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObserv __CFRunLoopSetSleeping(rl); ``` -第五步:进入休眠后,会等待 mach_port 消息,以便再次唤醒。只有以下4种情况才可以被再次唤醒。 +第五步:进入休眠后,会等待 mach_port 消息,以便再次唤醒。只有以下 4 种情况才可以被再次唤醒。 + - 基于 port 的 source 事件 - Timer 时间到 - RunLoop 超时 - 被调用者唤醒 + ```Objective-c do { if (kCFUseCollectableAllocator) { @@ -165,9 +143,9 @@ do { memset(msg_buffer, 0, sizeof(msg_buffer)); } msg = (mach_msg_header_t *)msg_buffer; - + __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); - + if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer. while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue)); @@ -186,6 +164,7 @@ do { ``` 第六步:唤醒时通知 Observer,RunLoop 的线程刚刚被唤醒了 + ```Objective-C // 通知 Observers: RunLoop 的线程刚刚被唤醒了 if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); @@ -195,9 +174,11 @@ if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObserve ``` 第七步:RunLoop 唤醒后,处理唤醒时收到的消息 + - 如果是 Timer 时间到,则触发 Timer 的回调 - 如果是 dispatch,则执行 block - 如果是 source1 事件,则处理这个事件 + ```Objective-C #if USE_MK_TIMER_TOO // 如果一个 Timer 到时间了,触发这个Timer的回调 @@ -230,7 +211,7 @@ if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObserve // 如果一个 Source1 (基于port) 发出事件了,处理这个事件 else { CFRUNLOOP_WAKEUP_FOR_SOURCE(); - + // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again. voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release); @@ -249,6 +230,7 @@ if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObserve ``` 第八步:根据当前 RunLoop 状态判断是否需要进入下一个 loop。当被外部强制停止或者 loop 超时,就不继续下一个 loop,否则进入下一个 loop + ```Objective-C if (sourceHandledThisLoop && stopAfterHandle) { // 进入loop时参数说处理完事件就返回 @@ -271,9 +253,9 @@ if (sourceHandledThisLoop && stopAfterHandle) { 完整且带有注释的 RunLoop 代码见[此处](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFRunLoop.c)。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质还是调用 Source0 事件的处理函数。 - ![RunLoop 状态](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-05-RunLoop.png) -RunLoop 6个状态 +RunLoop 6 个状态 + ```Objective-C typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { @@ -291,21 +273,19 @@ RunLoop 在进入睡眠前的方法执行时间过长而导致无法进入睡眠 一旦发现进入睡眠前的 KCFRunLoopBeforeSources 状态,或者唤醒后 KCFRunLoopAfterWaiting,在设置的时间阈值内没有变化,则可判断为卡顿,此时 dump 堆栈信息,还原案发现场,进而解决卡顿问题。 - 开启一个子线程,不断进行循环监测是否卡顿了。在 n 次都超过卡顿阈值后则认为卡顿了。卡顿之后进行堆栈 dump 并上报(具有一定的机制,数据处理在下一 part 讲)。 WatchDog 在不同状态下具有不同的值。 + - 启动(Launch):20s - 恢复(Resume):10s - 挂起(Suspend):10s - 退出(Quit):6s - 后台(Background):3min(在 iOS7 之前可以申请 10min;之后改为 3min;可连续申请,最多到 10min) -卡顿阈值的设置的依据是 WatchDog 的机制。APM 系统里面的阈值需要小于 WatchDog 的值,所以取值范围在 [1, 6] 之间,业界通常选择3秒。 - -通过 `long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)` 方法判断是否阻塞主线程,`Returns zero on success, or non-zero if the timeout occurred.` 返回非0则代表超时阻塞了主线程。 - +卡顿阈值的设置的依据是 WatchDog 的机制。APM 系统里面的阈值需要小于 WatchDog 的值,所以取值范围在 [1, 6] 之间,业界通常选择 3 秒。 +通过 `long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)` 方法判断是否阻塞主线程,`Returns zero on success, or non-zero if the timeout occurred.` 返回非 0 则代表超时阻塞了主线程。 ![RunLoop-ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-APM-RunLoopANR.jpg) @@ -317,7 +297,6 @@ Runloop 检测卡顿流程图如下: 关键代码如下: - ```Objective-c // 设置Runloop observer的运行环境 CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL}; @@ -359,27 +338,23 @@ dispatch_async(dispatch_get_global_queue(0, 0), ^{ }); ``` - - - - #### 3.2 子线程 ping 主线程监听的方式 -开启一个子线程,创建一个初始值为0的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为主线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 +开启一个子线程,创建一个初始值为 0 的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为主线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 ```Objective-c while (self.isCancelled == NO) { @autoreleasepool { __block BOOL isMainThreadNoRespond = YES; dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - + dispatch_async(dispatch_get_main_queue(), ^{ isMainThreadNoRespond = NO; dispatch_semaphore_signal(semaphore); }); - + [NSThread sleepForTimeInterval:self.threshold]; - + if (isMainThreadNoRespond) { if (self.handlerBlock) { self.handlerBlock(); // 外部在 block 内部 dump 堆栈(下面会讲),数据上报 @@ -390,8 +365,6 @@ while (self.isCancelled == NO) { } ``` - - ### 4. 堆栈 dump 方法堆栈的获取是一个麻烦事。理一下思路。`[NSThread callStackSymbols]` 可以获取当前线程的调用栈。但是当监控到卡顿发生,需要拿到主线程的堆栈信息就无能为力了。从任何线程回到主线程这条路走不通。先做个知识回顾。 @@ -402,7 +375,7 @@ while (self.isCancelled == NO) { ![函数调用栈](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-StackFrame.png) 上图表示为一个栈。分为若干个栈帧(Frame),每个栈帧对应一个函数调用。下面蓝色部分表示 `DrawSquare` 函数,它在执行的过程中调用了 `DrawLine` 函数,用绿色部分表示。 -可以看到栈帧由三部分组成:函数参数、返回地址、局部变量。比如在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数需要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。 +可以看到栈帧由三部分组成:函数参数、返回地址、局部变量。比如在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数需要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数 B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。 栈指针 Stack Pointer 表示当前栈的顶部,大多部分操作系统都是栈向下生长,所以栈指针是最小值。帧指针 Frame Pointer 指向的地址中,存储了上一次 Stack Pointer 的值,也就是返回地址。 @@ -410,8 +383,6 @@ while (self.isCancelled == NO) { 接下来的步骤就是拿到所有线程的 Stack Pointer 和 Frame Pointer。然后不断回溯,还原案发现场。 - - ### 5. Mach Task 知识 **Mach task:** @@ -431,8 +402,6 @@ kern_return_t task_threads ) ``` - - thread_info: ```c++ @@ -449,7 +418,7 @@ kern_return_t thread_info 系统方法 `kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);` 可以获取到所有的线程,不过这种方法获取到的线程信息是最底层的 **mach 线程**。 -对于每个线程,可以用 `kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt);` 方法获取它的所有信息,信息填充在 `_STRUCT_MCONTEXT` 类型的参数中,这个方法中有2个参数随着 CPU 架构不同而不同。所以需要定义宏屏蔽不同 CPU 之间的区别。 +对于每个线程,可以用 `kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt);` 方法获取它的所有信息,信息填充在 `_STRUCT_MCONTEXT` 类型的参数中,这个方法中有 2 个参数随着 CPU 架构不同而不同。所以需要定义宏屏蔽不同 CPU 之间的区别。 `_STRUCT_MCONTEXT` 结构体中,存储了当前线程的 Stack Pointer 和最顶部栈帧的 Frame pointer,进而回溯整个线程调用堆栈。 @@ -459,12 +428,10 @@ pthread 的 p 是 **POSIX** 的缩写,表示「可移植操作系统接口」 Unix 系统提供的 `task_threads` 和 `thread_get_state` 操作的都是内核系统,每个内核线程由 thread_t 类型的 id 唯一标识。pthread 的唯一标识是 pthread_t 类型。其中内核线程和 pthread 的转换(即 thread_t 和 pthread_t)很容易,因为 pthread 设计初衷就是「抽象内核线程」。 - - `memorystatus_action_neededpthread_create` 方法创建线程的回调函数为 **nsthreadLauncher**。 ```Objective-c -static void *nsthreadLauncher(void* thread) +static void *nsthreadLauncher(void* thread) { NSThread *t = (NSThread*)thread; [nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil]; @@ -475,30 +442,25 @@ static void *nsthreadLauncher(void* thread) } ``` -NSThreadDidStartNotification 其实就是字符串 @"_NSThreadDidStartNotification"。 +NSThreadDidStartNotification 其实就是字符串 @"\_NSThreadDidStartNotification"。 ```Objective-c -{number = 1, name = main} +{number = 1, name = main} ``` + 为了 NSThread 和内核线程对应起来,只能通过 name 一一对应。 pthread 的 API `pthread_getname_np` 也可获取内核线程名字。np 代表 not POSIX,所以不能跨平台使用。 思路概括为:将 NSThread 的原始名字存储起来,再将名字改为某个随机数(时间戳),然后遍历内核线程 pthread 的名字,名字匹配则 NSThread 和内核线程对应了起来。找到后将线程的名字还原成原本的名字。对于主线程,由于不能使用 `pthread_getname_np`,所以在当前代码的 load 方法中获取到 thread_t,然后匹配名字。 - ```Objective-c -static mach_port_t main_thread_id; +static mach_port_t main_thread_id; + (void)load { main_thread_id = mach_thread_self(); } ``` - - - ## 二、 App 启动时间监控 - - ### 1. App 启动时间的监控 应用启动时间是影响用户体验的重要因素之一,所以我们需要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。 @@ -509,16 +471,14 @@ static mach_port_t main_thread_id; 热启动:应用已经在后台运行(常见场景:比如用户使用 App 过程中点击 Home 键,再打开 App),由于某些事件将应用唤醒到前台,App 会在 `applicationWillEnterForeground:` 方法接受应用进入前台的事件 - - 思路比较简单。如下 - 在监控类的 `load` 方法中先拿到当前的时间值 - 监听 App 启动完成后的通知 `UIApplicationDidFinishLaunchingNotification` - 收到通知后拿到当前的时间 -- 步骤1和3的时间差就是 App 启动时间。 +- 步骤 1 和 3 的时间差就是 App 启动时间。 -`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后2个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 +`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后 2 个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 ```Objective-c mach_timebase_info_data_t g_apmmStartupMonitorTimebaseInfoData = 0; @@ -527,17 +487,16 @@ uint64_t timelapse = mach_absolute_time() - g_apmmLoadTime; double timeSpan = (timelapse * g_apmmStartupMonitorTimebaseInfoData.numer) / (g_apmmStartupMonitorTimebaseInfoData.denom * 1e9); ``` - - ### 2. 线上监控启动时间就好,但是在开发阶段需要对启动时间做优化。 -要优化启动时间,就先得知道在启动阶段到底做了什么事情,针对现状作出方案。 +要优化启动时间,就先得知道在启动阶段到底做了什么事情,针对现状作出方案。 pre-main 阶段定义为 App 开始启动到系统调用 main 函数这个阶段;main 阶段定义为 main 函数入口到主 UI 框架的 viewDidAppear。 App 启动过程: + - 解析 Info.plist:加载相关信息例如闪屏;沙盒建立、权限检查; -- Mach-O 加载:如果是胖二进制文件,寻找合适当前 CPU 架构的部分;加载所有依赖的 Mach-O 文件(递归调用 Mach-O 加载的方法);定义内部、外部指针引用,例如字符串、函数等;加载分类中的方法;c++ 静态对象加载、调用 Objc 的 `+load()` 函数;执行声明为 __attribute_((constructor)) 的 c 函数; +- Mach-O 加载:如果是胖二进制文件,寻找合适当前 CPU 架构的部分;加载所有依赖的 Mach-O 文件(递归调用 Mach-O 加载的方法);定义内部、外部指针引用,例如字符串、函数等;加载分类中的方法;c++ 静态对象加载、调用 Objc 的 `+load()` 函数;执行声明为 \__attribute_((constructor)) 的 c 函数; - 程序执行:调用 main();调用 UIApplicationMain();调用 applicationWillFinishLaunching(); Pre-Main 阶段 @@ -546,10 +505,10 @@ Pre-Main 阶段 Main 阶段 ![Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-Main.png) - -#### 2.1 加载 Dylib +#### 2.1 加载 Dylib 每个动态库的加载,dyld 需要 + - 分析所依赖的动态库 - 找到动态库的 Mach-O 文件 - 打开文件 @@ -558,27 +517,25 @@ Main 阶段 - 对动态库的每一个 segment 调用 mmap() 优化: + - 减少非系统库的依赖 - 使用静态库而不是动态库 - 合并非系统动态库为一个动态库 - - #### 2.2 Rebase && Binding 优化: + - 减少 Objc 类数量,减少 selector 数量,把未使用的类和函数都可以删掉 - 减少 c++ 虚函数数量 - 转而使用 Swift struct(本质就是减少符号的数量) - - #### 2.3 Initializers 优化: -- 使用 `+initialize` 代替 `+load` -- 不要使用过 attribute*((constructor)) 将方法显示标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用时才初始化,推迟了一部分工作耗时也尽量不要使用 c++ 的静态对象 +- 使用 `+initialize` 代替 `+load` +- 不要使用过 attribute\*((constructor)) 将方法显示标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用时才初始化,推迟了一部分工作耗时也尽量不要使用 c++ 的静态对象 #### 2.4 pre-main 阶段影响因素 @@ -590,20 +547,20 @@ Main 阶段 - ObjC 的 +load 越多,启动越慢。 优化手段: + - 减少依赖不必要的库,不管是动态库还是静态库;如果可以的话,把动态库改造成静态库;如果必须依赖动态库,则把多个非系统的动态库合并成一个动态库 -- 检查下 framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查 -- 合并或者删减一些OC类和函数。关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类(也可以用根据linkmap文件来分析,但是准确度不算很高) -有一个叫做[FUI](https://github.com/dblock/fui)的开源项目能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了C++的类模板 +- 检查下 framework 应当设为 optional 和 required,如果该 framework 在当前 App 支持的所有 iOS 系统版本都存在,那么就设为 required,否则就设为 optional,因为 optional 会有些额外的检查 +- 合并或者删减一些 OC 类和函数。关于清理项目中没用到的类,使用工具 AppCode 代码检查功能,查到当前项目中没有用到的类(也可以用根据 linkmap 文件来分析,但是准确度不算很高) + 有一个叫做[FUI](https://github.com/dblock/fui)的开源项目能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了 C++的类模板 - 删减一些无用的静态变量 - 删减没有被调用到或者已经废弃的方法 -- 将不必须在 +load 方法中做的事情延迟到 +initialize中,尽量不要用 C++ 虚函数(创建虚函数表有开销) -- 类和方法名不要太长:iOS每个类和方法名都在 __cstring 段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的 -因还是 Object-c 的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,Object-c 对象模型会把类/方法名字符串都保存下来; +- 将不必须在 +load 方法中做的事情延迟到 +initialize 中,尽量不要用 C++ 虚函数(创建虚函数表有开销) +- 类和方法名不要太长:iOS 每个类和方法名都在 \_\_cstring 段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的 + 因还是 Object-c 的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,Object-c 对象模型会把类/方法名字符串都保存下来; - 用 dispatch_once() 代替所有的 attribute((constructor)) 函数、C++ 静态对象初始化、ObjC 的 +load 函数; - 在设计师可接受的范围内压缩图片的大小,会有意外收获。 -压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的, -图片小了,IO操作量就小了,启动当然就会快了,比较靠谱的压缩算法是 TinyPNG。 - + 压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的, + 图片小了,IO 操作量就小了,启动当然就会快了,比较靠谱的压缩算法是 TinyPNG。 #### 2.5 main 阶段优化 @@ -612,35 +569,22 @@ Main 阶段 - 启动阶段使用多线程来进行初始化,把 CPU 性能发挥最大 - 使用纯代码而不是 xib 或者 storyboard 来描述 UI,尤其是主 UI 框架,比如 TabBarController。因为 xib 和 storyboard 还是需要解析成代码来渲染页面,多了一步。 - - ### 3. 启动时间加速 内存缺页异常?在使用中,访问虚拟内存的一个 page 而对应的物理内存缺不存在(没有被加载到物理内存中),则发生缺页异常。影响耗时,在几毫秒之内。 什么时候发生大量的缺页异常?一个应用程序刚启动的时候。 - - 启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。 二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。 - - 一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。 -其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以hook 肯定不行、fishhook 也不行,用 clang 插桩可以满足需求。 - - - - - +其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以 hook 肯定不行、fishhook 也不行,用 clang 插桩可以满足需求。 ## 三、 CPU 使用率监控 - - ### 1. CPU 架构 CPU(Central Processing Unit)中央处理器,市场上主流的架构有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。区别在于**不同的 CPU 设计理念和方法**。 @@ -651,11 +595,10 @@ RISC 架构要求软件来指定各个操作步骤。比如上面的乘法,指 目前市场是大部分的 iPhone 都是基于 arm64 架构的。且 arm 架构能耗低。 - - ### 2. 获取线程信息 讲完了区别来讲下如何做 CPU 使用率的监控 + - 开启定时器,按照设定的周期不断执行下面的逻辑 - 获取当前任务 task。从当前 task 中获取所有的线程信息(线程个数、线程数组) - 遍历所有的线程信息,判断是否有线程的 CPU 使用率超过设置的阈值 @@ -663,10 +606,11 @@ RISC 架构要求软件来指定各个操作步骤。比如上面的乘法,指 - 组装数据,上报数据 线程信息结构体 + ```Objective-c struct thread_basic_info { time_value_t user_time; /* user run time(用户运行时长) */ - time_value_t system_time; /* system run time(系统运行时长) */ + time_value_t system_time; /* system run time(系统运行时长) */ integer_t cpu_usage; /* scaled cpu usage percentage(CPU使用率,上限1000) */ policy_t policy; /* scheduling policy in effect(有效调度策略) */ integer_t run_state; /* run state (运行状态,见下) */ @@ -678,6 +622,7 @@ struct thread_basic_info { ``` 代码在讲堆栈还原的时候讲过,忘记的看一下上面的分析 + ```Objective-C thread_act_array_t threads; mach_msg_type_number_t threadCount = 0; @@ -690,29 +635,29 @@ for (int i = 0; i < threadCount; i++) { thread_info_data_t threadInfo; thread_basic_info_t threadBaseInfo; mach_msg_type_number_t threadInfoCount; - + kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount); - + if (kr == KERN_SUCCESS) { - + threadBaseInfo = (thread_basic_info_t)threadInfo; // todo:条件判断,看不明白 if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) { integer_t cpuUsage = threadBaseInfo->cpu_usage / 10; if (cpuUsage > CPUMONITORRATE) { - + NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary]; NSData *CPUPayloadData = [NSData data]; - + NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread]; // 1. 组装卡顿的 Meta 信息 CPUMetaDictionary[@"MONITOR_TYPE"] = APMMonitorCPUType; - + // 2. 组装卡顿的 Payload 信息(一个JSON对象,对象的 Key 为约定好的 STACK_TRACE, value 为 base64 后的堆栈信息) NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding]; NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0]; NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)}; - + NSError *error; // NSJSONWritingOptions 参数一定要传0,因为服务端需要根据 \n 处理逻辑,传递 0 则生成的 json 串不带 \n NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error]; @@ -721,18 +666,15 @@ for (int i = 0; i < threadCount; i++) { return; } CPUPayloadData = [parsedData copy]; - + // 3. 数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - [[HermesClient sharedInstance] sendWithType:APMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; + [[HermesClient sharedInstance] sendWithType:APMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; } } } } ``` - - - ## 四、 OOM 问题 ### 1. 基础知识准备 @@ -745,8 +687,6 @@ for (int i = 0; i < threadCount; i++) { iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手机系统都不支持。因为移动设备的大量存储器是**闪存**,它的读写速度远远小电脑所使用的硬盘,也就是说手机即使使用了**交换空间**技术,也因为闪存慢的问题,不能提升性能,所以索性就没有交换空间技术。 - - ### 2. iOS 内存知识 内存(RAM)与 CPU 一样都是系统中最稀少的资源,也很容易发生竞争,应用内存与性能直接相关。iOS 没有交换空间作为备选资源,所以内存资源尤为重要。 @@ -757,9 +697,9 @@ iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手 为什么设计 Jetsam 机制?因为设备的内存是有限的,所以内存资源非常重要。系统进程以及其他使用的 App 都会抢占这个资源。由于 iOS 不支持交换空间,一旦触发低内存事件,Jetsam 就会尽可能多的释放 App 所在内存,这样 iOS 系统上出现内存不足时,App 就会被系统杀掉,变现为 crash。 -2种情况触发 OOM:系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "**highg water mark**" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。 +2 种情况触发 OOM:系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "**highg water mark**" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。 -读了源码(xnu/bsd/kern/kern_memorystatus.c)会发现内存被杀也有2种机制,如下 +读了源码(xnu/bsd/kern/kern_memorystatus.c)会发现内存被杀也有 2 种机制,如下 highwater 处理 -> 我们的 App 占用内存不能超过单个限制 @@ -773,8 +713,7 @@ 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 - +4. 根据优先级从低到高开始杀,直到 memorystatus_avail_pages_below_pressure 内存过大的几种情况 @@ -785,54 +724,49 @@ memorystatus_act_aggressive 处理 -> 内存占用高,按照优先级杀死 App 内存不足时,系统会按照一定策略来腾出更多的空间供使用。比较常见的做法是将一部分优先级低的数据挪到磁盘上,该操作为称为 **page out**。之后再次访问这块数据的时候,系统会负责将它重新搬回到内存中,该操作被称为 **page in**。 - - -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) - - Clean Memory - Clean memory 包括3类:可以 `page out` 的内存、内存映射文件、App 使用到的 framework(每个 framework 都有 _DATA_CONST 段,通常都是 clean 状态,但使用 runtime swizling,那么变为 dirty)。 + Clean memory 包括 3 类:可以 `page out` 的内存、内存映射文件、App 使用到的 framework(每个 framework 都有 \_DATA_CONST 段,通常都是 clean 状态,但使用 runtime swizling,那么变为 dirty)。 - 一开始分配的 page 都是干净的(堆里面的对象分配除外),我们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。 + 一开始分配的 page 都是干净的(堆里面的对象分配除外),我们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。 - ![Clean memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeClean.png) + ![Clean memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeClean.png) - Dirty Memory - Dirty memory 包括4类:被 App 写入过数据的内存、所有堆区分配的对象、图像解码缓冲区、framework(framework 都有 _DATA 段和 _DATA_DIRTY 段,它们的内存都是 dirty)。 + Dirty memory 包括 4 类:被 App 写入过数据的内存、所有堆区分配的对象、图像解码缓冲区、framework(framework 都有 \_DATA 段和 \_DATA_DIRTY 段,它们的内存都是 dirty)。 - 在使用 framework 的过程中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减少 Dirty memory(因为单例一旦创建就不销毁,一直在内存中,系统不认为是 Dirty memory)。 + 在使用 framework 的过程中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减少 Dirty memory(因为单例一旦创建就不销毁,一直在内存中,系统不认为是 Dirty memory)。 - ![Dirty memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeDirty.png) + ![Dirty memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeDirty.png) - Compressed Memory - 由于闪存容量和读写限制,iOS 没有交换空间机制,而是在 iOS7 引入了 **memory compressor**。它是在内存紧张时候能够将最近一段时间未使用过的内存对象,内存压缩器会把对象压缩,释放出更多的 page。在需要时内存压缩器对其解压复用。在节省内存的同时提高了响应速度。 + 由于闪存容量和读写限制,iOS 没有交换空间机制,而是在 iOS7 引入了 **memory compressor**。它是在内存紧张时候能够将最近一段时间未使用过的内存对象,内存压缩器会把对象压缩,释放出更多的 page。在需要时内存压缩器对其解压复用。在节省内存的同时提高了响应速度。 - 比如 App 使用某 Framework,内部有个 NSDictionary 属性存储数据,使用了 3 pages 内存,在近期未被访问的时候 memory compressor 将其压缩为 1 page,再次使用的时候还原为 3 pages。 + 比如 App 使用某 Framework,内部有个 NSDictionary 属性存储数据,使用了 3 pages 内存,在近期未被访问的时候 memory compressor 将其压缩为 1 page,再次使用的时候还原为 3 pages。 -App 运行内存 = pageNumbers * pageSize。因为 Compressed Memory 属于 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize +App 运行内存 = pageNumbers \* pageSize。因为 Compressed Memory 属于 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize 设备不同,内存占用上限不同,App 上限较高,extension 上限较低,超过上限 crash 到 `EXC_RESOURCE_EXCEPTION`。 ![Memory footprint](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryFootprint.png) 接下来谈一下如何获取内存上限,以及如何监控 App 因为占用内存过大而被强杀。 - - ### 3. 获取内存信息 #### 3.1 通过 JetsamEvent 日志计算内存限制值 当 App 被 Jetsam 机制杀死时,手机会生成系统日志。查看路径:Settings-Privacy-Analytics & Improvements- Analytics Data(设置-隐私- 分析与改进-分析数据),可以看到 `JetsamEvent-2020-03-14-161828.ips` 形式的日志,以 JetsamEvent 开头。这些 JetsamEvent 日志都是 iOS 系统内核强杀掉那些优先级不高(idle、frontmost、suspended)且占用内存超过系统内存限制的 App 留下的。 -日志包含了 App 的内存信息。可以查看到 日志最顶部有 `pageSize` 字段,查找到 per-process-limit,该节点所在结构里的 `rpages` ,将 rpages * pageSize 即可得到 OOM 的阈值。 +日志包含了 App 的内存信息。可以查看到 日志最顶部有 `pageSize` 字段,查找到 per-process-limit,该节点所在结构里的 `rpages` ,将 rpages \* pageSize 即可得到 OOM 的阈值。 日志中 largestProcess 字段代表 App 名称;reason 字段代表内存原因;states 字段代表奔溃时 App 的状态( idle、suspended、frontmost...)。 -为了测试数据的准确性,我将测试2台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码如下 +为了测试数据的准确性,我将测试 2 台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码如下 ```objective-c - (void)viewDidLoad { @@ -925,9 +859,7 @@ iPhone 6s plus/13.3.1 数据如下: } ``` -iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450.09375M - - +iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024\*1024)=1450.09375M iPhone 11 Pro/13.3.1 数据如下: @@ -1007,9 +939,7 @@ iPhone 11 Pro/13.3.1 数据如下: } ``` -iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384\*134278)/(1024*1024)=2098.09375M - - +iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384\*134278)/(1024\*1024)=2098.09375M **iOS 系统如何发现 Jetsam ?** @@ -1037,15 +967,13 @@ kmeminit(); #endif /* CONFIG_MEMORYSTATUS */ ``` -**主要作用就是开启了2个优先级最高的线程,来监控整个系统的内存情况。** - - +**主要作用就是开启了 2 个优先级最高的线程,来监控整个系统的内存情况。** CONFIG_FREEZE 开启时,内核对进程进行冷冻而不是杀死。冷冻功能是由内核中启动一个 `memorystatus_freeze_thread` 进行,这个进程在收到信号后调用 `memorystatus_freeze_top_process` 进行冷冻。 iOS 系统会开启优先级最高的线程 `vm_pressure_monitor` 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 进程。iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。有关 Jetsam 也就是 memorystatus 相关的逻辑,可以在 XNU 项目中的 **kern_memorystatus.h** 和 **kern_memorystatus.c **源码中查看。 -iOS 系统因内存占用过高会强杀 App 前,至少有 6秒钟可以用来做优先级判断,JetsamEvent 日志也是在这6秒内生成的。 +iOS 系统因内存占用过高会强杀 App 前,至少有 6 秒钟可以用来做优先级判断,JetsamEvent 日志也是在这 6 秒内生成的。 上文提到了 iOS 系统没有交换空间,于是引入了 **MemoryStatus 机制(也称为 Jetsam)**。也就是说在 iOS 系统上释放尽可能多的内存供当前 App 使用。这个机制表现在优先级上,就是先强杀后台应用;如果内存还是不够多,就强杀掉当前应用。在 MacOS 中,MemoryStatus 只会强杀掉标记为空闲退出的进程。 @@ -1053,8 +981,6 @@ MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 的线程,它负 当监控线程发现某 App 有内存压力时,就发出通知,此时有内存的 App 就去执行 `didReceiveMemoryWarning` 代理方法。在这个时机,我们还有机会做一些内存资源释放的逻辑,也许会避免 App 被系统杀死。 - - **源码角度查看问题** iOS 系统内核有一个数组,专门维护线程的优先级。数组的每一项是一个包含进程链表的结构体。结构体如下: @@ -1097,9 +1023,9 @@ memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT]; #define JETSAM_PRIORITY_MAX 21 ``` -可以明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为10。 +可以明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为 3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为 10。 -优先级规则是:内核线程优先级 > 操作系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被降低。 +优先级规则是:内核线程优先级 > 操作系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被降低。 在 kern_memorystatus.c 中可以看到 OOM 可能的原因: @@ -1131,7 +1057,7 @@ memorystatus_init(void) // ... /* Initialize the jetsam_threads state array */ jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads); - + /* Initialize all the jetsam threads */ for (i = 0; i < max_jetsam_threads; i++) { @@ -1208,13 +1134,7 @@ memorystatus_init(void) 可以看出:用户态的应用程序的线程不可能高于操作系统和内核。而且,用户态的应用程序间的线程优先级分配也有区别,比如处于前台的应用程序优先级高于处于后台的应用程序优先级。iOS 上应用程序优先级最高的是 SpringBoard;此外线程的优先级不是一成不变的。Mach 会根据线程的利用率和系统整体负载动态调整线程优先级。如果耗费 CPU 太多就降低线程优先级,如果线程过度挨饿,则会提升线程优先级。但是无论怎么变,程序都不能超过其所在线程的优先级区间范围。 - - - - -可以看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(一般情况为1,特殊情况下可能为3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0~127。上文的宏定义是进程优先级,区间为:-2~19)。 - - +可以看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(一般情况为 1,特殊情况下可能为 3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0 ~ 127。上文的宏定义是进程优先级,区间为:-2~19)。 紧接着,分析下 memorystatus_thread 函数,主要负责线程启动的初始化 @@ -1313,7 +1233,7 @@ memorystatus_thread(void *param __unused, wait_result_t wr __unused) } goto done; } - + if (memorystatus_avail_pages_below_critical()) { /* * Still under pressure and unable to kill a process - purge corpse memory @@ -1330,8 +1250,8 @@ memorystatus_thread(void *param __unused, wait_result_t wr __unused) panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages); } } - -done: + +done: } ``` @@ -1355,7 +1275,7 @@ memorystatus_action_needed(void) 它通过 vm_pagepout 发送的内存压力来判断当前内存资源是否紧张。几种情况:频繁的页面换出换进 is_reason_thrashing, Mach Zone 耗尽了 is_reason_zone_map_exhaustion、以及可用的页低于了 memory status_available_pages 这个门槛。 -继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,如果进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。 +继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,如果进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。 通常来说单个 App 很难触碰到 high water mark,如果不能结束任何进程,最终走到 memorystatus_act_aggressive,也就是大多数 OOM 发生的地方。 @@ -1364,11 +1284,11 @@ static boolean_t memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) { // ... - if ( (jld_bucket_count == 0) || + if ( (jld_bucket_count == 0) || (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) { - /* - * Refresh evaluation parameters + /* + * Refresh evaluation parameters */ jld_timestamp_msecs = jld_now_msecs; jld_idle_kill_candidates = jld_bucket_count; @@ -1393,9 +1313,7 @@ memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ } ``` -其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我们可以在6秒内做些处理。 - - +其中 memorystatus_jld_eval_period_msecs 取值最小 6 秒。所以我们可以在 6 秒内做些处理。 #### 3.2 开发者们整理所得 @@ -1436,8 +1354,6 @@ memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ | iPhone11(iOS 13.1.3) | 2068 | 3844 | 54% | | iPhone11 Pro Max(iOS 13.2.3) | 2067 | 3740 | 55% | - - #### 3.3 触发当前 App 的 high water mark 我们可以写定时器,不断的申请内存,之后再通过 `phys_footprint` 打印当前占用内存,按道理来说不断申请内存即可触发 Jetsam 机制,强杀 App,那么**最后一次打印的内存占用也就是当前设备的内存上限值**。 @@ -1450,7 +1366,7 @@ timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selec UIImage *image = [UIImage imageNamed:@"AppIcon"]; imageView.image = image; [array addObject:imageView]; - + memoryLimitSizeMB = [self usedSizeOfMemory]; if (memoryWarningSizeMB && memoryLimitSizeMB) { NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB); @@ -1469,21 +1385,19 @@ timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selec } ``` - - #### 3.4 适用于 iOS13 系统的获取方式 iOS13 开始 中 `size_t os_proc_available_memory(void); ` 可以查看当前可用内存。 -> Return Value +> Return Value > -> The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns `0`. +> The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns `0`. > -> Discussion +> Discussion > -> Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device. +> Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device. > -> Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently. +> Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently. > > Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs. > @@ -1495,8 +1409,6 @@ if (@available(iOS 13.0, *)) { } ``` - - App 内存信息的 API 可以在 Mach 层找到,`mach_task_basic_info` 结构体存储了 Mach task 的内存使用信息,其中 phys_footprint 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。 ```Objective-c @@ -1529,7 +1441,7 @@ CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0); 可能有人好奇不应该是 `resident_size` 这个字段获取内存的使用情况吗?一开始测试后发现 resident_size 和 Xcode 测量结果差距较大。而使用 phys_footprint 则接近于 Xcode 给出的结果。且可以从 [WebKit 源码](https://github.com/WebKit/webkit/blob/52bc6f0a96a062cb0eb76e9a81497183dc87c268/Source/WTF/wtf/cocoa/MemoryFootprintCocoa.cpp)中得到印证。 -所以在 iOS13 上,我们可以通过 `os_proc_available_memory` 获取到当前可以用内存,通过 `phys_footprint` 获取到当前 App 占用内存,2者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。 +所以在 iOS13 上,我们可以通过 `os_proc_available_memory` 获取到当前可以用内存,通过 `phys_footprint` 获取到当前 App 占用内存,2 者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。 ```objective-c - (CGFloat)limitSizeOfMemory { @@ -1547,9 +1459,7 @@ CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0); } ``` -当前可以使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值一样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450.09375M」。 - - +当前可以使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值一样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024\*1024)=1450.09375M」。 #### 3.5 通过 XNU 获取内存限制值 @@ -1591,8 +1501,6 @@ typedef struct memorystatus_priority_entry { #define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., ``` - - 伪代码 ```objective-c @@ -1600,7 +1508,7 @@ struct memorystatus_priority_entry memStatus[NUM_ENTRIES]; size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES; int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count); if (rc < 0) { - NSLog(@"memorystatus_control"); + NSLog(@"memorystatus_control"); return ; } @@ -1616,15 +1524,13 @@ 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 的内存上限值。 ### 4. 如何判定发生了 OOM OOM 导致 crash 前,app 一定会收到低内存警告吗? -做2组对比实验: +做 2 组对比实验: ```objective-c // 实验1 @@ -1665,28 +1571,24 @@ for (NSInteger index = 0; index < 10000000; index++) { 现象: 1. 在 viewDidLoad 也就是主线程中内存消耗过大,系统并不会发出低内存警告,直接 Crash。因为内存增长过快,主线程很忙。 -2. 多线程的情况下,App 因内存增长过快,会收到低内存警告,AppDelegate 中的`applicationDidReceiveMemoryWarning` 先执行,随后是当前 VC 的 `didReceiveMemoryWarning`。 +2. 多线程的情况下,App 因内存增长过快,会收到低内存警告,AppDelegate 中的`applicationDidReceiveMemoryWarning` 先执行,随后是当前 VC 的 `didReceiveMemoryWarning`。 结论: -**收到低内存警告不一定会 Crash,因为有6秒钟的系统判断时间,6秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** +**收到低内存警告不一定会 Crash,因为有 6 秒钟的系统判断时间,6 秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** - - -### 5. 内存信息收集 +### 5. 内存信息收集 要想精确的定位问题,就需要 dump 所有对象及其内存信息。当内存接近系统内存上限的时候,收集并记录所需信息,结合一定的数据上报机制,上传到服务器,分析并修复。 还需要知道每个对象具体是在哪个函数里创建出来的,以便还原“案发现场”。 -源代码(libmalloc/malloc),内存分配函数 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) @@ -1748,7 +1650,7 @@ static void * default_zone_malloc(malloc_zone_t *zone, size_t size) { zone = runtime_default_zone(); - + return zone->malloc(zone, size); } @@ -1803,9 +1705,9 @@ void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) } ``` -其分配实现是 `zone->malloc` 根据之前的分析,就是szone_t结构体对象中对应的malloc实现。 +其分配实现是 `zone->malloc` 根据之前的分析,就是 szone_t 结构体对象中对应的 malloc 实现。 -在创建szone之后,做了一系列如下的初始化操作。 +在创建 szone 之后,做了一系列如下的初始化操作。 ```c++ // Initialize the security token. @@ -1830,8 +1732,6 @@ szone->basic_zone.claimed_address = (void *)szone_claimed_address; 其他使用 scalable_zone 分配内存的函数的方法也类似,所以大内存的分配,不管外部函数如何封装,最终都会调用到 malloc_logger 函数。所以我们可以用 fishhook 去 hook 这个函数,然后记录内存分配情况,结合一定的数据上报机制,上传到服务器,分析并修复。 - - ``` // For logging VM allocation and deallocation, arg1 here // is the mach_port_name_t of the target task in which the @@ -1844,43 +1744,39 @@ typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, ui extern malloc_logger_t *__syscall_logger; ``` -当 malloc_logger 和 __syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放通过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,我们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 DSYM 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样 **符号表地址 = 堆栈地址 - slide。** +当 malloc_logger 和 \_\_syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放通过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,我们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 DSYM 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样 **符号表地址 = 堆栈地址 - slide。** 小 tips: ASLR(Address space layout randomization):常见称呼为位址空间随机载入、位址空间配置随机化、位址空间布局随机化,是一种防止内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的定址空间来放置攻击者能可靠地跳转到内存的特定位置来操作函数。现代作业系统一般都具备该机制。 -函数地址 add: 函数真实的实现地址; +函数地址 add: 函数真实的实现地址; 函数虚拟地址:`vm_add`; -ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add`。也就是:`*(base +offset)= imp`。 - - +ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add`。也就是:`*(base +offset)= imp`。 由于腾讯也开源了自己的 OOM 定位方案- [OOMDetector](https://github.com/Tencent/OOMDetector) ,有了现成的轮子,那么用好就可以了,所以对于内存的监控思路就是找到系统给 App 的内存上限,然后当接近内存上限值的时候,dump 内存情况,组装基础数据信息成一个合格的上报数据,经过一定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不同工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(情况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 问题分析处理后要么发布新版本,要么 hot fix。 - - ### 6. 开发阶段针对内存我们能做些什么 1. 图片缩放 - WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部分内存,还会生成中间位图 bitmap 消耗大量内存。而 **ImageIO** 不存在上述2种弊端,只会占用最终图片大小的内存 + WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部分内存,还会生成中间位图 bitmap 消耗大量内存。而 **ImageIO** 不存在上述 2 种弊端,只会占用最终图片大小的内存 - 做了2组对比实验:给 App 显示一张图片 + 做了 2 组对比实验:给 App 显示一张图片 ```objective-c // 方法1: 19.6M UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"] newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)]; self.imageView.image = imageResult; - + // 方法2: 14M NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]); UIImage *imageResult = [self scaledImageWithData:data withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp]; self.imageView.image = imageResult; - + - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize { UIGraphicsBeginImageContextWithOptions(newSize, NO, 0); @@ -1889,7 +1785,7 @@ ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每 UIGraphicsEndImageContext(); return newImage; } - + - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation { CGFloat maxPixelSize = MAX(size.width, size.height); @@ -1908,31 +1804,31 @@ ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每 2. 合理使用 autoreleasepool - 我们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,我们如果在不断申请内存,比如各种循环,那么我们就需要手动添加 autoreleasepool,避免短时间内内存猛涨发生 OOM。 +我们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,我们如果在不断申请内存,比如各种循环,那么我们就需要手动添加 autoreleasepool,避免短时间内内存猛涨发生 OOM。 - 对比实验 +对比实验 - ```objective-c - // 实验1 - NSMutableArray *array = [NSMutableArray array]; - for (NSInteger index = 0; index < 10000000; index++) { +```objective-c +// 实验1 +NSMutableArray *array = [NSMutableArray array]; +for (NSInteger index = 0; index < 10000000; index++) { + NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; + NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; + [array addObject:resultString]; +} + +// 实验2 +NSMutableArray *array = [NSMutableArray array]; +for (NSInteger index = 0; index < 10000000; index++) { + @autoreleasepool { NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; [array addObject:resultString]; } - - // 实验2 - NSMutableArray *array = [NSMutableArray array]; - for (NSInteger index = 0; index < 10000000; index++) { - @autoreleasepool { - NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; - NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; - [array addObject:resultString]; - } - } - ``` +} +``` - 实验1消耗内存 739.6M,实验2消耗内存 587M。 +实验 1 消耗内存 739.6M,实验 2 消耗内存 587M。 3. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。 @@ -1942,15 +1838,13 @@ ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每 其他的开发习惯就不一一描述了,良好的开发习惯和代码意识是需要平时注意修炼的。 - - ### 7. 现状及其改进 在使用了一波业界优秀的的内存监控工具后发现了一些问题,比如 `MLeaksFinder`、`OOMDetector`、`FBRetainCycleDetector`等都有一些问题。比如 `MLeaksFinder` 因为单纯通过 VC 的 push、pop 等检测内存泄露的情况,会存在误报的情况。`FBRetainCycleDetector` 则因为对象深度优先遍历,会有一些性能问题,影响 App 性能。`OOMDetector` 因为没有合适的触发时机。 -思路有2种: +思路有 2 种: -- `MLeaksFinder` + `FBRetainCycleDetector` 结合提高准确性 +- `MLeaksFinder` + `FBRetainCycleDetector` 结合提高准确性 - 借鉴头条的实现方案:基于内存快照技术的线上方案,我们称之为——线上 Memory Graph。(引用如下) > - 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。 @@ -1958,16 +1852,12 @@ ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每 核心原理是: 扫描进程中所有的 Dirty 内存,通过内存节点中保存的其他内存节点的地址值,建立起内存节点之间的引用关系的有向图。 -全部的讲解可以看[这里]((https://mp.weixin.qq.com/s/4-4M9E8NziAgshlwB7Sc6g))。对于 Memory Graph 的实现细节感兴趣的可以看这篇[文章](https://juejin.cn/post/6895583288451465230) - - +全部的讲解可以看[这里](<(https://mp.weixin.qq.com/s/4-4M9E8NziAgshlwB7Sc6g)>)。对于 Memory Graph 的实现细节感兴趣的可以看这篇[文章](https://juejin.cn/post/6895583288451465230) ## 五、 App 网络监控 移动网络环境一直很复杂,WIFI、2G、3G、4G、5G 等,用户使用 App 的过程中可能在这几种类型之间切换,这也是移动网络和传统网络间的一个区别,被称为「Connection Migration」。此外还存在 DNS 解析缓慢、失败率高、运营商劫持等问题。用户在使用 App 时因为某些原因导致体验很差,要想针对网络情况进行改善,必须有清晰的监控手段。 - - ### 1. App 网络请求过程 ![网络请求各阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-03-NetworkTime.png) @@ -1978,9 +1868,9 @@ App 发送一次网络请求一般会经历下面几个关键步骤: Domain Name system,网络域名名称系统,本质上就是将`域名`和`IP 地址` 相互映射的一个分布式数据库,使人们更方便的访问互联网。首先会查询本地的 DNS 缓存,查找失败就去 DNS 服务器查询,这其中可能会经过非常多的节点,涉及到**递归查询和迭代查询**的过程。运营商可能不干人事:一种情况就是出现运营商劫持的现象,表现为你在 App 内访问某个网页的时候会看到和内容不相关的广告;另一种可能的情况就是把你的请求丢给非常远的基站去做 DNS 解析,导致我们 App 的 DNS 解析时间较长,App 网络效率低。一般做 HTTPDNS 方案去自行解决 DNS 的问题。 -- TCP 3次握手 +- TCP 3 次握手 - 关于 TCP 握手过程中为什么是3次握手而不是2次、4次,可以查看这篇[文章](https://draveness.me/whys-the-design-tcp-three-way-handshake/)。 + 关于 TCP 握手过程中为什么是 3 次握手而不是 2 次、4 次,可以查看这篇[文章](https://draveness.me/whys-the-design-tcp-three-way-handshake/)。 - TLS 握手 @@ -1998,8 +1888,6 @@ App 发送一次网络请求一般会经历下面几个关键步骤: 服务端返回响应给客户端,根据 HTTP header 信息中的状态码判断本次请求是否成功、是否走缓存、是否需要重定向。 - - ### 2. 监控原理 | 名称 | 说明 | @@ -2012,11 +1900,9 @@ iOS 网络框架层级关系如下: ![Network Level](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-05-NetworkLevel.png) -iOS 网络现状是由4层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。 - -目前业界对于网络监控主要有2种:一种是通过 NSURLProtocol 监控、一种是通过 Hook 来监控。下面介绍几种办法来监控网络请求,各有优缺点。 - +iOS 网络现状是由 4 层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。 +目前业界对于网络监控主要有 2 种:一种是通过 NSURLProtocol 监控、一种是通过 Hook 来监控。下面介绍几种办法来监控网络请求,各有优缺点。 #### 2.1 方案一:NSURLProtocol 监控 App 网络请求 @@ -2061,7 +1947,7 @@ iOS 10 之后,NSURLSessionTaskDelegate 中增加了一个新的代理方法: @end ``` -其中:`taskInterval` 表示任务从创建到完成话费的总时间,任务的创建时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;`redirectCount` 表示被重定向的次数;`transactionMetrics` 数组包含了任务执行过程中每个请求/响应事务中收集的指标,各项参数如下: +其中:`taskInterval` 表示任务从创建到完成话费的总时间,任务的创建时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;`redirectCount` 表示被重定向的次数;`transactionMetrics` 数组包含了任务执行过程中每个请求/响应事务中收集的指标,各项参数如下: ```objective-c /* @@ -2120,7 +2006,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) /* * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手开始的时间 * - * For example, this would correspond to the time immediately before the user agent started the TLS handshake. + * For example, this would correspond to the time immediately before the user agent started the TLS handshake. * * If an encrypted connection was not used, this attribute is set to nil. */ @@ -2235,7 +2121,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) /* * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler. 给代理方法或者完成后处理的回调的数据大小 - + */ @property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); @@ -2252,7 +2138,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) /* * localPort is the port number of the local interface for the connection. 当前连接下的本地端口号 - + * * For multipath protocols, this is the local port of the initial flow. * @@ -2333,8 +2219,6 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) @end ``` - - 网络监控简单代码 ```objective-c @@ -2362,7 +2246,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) @interface NetworkMonitorDataModel : NetworkMonitorBaseDataModel //客户端发起请求的时间 @property (nonatomic, assign) UInt64 requestDate; -//客户端开始请求到开始dns解析的等待时间,单位ms +//客户端开始请求到开始dns解析的等待时间,单位ms @property (nonatomic, assign) int waitDNSTime; //DNS 解析耗时 @property (nonatomic, assign) int dnsLookupTime; @@ -2394,7 +2278,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) @property (nonatomic, strong) NSString *stackTrace; @end - + // 继承自 NSURLProtocol 抽象类,实现响应方法,代理网络请求 @interface CustomURLProtocol () @@ -2429,7 +2313,7 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) if (error) { NSURLRequest *request = task.currentRequest; if (request) { - self.errModel.requestUrl = request.URL.absoluteString; + self.errModel.requestUrl = request.URL.absoluteString; self.errModel.httpMethod = request.HTTPMethod; self.errModel.requestParams = request.URL.query; } @@ -2496,8 +2380,6 @@ API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) } ``` - - #### 2.2 方案二:NSURLProtocol 监控 App 网络请求之黑魔法篇 文章上面 [2.1 ](#network-2.1)分析到了 NSURLSessionTaskMetrics 由于兼容性问题,对于网络监控来说似乎不太完美,但是自后在搜资料的时候看到了一篇[文章](https://www.jianshu.com/p/1c34147030d1)。文章在分析 WebView 的网络监控的时候分析 Webkit 源码的时候发现了下面代码 @@ -2515,7 +2397,7 @@ void setCollectsTimingData() #endif ``` -也就是说明 NSURLConnection 本身有一套 `TimingData` 的收集 API,只是没有暴露给开发者,苹果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 `_setCollectsTimingData:` 、`_timingData` 2个 api(iOS8 以后可以使用)。 +也就是说明 NSURLConnection 本身有一套 `TimingData` 的收集 API,只是没有暴露给开发者,苹果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 `_setCollectsTimingData:` 、`_timingData` 2 个 api(iOS8 以后可以使用)。 NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 TimingData 了。 @@ -2560,14 +2442,14 @@ NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 Tim static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Class class = [self class]; - + SEL originalSelector = @selector(initWithRequest:delegate:); SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:); - + Method originalMethod = class_getInstanceMethod(class, originalSelector); Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); method_exchangeImplementations(originalMethod, swizzledMethod); - + NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]; SEL selector = NSSelectorFromString(selectorName); [NSURLConnection performSelector:selector withObject:@(YES)]; @@ -2588,11 +2470,9 @@ NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 Tim @end ``` - - #### 2.3 方案三:Hook -iOS 中 hook 技术有2类,一种是 NSProxy,一种是 method swizzling(isa swizzling) +iOS 中 hook 技术有 2 类,一种是 NSProxy,一种是 method swizzling(isa swizzling) ##### 2.3.1 方法一 @@ -2600,27 +2480,23 @@ iOS 中 hook 技术有2类,一种是 NSProxy,一种是 method swizzling(is 面向切面程序设计(Aspect-oriented Programming,AOP)是计算机科学中的一种程序设计范型,将**横切关注点**与业务主体进一步分离,以提高程序代码的模块化程度。在不修改源代码的情况下给程序动态增加功能。其核心思想是将业务逻辑(核心关注点,系统主要功能)与公共功能(横切关注点,比如日志系统)进行分离,降低复杂性,保持系统模块化程度、可维护性、可重用性。常被用在日志系统、性能统计、安全控制、事务处理、异常处理等场景下。 -在 iOS 中 AOP 的实现是基于 Runtime 机制,目前由3种方式:Method Swizzling、NSProxy、FishHook(主要用用于 hook c 代码)。 +在 iOS 中 AOP 的实现是基于 Runtime 机制,目前由 3 种方式:Method Swizzling、NSProxy、FishHook(主要用用于 hook c 代码)。 文章上面 [2.1 ](#network-2.1)讨论了满足大多数的需求的场景,NSURLProtocol 监控了 NSURLConnection、NSURLSession 的网络请求,自身代理后可以发起网络请求并得到诸如请求开始时间、请求结束时间、header 信息等,但是无法得到非常详细的网络性能数据,比如 DNS 开始解析时间、DNS 解析用了多久、reponse 开始返回的时间、返回了多久等。 iOS10 之后 NSURLSessionTaskDelegate 增加了一个代理方法 `- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));`,可以获取到精确的各项网络数据。但是具有兼容性。文章上面 [2.2 ](#network-2.2)讨论了从 Webkit 源码中得到的信息,通过私有方法 `_setCollectsTimingData:` 、`_timingData` 可以获取到 TimingData。 -但是如果需要监全部的网络请求就不能满足需求了,查阅资料后发现了阿里百川有 APM 的解决方案,于是有了方案3,对于网络监控需要做如下的处理 +但是如果需要监全部的网络请求就不能满足需求了,查阅资料后发现了阿里百川有 APM 的解决方案,于是有了方案 3,对于网络监控需要做如下的处理 ![network hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-network_monitor.png) - - 可能对于 CFNetwork 比较陌生,可以看一下 CFNetwork 的层级和简单用法 ![CFNetwork Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-CFNetworkStructure.png) - - CFNetwork 的基础是 CFSocket 和 CFStream。 -CFSocket:Socket 是网络通信的底层基础,可以让2个 socket 端口互发数据,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了所有的 BSD 功能,此外加入了 RunLoop。 +CFSocket:Socket 是网络通信的底层基础,可以让 2 个 socket 端口互发数据,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了所有的 BSD 功能,此外加入了 RunLoop。 -CFStream:提供了与设备无关的读写数据方法,使用它可以为内存、文件、网络(使用 socket)的数据建立流,使用 stream 可以不必将所有数据写入到内存中。CFStream 提供 API 对2种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。 +CFStream:提供了与设备无关的读写数据方法,使用它可以为内存、文件、网络(使用 socket)的数据建立流,使用 stream 可以不必将所有数据写入到内存中。CFStream 提供 API 对 2 种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。 简单 Demo @@ -2630,12 +2506,12 @@ CFStream:提供了与设备无关的读写数据方法,使用它可以为内 CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL); CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1); CFRelease(urlRef); - + CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef); CFRelease(httpMessageRef); - + CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); - + CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered); CFStreamClientContext context = { 0, @@ -2660,8 +2536,8 @@ void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStrea CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead); } } while (numberOfBytesRead > 0); - - + + CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader); if (responseBytes) { if (response) { @@ -2669,17 +2545,17 @@ void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStrea } CFRelease(responseBytes); } - + // close and cleanup CFReadStreamClose(stream); CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); CFRelease(stream); - + // print response if (response) { CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response); CFRelease(response); - + printResponseData(reponseBodyData); CFRelease(reponseBodyData); } @@ -2696,13 +2572,13 @@ void printResponseData (CFDataRef responseData) { } // console { - "args": {}, + "args": {}, "headers": { - "Host": "httpbin.org", - "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", + "Host": "httpbin.org", + "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564" - }, - "origin": "183.159.122.102", + }, + "origin": "183.159.122.102", "url": "https://httpbin.org/get" } ``` @@ -2715,84 +2591,80 @@ NSURLSession、NSURLConnection hook 如下。 ![NSURLConnection Hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-NSURLConnectionHook.jpeg) - - 业界有 APM 针对 CFNetwork 的方案,整理描述下: CFNetwork 是 c 语言实现的,要对 c 代码进行 hook 需要使用 Dynamic Loader Hook 库 - [fishhook](https://github.com/facebook/fishhook)。 > **Dynamic Loader**(dyld)通过更新 **Mach-O** 文件中保存的指针的方法来绑定符号。借用它可以在 **Runtime** 修改 **C** 函数调用的函数指针。**fishhook** 的实现原理:遍历 `__DATA segment` 里面 `__nl_symbol_ptr` 、`__la_symbol_ptr` 两个 section 里面的符号,通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到自己要替换的函数,达到 hook 的目的。 -> /* Returns the number of bytes read, or -1 if an error occurs preventing any +> /\* Returns the number of bytes read, or -1 if an error occurs preventing any > -> bytes from being read, or 0 if the stream's end was encountered. +> bytes from being read, or 0 if the stream's end was encountered. > -> It is an error to try and read from a stream that hasn't been opened first. +> It is an error to try and read from a stream that hasn't been opened first. > -> This call will block until at least one byte is available; it will NOT block +> This call will block until at least one byte is available; it will NOT block > -> until the entire buffer can be filled. To avoid blocking, either poll using +> until the entire buffer can be filled. To avoid blocking, either poll using > -> CFReadStreamHasBytesAvailable() or use the run loop and listen for the +> CFReadStreamHasBytesAvailable() or use the run loop and listen for the > -> kCFStreamEventHasBytesAvailable event for notification of data available. */ +> kCFStreamEventHasBytesAvailable event for notification of data available. \*/ > > CF_EXPORT > -> CFIndex CFReadStreamRead(CFReadStreamRef **_Null_unspecified** stream, UInt8 * **_Null_unspecified** buffer, CFIndex bufferLength); +> CFIndex CFReadStreamRead(CFReadStreamRef **\_Null_unspecified** stream, UInt8 \* **\_Null_unspecified** buffer, CFIndex bufferLength); CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式来接受服务器的响应。当回调函数受到 - - 具体步骤及其关键代码如下,以 NSURLConnection 举例 - 因为要 Hook 挺多地方,所以写一个 method swizzling 的工具类 ```objective-c #import - + NS_ASSUME_NONNULL_BEGIN - + @interface NSObject (hook) - + /** hook对象方法 - + @param originalSelector 需要hook的原始对象方法 @param swizzledSelector 需要替换的对象方法 */ + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; - + /** hook类方法 - + @param originalSelector 需要hook的原始类方法 @param swizzledSelector 需要替换的类方法 */ + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; - + @end - + NS_ASSUME_NONNULL_END - + + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { class_swizzleInstanceMethod(self, originalSelector, swizzledSelector); } - + + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 Class class2 = object_getClass(self); class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); } - + void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) { Method originMethod = class_getInstanceMethod(class, originalSEL); Method replaceMethod = class_getInstanceMethod(class, replacementSEL); - + if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod))) { class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod)); @@ -2806,46 +2678,46 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 ```objective-c #import - + NS_ASSUME_NONNULL_BEGIN - + // 为 NSURLConnection、NSURLSession、CFNetwork 代理设置代理转发 @interface NetworkDelegateProxy : NSProxy - + + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate; - + @end - + NS_ASSUME_NONNULL_END - + // .m @interface NetworkDelegateProxy () { id _originalTarget; id _NewDelegate; } - + @end - - + + @implementation NetworkDelegateProxy - + #pragma mark - life cycle - + + (instancetype)sharedInstance { static NetworkDelegateProxy *_sharedInstance = nil; - + static dispatch_once_t onceToken; - + dispatch_once(&onceToken, ^{ _sharedInstance = [NetworkDelegateProxy alloc]; }); - + return _sharedInstance; } - - + + #pragma mark - public Method - + + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate { NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance]; @@ -2853,7 +2725,7 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 instance->_NewDelegate = newDelegate; return instance; } - + - (void)forwardInvocation:(NSInvocation *)invocation { if ([_originalTarget respondsToSelector:invocation.selector]) { @@ -2861,12 +2733,12 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 [((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation]; } } - + - (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel { return [_originalTarget methodSignatureForSelector:sel]; } - + @end ``` @@ -2874,45 +2746,45 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 ```objective-c // NetworkImplementor.m - + #pragma mark-NSURLConnectionDelegate - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { NSLog(@"%s", __func__); } - + - (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response { NSLog(@"%s", __func__); return request; } - + #pragma mark-NSURLConnectionDataDelegate - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { NSLog(@"%s", __func__); } - + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { NSLog(@"%s", __func__); } - + - (void)connection:(NSURLConnection *)connection didSendBodyData:(NSInteger)bytesWritten totalBytesWritten:(NSInteger)totalBytesWritten totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite { NSLog(@"%s", __func__); } - + - (void)connectionDidFinishLoading:(NSURLConnection *)connection { NSLog(@"%s", __func__); } - + #pragma mark-NSURLConnectionDownloadDelegate - (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { NSLog(@"%s", __func__); } - + - (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { NSLog(@"%s", __func__); } - + - (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL { NSLog(@"%s", __func__); } @@ -2924,7 +2796,7 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 ```objective-c // NSURLConnection+Monitor.m @implementation NSURLConnection (Monitor) - + + (void)load { static dispatch_once_t onceToken; @@ -2934,7 +2806,7 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 } }); } - + - (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate { /* @@ -2950,23 +2822,23 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 return [self apm_initWithRequest:rq delegate:delegate]; } else { [rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"]; - + NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new]; [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - + [self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; [self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - + [self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"]; [self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"]; delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate]; - + // 调用 hook 之前的初始化方法,返回 NSURLConnection return [self apm_initWithRequest:rq delegate:delegate]; } } - + - (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag { if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) { @@ -2980,19 +2852,17 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag); } } - + @end ``` - - 这样下来就是可以监控到网络信息了,然后将数据交给数据上报 SDK,按照下发的数据上报策略去上报数据。 ##### 2.3.2 方法二 其实,针对上述的需求还有另一种方法一样可以达到目的,那就是 **isa swizzling**。 -顺道说一句,上面针对 NSURLConnection、NSURLSession、NSInputStream 代理对象的 hook 之后,利用 NSProxy 实现代理对象方法的转发,有另一种方法可以实现,那就是 **isa swizzling**。 +顺道说一句,上面针对 NSURLConnection、NSURLSession、NSInputStream 代理对象的 hook 之后,利用 NSProxy 实现代理对象方法的转发,有另一种方法可以实现,那就是 **isa swizzling**。 - Method swizzling 原理 @@ -3026,16 +2896,14 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 struct objc_object { Class _Nonnull isa OBJC_ISA_AVAILABILITY; }; - + /// A pointer to an instance of a class. typedef struct objc_object *id; - + ``` ![isa swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-13-isaSwizzling.png) - - 我们来分析一下为什么修改 `isa` 可以实现目的呢? 1. 写 APM 监控的人没办法确定业务代码 @@ -3051,9 +2919,7 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 按照这个思路,我们也可以对 NSURLConnection、NSURLSession 的 load 方法中动态创建子类,在子类中重写方法,比如 `- (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately;` ,然后将 NSURLSession、NSURLConnection 的 isa 指向动态创建的子类。在这些方法处理完之后还原本身的 isa 指针。 -不过 isa swizzling 针对的还是 method swizzling,代理对象不确定,还是需要 NSProxy 进行动态处理。 - - +不过 isa swizzling 针对的还是 method swizzling,代理对象不确定,还是需要 NSProxy 进行动态处理。 至于如何修改 isa,我写一个简单的 Demo 来模拟 KVO @@ -3066,17 +2932,17 @@ CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式 Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0); // 生成后不能马上使用,必须先注册 objc_registerClassPair(myclass); - + //2. 重写 setter 方法 class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@"); - + // class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@"); //3. 修改 isa object_setClass(self, myclass); - + //4. 将观察者保存到当前对象里面 objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN); - + //5. 将传递的上下文绑定到当前对象里面 objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN); } @@ -3102,7 +2968,7 @@ void setName (id self, SEL _cmd, NSString *name) { object_setClass(self, class_getSuperclass(class)); //2. 调用父类的 setName 方法 objc_msgSend(self, @selector(setName:), name); - + //3. 调用观察 id observer = objc_getAssociatedObject(self, "observer"); id context = objc_getAssociatedObject(self, "context"); @@ -3116,8 +2982,6 @@ void setName (id self, SEL _cmd, NSString *name) { @end ``` - - #### 2.4 方案四:监控 App 常见网络请求 本着成本的原因,由于现在大多数的项目的网络能力都是通过 [AFNetworking](https://github.com/AFNetworking/AFNetworking) 完成的,所以本文的网络监控可以快速完成。 @@ -3135,9 +2999,9 @@ AFNetworking 在发起网络的时候会有相应的通知。`AFNetworkingTaskDi }]; self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { - + __strong __typeof(weakSelf)strongSelf = weakSelf; - + NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey]; NSURLSessionTask *task = note.object; if (!error) { @@ -3149,11 +3013,12 @@ self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForN } }]; ``` + 在 networkRecoder 的方法里面去组装数据,交给数据上报组件,等到合适的时机策略去上报。 因为网络是一个异步的过程,所以当网络请求开始的时候需要为每个网络设置唯一标识,等到网络请求完成后再根据每个请求的标识,判断该网络耗时多久、是否成功等。所以措施是为 **NSURLSessionTask** 添加分类,通过 runtime 增加一个属性,也就是唯一标识。 -这里插一嘴,为 Category 命名、以及内部的属性和方法命名的时候需要注意下。假如不注意会怎么样呢?假如你要为 NSString 类增加身份证号码中间位数隐藏的功能,那么写代码久了的老司机 A,为 NSString 增加了一个方法名,叫做 getMaskedIdCardNumber,但是他的需求是从 [9, 12] 这4位字符串隐藏掉。过了几天同事 B 也遇到了类似的需求,他也是一位老司机,为 NSString 增加了一个也叫 getMaskedIdCardNumber 的方法,但是他的需求是从 [8, 11] 这4位字符串隐藏,但是他引入工程后发现输出并不符合预期,为该方法写的单测没通过,他以为自己写错了截取方法,检查了几遍才发现工程引入了另一个 NSString 分类,里面的方法同名 😂 真坑。 +这里插一嘴,为 Category 命名、以及内部的属性和方法命名的时候需要注意下。假如不注意会怎么样呢?假如你要为 NSString 类增加身份证号码中间位数隐藏的功能,那么写代码久了的老司机 A,为 NSString 增加了一个方法名,叫做 getMaskedIdCardNumber,但是他的需求是从 [9, 12] 这 4 位字符串隐藏掉。过了几天同事 B 也遇到了类似的需求,他也是一位老司机,为 NSString 增加了一个也叫 getMaskedIdCardNumber 的方法,但是他的需求是从 [8, 11] 这 4 位字符串隐藏,但是他引入工程后发现输出并不符合预期,为该方法写的单测没通过,他以为自己写错了截取方法,检查了几遍才发现工程引入了另一个 NSString 分类,里面的方法同名 😂 真坑。 下面的例子是 SDK,但是日常开发也是一样。 @@ -3162,6 +3027,7 @@ self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForN - Category 方法名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加方法名,也就是`SDK名称简写_方法名称`。比如 `-(BOOL)JuhuaSuanAPM__isGzippedData` 例子如下: + ```Objective-c #import @@ -3188,12 +3054,8 @@ self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForN @end ``` - - #### 2.5 iOS 流量监控 - - ##### 2.5.1 HTTP 请求、响应数据结构 HTTP 请求报文结构 @@ -3205,12 +3067,10 @@ HTTP 请求报文结构 ![响应报文结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-16-HTTPResponseStructure.png) 1. HTTP 报文是格式化的数据块,每条报文由三部分组成:对报文进行描述的起始行、包含属性的首部块、以及可选的包含数据的主体部分。 -2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一个由2个字符组成的行终止序列作为结束(包括一个回车符、一个换行符) +2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一个由 2 个字符组成的行终止序列作为结束(包括一个回车符、一个换行符) 3. 实体的主体或者报文的主体是一个可选的数据块。与起始行和首部不同的是,主体中可以包含文本或者二进制数据,也可以为空。 4. HTTP 首部(也就是 Headers)总是应该以一个空行结束,即使没有实体部分。浏览器发送了一个空白行来通知服务器,它已经结束了该头信息的发送。 - - 请求报文的格式 ```powershell @@ -3229,8 +3089,6 @@ HTTP 请求报文结构 ``` - - 下图是打开 Chrome 查看极课时间网页的请求信息。包括响应行、响应头、响应体等信息。 ![请求数据结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-13-HTTPDataStructure.png) @@ -3241,9 +3099,7 @@ HTTP 请求报文结构 我们都知道在 HTTP 通信中,响应数据会使用 gzip 或其他压缩方式压缩,用 NSURLProtocol 等方案监听,用 NSData 类型去计算分析流量等会造成数据的不精确,因为正常一个 HTTP 响应体的内容是使用 gzip 或其他压缩方式压缩的,所以使用 NSData 会偏大。 - - -##### 2.5.2 问题 +##### 2.5.2 问题 1. Request 和 Response 不一定成对存在 @@ -3265,13 +3121,11 @@ HTTP 请求报文结构 - 监控技术方案在对 body 部分的字节大小计算,因采用 `exceptedContentLength` 导致不够准确 - 监控技术方案忽略了响应体使用 gzip 压缩。真正的网络通信过程中,客户端在发起请求的请求头中 `Accept-Encoding` 字段代表客户端支持的数据压缩方式(表明客户端可以正常使用数据时支持的压缩方法),同样服务端根据客户端想要的压缩方式、服务端当前支持的压缩方式,最后处理数据,在响应头中`Content-Encoding` 字段表示当前服务器采用了什么压缩方式。 - - ##### 2.5.3 技术实现 第五部分讲了网络拦截的各种原理和技术方案,这里拿 NSURLProtocol 来说实现流量监控(Hook 的方式)。从上述知道了我们需要什么样的,那么就逐步实现吧。 -###### 2.5.3.1 Request 部分 +###### 2.5.3.1 Request 部分 1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 @@ -3291,82 +3145,82 @@ HTTP 请求报文结构 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 + + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { [self.responseData appendData:data]; [self.client URLProtocol:self didLoadData:data]; } ``` -3. Status Line 部分 +3. Status Line 部分 - NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,所以要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 可以实现。 +NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,所以要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 可以实现。 - **思路:将 NSURLResponse 通过 `_CFURLResponse` 转换为 `CFTypeRef`,然后再将 `CFTypeRef` 转换为 `CFHTTPMessageRef`,再通过 `CFHTTPMessageCopyResponseStatusLine` 获取 `CFHTTPMessageRef` 的 Status Line 信息。** +**思路:将 NSURLResponse 通过 `_CFURLResponse` 转换为 `CFTypeRef`,然后再将 `CFTypeRef` 转换为 `CFHTTPMessageRef`,再通过 `CFHTTPMessageCopyResponseStatusLine` 获取 `CFHTTPMessageRef` 的 Status Line 信息。** - 将读取 Status Line 的功能添加一个 NSURLResponse 的分类。 +将读取 Status Line 的功能添加一个 NSURLResponse 的分类。 - ```objective-c - // NSURLResponse+apm_FetchStatusLineFromCFNetwork.h - #import - - NS_ASSUME_NONNULL_BEGIN - - @interface NSURLResponse (apm_FetchStatusLineFromCFNetwork) - - - (NSString *)apm_fetchStatusLineFromCFNetwork; - - @end - - NS_ASSUME_NONNULL_END - - // NSURLResponse+apm_FetchStatusLineFromCFNetwork.m - #import "NSURLResponse+apm_FetchStatusLineFromCFNetwork.h" - #import - - - #define SuppressPerformSelectorLeakWarning(Stuff) \ - do { \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \ - Stuff; \ - _Pragma("clang diagnostic pop") \ - } while (0) - - typedef CFHTTPMessageRef (*APMURLResponseFetchHTTPResponse)(CFURLRef response); - - @implementation NSURLResponse (apm_FetchStatusLineFromCFNetwork) - - - (NSString *)apm_fetchStatusLineFromCFNetwork - { - NSString *statusLine = @""; - NSString *funcName = @"CFURLResponseGetHTTPResponse"; - APMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]); - - SEL getSelector = NSSelectorFromString(@"_CFURLResponse"); - if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) { - CFTypeRef cfResponse; - SuppressPerformSelectorLeakWarning( - cfResponse = CFBridgingRetain([self performSelector:getSelector]); - ); - if (NULL != cfResponse) { - CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse); - statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef); - CFRelease(cfResponse); - } - } - return statusLine; - } - - @end - ``` +```objective-c +// NSURLResponse+apm_FetchStatusLineFromCFNetwork.h +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSURLResponse (apm_FetchStatusLineFromCFNetwork) + +- (NSString *)apm_fetchStatusLineFromCFNetwork; + +@end + +NS_ASSUME_NONNULL_END + +// NSURLResponse+apm_FetchStatusLineFromCFNetwork.m +#import "NSURLResponse+apm_FetchStatusLineFromCFNetwork.h" +#import + + +#define SuppressPerformSelectorLeakWarning(Stuff) \ +do { \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \ + Stuff; \ + _Pragma("clang diagnostic pop") \ +} while (0) + +typedef CFHTTPMessageRef (*APMURLResponseFetchHTTPResponse)(CFURLRef response); + +@implementation NSURLResponse (apm_FetchStatusLineFromCFNetwork) + +- (NSString *)apm_fetchStatusLineFromCFNetwork +{ + NSString *statusLine = @""; + NSString *funcName = @"CFURLResponseGetHTTPResponse"; + APMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]); + + SEL getSelector = NSSelectorFromString(@"_CFURLResponse"); + if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) { + CFTypeRef cfResponse; + SuppressPerformSelectorLeakWarning( + cfResponse = CFBridgingRetain([self performSelector:getSelector]); + ); + if (NULL != cfResponse) { + CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse); + statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef); + CFRelease(cfResponse); + } + } + return statusLine; +} + +@end +``` 4. 将获取到的 Status Line 转换为 NSData,再计算大小 @@ -3386,7 +3240,7 @@ HTTP 请求报文结构 `allHeaderFields` 获取到 NSDictionary,然后按照 `key: value` 拼接成字符串,然后转换成 NSData 计算大小 - 注意:`key: value` key 后是有空格的,curl 或者 chrome Network 面板可以查看印证下。 + 注意:`key: value` key 后是有空格的,curl 或者 chrome Network 面板可以查看印证下。 ```objective-c - (NSUInteger)apm_getHeadersLength @@ -3415,39 +3269,39 @@ HTTP 请求报文结构 Body 大小的计算不能直接使用 excepectedContentLength,官方文档说明了其不准确性,只可以作为参考。或者 `allHeaderFields` 中的 `Content-Length` 值也是不够准确的。 - > /*! + > /\*! > - > **@abstract** Returns the expected content length of the receiver. + > **@abstract** Returns the expected content length of the receiver. > - > **@discussion** Some protocol implementations report a content length + > **@discussion** Some protocol implementations report a content length > - > as part of delivering load metadata, but not all protocols + > as part of delivering load metadata, but not all protocols > - > guarantee the amount of data that will be delivered in actuality. + > guarantee the amount of data that will be delivered in actuality. > - > Hence, this method returns an expected amount. Clients should use + > Hence, this method returns an expected amount. Clients should use > - > this value as an advisory, and should be prepared to deal with + > this value as an advisory, and should be prepared to deal with > - > either more or less data. + > either more or less data. > - > **@result** The expected content length of the receiver, or -1 if + > **@result** The expected content length of the receiver, or -1 if > - > there is no expectation that can be arrived at regarding expected + > there is no expectation that can be arrived at regarding expected > - > content length. + > 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` 可有可无。 + - 在 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. + 数据以一系列分块的形式进行发送 `Content-Length` 首部在这种情况下不被发送. 在每一个分块的开头需要添加当前分块的长度, 以十六进制的形式表示,后面紧跟着 `\r\n` , 之后是分块本身, 后面也是 `\r\n` ,终止块是一个常规的分块, 不同之处在于其长度为 0. 我们之前拿 NSMutableData 记录了数据,所以我们可以在 `stopLoading `方法中计算出 Body 大小。步骤如下: @@ -3464,12 +3318,12 @@ HTTP 请求报文结构 - 在 `stopLoading` 方法中拿到 `allHeaderFields` 字典,获取 `Content-Encoding` key 的值,如果是 **gzip**,则在 `stopLoading` 中将 NSData 处理为 gzip 压缩后的数据,再计算大小。(gzip 相关功能可以使用这个[工具](https://github.com/nicklockwood/GZIP)) 需要额外计算一个空白行的长度 - + ```objective-c - (void)stopLoadi { [self.internalConnection cancel]; - + HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; model.path = self.request.URL.path; model.host = self.request.URL.host; @@ -3491,7 +3345,7 @@ HTTP 请求报文结构 } ``` -###### 2.5.3.2 Resquest 部分 +###### 2.5.3.2 Resquest 部分 1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 @@ -3511,14 +3365,14 @@ HTTP 请求报文结构 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 + + - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { [self.responseData appendData:data]; [self.client URLProtocol:self didLoadData:data]; @@ -3557,15 +3411,15 @@ HTTP 请求报文结构 { NSDictionary *headerFields = self.allHTTPHeaderFields; NSDictionary *cookiesHeader = [self apm_fetchCookies]; - + if (cookiesHeader.count) { NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields]; [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader]; headerFields = [headerDictionaryWithCookies copy]; } - + NSString *headerString = @""; - + for (NSString *key in headerFields.allKeys) { headerString = [headerString stringByAppendingString:key]; headerString = [headerString stringByAppendingString:@": "]; @@ -3578,7 +3432,7 @@ HTTP 请求报文结构 headersLength = headerData.length; return headerString; } - + - (NSDictionary *)apm_fetchCookies { NSDictionary *cookiesHeaderDictionary; @@ -3600,7 +3454,7 @@ HTTP 请求报文结构 { NSDictionary *headerFields = self.allHTTPHeaderFields; NSUInteger bodyLength = [self.HTTPBody length]; - + if ([headerFields objectForKey:@"Content-Encoding"]) { NSData *bodyData; if (self.HTTPBody == nil) { @@ -3634,7 +3488,7 @@ HTTP 请求报文结构 self.internalResponse = response; [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; } - + HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; model.path = request.URL.path; model.host = request.URL.host; @@ -3644,30 +3498,26 @@ HTTP 请求报文结构 model.bodyLength = [connection.currentRequest dgm_getBodyLength]; model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength]; model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; - + NSDictionary *networkTrafficDictionary = [model convertToDictionary]; [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; return request; } ``` - - ## 六、 电量消耗 -移动设备上电量一直是比较敏感的问题,如果用户在某款 App 的时候发现耗电量严重、手机发热严重,那么用户很大可能会马上卸载这款 App。所以需要在开发阶段关心耗电量问题。 +移动设备上电量一直是比较敏感的问题,如果用户在某款 App 的时候发现耗电量严重、手机发热严重,那么用户很大可能会马上卸载这款 App。所以需要在开发阶段关心耗电量问题。 一般来说遇到耗电量较大,我们立马会想到是不是使用了定位、是不是使用了频繁网络请求、是不是不断循环做某件事情? 开发阶段基本没啥问题,我们可以结合 `Instrucments` 里的 `Energy Log` 工具来定位问题。但是线上问题就需要代码去监控耗电量,可以作为 APM 的能力之一。 - - ### 1. 如何获取电量 -在 iOS 中,`IOKit` 是一个私有框架,用来获取硬件和设备的详细信息,也是硬件和内核服务通信的底层框架。所以我们可以通过 `IOKit `来获取硬件信息,从而获取到电量信息。步骤如下: +在 iOS 中,`IOKit` 是一个私有框架,用来获取硬件和设备的详细信息,也是硬件和内核服务通信的底层框架。所以我们可以通过 `IOKit `来获取硬件信息,从而获取到电量信息。步骤如下: -- 首先在苹果开放源代码 opensource 中找到 [IOPowerSources.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPowerSources.h.auto.html)、[IOPSKeys.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPSKeys.h)。在 Xcode 的 `Package Contents` 里面找到 `IOKit.framework`。 路径为 `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework` +- 首先在苹果开放源代码 opensource 中找到 [IOPowerSources.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPowerSources.h.auto.html)、[IOPSKeys.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPSKeys.h)。在 Xcode 的 `Package Contents` 里面找到 `IOKit.framework`。 路径为 `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework` - 然后将 IOPowerSources.h、IOPSKeys.h、IOKit.framework 导入项目工程 - 设置 UIDevice 的 batteryMonitoringEnabled 为 true - 获取到的耗电量精确度为 1% @@ -3725,15 +3575,13 @@ HTTP 请求报文结构 } ``` - - ### 3. 开发阶段针对电量消耗我们能做什么 -CPU 密集运算是耗电量主要原因。所以我们对 CPU 的使用需要精打细算。尽量避免让 CPU 做无用功。对于大量数据的复杂运算,可以借助服务器的能力、GPU 的能力。如果方案设计必须是在 CPU 上完成数据的运算,则可以利用 GCD 技术,使用 `dispatch_block_create_with_qos_class(<#dispatch_block_flags_t flags#>, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)()` 并指定 队列的 qos 为 `QOS_CLASS_UTILITY`。将任务提交到这个队列的 block 中,在 QOS_CLASS_UTILITY 模式下,系统针对大量数据的计算,做了电量优化 +CPU 密集运算是耗电量主要原因。所以我们对 CPU 的使用需要精打细算。尽量避免让 CPU 做无用功。对于大量数据的复杂运算,可以借助服务器的能力、GPU 的能力。如果方案设计必须是在 CPU 上完成数据的运算,则可以利用 GCD 技术,使用 `dispatch_block_create_with_qos_class(<#dispatch_block_flags_t flags#>, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)()` 并指定 队列的 qos 为 `QOS_CLASS_UTILITY`。将任务提交到这个队列的 block 中,在 QOS_CLASS_UTILITY 模式下,系统针对大量数据的计算,做了电量优化 除了 CPU 大量运算,I/O 操作也是耗电主要原因。业界常见方案都是将「碎片化的数据写入磁盘存储」这个操作延后,先在内存中聚合吗,然后再进行磁盘存储。碎片化数据先聚合,在内存中进行存储的机制,iOS 提供 `NSCache` 这个对象。 -NSCache 是线程安全的,NSCache 会在达到达预设的缓存空间的条件时清理缓存,此时会触发 `- (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj;` 方法回调,在该方法内部对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 次数少了,对电量的消耗也就减少了。 +NSCache 是线程安全的,NSCache 会在达到达预设的缓存空间的条件时清理缓存,此时会触发 `- (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj;` 方法回调,在该方法内部对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 次数少了,对电量的消耗也就减少了。 NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读取缓存处理时,没直接读取硬盘文件(I/O),而是使用系统的 NSCache。 @@ -3755,14 +3603,11 @@ NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读 可以看到主要逻辑是先从磁盘中读取图片,如果配置允许开启内存缓存,则将图片保存到 NSCache 中,使用的时候也是从 NSCache 中读取图片。NSCache 的 `totalCostLimit、countLimit` 属性, -`- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;` 方法用来设置缓存条件。所以我们写磁盘、内存的文件操作时可以借鉴该策略,以优化耗电量。 - - - +`- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;` 方法用来设置缓存条件。所以我们写磁盘、内存的文件操作时可以借鉴该策略,以优化耗电量。 ## 七、 Crash 监控 -### 1. 异常相关知识回顾 +### 1. 异常相关知识回顾 #### 1.1 Mach 层对异常的处理 @@ -3779,8 +3624,6 @@ Mach 的异常处理模型和其他的异常处理模型不同,其他模型的 异常首先是由处理器陷阱引发的。为了处理陷阱,每一个现代的内核都会安插陷阱处理程序。这些底层函数是由内核的汇编部分安插的。 - - #### 1.2 BSD 层对异常的处理 BSD 层是用户态主要使用的 XUN 接口,这一层展示了一个符合 POSIX 标准的接口。开发者可以使用 UNIX 系统的一切功能,但不需要了解 Mach 层的细节实现。 @@ -3791,8 +3634,6 @@ Mach 异常都在 host 层被 `ux_exception` 转换为相应的 unix 信号, ![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 日志 @@ -3828,22 +3669,16 @@ Thread 0 Crashed: 5 APMMonitorExample 0x000000010004c618 0x100044000 + 34328 (-[MakeCrashHandler throwUncaughtNSException] + 80) ``` -会发现,Crash 日志中 `Exception Type` 项由2部分组成:Mach 异常 + Unix 信号。 - -所以 `Exception Type: EXC_CRASH (SIGABRT)` 表示:Mach 层发生了 `EXC_CRASH` 异常,在 host 层被转换为 `SIGABRT` 信号投递到出错的线程。 - +会发现,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 @@ -3853,7 +3688,7 @@ KSCrash 功能齐全,可以捕获如下类型的 Crash - Main thread deadlock (experimental) - Custom crashes (e.g. from scripting languages) -所以分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 监控实现原理。 +所以分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 监控实现原理。 #### 2.1. Mach 层异常处理 @@ -3863,8 +3698,6 @@ KSCrash 功能齐全,可以捕获如下类型的 Crash ![KSCrash流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-20-KSCrashStructure.png) - - 对于 Mach 异常捕获,可以注册一个异常端口,该端口负责对当前任务的所有线程进行监听。 下面来看看关键代码: @@ -3975,7 +3808,7 @@ static bool installExceptionHandler() pthread_attr_destroy(&attr); g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread); ksmc_addReservedThread(g_primaryMachThread); - + KSLOG_DEBUG("Mach exception handler installed."); return true; @@ -4168,9 +4001,7 @@ static void restoreExceptionPorts(void) } ``` - - -#### 2.2. Signal 异常处理 +#### 2.2. Signal 异常处理 对于 Mach 异常,操作系统会将其转换为对应的 `Unix 信号`,所以开发者可以通过注册 `signanHandler` 的方式来处理。 @@ -4268,7 +4099,7 @@ static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext) { ksmc_suspendEnvironment(); kscm_notifyFatalExceptionCaptured(false); - + KSLOG_DEBUG("Filling out context."); KSMC_NEW_CONTEXT(machineContext); ksmc_getContextForSignal(userContext, machineContext); @@ -4311,7 +4142,7 @@ static void uninstallSignalHandler(void) KSLOG_DEBUG("Restoring original handler for signal %d", fatalSignals[i]); sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL); } - + KSLOG_DEBUG("Signal handlers uninstalled."); } ``` @@ -4322,7 +4153,7 @@ static void uninstallSignalHandler(void) 为什么这么做?一个进程可能有 n 个线程,每个线程都有自己的任务,假如某个线程执行出错,这样就会导致整个进程的崩溃。所以为了信号处理函数正常运行,需要为信号处理函数设置单独的运行空间。另一种情况是递归函数将系统默认的栈空间用尽了,但是信号处理函数使用的栈是它实现在堆中分配的空间,而不是系统默认的栈,所以它仍旧可以正常工作。 -2. `int sigaltstack(const stack_t * __restrict, stack_t * __restrict)` 函数的二个参数都是 `stack_t` 结构的指针,存储了可替换信号栈的信息(栈的起始地址、栈的长度、状态)。第1个参数该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数用来返回上一次建立的“可替换信号栈”的信息(如果有的话)。 +2. `int sigaltstack(const stack_t * __restrict, stack_t * __restrict)` 函数的二个参数都是 `stack_t` 结构的指针,存储了可替换信号栈的信息(栈的起始地址、栈的长度、状态)。第 1 个参数该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数用来返回上一次建立的“可替换信号栈”的信息(如果有的话)。 ```c _STRUCT_SIGALTSTACK @@ -4340,7 +4171,7 @@ static void uninstallSignalHandler(void) /* * 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 */ @@ -4351,7 +4182,7 @@ static void uninstallSignalHandler(void) `ss_flags` 为 `SS_ONSTACK` 时,表示进程当前正在“可替换信号栈”中执行,如果此时试图去建立一个新的“可替换信号栈”,那么会遇到 `EPERM` (禁止该动作) 的错误;为 `SS_DISABLE` 说明当前没有已建立的“可替换信号栈”,禁止建立“可替换信号栈”。 -3. `int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);` +3. `int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);` 第一个函数表示需要处理的信号值,但不能是 `SIGKILL` 和 `SIGSTOP` ,这两个信号的处理函数不允许用户重写,因为它们给超级用户提供了终止程序的方法( `SIGKILL` and `SIGSTOP` cannot be caught, blocked, or ignored); @@ -4370,17 +4201,15 @@ static void uninstallSignalHandler(void) `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` 信号。 +iOS 工程中某些功能的实现可能使用了 C、C++等。假如抛出 C++ 异常,如果该异常可以被转换为 NSException,则走 OC 异常捕获机制,如果不能转换,则继续走 C++ 异常流程,也就是 `default_terminate_handler`。这个 C++ 异常的默认 terminate 函数内部调用 `abort_message` 函数,最后触发了一个 `abort` 调用,系统产生一个 `SIGABRT` 信号。 -在系统抛出 C++ 异常后,加一层 `try...catch...` 来判断该异常是否可以转换为 `NSException`,再重新抛出的C++异常。此时异常的现场堆栈已经消失,所以上层通过捕获 `SIGABRT` 信号是无法还原发生异常时的场景,即异常堆栈缺失。 +在系统抛出 C++ 异常后,加一层 `try...catch...` 来判断该异常是否可以转换为 `NSException`,再重新抛出的 C++异常。此时异常的现场堆栈已经消失,所以上层通过捕获 `SIGABRT` 信号是无法还原发生异常时的场景,即异常堆栈缺失。 -为什么?`try...catch...` 语句内部会调用 `__cxa_rethrow()` 抛出异常,`__cxa_rethrow()` 内部又会调用 `unwind`,`unwind` 可以简单理解为函数调用的逆调用,主要用来清理函数调用过程中每个函数生成的局部变量,一直到最外层的 catch 语句所在的函数,并把控制移交给 catch 语句,这就是C++异常的堆栈消失原因。 +为什么?`try...catch...` 语句内部会调用 `__cxa_rethrow()` 抛出异常,`__cxa_rethrow()` 内部又会调用 `unwind`,`unwind` 可以简单理解为函数调用的逆调用,主要用来清理函数调用过程中每个函数生成的局部变量,一直到最外层的 catch 语句所在的函数,并把控制移交给 catch 语句,这就是 C++异常的堆栈消失原因。 ```c++ static void setEnabled(bool isEnabled) @@ -4424,8 +4253,6 @@ void kssc_initCursor(KSStackCursor *cursor, } ``` - - ```c++ static void CPPExceptionTerminate(void) { @@ -4437,7 +4264,7 @@ static void CPPExceptionTerminate(void) { name = tinfo->name(); } - + if(name == NULL || strcmp(name, "NSException") != 0) { kscm_notifyFatalExceptionCaptured(false); @@ -4510,8 +4337,6 @@ catch(TYPE value)\ } ``` - - #### 2.4. Objective-C 异常处理 对于 OC 层面的 NSException 异常处理较为容易,可以通过注册 `NSUncaughtExceptionHandler` 来捕获异常信息,通过 NSException 参数来做 Crash 信息的收集,交给数据上报组件。 @@ -4527,7 +4352,7 @@ static void setEnabled(bool isEnabled) KSLOG_DEBUG(@"Backing up original handler."); // 记录之前的 OC 异常处理函数 g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler(); - + KSLOG_DEBUG(@"Setting new handler."); // 设置新的 OC 异常处理函数 NSSetUncaughtExceptionHandler(&handleException); @@ -4542,8 +4367,6 @@ static void setEnabled(bool isEnabled) } ``` - - #### 2.5. 主线程死锁 主线程死锁的检测和 ANR 的检测有些类似 @@ -4629,7 +4452,7 @@ static void setEnabled(bool isEnabled) crashContext->registersAreValid = false; crashContext->offendingMachineContext = machineContext; crashContext->stackCursor = &stackCursor; - + kscm_handleException(crashContext); ksmc_resumeEnvironment(); @@ -4668,7 +4491,7 @@ void kscm_handleException(struct KSCrash_MonitorContext* context) // 真正处理 crash 信息,保存 json 格式的 crash 信息 g_onExceptionEvent(context); - + if(g_handlingFatalException && !g_crashedDuringExceptionHandling) { KSLOG_DEBUG("Exception is fatal. Restoring original handlers."); @@ -4677,7 +4500,7 @@ void kscm_handleException(struct KSCrash_MonitorContext* context) } ``` -`g_onExceptionEvent` 是一个 block,声明为 `static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);` 在 `KSCrashMonitor.c` 中被赋值 +`g_onExceptionEvent` 是一个 block,声明为 `static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);` 在 `KSCrashMonitor.c` 中被赋值 ```c void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monitorContext)) @@ -4686,7 +4509,7 @@ void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monito } ``` -`kscm_setEventCallback()` 函数在 `KSCrashC.c` 文件中被调用 +`kscm_setEventCallback()` 函数在 `KSCrashC.c` 文件中被调用 ```c KSCrashMonitorType kscrash_install(const char* appName, const char* const installPath) @@ -4716,7 +4539,7 @@ KSCrashMonitorType kscrash_install(const char* appName, const char* const instal printPreviousLog(g_consoleLogPath); } kslog_setLogFilename(g_consoleLogPath, true); - + ksccd_init(60); // 设置 crash 发生时的 callback 函数 kscm_setEventCallback(onCrash); @@ -4754,7 +4577,7 @@ static void onCrash(struct KSCrash_MonitorContext* monitorContext) } ``` -接下来的函数就是具体的日志写入文件的实现。2个函数做的事情相似,都是格式化为 json 形式并写入文件。区别在于 crash 写入时如果再次发生 crash, 则走简易版的写入逻辑 `kscrashreport_writeRecrashReport()`,否则走标准的写入逻辑 `kscrashreport_writeStandardReport()`。 +接下来的函数就是具体的日志写入文件的实现。2 个函数做的事情相似,都是格式化为 json 形式并写入文件。区别在于 crash 写入时如果再次发生 crash, 则走简易版的写入逻辑 `kscrashreport_writeRecrashReport()`,否则走标准的写入逻辑 `kscrashreport_writeStandardReport()`。 ```c bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, char* writeBuffer, int writeBufferLength) @@ -4768,11 +4591,11 @@ bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, c #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 @@ -4787,8 +4610,6 @@ bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, c } ``` - - ```c /** * Write a standard crash report to a file. @@ -4811,7 +4632,7 @@ void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* cons } ksccd_freeze(); - + KSJSONEncodeContext jsonContext; jsonContext.userData = &bufferedWriter; KSCrashReportWriter concreteWriter; @@ -4870,7 +4691,7 @@ void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* cons writeDebugInfo(writer, KSCrashField_Debug, monitorContext); } writer->endContainer(writer); - + ksjson_endEncode(getJsonContext(writer)); ksfu_closeBufferedWriter(&bufferedWriter); ksccd_unfreeze(); @@ -4954,8 +4775,6 @@ void kscrashreport_writeRecrashReport(const struct KSCrash_MonitorContext* const } ``` - - ##### 2.6.2 Crash 日志的读取逻辑 当前 App 在 Crash 之后,KSCrash 将数据保存到 App 沙盒目录下,App 下次启动后我们读取存储的 crash 文件,然后处理数据并上传。 @@ -4964,7 +4783,7 @@ App 启动后函数调用: ` [KSCrashInstallation sendAllReportsWithCompletion:]` -> `[KSCrash sendAllReportsWithCompletion:]` -> `[KSCrash allReports]` -> `[KSCrash reportWithIntID:]` ->`[KSCrash loadCrashReportJSONWithID:]` -> `kscrs_readReport ` -在 `sendAllReportsWithCompletion` 里读取沙盒里的Crash 数据。 +在 `sendAllReportsWithCompletion` 里读取沙盒里的 Crash 数据。 ```objective-c // 先通过读取文件夹,遍历文件夹内的文件数量来判断 crash 报告的个数 @@ -5009,7 +4828,7 @@ done: [reports addObject:report]; } } - + return reports; } @@ -5139,7 +4958,7 @@ 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; @@ -5148,17 +4967,15 @@ static int64_t getReportIDFromFilename(const char* filename) ![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 属性来监控,比如下面的代码 +这部分简单粗暴,直接通过 JSContext 对象的 exceptionHandler 属性来监控,比如下面的代码 ```objective-c jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) { - // 处理 jscore 相关的异常信息 + // 处理 jscore 相关的异常信息 }; ``` @@ -5168,23 +4985,28 @@ jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) { ```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 异常监控 +##### 2.7.3 React Native 异常监控 小实验:下图是写了一个 RN Demo 工程,在 Debug Text 控件上加了事件监听代码,内部人为触发 crash ```jsx -{1+qw;}}>Debug + { + 1 + qw; + }} +> + Debug + ``` -对比组1: +对比组 1: 条件: iOS 项目 debug 模式。在 RN 端增加了异常处理的代码。 @@ -5194,8 +5016,6 @@ window.onerror = function (msg, url, lineNumber, columnNumber, error) { 查看到 crash stack 后点击可以跳转到 sourceMap 的地方。 - - Tips:RN 项目打 Release 包 - 在项目根目录下创建文件夹( release_iOS),作为资源的输出文件夹 @@ -5206,15 +5026,13 @@ Tips:RN 项目打 Release 包 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 工程中即可 +- 将 release_iOS 文件夹内的 `.jsbundle` 和 `assets` 文件夹内容拖入到 iOS 工程中即可 - - -对比组2: +对比组 2: 条件:iOS 项目 release 模式。在 RN 端不增加异常处理代码 -操作:运行 iOS 工程,点击按钮模拟 crash +操作:运行 iOS 工程,点击按钮模拟 crash 现象:iOS 项目奔溃。截图以及日志如下 @@ -5279,37 +5097,33 @@ value@(null):(null) 17 libsystem_pthread.dylib 0x00007fff51c07b77 start_wqthread + 15 ) libc++abi.dylib: terminating with uncaught exception of type NSException -(lldb) +(lldb) ``` - - -Tips:如何在 RN release 模式下调试(看到 js 侧的 console 信息) +Tips:如何在 RN release 模式下调试(看到 js 侧的 console 信息) - 在 `AppDelegate.m` 中引入 `#import ` -- 在 `- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions` 中加入 `RCTSetLogThreshold(RCTLogLevelTrace);` +- 在 `- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions` 中加入 `RCTSetLogThreshold(RCTLogLevelTrace);` - - -对比组3: +对比组 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) + 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。 @@ -5318,16 +5132,12 @@ global.ErrorUtils.setGlobalHandler((e) => { ![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 模式下则会白屏或者闪退,为了体验和质量把控需要做异常监控。 @@ -5358,7 +5168,7 @@ type Fn = (...Args) => Return; */ let _globalHandler: ErrorHandler = function onError( e: mixed, - isFatal: boolean, + isFatal: boolean ) { throw e; }; @@ -5392,7 +5202,7 @@ const ErrorUtils = { // 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, + unused_name?: ?string ): ?TOut { try { _inGuard++; @@ -5408,7 +5218,7 @@ const ErrorUtils = { applyWithGuardIfNeeded, TOut>( fun: Fn, context?: ?mixed, - args?: ?TArgs, + args?: ?TArgs ): ?TOut { if (ErrorUtils.inGuard()) { // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work @@ -5424,22 +5234,22 @@ const ErrorUtils = { guard, TOut>( fun: Fn, name?: ?string, - context?: ?mixed, + 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); + if (typeof fun !== "function") { + console.warn("A function must be passed to ErrorUtils.guard, got ", fun); return null; } - const guardName = name ?? fun.name ?? ''; + const guardName = name ?? fun.name ?? ""; function guarded(...args: TArgs): ?TOut { return ErrorUtils.applyWithGuard( fun, context ?? this, args, null, - guardName, + guardName ); } @@ -5460,13 +5270,11 @@ global.ErrorUtils.setGlobalHandler(e => { }, true); ``` - - ###### 2.7.3.2 组件问题 -其实对于 RN 的 crash 处理还有个需要注意的就是 **React Error Boundaries**。[详细资料](https://zh-hans.reactjs.org/docs/error-boundaries.html) +其实对于 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 并没有提供一种在组件中优雅处理这些错误的方式,也无法从错误中恢复。 +> 过去,组件内的 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 引入了一个新的概念 —— 错误边界。 > @@ -5477,15 +5285,13 @@ global.ErrorUtils.setGlobalHandler(e => { 而不能捕获以下异常: - Event handlers(事件处理函数) -- Asynchronous code(异步代码,如setTimeout、promise等) +- 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 错误,都已经被监控处理了。接下来就看看如何从工程化层面解决这些问题 - - +至此 RN 的 crash 分为 2 种,分别是 js 逻辑错误、组件 js 错误,都已经被监控处理了。接下来就看看如何从工程化层面解决这些问题 ##### 2.7.4 RN Crash 还原 @@ -5511,8 +5317,8 @@ function parseJSError(aLine, aColumn) { // 输出到控制台 console.log(parseData); // 输出到文件中 - fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) { - if(err) { + fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) { + if(err) { console.log(err); } }); @@ -5530,7 +5336,14 @@ parseJSError(line, column); 1. 在 Text 的点击事件上模拟 crash ```jsx - {1+qw;}}>Debug + { + 1 + qw; + }} + > + Debug + ``` 2. 将 RN 项目打 bundle 包、产出 sourceMap 文件。执行命令, @@ -5557,8 +5370,6 @@ parseJSError(line, column); ![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、提供源文件下载功能。 @@ -5568,7 +5379,7 @@ parseJSError(line, column); - 存储打包前的所有文件(install) 2. 开发产品侧 RN 分析界面。点击收集到的 RN crash,在详情页可以看到具体的文件、代码行数、代码列数。可以看到具体的代码,可以看到 RN stack trace、Native stack trace。(具体技术实现上面讲过了) 3. 由于 souece map 文件较大,RN 解析过长虽然不久,但是是对计算资源的消耗,所以需要设计高效读取方式 -4. SourceMap 在 iOS、Android 模式下不一样,所以 SoureceMap 存储需要区分 os。 +4. SourceMap 在 iOS、Android 模式下不一样,所以 SoureceMap 存储需要区分 os。 ### 3. KSCrash 的使用包装 @@ -5591,9 +5402,9 @@ parseJSError(line, column); #import "APMCrashInstallation.h" #import #import "APMCrashReporterSink.h" - + @implementation APMCrashInstallation - + + (instancetype)sharedInstance { static APMCrashInstallation *sharedInstance = nil; static dispatch_once_t onceToken; @@ -5602,16 +5413,16 @@ parseJSError(line, column); }); return sharedInstance; } - + - (id)init { return [super initWithRequiredProperties: nil]; } - + - (id)sink { APMCrashReporterSink *sink = [[APMCrashReporterSink alloc] init]; return [sink defaultCrashReportFilterSetAppleFmt]; } - + @end ``` @@ -5621,16 +5432,16 @@ parseJSError(line, column); // .h #import #import - + @interface APMCrashReporterSink : NSObject - + - (id ) defaultCrashReportFilterSetAppleFmt; - + @end - + // .m #pragma mark - public Method - + - (id ) defaultCrashReportFilterSetAppleFmt { return [KSCrashReportFilterPipeline filterWithFilters: @@ -5642,7 +5453,7 @@ parseJSError(line, column); 其中 `defaultCrashReportFilterSetAppleFmt` 方法内部返回了一个 `KSCrashReportFilterPipeline` 类方法 `filterWithFilters` 的结果。 - `APMCrashReportFilterAppleFmt` 是一个继承自 `KSCrashReportFilterAppleFmt` 的类,遵循了 `KSCrashReportFilter` 协议。协议方法允许开发者处理 Crash 的数据格式。 + `APMCrashReportFilterAppleFmt` 是一个继承自 `KSCrashReportFilterAppleFmt` 的类,遵循了 `KSCrashReportFilter` 协议。协议方法允许开发者处理 Crash 的数据格式。 ```objective-c /** Filter the specified reports. @@ -5654,15 +5465,13 @@ parseJSError(line, column); onCompletion:(KSCrashReportFilterCompletion) onCompletion; ``` - - ```objective-c #import - + @interface APMCrashReportFilterAppleFmt : KSCrashReportFilterAppleFmt - + @end - + // .m - (void) filterReports:(NSArray*)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion { @@ -5677,7 +5486,7 @@ parseJSError(line, column); } kscrash_callCompletion(onCompletion, filteredReports, YES, nil); } - + /** @brief 获取Crash JSON中的crash时间、mach name、signal name和apple report */ @@ -5686,13 +5495,13 @@ parseJSError(line, column); 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]; } ``` @@ -5719,8 +5528,6 @@ parseJSError(line, column); @property(atomic,readwrite,assign) KSReportWriteCallback onCrash; ``` - - ```objective-c + (instancetype)sharedInstance { @@ -5731,14 +5538,14 @@ parseJSError(line, column); }); return _sharedManager; } - - + + #pragma mark - public Method - + - (void)startMonitor { APMMLog(@"crash monitor started"); - + #ifdef DEBUG BOOL _trackingCrashOnDebug = [APMMonitorConfig sharedInstance].trackingCrashOnDebug; if (_trackingCrashOnDebug) { @@ -5748,19 +5555,19 @@ parseJSError(line, column); [self installKSCrash]; #endif } - + #pragma mark - private method - + static void onCrash(const KSCrashReportWriter* writer) { NSString *sessionId = [NSString stringWithFormat:@"\"%@\"", ***]]; writer->addJSONElement(writer, "SESSION_ID", [sessionId UTF8String], true); - + NSString *appLaunchTime = ***; writer->addJSONElement(writer, "USER_APP_START_DATE", [[NSString stringWithFormat:@"\"%@\"", appLaunchTime] UTF8String], true); // ... } - + - (void)installKSCrash { [[APMCrashInstallation sharedInstance] install]; @@ -5786,7 +5593,7 @@ parseJSError(line, column); } return; } - + id sink = [self sink]; if(sink == nil) { @@ -5795,9 +5602,9 @@ parseJSError(line, column); 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]; @@ -5810,9 +5617,9 @@ parseJSError(line, column); - (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) { @@ -5841,7 +5648,7 @@ parseJSError(line, column); kscrash_callCompletion(onCompletion, reports, YES, nil); return; } - + if(self.sink == nil) { kscrash_callCompletion(onCompletion, reports, NO, @@ -5850,7 +5657,7 @@ parseJSError(line, column); description:@"No sink set. Crash reports not sent."]); return; } - + [self.sink filterReports:reports onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) { @@ -5882,29 +5689,25 @@ parseJSError(line, column); kscrash_callCompletion(onCompletion, reports, YES, nil); } ``` - - 至此,概括下 KSCrash 做的事情,提供各种 crash 的监控能力,在 crash 后将进程信息、基本信息、异常信息、线程信息等用 c 高效转换为 json 写入文件,App 下次启动后读取本地的 crash 文件夹中的 crash 日志,让开发者可以自定义 key、value 然后去上报日志到 APM 系统,然后删除本地 crash 文件夹中的日志。 - - -### 4. 符号化 + 至此,概括下 KSCrash 做的事情,提供各种 crash 的监控能力,在 crash 后将进程信息、基本信息、异常信息、线程信息等用 c 高效转换为 json 写入文件,App 下次启动后读取本地的 crash 文件夹中的 crash 日志,让开发者可以自定义 key、value 然后去上报日志到 APM 系统,然后删除本地 crash 文件夹中的日志。 + +### 4. 符号化 应用 crash 之后,系统会生成一份崩溃日志,存储在设置中,应用的运行状态、调用堆栈、所处线程等信息会记录在日志中。但是这些日志是地址,并不可读,所以需要进行符号化还原。 -#### 4.1 .DSYM 文件 +#### 4.1 .DSYM 文件 -`.DSYM` (debugging symbol)文件是保存十六进制函数地址映射信息的中转文件,调试信息(symbols)都包含在该文件中。Xcode 工程每次编译运行都会生成新的 `.DSYM` 文���。默认情况下 debug 模式时不生成 `.DSYM` ,可以在 Build Settings -> Build Options -> Debug Information Format 后将值 `DWARF` 修改为 `DWARF with DSYM File`,这样再次编译运行就可以生成 `.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` 文件中包含 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. @@ -5915,7 +5718,7 @@ DWARF 全称是 Debugging With Arbitrary Record Formats,是一种使用属性 DWARF 是可执行程序与源代码关系的一个紧凑表示。 -大多数现代编程语言都是块结构:每个实体(一个类、一个函数)被包含在另一个实体中。一个 c 程序,每个文件可能包含多个数据定义、多个变量、多个函数,所以 DWARF 遵循这个模型,也是块结构。DWARF 里基本的描述项是调试信息项 DIE(Debugging Information Entry)。一个 DIE 有一个标签,表示这个 DIE 描述了什么以及一个填入了细节并进一步描述该项的属性列表(类比 html、xml 结构)。一个 DIE(除了最顶层的)被一个父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个DIE的引用(比如一个函数的返回值类型)。 +大多数现代编程语言都是块结构:每个实体(一个类、一个函数)被包含在另一个实体中。一个 c 程序,每个文件可能包含多个数据定义、多个变量、多个函数,所以 DWARF 遵循这个模型,也是块结构。DWARF 里基本的描述项是调试信息项 DIE(Debugging Information Entry)。一个 DIE 有一个标签,表示这个 DIE 描述了什么以及一个填入了细节并进一步描述该项的属性列表(类比 html、xml 结构)。一个 DIE(除了最顶层的)被一个父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个 DIE 的引用(比如一个函数的返回值类型)。 DWARF 文件中的数据如下: @@ -5951,7 +5754,7 @@ DWARF 文件中的数据如下: | DW_TAG_data_memver_location | 表示位置信息 | | DW_TAG_virtuality | 在虚拟时设置 | -简单看一个 DWARF 的例子:将测试工程的 `.DSYM` 文件夹下的 DWARF 文件用下面命令解析 +简单看一个 DWARF 的例子:将测试工程的 `.DSYM` 文件夹下的 DWARF 文件用下面命令解析 ```shell dwarfdump -F --debug-info Test.app.DSYM/Contents/Resources/DWARF/Test > debug-info.txt @@ -6087,9 +5890,7 @@ Test.app.DSYM/Contents/Resources/DWARF/Test: file format Mach-O arm64 这里就不粘贴全部内容了(太长了)。可以看到 DIE 包含了函数开始地址、结束地址、函数名、文件名、所在行数,对于给定的地址,找到函数开始地址、结束地址之间包含该地址的 DIE,则可以还原函数名和文件名信息。 - - -debug_line 可以还原文件行数等信息 +debug_line 可以还原文件行数等信息 ```shell dwarfdump -F --debug-line Test.app.DSYM/Contents/Resources/DWARF/Test > debug-inline.txt @@ -6204,9 +6005,9 @@ 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 +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 @@ -6268,11 +6069,9 @@ file_names[ 4]: 可以看到 debug_line 里包含了每个代码地址对应的行数。上面贴了 AppDelegate 的部分。 +#### 4.3 symbols - -#### 4.3 symbols - -> 在链接中,我们将函数和变量统称为符合(Symbol),函数名或变量名就是符号名(Symbol Name),我们可以将符号看成是链接中的粘合剂,整个链接过程正是基于符号才能正确完成的。 +> 在链接中,我们将函数和变量统称为符合(Symbol),函数名或变量名就是符号名(Symbol Name),我们可以将符号看成是链接中的粘合剂,整个链接过程正是基于符号才能正确完成的。 上述文字来自《程序员的自我修养》。所以符号就是函数、变量、类的统称。 @@ -6288,10 +6087,6 @@ file_names[ 4]: <起始地址> <结束地址> <函数> [<文件名:行号>] ``` - - - - #### 4.4 **如何获取地址?** image 加载的时候会进行相对基地址进行重定位,并且每次加载的基地址都不一样,函数栈 frame 的地址是重定位后的绝对地址,我们要的是重定位前的相对地址。 @@ -6325,7 +6120,7 @@ Binary Images: 0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test ``` -所以 frame 5 的相对地址为 `0x102fe592c - 0x102fe0000 `。再使用 命令可以还原符号信息。 +所以 frame 5 的相对地址为 `0x102fe592c - 0x102fe0000 `。再使用 命令可以还原符号信息。 使用 atos 来解析,`0x102fe0000` 为 image 加载的开始地址,`0x102fe592c` 为 frame 需要还原的地址。 @@ -6333,8 +6128,6 @@ Binary Images: atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x102fe592c ``` - - #### 4.5 UUID - crash 文件的 UUID @@ -6355,7 +6148,7 @@ atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x Test App 的 UUID 为 `37eaa57df2523d95969e47a9a1d69ce5`. -- .DSYM 文件的 UUID +- .DSYM 文件的 UUID ```shell dwarfdump --uuid Test.app.DSYM @@ -6379,8 +6172,6 @@ atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app/Test ``` - - #### 4.6 符号化(解析 Crash 日志) 上述篇幅分析了如何捕获各种类型的 crash,App 在用户手中我们通过技术手段可以获取 crash 案发现场信息并结合一定的机制去上报,但是这种堆栈是十六进制的地址,无法定位问题,所以需要做符号化处理。 @@ -6391,15 +6182,15 @@ atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Developer/Xcode/Archives`。 -解析方法一般有2种: +解析方法一般有 2 种: -- 使用 **symbolicatecrash** +- 使用 **symbolicatecrash** symbolicatecrash 是 Xcode 自带的 crash 日志分析工具,先确定所在路径,在终端执行下面的命令 - ````shell + ```shell find /Applications/Xcode.app -name symbolicatecrash -type f - ```` + ``` 会返回几个路径,找到 `iPhoneSimulator.platform` 所在那一行 @@ -6421,7 +6212,7 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ``` -- 使用 atos +- 使用 atos 区别于 symbolicatecrash,atos 较为灵活,只要 `.crash` 和 `.DSYM` 或者 `.crash` 和 `.app` 文件对应即可。 @@ -6431,7 +6222,7 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev xcrun atos -o Test.app.DSYM/Contents/Resources/DWARF/Test -arch armv7 -l 0x1023c592c ``` - 也可以解析 .app 文件(不存在 .DSYM 文件),其中xxx为段地址,xx为偏移地址 + 也可以解析 .app 文件(不存在 .DSYM 文件),其中 xxx 为段地址,xx 为偏移地址 ```shell atos -arch architecture -o binary -l xxx xx @@ -6439,8 +6230,6 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev 因为我们的 App 可能有很多,每个 App 在用户手中可能是不同的版本,所以在 APM 拦截之后需要符号化的时候需要将 crash 文件和 `.DSYM` 文件一一对应,才能正确符号化,对应的原则就是 **UUID** 一致。 - - #### 4.7 系统库符号化解析 我们每次真机连接 Xcode 运行程序,会提示等待,其实系统为了堆栈解析,都会把当前版本的系统符号库自动导入到 `/Users/你自己的用户名/Library/Developer/Xcode/iOS DeviceSupport` 目录下安装了一大堆系统库的符号化文件。你可以访问下面目录看看 @@ -6451,9 +6240,7 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev ![系统符号化文件](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-28-SymbolicateLib.png) - - -### 5. 服务端处理 +### 5. 服务端处理 ##### 5.1 ELK 日志系统 @@ -6463,7 +6250,7 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev ![ELK架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-14-ELK.png) -上图展示了一个 ELK 的日志架构图。简单说明下: +上图展示了一个 ELK 的日志架构图。简单说明下: - Logstash 和 ES 之前存在一个 Kafka 层,因为 Logstash 是架设在数据资源服务器上,将收集到的数据进行实时过滤,过滤需要消耗时间和内存,所以存在 Kafka,起到了数据缓冲存储作用,因为 Kafka 具备非常出色的读写性能。 - 再一步就是 Logstash 从 Kafka 里面进行读取数据,将数据过滤、处理,将结果传输到 ES @@ -6473,21 +6260,17 @@ app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Dev ![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 -> 产品侧(显示端)对数据进行分类、报表、报警等操作。 +所以整个流程就是:客户端 APM SDK 收集 crash log -> Kafka 存储 -> Mac 机执行定时任务符号化 -> 数据回传 Kafka -> 产品侧(显示端)对数据进行分类、报表、报警等操作。 因为公司的产品线有多条,相应的 App 有多个,用户使用的 App 版本也各不相同,所以 crash 日志分析必须要有正确的 .DSYM 文件,那么多 App 的不同版本,自动化就变得非常重要了。 -自动化有2种手段,规模小一点的公司或者图省事,可以在 Xcode中 添加 runScript 脚本代码来自动在 release 模式下上传DSYM)。 +自动化有 2 种手段,规模小一点的公司或者图省事,可以在 Xcode 中 添加 runScript 脚本代码来自动在 release 模式下上传 DSYM)。 因为我们大前端有一套体系,可以同时管理 iOS SDK、iOS App、Android SDK、Android App、Node、React、React Native 工程项目的初始化、依赖管理、构建(持续集成、Unit Test、Lint、统跳检测)、测试、打包、部署、动态能力(热更新、统跳路由下发)等能力于一身。可以基于各个阶段做能力的插入,所以可以在打包系统中,当调用打包后在打包机上传 `.DSYM` 文件到七牛云存储(规则可以是以 AppName + Version 为 key,value 为 .DSYM 文件)。 @@ -6500,7 +6283,7 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 - Symbolication Service 作为整个监控系统的一个组成部分,是专注于 crash report 符号化的微服务。 - 接收来自任务调度框架的包含预处理过的 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对 crash report 做符号化解析,计算 hash,并将 hash 响应给「数据处理和任务调度框架」。 -- 接收来自 APM 管理系统的包含原始 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对crash report 做符号化解析,并将符号化的 crash report 响应给 APM 管理系统。 +- 接收来自 APM 管理系统的包含原始 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对 crash report 做符号化解析,并将符号化的 crash report 响应给 APM 管理系统。 - 脚手架 cli 有个能力就是调用打包系统的打包构建能力,会根据项目的特点,选择合适的打包机(打包平台是维护了多个打包任务,不同任务根据特点被派发到不同的打包机上,任务详情页可以看到依赖的下载、编译、运行过程等,打包好的产物包括二进制包、下载二维码等等) ![符号化流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-symolication_flow.png) @@ -6511,16 +6294,12 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 ![符号化技术设计图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMServerWorker.png) -简单说明下,符号化流程是一个主从模式,一台 master 机,多个 slave 机,master 机读取 .DSYM 和 crash 结果的 cache。「数据处理和任务调度框架」调度符号化服务(内部2个 symbolocate worker)同时从七牛云上获取 .DSYM 文件。 +简单说明下,符号化流程是一个主从模式,一台 master 机,多个 slave 机,master 机读取 .DSYM 和 crash 结果的 cache。「数据处理和任务调度框架」调度符号化服务(内部 2 个 symbolocate worker)同时从七牛云上获取 .DSYM 文件。 系统架构图如下 ![符号化服务架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-SymbolicateServerArch.png) - - - - ## 八、 APM 小结 1. 通常来说各个端的监控能力是不太一致的,技术实现细节也不统一。所以在技术方案评审的时候需要将监控能力对齐统一。每个能力在各个端的数据字段必须对齐(字段个数、名称、数据类型和精度),因为 APM 本身是一个闭环,监控了之后需符号化解析、数据整理,进行产品化开发、最后需要监控大盘展示等 @@ -6536,7 +6315,7 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 ```objective-c /* android 端 - + 根据设备分级,一般超过 300ms 视为一次卡顿 hook 系统 loop,在消息处理前后插桩,用以计算每条消息的时长 开启另外线程 dump 堆栈,处理结束后关闭 @@ -6553,14 +6332,14 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 } } }) - + /* iOS 端 - + 子线程通过 ping 主线程来确认主线程当前是否卡顿。 卡顿阈值设置为 300ms,超过阈值时认为卡顿。 卡顿时获取主线程的堆栈,并存储上传。 - */ + */ - (void) main() { while (self.cancle == NO) { self.isMainThreadBlocked = YES; @@ -6584,30 +6363,24 @@ Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化 说明: - 埋点 SDK,通过 sessionId 来关联日志数据 - + 7. APM 技术方案本身是随着技术手段、分析需求不断调整升级的。上图的几个结构示意图是早期几个版本的,目前使用的是在此基础上进行了升级和结构调整,提几个关键词:Hermes、Flink SQL、InfluxDB。 - - - - ## 参考资料 - [iOS 保持界面流畅的技巧](https://blog.ibireme.com/2015/11/12/smooth_user_interfaces_for_ios/) - [Call Stack](https://en.wikipedia.org/wiki/Call_stack) - [关于函数调用栈(call stack)的个人理解](https://blog.csdn.net/VarusK/article/details/83031643) - [获取任意线程调用栈的那些事](https://bestswifter.com/callstack/) -- [iOS启动时间优化](https://www.zoomfeng.com/blog/launch-time.html) -- [WWDC2019之启动时间与Dyld3](https://www.zoomfeng.com/blog/launch-optimize-from-wwdc2019.html) +- [iOS 启动时间优化](https://www.zoomfeng.com/blog/launch-time.html) +- [WWDC2019 之启动时间与 Dyld3](https://www.zoomfeng.com/blog/launch-optimize-from-wwdc2019.html) - [Apple-libmalloc](https://opensource.apple.com/tarballs/libmalloc/) - [Apple-XNU](https://opensource.apple.com/tarballs/xnu/) -- [OOM探究:XNU 内存状态管理](https://www.jianshu.com/p/4458700a8ba8) +- [OOM 探究:XNU 内存状态管理](https://www.jianshu.com/p/4458700a8ba8) - [Reducing FOOMs in the Facebook iOS app](https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/) -- [iOS内存abort(Jetsam) 原理探究](https://satanwoo.github.io/2017/10/18/abort/) -- [iOS微信内存监控](https://wetest.qq.com/lab/view/367.html?from=coop_gad) -- [iOS堆栈信息解析(函数地址与符号关联)](https://www.jianshu.com/p/df5b08330afd) +- [iOS 内存 abort(Jetsam) 原理探究](https://satanwoo.github.io/2017/10/18/abort/) +- [iOS 微信内存监控](https://wetest.qq.com/lab/view/367.html?from=coop_gad) +- [iOS 堆栈信息解析(函数地址与符号关联)](https://www.jianshu.com/p/df5b08330afd) - [Apple-CFNetwork Programming Guide](https://developer.apple.com/library/archive/documentation/Networking/Conceptual/CFNetwork/Introduction/Introduction.html#//apple_ref/doc/uid/TP30001132-CH1-DontLinkElementID_30) - [MDN-HTTP Messages](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages) - [DWARF 和符号化](https://junyixie.github.io/2018/09/30/dwarf和符号化/) - - diff --git a/Chapter1 - iOS/1.81.md b/Chapter1 - iOS/1.81.md index ccce7ed..86b2d31 100644 --- a/Chapter1 - iOS/1.81.md +++ b/Chapter1 - iOS/1.81.md @@ -24,7 +24,7 @@ int main(int argc, const char * argv[]) { 在 foo 方法里面下断点,见下图 -![rename symbol](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-25-asm.png) +![rename symbol](./../assets/2020-02-25-asm.png) 可以看到,`foo` 方法的 symbol 被变为 `@杭城小刘`,变量 `age` 被变为 `objc_age`。 @@ -43,7 +43,7 @@ int main(int argc, char * argv[]) { } ``` -![App main 方法 rename 失败](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-25-asm2.png) +![App main 方法 rename 失败](./../assets/2020-02-25-asm2.png) 可以看到 App 工程主入口函数 `main` 函数,想修改为 `mook_main`。但是报错 `ld: entry point (_main) undefined. fir architecture x86_64` diff --git a/Chapter1 - iOS/1.94.md b/Chapter1 - iOS/1.94.md index 54a09bf..add94b2 100644 --- a/Chapter1 - iOS/1.94.md +++ b/Chapter1 - iOS/1.94.md @@ -1,15 +1,23 @@ -# APM +# APM - Wake Up -## 启动时间的监控和治理 -- https://everettjf.github.io/2018/08/24/most-simple-task-queue-model/ -- https://github.com/izhangxb/GMTC/blob/master/全球移动技术大会GMTC%202017%20PPT/手淘iOS性能优化探索%20.pdf -- https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA -- http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/ -- https://www.jianshu.com/p/c14987eee107 -- https://time.geekbang.org/column/article/85331 -- https://punmy.cn/2018/06/18/15278496835424.html -- https://www.shangmayuan.com/a/a14fb820d1bc4457b018bf7b.html +> + +网传:如果在老设备上,使用最新的 iOS 系统,苹果会自动降频(CPU 频率),从而让你的 iPhone 看上去很卡,让你主动去购买新的设备。 -> 我知道可以借助三方工具类BSBacktraceLogger获取主线程调用栈([BSBacktraceLogger bs_backtraceOfMainThread];),然后定时0.01秒计算各方法的调用耗时。但是具体不知道该怎么计算啊,望老师指点! -作者回复: 连续相同的堆栈,将其时间相加就是那个堆栈方法的耗时。 \ No newline at end of file +其实,苹果在 iOS 13 的时候,在内核中加入了一个新的性能衡量指标`wakeup`。CPU 频率和设备电池有关系。看看 ARM 架构中对于 CPU 功耗问题的描述: + +> Many ARM systems are mobile devices and powered by batteries. In such systems, optimization of power use, and total energy use, is a key design constraint. Programmers often spend significant amounts of time trying to save battery life in such systems. + +由于ARM被大量使用于低功耗设备,而这些设备往往会由电池来作为驱动,所以 ARM 在硬件层面就对功耗这个问题进行了优化设计。 + +功耗可以分为2种类型,即静态功耗与动态功耗。 +静态功耗指的是只要 CPU 通上电,由于芯片无法保证绝对绝缘,所以会存在“漏电”的情况,而且越大的芯片这种问题越严重,这也是芯片厂家为什么拼命的研究更小尺寸芯片的原因。这部分功耗由于是硬件本身决定的,所以我们无法去控制,而这种类型功耗占比不大。 + +动态功耗指的是 CPU 运行期间,接通时钟后,执行指令所带来的额外开销,而这个开销会和时钟周期频率相关,频率越高,耗电量越大。这也就说明了苹果为什么会控制 CPU 使用率,而相关研究(Facebook 也做过)也表明,CPU 在20以下和20以上的能耗几乎是成倍的增加。 + +且苹果在具体哪个系统推出了 Battery Health 模块,其中有个 Maximum Capacity 的指标。用于判断电池的性能。苹果在这个开放出来之前,肯定已经在收集电池的健康状况。 + +iOS 11.3 及更高版本优化了性能管理功能,会定期评估所需的性能管理程度,以避免意外关机。如果电池健康能够满足系统所观察到的对峰值电源的要求,则系统会调低性能管理的程度。如果再次出现意外关机,则系统会调高性能管理的程度。这种评估是持续进行的,使得性能管理更能适应实际情况。 + +综上:当老设备运行新的 iOS 操作系统,系统会判断电池健康状态,如果电池不够健康,那么系统为了防止电池持续损坏(当 CPU 以较高频率工作,则会损坏电池设备,降低寿命),会自动降低 CPU 频率。其中这个有个判断标准,这个标准是不断变化的。 \ No newline at end of file diff --git a/Chapter1 - iOS/chapter1.md b/Chapter1 - iOS/chapter1.md index 1171f36..696b239 100644 --- a/Chapter1 - iOS/chapter1.md +++ b/Chapter1 - iOS/chapter1.md @@ -97,3 +97,4 @@ * [91、二进制重排](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.91.md) * [92、flutter 无痕埋点](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.92.md) * [93、flutter 新功能引导](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.93.md) + * [94、APM-Wake Up](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.94.md) diff --git a/Chapter7 - Geek Talk/7.10.md b/Chapter7 - Geek Talk/7.10.md index 6be57a0..3496e4d 100644 --- a/Chapter7 - Geek Talk/7.10.md +++ b/Chapter7 - Geek Talk/7.10.md @@ -335,6 +335,22 @@ function proxy_on(){ +## 九、iTerm 提示升级,升级后终端报错 + +![报错信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-05-05-agnosterConfiguration.png) + +报错信息如上图所示。复制这个路径信息到 Finder 中。按住快捷键 `Command + Shift + G`,在弹出的面板中输入路径,点击回车,会看到定位到一个文件。用 VSCode 打开后看到如下所示,就是代码配置文件冲突 + +![报错信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-05-05-agnosterConflicts.png) + +解决冲突即可。 + + + + + + + diff --git a/SUMMARY.md b/SUMMARY.md index 3372d28..67a6357 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -96,7 +96,7 @@ * [91、二进制重排](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.91.md) * [92、flutter 无痕埋点](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.92.md) * [93、flutter 新功能引导](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.93.md) - + * [94、APM-Wake Up](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.94.md) * [Chapter2 - Web FrontEnd](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/chapter2.md) * [1、-last-child与-last-of-type你只是会用,有研究过区别吗?](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/2.1.md) diff --git a/assets/2021-05-05-agnosterConfiguration.png b/assets/2021-05-05-agnosterConfiguration.png new file mode 100644 index 0000000..311e14b Binary files /dev/null and b/assets/2021-05-05-agnosterConfiguration.png differ diff --git a/assets/2021-05-05-agnosterConflicts.png b/assets/2021-05-05-agnosterConflicts.png new file mode 100644 index 0000000..efd0fa0 Binary files /dev/null and b/assets/2021-05-05-agnosterConflicts.png differ diff --git a/assets/2021-05-28-WKWebViewRequestHook b/assets/2021-05-28-WKWebViewRequestHook new file mode 100644 index 0000000..6396b05 Binary files /dev/null and b/assets/2021-05-28-WKWebViewRequestHook differ