文章将近50000字,拆分为一、二。Github 上完整文章阅读体验更佳,请点击访问 Githubnode
APM 是 Application Performance Monitoring 的缩写,监视和管理软件应用程序的性能和可用性。应用性能管理对一个应用的持续稳定运行相当重要。因此这篇文章就从一个 iOS App 的性能管理的纬度谈谈如何精确监控以及数据如何上报等技术点ios
App 的性能问题是影响用户体验的重要因素之一。性能问题主要包含:Crash、网络请求错误或者超时、UI 响应速度慢、主线程卡顿、CPU 和内存使用率高、耗电量大等等。大多数的问题缘由在于开发者错误地使用了线程锁、系统函数、编程规范问题、数据结构等等。解决问题的关键在于尽早的发现和定位问题。c++
本篇文章着重总结了 APM 的缘由以及如何收集数据。APM 数据收集后结合数据上报机制,按照必定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合姊妹篇, 总结了如何打造一款灵活可配置、功能强大的数据上报组件。git
卡顿问题,就是在主线程上没法响应用户交互的问题。影响着用户的直接体验,因此针对 App 的卡顿监控是 APM 里面重要的一环。github
FPS(frame per second)每秒钟的帧刷新次数,iPhone 手机以 60 为最佳,iPad 某些型号是 120,也是做为卡顿监控的一项参考参数,为何说是参考参数?由于它不许确。先说说怎么获取到 FPS。CADisplayLink 是一个系统定时器,会以帧刷新频率同样的速率来刷新视图。 [CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]
。至于为何不许咱们来看看下面的示例代码web
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)];
[_displayLink setPaused:YES];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
复制代码
代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。因此仍是 CPU 层面的操做,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看ajax
讲讲老式的 CRT 显示器的原理。 CRT 电子枪按照上面方式,从上到下一行行扫描,扫面完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其余硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;当一帧画面绘制完成后,电子枪恢复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(Vertical synchronization),简称 VSync。显示器一般以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。虽然如今的显示器基本都是液晶显示屏,可是原理保持不变。objective-c
一般,屏幕上一张画面的显示是由 CPU、GPU 和显示器是按照上图的方式协同工做的。CPU 根据工程师写的代码计算好须要现实的内容(好比视图建立、布局计算、图片解码、文本绘制等),而后把计算结果提交到 GPU,GPU 负责图层合成、纹理渲染,随后 GPU 将渲染结果提交到帧缓冲区。随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,通过数模转换传递给显示器显示。算法
在帧缓冲区只有一个的状况下,帧缓冲区的读取和刷新都存在效率问题,为了解决效率问题,显示系统会引入2个缓冲区,即双缓冲机制。在这种状况下,GPU 会预先渲染好一帧放入帧缓冲区,让视频控制器来读取,当下一帧渲染好后,GPU 直接把视频控制器的指针指向第二个缓冲区。提高了效率。chrome
目前来看,双缓冲区提升了效率,可是带来了新的问题:当视频控制器还未读取完成时,即屏幕内容显示了部分,GPU 将新渲染好的一帧提交到另外一个帧缓冲区并把视频控制器的指针指向新的帧缓冲区,视频控制器就会把新的一帧数据的下半段显示到屏幕上,形成画面撕裂的状况。
为了解决这个问题,GPU 一般有一个机制叫垂直同步信号(V-Sync),当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新。这样的几个机制解决了画面撕裂的状况,也增长了画面流畅度。但须要更多的计算资源
答疑
可能有些人会看到「当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新」这里会想,GPU 收到 V-Sync 才进行新的一帧渲染和帧缓冲区的更新,那是否是双缓冲区就失去意义了?
设想一个显示器显示第一帧图像和第二帧图像的过程。首先在双缓冲区的状况下,GPU 首先渲染好一帧图像存入到帧缓冲区,而后让视频控制器的指针直接直接这个缓冲区,显示第一帧图像。第一帧图像的内容显示完成后,视频控制器发送 V-Sync 信号,GPU 收到 V-Sync 信号后渲染第二帧图像并将视频控制器的指针指向第二个帧缓冲区。
看上去第二帧图像是在等第一帧显示后的视频控制器发送 V-Sync 信号。是吗?真是这样的吗? 😭 想啥呢,固然不是。 🐷 否则双缓冲区就没有存在的意义了
揭秘。请看下图
当第一次 V-Sync 信号到来时,先渲染好一帧图像放到帧缓冲区,可是不展现,当收到第二个 V-Sync 信号后读取第一次渲染好的结果(视频控制器的指针指向第一个帧缓冲区),并同时渲染新的一帧图像并将结果存入第二个帧缓冲区,等收到第三个 V-Sync 信号后,读取第二个帧缓冲区的内容(视频控制器的指针指向第二个帧缓冲区),并开始第三帧图像的渲染并送入第一个帧缓冲区,依次不断循环往复。
请查看资料:Multiple buffering
VSync 信号到来后,系统图形服务会经过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图建立、布局计算、图片解码、文本绘制等)。而后将计算的内容提交到 GPU,GPU 通过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示以前渲染好的结果。在垂直同步机制的状况下,若是在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会形成该帧的丢弃,等待下一次机会再显示,这时候屏幕上仍是以前渲染的图像,因此这就是 CPU、GPU 层面界面卡顿的缘由。
目前 iOS 设备有双缓存机制,也有三缓冲机制,Android 如今主流是三缓冲机制,在早期是单缓冲机制。 iOS 三缓冲机制例子
CPU 和 GPU 资源消耗缘由不少,好比对象的频繁建立、属性调整、文件读取、视图层级的调整、布局的计算(AutoLayout 视图个数多了就是线性方程求解难度变大)、图片解码(大图的读取优化)、图像绘制、文本渲染、数据库读取(多读仍是多写乐观锁、悲观锁的场景)、锁的使用(举例:自旋锁使用不当会浪费 CPU)等方面。开发者根据自身经验寻找最优解(这里不是本文重点)。
CADisplayLink 确定不用了,这个 FPS 仅做为参考。通常来说,卡顿的监测有2种方案:监听 RunLoop 状态回调、子线程 ping 主线程
RunLoop 负责监听输入源进行调度处理。好比网络、输入设备、周期性或者延迟事件、异步回调等。RunLoop 会接收2种类型的输入源:一种是来自另外一个线程或者来自不一样应用的异步消息(source0事件)、另外一种是来自预约或者重复间隔的事件。
RunLoop 状态以下图
第一步:通知 Observers,RunLoop 要开始进入 loop,紧接着进入 loop
if (currentMode->_observerMask & kCFRunLoopEntry )
// 通知 Observers: RunLoop 即将进入 loop
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
// 进入loop
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
复制代码
第二步:开启 do while 循环保活线程,通知 Observers,RunLoop 触发 Timer 回调、Source0 回调,接着执行被加入的 block
if (rlm->_observerMask & kCFRunLoopBeforeTimers)
// 通知 Observers: RunLoop 即将触发 Timer 回调
__CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);
if (rlm->_observerMask & kCFRunLoopBeforeSources)
// 通知 Observers: RunLoop 即将触发 Source 回调
__CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);
// 执行被加入的block
__CFRunLoopDoBlocks(rl, rlm);
复制代码
第三步:RunLoop 在触发 Source0 回调后,若是 Source1 是 ready 状态,就会跳转到 handle_msg 去处理消息。
// 若是有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 而后跳转去处理消息
if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
msg = (mach_msg_header_t *)msg_buffer;
if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {
goto handle_msg;
}
#elif DEPLOYMENT_TARGET_WINDOWS
if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) {
goto handle_msg;
}
#endif
}
复制代码
第四步:回调触发后,通知 Observers 即将进入休眠状态
Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)
if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
__CFRunLoopSetSleeping(rl);
复制代码
第五步:进入休眠后,会等待 mach_port 消息,以便再次唤醒。只有如下4种状况才能够被再次唤醒。
do {
if (kCFUseCollectableAllocator) {
// objc_clear_stack(0);
// <rdar://problem/16393959>
memset(msg_buffer, 0, sizeof(msg_buffer));
}
msg = (mach_msg_header_t *)msg_buffer;
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);
if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
// Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer.
while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue));
if (rlm->_timerFired) {
// Leave livePort as the queue port, and service timers below
rlm->_timerFired = false;
break;
} else {
if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg);
}
} else {
// Go ahead and leave the inner loop.
break;
}
} while (1);
复制代码
第六步:唤醒时通知 Observer,RunLoop 的线程刚刚被唤醒了
// 通知 Observers: RunLoop 的线程刚刚被唤醒了
if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);
// 处理消息
handle_msg:;
__CFRunLoopSetIgnoreWakeUps(rl);
复制代码
第七步:RunLoop 唤醒后,处理唤醒时收到的消息
#if USE_MK_TIMER_TOO
// 若是一个 Timer 到时间了,触发这个Timer的回调
else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) {
CFRUNLOOP_WAKEUP_FOR_TIMER();
// On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled.
// In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754
if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
// Re-arm the next timer
__CFArmNextTimerInMode(rlm, rl);
}
}
#endif
// 若是有dispatch到main_queue的block,执行block
else if (livePort == dispatchPort) {
CFRUNLOOP_WAKEUP_FOR_DISPATCH();
__CFRunLoopModeUnlock(rlm);
__CFRunLoopUnlock(rl);
_CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL);
#if DEPLOYMENT_TARGET_WINDOWS
void *msg = 0;
#endif
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
_CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL);
__CFRunLoopLock(rl);
__CFRunLoopModeLock(rlm);
sourceHandledThisLoop = true;
didDispatchPortLastTime = true;
}
// 若是一个 Source1 (基于port) 发出事件了,处理这个事件
else {
CFRUNLOOP_WAKEUP_FOR_SOURCE();
// If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again.
voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release);
CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort);
if (rls) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
mach_msg_header_t *reply = NULL;
sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop;
if (NULL != reply) {
(void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL);
CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply);
}
#elif DEPLOYMENT_TARGET_WINDOWS
sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop;
#endif
复制代码
第八步:根据当前 RunLoop 状态判断是否须要进入下一个 loop。当被外部强制中止或者 loop 超时,就不继续下一个 loop,不然进入下一个 loop
if (sourceHandledThisLoop && stopAfterHandle) {
// 进入loop时参数说处理完事件就返回
retVal = kCFRunLoopRunHandledSource;
} else if (timeout_context->termTSR < mach_absolute_time()) {
// 超出传入参数标记的超时时间了
retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(rl)) {
__CFRunLoopUnsetStopped(rl);
// 被外部调用者强制中止了
retVal = kCFRunLoopRunStopped;
} else if (rlm->_stopped) {
rlm->_stopped = false;
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) {
// source/timer一个都没有
retVal = kCFRunLoopRunFinished;
}
复制代码
完整且带有注释的 RunLoop 代码见此处。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质仍是调用 Source0 事件的处理函数。
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // 进入 loop
kCFRunLoopBeforeTimers , // 触发 Timer 回调
kCFRunLoopBeforeSources , // 触发 Source0 回调
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
kCFRunLoopExit , // 退出 loop
kCFRunLoopAllActivities // loop 全部状态改变
}
复制代码
RunLoop 在进入睡眠前的方法执行时间过长而致使没法进入睡眠,或者线程唤醒后接收消息时间过长而没法进入下一步,都会阻塞线程。若是是主线程,则表现为卡顿。
一旦发现进入睡眠前的 KCFRunLoopBeforeSources 状态,或者唤醒后 KCFRunLoopAfterWaiting,在设置的时间阈值内没有变化,则可判断为卡顿,此时 dump 堆栈信息,还原案发现场,进而解决卡顿问题。
开启一个子线程,不断进行循环监测是否卡顿了。在 n 次都超过卡顿阈值后则认为卡顿了。卡顿以后进行堆栈 dump 并上报(具备必定的机制,数据处理在下一 part 讲)。
WatchDog 在不一样状态下具备不一样的值。
卡顿阈值的设置的依据是 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 状态那么多,为何选择 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?由于大部分卡顿都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之间。好比 Source0 类型的 App 内部事件等
Runloop 检测卡顿流程图以下:
关键代码以下:
// 设置Runloop observer的运行环境
CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
// 建立Runloop observer对象
_observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
// 将新建的observer加入到当前thread的runloop
CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
// 建立信号
_semaphore = dispatch_semaphore_create(0);
__weak __typeof(self) weakSelf = self;
// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
while (YES) {
if (strongSelf.isCancel) {
return;
}
// N次卡顿超过阈值T记录为一次卡顿
long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC));
if (semaphoreWait != 0) {
if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) {
if (++strongSelf.countTime < strongSelf.standstillCount){
continue;
}
// 堆栈信息 dump 并结合数据上报机制,按照必定策略上传数据到服务器。堆栈 dump 会在下面讲解。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲
}
}
strongSelf.countTime = 0;
}
});
复制代码
开启一个子线程,建立一个初始值为0的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),若是没成功则认为猪线程发生了卡顿状况,此时 dump 堆栈信息并结合数据上报机制,按照必定策略上传数据到服务器。数据上报会在 打造功能强大、灵活可配置的数据上报组件 讲
while (self.isCancelled == NO) {
@autoreleasepool {
__block BOOL isMainThreadNoRespond = YES;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_main_queue(), ^{
isMainThreadNoRespond = NO;
dispatch_semaphore_signal(semaphore);
});
[NSThread sleepForTimeInterval:self.threshold];
if (isMainThreadNoRespond) {
if (self.handlerBlock) {
self.handlerBlock(); // 外部在 block 内部 dump 堆栈(下面会讲),数据上报
}
}
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
}
}
复制代码
方法堆栈的获取是一个麻烦事。理一下思路。[NSThread callStackSymbols]
能够获取当前线程的调用栈。可是当监控到卡顿发生,须要拿到主线程的堆栈信息就无能为力了。从任何线程回到主线程这条路走不通。先作个知识回顾。
在计算机科学中,调用堆栈是一种栈类型的数据结构,用于存储有关计算机程序的线程信息。这种栈也叫作执行堆栈、程序堆栈、控制堆栈、运行时堆栈、机器堆栈等。调用堆栈用于跟踪每一个活动的子例程在完成执行后应该返回控制的点。
维基百科搜索到 “Call Stack” 的一张图和例子,以下
DrawSquare
函数,它在执行的过程当中调用了
DrawLine
函数,用绿色部分表示。
能够看到栈帧由三部分组成:函数参数、返回地址、局部变量。好比在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数须要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。
栈指针 Stack Pointer 表示当前栈的顶部,大多部分操做系统都是栈向下生长,因此栈指针是最小值。帧指针 Frame Pointer 指向的地址中,存储了上一次 Stack Pointer 的值,也就是返回地址。
大多数操做系统中,每一个栈帧还保存了上一个栈帧的帧指针。所以知道当前栈帧的 Stack Pointer 和 Frame Pointer 就能够不断回溯,递归获取栈底的帧。
接下来的步骤就是拿到全部线程的 Stack Pointer 和 Frame Pointer。而后不断回溯,还原案发现场。
Mach task:
App 在运行的时候,会对应一个 Mach Task,而 Task 下可能有多条线程同时执行任务。《OS X and iOS Kernel Programming》 中描述 Mach Task 为:任务(Task)是一种容器对象,虚拟内存空间和其余资源都是经过这个容器对象管理的,这些资源包括设备和其余句柄。简单归纳为:Mack task 是一个机器无关的 thread 的执行环境抽象。
做用: task 能够理解为一个进程,包含它的线程列表。
结构体:task_threads,将 target_task 任务下的全部线程保存在 act_list 数组中,数组个数为 act_listCnt
kern_return_t task_threads
(
task_t traget_task,
thread_act_array_t *act_list, //线程指针列表
mach_msg_type_number_t *act_listCnt //线程个数
)
复制代码
thread_info:
kern_return_t thread_info
(
thread_act_t target_act,
thread_flavor_t flavor,
thread_info_t thread_info_out,
mach_msg_type_number_t *thread_info_outCnt
);
复制代码
如何获取线程的堆栈数据:
系统方法 kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);
能够获取到全部的线程,不过这种方法获取到的线程信息是最底层的 mach 线程。
对于每一个线程,能够用 kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt);
方法获取它的全部信息,信息填充在 _STRUCT_MCONTEXT
类型的参数中,这个方法中有2个参数随着 CPU 架构不一样而不一样。因此须要定义宏屏蔽不一样 CPU 之间的区别。
_STRUCT_MCONTEXT
结构体中,存储了当前线程的 Stack Pointer 和最顶部栈帧的 Frame pointer,进而回溯整个线程调用堆栈。
可是上述方法拿到的是内核线程,咱们须要的信息是 NSThread,因此须要将内核线程转换为 NSThread。
pthread 的 p 是 POSIX 的缩写,表示「可移植操做系统接口」(Portable Operating System Interface)。设计初衷是每一个系统都有本身独特的线程模型,且不一样系统对于线程操做的 API 都不同。因此 POSIX 的目的就是提供抽象的 pthread 以及相关 API。这些 API 在不一样的操做系统中有不一样的实现,可是完成的功能一致。
Unix 系统提供的 task_threads
和 thread_get_state
操做的都是内核系统,每一个内核线程由 thread_t 类型的 id 惟一标识。pthread 的惟一标识是 pthread_t 类型。其中内核线程和 pthread 的转换(即 thread_t 和 pthread_t)很容易,由于 pthread 设计初衷就是「抽象内核线程」。
memorystatus_action_neededpthread_create
方法建立线程的回调函数为 nsthreadLauncher。
static void *nsthreadLauncher(void* thread)
{
NSThread *t = (NSThread*)thread;
[nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil];
[t _setName: [t name]];
[t main];
[NSThread exit];
return NULL;
}
复制代码
NSThreadDidStartNotification 其实就是字符串 @"_NSThreadDidStartNotification"。
<NSThread: 0x...>{number = 1, name = main}
复制代码
为了 NSThread 和内核线程对应起来,只能经过 name 一一对应。 pthread 的 API pthread_getname_np
也可获取内核线程名字。np 表明 not POSIX,因此不能跨平台使用。
思路归纳为:将 NSThread 的原始名字存储起来,再将名字改成某个随机数(时间戳),而后遍历内核线程 pthread 的名字,名字匹配则 NSThread 和内核线程对应了起来。找到后将线程的名字还原成本来的名字。对于主线程,因为不能使用 pthread_getname_np
,因此在当前代码的 load 方法中获取到 thread_t,而后匹配名字。
static mach_port_t main_thread_id;
+ (void)load {
main_thread_id = mach_thread_self();
}
复制代码
应用启动时间是影响用户体验的重要因素之一,因此咱们须要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。
冷启动:App 还没有运行,必须加载并构建整个应用。完成应用的初始化。冷启动存在较大优化空间。冷启动时间从 application: didFinishLaunchingWithOptions:
方法开始计算,App 通常在这里进行各类 SDK 和 App 的基础初始化工做。
热启动:应用已经在后台运行(常见场景:好比用户使用 App 过程当中点击 Home 键,再打开 App),因为某些事件将应用唤醒到前台,App 会在 applicationWillEnterForeground:
方法接受应用进入前台的事件
思路比较简单。以下
load
方法中先拿到当前的时间值UIApplicationDidFinishLaunchingNotification
mach_absolute_time
是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增长。是一个纳秒级别的数字。获取先后2个纳秒后须要转换到秒。须要基于系统时间的基准,经过 mach_timebase_info
得到。
mach_timebase_info_data_t g_cmmStartupMonitorTimebaseInfoData = 0;
mach_timebase_info(&g_cmmStartupMonitorTimebaseInfoData);
uint64_t timelapse = mach_absolute_time() - g_cmmLoadTime;
double timeSpan = (timelapse * g_cmmStartupMonitorTimebaseInfoData.numer) / (g_cmmStartupMonitorTimebaseInfoData.denom * 1e9);
复制代码
要优化启动时间,就先得知道在启动阶段到底作了什么事情,针对现状做出方案。
pre-main 阶段定义为 App 开始启动到系统调用 main 函数这个阶段;main 阶段定义为 main 函数入口到主 UI 框架的 viewDidAppear。
App 启动过程:
+load()
函数;执行声明为 _attribute((constructor)) 的 c 函数;Pre-Main 阶段
Main 阶段
每一个动态库的加载,dyld 须要
优化:
优化:
优化:
+initialize
代替 +load
优化手段:
CPU(Central Processing Unit)中央处理器,市场上主流的架构有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。区别在于不一样的 CPU 设计理念和方法。
早期 CPU 所有是 CISC 架构,设计目的是用最少的机器语言指令来完成所需的计算任务。好比对于乘法运算,在 CISC 架构的 CPU 上。一条指令 MUL ADDRA, ADDRB
就能够将内存 ADDRA 和内存 ADDRB 中的数香乘,并将结果存储在 ADDRA 中。作的事情就是:将 ADDRA、ADDRB 中的数据读入到寄存器,相乘的结果写入到内存的操做依赖于 CPU 设计,因此 CISC 架构会增长 CPU 的复杂性和对 CPU 工艺的要求。
RISC 架构要求软件来指定各个操做步骤。好比上面的乘法,指令实现为 MOVE A, ADDRA; MOVE B, ADDRB; MUL A, B; STR ADDRA, A;
。这种架构能够下降 CPU 的复杂性以及容许在一样的工艺水平下生产出功能更增强大的 CPU,可是对于编译器的设计要求更高。
目前市场是大部分的 iPhone 都是基于 arm64 架构的。且 arm 架构能耗低。
讲完了区别来说下如何作 CPU 使用率的监控
线程信息结构体
struct thread_basic_info {
time_value_t user_time; /* user run time(用户运行时长) */
time_value_t system_time; /* system run time(系统运行时长) */
integer_t cpu_usage; /* scaled cpu usage percentage(CPU使用率,上限1000) */
policy_t policy; /* scheduling policy in effect(有效调度策略) */
integer_t run_state; /* run state (运行状态,见下) */
integer_t flags; /* various flags (各类各样的标记) */
integer_t suspend_count; /* suspend count for thread(线程挂起次数) */
integer_t sleep_time; /* number of seconds that thread
* has been sleeping(休眠时间) */
};
复制代码
代码在讲堆栈还原的时候讲过,忘记的看一下上面的分析
thread_act_array_t threads;
mach_msg_type_number_t threadCount = 0;
const task_t thisTask = mach_task_self();
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
if (kr != KERN_SUCCESS) {
return ;
}
for (int i = 0; i < threadCount; i++) {
thread_info_data_t threadInfo;
thread_basic_info_t threadBaseInfo;
mach_msg_type_number_t threadInfoCount;
kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
if (kr == KERN_SUCCESS) {
threadBaseInfo = (thread_basic_info_t)threadInfo;
// todo:条件判断,看不明白
if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
if (cpuUsage > CPUMONITORRATE) {
NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary];
NSData *CPUPayloadData = [NSData data];
NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread];
// 1. 组装卡顿的 Meta 信息
CPUMetaDictionary[@"MONITOR_TYPE"] = CMMonitorCPUType;
// 2. 组装卡顿的 Payload 信息(一个JSON对象,对象的 Key 为约定好的 STACK_TRACE, value 为 base64 后的堆栈信息)
NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding];
NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0];
NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)};
NSError *error;
// NSJSONWritingOptions 参数必定要传0,由于服务端须要根据 \n 处理逻辑,传递 0 则生成的 json 串不带 \n
NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error];
if (error) {
CMMLog(@"%@", error);
return;
}
CPUPayloadData = [parsedData copy];
// 3. 数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲
[[PrismClient sharedInstance] sendWithType:CMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData];
}
}
}
}
复制代码
硬盘:也叫作磁盘,用于存储数据。你存储的歌曲、图片、视频都是在硬盘里。
内存:因为硬盘读取速度较慢,若是 CPU 运行程序期间,全部的数据都直接从硬盘中读取,则很是影响效率。因此 CPU 会将程序运行所须要的数据从硬盘中读取到内存中。而后 CPU 与内存中的数据进行计算、交换。内存是易失性存储器(断电后,数据消失)。内存条区是计算机内部(在主板上)的一些存储器,用来保存 CPU 运算的中间数据和结果。内存是程序与 CPU 之间的桥梁。从硬盘读取出数据或者运行程序提供给 CPU。
虚拟内存 是计算机系统内存管理的一种技术。它使得程序认为它拥有连续的可用内存,而实际上,它一般被分割成多个物理内存碎片,可能部分暂时存储在外部磁盘(硬盘)存储器上(当须要使用时则用硬盘中数据交换到内存中)。Windows 系统中称为 “虚拟内存”,Linux/Unix 系统中称为 ”交换空间“。
iOS 不支持交换空间?不仅是 iOS 不支持交换空间,大多数手机系统都不支持。由于移动设备的大量存储器是闪存,它的读写速度远远小电脑所使用的硬盘,也就是说手机即便使用了交换空间技术,也由于闪存慢的问题,不能提高性能,因此索性就没有交换空间技术。
内存(RAM)与 CPU 同样都是系统中最稀少的资源,也很容易发生竞争,应用内存与性能直接相关。iOS 没有交换空间做为备选资源,因此内存资源尤其重要。
什么是 OOM?是 out-of-memory 的缩写,字面意思是超过了内存限制。分为 FOOM(foreground OOM)和 BOOM(background OOM)。它是由 iOS 的 Jetsam
机制形成的一种非主流 Crash,它不能经过 Signal 这种监控方案所捕获。
什么是 Jetsam 机制?Jetsam 机制能够理解为系统为了控制内存资源过分使用而采用的一种管理机制。Jetsam 机制是运行在一个独立的进程中,每一个进程都有一个内存阈值,一旦超过这个内存阈值,Jetsam 会当即杀掉这个进程。
为何设计 Jetsam 机制?由于设备的内存是有限的,因此内存资源很是重要。系统进程以及其余使用的 App 都会抢占这个资源。因为 iOS 不支持交换空间,一旦触发低内存事件,Jetsam 就会尽量多的释放 App 所在内存,这样 iOS 系统上出现内存不足时,App 就会被系统杀掉,变现为 crash。
2种状况触发 OOM:系统因为总体内存使用太高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "highg water mark" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。
读了源码(xnu/bsd/kern/kern_memorystatus.c)会发现内存被杀也有2种机制,以下
highwater 处理 -> 咱们的 App 占用内存不能超过单个限制
memorystatus_act_aggressive 处理 -> 内存占用高,按照优先级杀死
内存过大的几种状况
App 内存不足时,系统会按照必定策略来腾出更多的空间供使用。比较常见的作法是将一部分优先级低的数据挪到磁盘上,该操做为称为 page out。以后再次访问这块数据的时候,系统会负责将它从新搬回到内存中,该操做被称为 page in。
Memory page** 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。一般它是 16KB 大小,且有3种类型的 page。
Clean Memory Clean memory 包括3类:能够 page out
的内存、内存映射文件、App 使用到的 framework(每一个 framework 都有 _DATA_CONST 段,一般都是 clean 状态,但使用 runtime swizling,那么变为 dirty)。
一开始分配的 page 都是干净的(堆里面的对象分配除外),咱们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。
Dirty Memory
Dirty memory 包括4类:被 App 写入过数据的内存、全部堆区分配的对象、图像解码缓冲区、framework(framework 都有 _DATA 段和 _DATA_DIRTY 段,它们的内存都是 dirty)。
在使用 framework 的过程当中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减小 Dirty memory(由于单例一旦建立就不销毁,一直在内存中,系统不认为是 Dirty memory)。
Compressed Memory
因为闪存容量和读写限制,iOS 没有交换空间机制,而是在 iOS7 引入了 memory compressor。它是在内存紧张时候可以将最近一段时间未使用过的内存对象,内存压缩器会把对象压缩,释放出更多的 page。在须要时内存压缩器对其解压复用。在节省内存的同时提升了响应速度。
好比 App 使用某 Framework,内部有个 NSDictionary 属性存储数据,使用了 3 pages 内存,在近期未被访问的时候 memory compressor 将其压缩为 1 page,再次使用的时候还原为 3 pages。
App 运行内存 = pageNumbers * pageSize。由于 Compressed Memory 属于 Dirty memory。因此 Memory footprint = dirtySize + CompressedSize
设备不一样,内存占用上限不一样,App 上限较高,extension 上限较低,超过上限 crash 到 EXC_RESOURCE_EXCEPTION
。
接下来谈一下如何获取内存上限,以及如何监控 App 由于占用内存过大而被强杀。
当 App 被 Jetsam 机制杀死时,手机会生成系统日志。查看路径:Settings-Privacy-Analytics & Improvements- Analytics Data(设置-隐私- 分析与改进-分析数据),能够看到 JetsamEvent-2020-03-14-161828.ips
形式的日志,以 JetsamEvent 开头。这些 JetsamEvent 日志都是 iOS 系统内核强杀掉那些优先级不高(idle、frontmost、suspended)且占用内存超过系统内存限制的 App 留下的。
日志包含了 App 的内存信息。能够查看到 日志最顶部有 pageSize
字段,查找到 per-process-limit,该节点所在结构里的 rpages
,将 rpages * pageSize 便可获得 OOM 的阈值。
日志中 largestProcess 字段表明 App 名称;reason 字段表明内存缘由;states 字段表明奔溃时 App 的状态( idle、suspended、frontmost...)。
为了测试数据的准确性,我将测试2台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的全部 App 完全退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码以下
- (void)viewDidLoad {
[super viewDidLoad];
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
UIImage *image = [UIImage imageNamed:@"AppIcon"];
imageView.image = image;
[array addObject:imageView];
}
}
复制代码
iPhone 6s plus/13.3.1 数据以下:
{"bug_type":"298","timestamp":"2020-03-19 17:23:45.94 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"DA8AF66D-24E8-458C-8734-981866942168"}
{
"crashReporterKey" : "fc9b659ce486df1ed1b8062d5c7c977a7eb8c851",
"kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:10:44 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_S8000",
"product" : "iPhone8,2",
"incident" : "DA8AF66D-24E8-458C-8734-981866942168",
"date" : "2020-03-19 17:23:45.93 +0800",
"build" : "iPhone OS 13.3.1 (17D50)",
"timeDelta" : 332,
"memoryStatus" : {
"compressorSize" : 48499,
"compressions" : 7458651,
"decompressions" : 5190200,
"zoneMapCap" : 744407040,
"largestZone" : "APFS_4K_OBJS",
"largestZoneSize" : 41402368,
"pageSize" : 16384,
"uncompressed" : 104065,
"zoneMapSize" : 141606912,
"memoryPages" : {
"active" : 26214,
"throttled" : 0,
"fileBacked" : 14903,
"wired" : 20019,
"anonymous" : 37140,
"purgeable" : 142,
"inactive" : 23669,
"free" : 2967,
"speculative" : 2160
}
},
"largestProcess" : "Test",
"genCounter" : 0,
"processes" : [
{
"uuid" : "39c5738b-b321-3865-a731-68064c4f7a6f",
"states" : [
"daemon",
"idle"
],
"lifetimeMax" : 188,
"age" : 948223699030,
"purgeable" : 0,
"fds" : 25,
"coalition" : 422,
"rpages" : 177,
"pid" : 282,
"idleDelta" : 824711280,
"name" : "com.apple.Safari.SafeBrowsing.Se",
"cpuTime" : 10.275422000000001
},
// ...
{
"uuid" : "83dbf121-7c0c-3ab5-9b66-77ee926e1561",
"states" : [
"frontmost"
],
"killDelta" : 2592,
"genCount" : 0,
"age" : 1531004794,
"purgeable" : 0,
"fds" : 50,
"coalition" : 1047,
"rpages" : 92806,
"reason" : "per-process-limit",
"pid" : 2384,
"cpuTime" : 59.464373999999999,
"name" : "Test",
"lifetimeMax" : 92806
},
// ...
]
}
复制代码
iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384*92806)/(1024*1024)=1450.09375M
iPhone 11 Pro/13.3.1 数据以下:
{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"}
{
"crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276",
"kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030",
"product" : "iPhone12,3",
"incident" : "7F111601-BC7A-4BD7-A468-CE3370053057",
"date" : "2020-03-19 17:30:28.39 +0800",
"build" : "iPhone OS 13.3.1 (17D50)",
"timeDelta" : 189,
"memoryStatus" : {
"compressorSize" : 66443,
"compressions" : 25498129,
"decompressions" : 15532621,
"zoneMapCap" : 1395015680,
"largestZone" : "APFS_4K_OBJS",
"largestZoneSize" : 41222144,
"pageSize" : 16384,
"uncompressed" : 127027,
"zoneMapSize" : 169639936,
"memoryPages" : {
"active" : 58652,
"throttled" : 0,
"fileBacked" : 20291,
"wired" : 45838,
"anonymous" : 96445,
"purgeable" : 4,
"inactive" : 54368,
"free" : 5461,
"speculative" : 3716
}
},
"largestProcess" : "杭城小刘",
"genCounter" : 0,
"processes" : [
{
"uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7",
"states" : [
"daemon",
"idle"
],
"lifetimeMax" : 171,
"age" : 5151034269954,
"purgeable" : 0,
"fds" : 50,
"coalition" : 66,
"rpages" : 164,
"pid" : 11276,
"idleDelta" : 3801132318,
"name" : "wcd",
"cpuTime" : 3.430787
},
// ...
{
"uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0",
"states" : [
"frontmost"
],
"killDelta" : 4345,
"genCount" : 0,
"age" : 654480778,
"purgeable" : 0,
"fds" : 50,
"coalition" : 1718,
"rpages" : 134278,
"reason" : "per-process-limit",
"pid" : 14206,
"cpuTime" : 23.955463999999999,
"name" : "杭城小刘",
"lifetimeMax" : 134278
},
// ...
]
}
复制代码
iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384*134278)/(1024*1024)=2098.09375M
iOS 系统如何发现 Jetsam ?
MacOS/iOS 是一个 BSD 衍生而来的系统,其内核是 Mach,可是对于上层暴露的接口通常是基于 BSD 层对 Mach 的包装后的。Mach 是一个微内核的架构,真正的虚拟内存管理也是在其中进行的,BSD 对内存管理提供了上层接口。Jetsam 事件也是由 BSD 产生的。bsd_init
函数是入口,其中基本都是在初始化各个子系统,好比虚拟内存管理等。
// 1. Initialize the kernel memory allocator, 初始化 BSD 内存 Zone,这个 Zone 是基于 Mach 内核的zone 构建
kmeminit();
// 2. Initialise background freezing, iOS 上独有的特性,内存和进程的休眠的常驻监控线程
#if CONFIG_FREEZE
#ifndef CONFIG_MEMORYSTATUS
#error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS"
#endif
/* Initialise background freezing */
bsd_init_kprintf("calling memorystatus_freeze_init\n");
memorystatus_freeze_init();
#endif>
// 3. iOS 独有,JetSAM(即低内存事件的常驻监控线程)
#if CONFIG_MEMORYSTATUS
/* Initialize kernel memory status notifications */
bsd_init_kprintf("calling memorystatus_init\n");
memorystatus_init();
#endif /* CONFIG_MEMORYSTATUS */
复制代码
主要做用就是开启了2个优先级最高的线程,来监控整个系统的内存状况。
CONFIG_FREEZE 开启时,内核对进程进行冷冻而不是杀死。冷冻功能是由内核中启动一个 memorystatus_freeze_thread
进行,这个进程在收到信号后调用 memorystatus_freeze_top_process
进行冷冻。
iOS 系统会开启优先级最高的线程 vm_pressure_monitor
来监控系统的内存压力状况,并经过一个堆栈来维护全部 App 进程。iOS 系统还会维护一个内存快照表,用于保存每一个进程内存页的消耗状况。有关 Jetsam 也就是 memorystatus 相关的逻辑,能够在 XNU 项目中的 kern_memorystatus.h 和 **kern_memorystatus.c **源码中查看。
iOS 系统因内存占用太高会强杀 App 前,至少有 6秒钟能够用来作优先级判断,JetsamEvent 日志也是在这6秒内生成的。
上文提到了 iOS 系统没有交换空间,因而引入了 MemoryStatus 机制(也称为 Jetsam)。也就是说在 iOS 系统上释放尽量多的内存供当前 App 使用。这个机制表如今优先级上,就是先强杀后台应用;若是内存仍是不够多,就强杀掉当前应用。在 MacOS 中,MemoryStatus 只会强杀掉标记为空闲退出的进程。
MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 的线程,它负责强杀 App 和记录日志,不会发送消息,因此内存压力检测线程没法获取到强杀 App 的消息。
当监控线程发现某 App 有内存压力时,就发出通知,此时有内存的 App 就去执行 didReceiveMemoryWarning
代理方法。在这个时机,咱们还有机会作一些内存资源释放的逻辑,也许会避免 App 被系统杀死。
源码角度查看问题
iOS 系统内核有一个数组,专门维护线程的优先级。数组的每一项是一个包含进程链表的结构体。结构体以下:
#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1)
typedef struct memstat_bucket {
TAILQ_HEAD(, proc) list;
int count;
} memstat_bucket_t;
memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT];
复制代码
在 kern_memorystatus.h 中能够看到进行优先级信息
#define JETSAM_PRIORITY_IDLE_HEAD -2
/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */
#define JETSAM_PRIORITY_IDLE 0
#define JETSAM_PRIORITY_IDLE_DEFERRED 1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/
#define JETSAM_PRIORITY_AGING_BAND1 JETSAM_PRIORITY_IDLE_DEFERRED
#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC 2
#define JETSAM_PRIORITY_AGING_BAND2 JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC
#define JETSAM_PRIORITY_BACKGROUND 3
#define JETSAM_PRIORITY_ELEVATED_INACTIVE JETSAM_PRIORITY_BACKGROUND
#define JETSAM_PRIORITY_MAIL 4
#define JETSAM_PRIORITY_PHONE 5
#define JETSAM_PRIORITY_UI_SUPPORT 8
#define JETSAM_PRIORITY_FOREGROUND_SUPPORT 9
#define JETSAM_PRIORITY_FOREGROUND 10
#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY 12
#define JETSAM_PRIORITY_CONDUCTOR 13
#define JETSAM_PRIORITY_HOME 16
#define JETSAM_PRIORITY_EXECUTIVE 17
#define JETSAM_PRIORITY_IMPORTANT 18
#define JETSAM_PRIORITY_CRITICAL 19
#define JETSAM_PRIORITY_MAX 21
复制代码
能够明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为10。
优先级规则是:内核线程优先级 > 操做系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被下降。
在 kern_memorystatus.c 中能够看到 OOM 可能的缘由:
/* For logging clarity */
static const char *memorystatus_kill_cause_name[] = {
"" , /* kMemorystatusInvalid */
"jettisoned" , /* kMemorystatusKilled */
"highwater" , /* kMemorystatusKilledHiwat */
"vnode-limit" , /* kMemorystatusKilledVnodes */
"vm-pageshortage" , /* kMemorystatusKilledVMPageShortage */
"proc-thrashing" , /* kMemorystatusKilledProcThrashing */
"fc-thrashing" , /* kMemorystatusKilledFCThrashing */
"per-process-limit" , /* kMemorystatusKilledPerProcessLimit */
"disk-space-shortage" , /* kMemorystatusKilledDiskSpaceShortage */
"idle-exit" , /* kMemorystatusKilledIdleExit */
"zone-map-exhaustion" , /* kMemorystatusKilledZoneMapExhaustion */
"vm-compressor-thrashing" , /* kMemorystatusKilledVMCompressorThrashing */
"vm-compressor-space-shortage" , /* kMemorystatusKilledVMCompressorSpaceShortage */
};
复制代码
查看 memorystatus_init 这个函数中初始化 Jetsam 线程的关键代码
__private_extern__ void memorystatus_init(void) {
// ...
/* Initialize the jetsam_threads state array */
jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads);
/* Initialize all the jetsam threads */
for (i = 0; i < max_jetsam_threads; i++) {
result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);
if (result == KERN_SUCCESS) {
jetsam_threads[i].inited = FALSE;
jetsam_threads[i].index = i;
thread_deallocate(jetsam_threads[i].thread);
} else {
panic("Could not create memorystatus_thread %d", i);
}
}
}
复制代码
/*
* High-level priority assignments
*
*************************************************************************
* 127 Reserved (real-time)
* A
* +
* (32 levels)
* +
* V
* 96 Reserved (real-time)
* 95 Kernel mode only
* A
* +
* (16 levels)
* +
* V
* 80 Kernel mode only
* 79 System high priority
* A
* +
* (16 levels)
* +
* V
* 64 System high priority
* 63 Elevated priorities
* A
* +
* (12 levels)
* +
* V
* 52 Elevated priorities
* 51 Elevated priorities (incl. BSD +nice)
* A
* +
* (20 levels)
* +
* V
* 32 Elevated priorities (incl. BSD +nice)
* 31 Default (default base for threads)
* 30 Lowered priorities (incl. BSD -nice)
* A
* +
* (20 levels)
* +
* V
* 11 Lowered priorities (incl. BSD -nice)
* 10 Lowered priorities (aged pri's)
* A
* +
* (11 levels)
* +
* V
* 0 Lowered priorities (aged pri's / idle)
*************************************************************************
*/
复制代码
能够看出:用户态的应用程序的线程不可能高于操做系统和内核。并且,用户态的应用程序间的线程优先级分配也有区别,好比处于前台的应用程序优先级高于处于后台的应用程序优先级。iOS 上应用程序优先级最高的是 SpringBoard;此外线程的优先级不是一成不变的。Mach 会根据线程的利用率和系统总体负载动态调整线程优先级。若是耗费 CPU 太多就下降线程优先级,若是线程过分挨饿,则会提高线程优先级。可是不管怎么变,程序都不能超过其所在线程的优先级区间范围。
能够看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(通常状况为1,特殊状况下可能为3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0~127。上文的宏定义是进程优先级,区间为:-2~19)。
紧接着,分析下 memorystatus_thread 函数,主要负责线程启动的初始化
static void memorystatus_thread(void *param __unused, wait_result_t wr __unused) {
//...
while (memorystatus_action_needed()) {
boolean_t killed;
int32_t priority;
uint32_t cause;
uint64_t jetsam_reason_code = JETSAM_REASON_INVALID;
os_reason_t jetsam_reason = OS_REASON_NULL;
cause = kill_under_pressure_cause;
switch (cause) {
case kMemorystatusKilledFCThrashing:
jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING;
break;
case kMemorystatusKilledVMCompressorThrashing:
jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING;
break;
case kMemorystatusKilledVMCompressorSpaceShortage:
jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE;
break;
case kMemorystatusKilledZoneMapExhaustion:
jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION;
break;
case kMemorystatusKilledVMPageShortage:
/* falls through */
default:
jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE;
cause = kMemorystatusKilledVMPageShortage;
break;
}
/* Highwater */
boolean_t is_critical = TRUE;
if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) {
if (is_critical == FALSE) {
/* * For now, don't kill any other processes. */
break;
} else {
goto done;
}
}
jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code);
if (jetsam_reason == OS_REASON_NULL) {
printf("memorystatus_thread: failed to allocate jetsam reason\n");
}
if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) {
goto done;
}
/* * memorystatus_kill_top_process() drops a reference, * so take another one so we can continue to use this exit reason * even after it returns */
os_reason_ref(jetsam_reason);
/* LRU */
killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors);
sort_flag = FALSE;
if (killed) {
if (memorystatus_post_snapshot(priority, cause) == TRUE) {
post_snapshot = TRUE;
}
/* Jetsam Loop Detection */
if (memorystatus_jld_enabled == TRUE) {
if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) {
jld_idle_kills++;
} else {
/* * We've reached into bands beyond idle deferred. * We make no attempt to monitor them */
}
}
if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) {
/* * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT * then we attempt to relieve pressure by purging corpse memory. */
task_purge_all_corpses();
corpse_list_purged = TRUE;
}
goto done;
}
if (memorystatus_avail_pages_below_critical()) {
/* * Still under pressure and unable to kill a process - purge corpse memory */
if (total_corpses_count() > 0) {
task_purge_all_corpses();
corpse_list_purged = TRUE;
}
if (memorystatus_avail_pages_below_critical()) {
/* * Still under pressure and unable to kill a process - panic */
panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages);
}
}
done:
}
复制代码
能够看到它开启了一个 循环,memorystatus_action_needed() 来做为循环条件,持续释放内存。
static boolean_t memorystatus_action_needed(void) {
#if CONFIG_EMBEDDED
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}
复制代码
它经过 vm_pagepout 发送的内存压力来判断当前内存资源是否紧张。几种状况:频繁的页面换出换进 is_reason_thrashing, Mach Zone 耗尽了 is_reason_zone_map_exhaustion、以及可用的页低于了 memory status_available_pages 这个门槛。
继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程当中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,经过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,若是进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。
一般来讲单个 App 很难触碰到 high water mark,若是不能结束任何进程,最终走到 memorystatus_act_aggressive,也就是大多数 OOM 发生的地方。
static boolean_t memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) {
// ...
if ( (jld_bucket_count == 0) ||
(jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) {
/* * Refresh evaluation parameters */
jld_timestamp_msecs = jld_now_msecs;
jld_idle_kill_candidates = jld_bucket_count;
*jld_idle_kills = 0;
jld_eval_aggressive_count = 0;
jld_priority_band_max = JETSAM_PRIORITY_UI_SUPPORT;
}
//...
}
复制代码
上述代码看到,判断要不要真正执行 kill 是根据必定的时间间判断的,条件是 jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs
。 也就是在 memorystatus_jld_eval_period_msecs 后才发生条件里面的 kill。
/* Jetsam Loop Detection */
if (max_mem <= (512 * 1024 * 1024)) {
/* 512 MB devices */
memorystatus_jld_eval_period_msecs = 8000; /* 8000 msecs == 8 second window */
} else {
/* 1GB and larger devices */
memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */
}
复制代码
其中 memorystatus_jld_eval_period_msecs 取值最小6秒。因此咱们能够在6秒内作些处理。
stackoverflow 上有一份数据,整理了各类设备的 OOM 临界值
device | crash amount:MB | total amount:MB | percentage of total |
---|---|---|---|
iPad1 | 127 | 256 | 49% |
iPad2 | 275 | 512 | 53% |
iPad3 | 645 | 1024 | 62% |
iPad4(iOS 8.1) | 585 | 1024 | 57% |
Pad Mini 1st Generation | 297 | 512 | 58% |
iPad Mini retina(iOS 7.1) | 696 | 1024 | 68% |
iPad Air | 697 | 1024 | 68% |
iPad Air 2(iOS 10.2.1) | 1383 | 2048 | 68% |
iPad Pro 9.7"(iOS 10.0.2 (14A456)) | 1395 | 1971 | 71% |
iPad Pro 10.5”(iOS 11 beta4) | 3057 | 4000 | 76% |
iPad Pro 12.9” (2015)(iOS 11.2.1) | 3058 | 3999 | 76% |
iPad 10.2(iOS 13.2.3) | 1844 | 2998 | 62% |
iPod touch 4th gen(iOS 6.1.1) | 130 | 256 | 51% |
iPod touch 5th gen | 286 | 512 | 56% |
iPhone4 | 325 | 512 | 63% |
iPhone4s | 286 | 512 | 56% |
iPhone5 | 645 | 1024 | 62% |
iPhone5s | 646 | 1024 | 63% |
iPhone6(iOS 8.x) | 645 | 1024 | 62% |
iPhone6 Plus(iOS 8.x) | 645 | 1024 | 62% |
iPhone6s(iOS 9.2) | 1396 | 2048 | 68% |
iPhone6s Plus(iOS 10.2.1) | 1396 | 2048 | 68% |
iPhoneSE(iOS 9.3) | 1395 | 2048 | 68% |
iPhone7(iOS 10.2) | 1395 | 2048 | 68% |
iPhone7 Plus(iOS 10.2.1) | 2040 | 3072 | 66% |
iPhone8(iOS 12.1) | 1364 | 1990 | 70% |
iPhoneX(iOS 11.2.1) | 1392 | 2785 | 50% |
iPhoneXS(iOS 12.1) | 2040 | 3754 | 54% |
iPhoneXS Max(iOS 12.1) | 2039 | 3735 | 55% |
iPhoneXR(iOS 12.1) | 1792 | 2813 | 63% |
iPhone11(iOS 13.1.3) | 2068 | 3844 | 54% |
iPhone11 Pro Max(iOS 13.2.3) | 2067 | 3740 | 55% |
咱们能够写定时器,不断的申请内存,以后再经过 phys_footprint
打印当前占用内存,按道理来讲不断申请内存便可触发 Jetsam 机制,强杀 App,那么最后一次打印的内存占用也就是当前设备的内存上限值。
timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES];
- (void)allocateMemory {
UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
UIImage *image = [UIImage imageNamed:@"AppIcon"];
imageView.image = image;
[array addObject:imageView];
memoryLimitSizeMB = [self usedSizeOfMemory];
if (memoryWarningSizeMB && memoryLimitSizeMB) {
NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB);
}
}
- (int)usedSizeOfMemory {
task_vm_info_data_t taskInfo;
mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);
if (kernReturn != KERN_SUCCESS) {
return 0;
}
return (int)(taskInfo.phys_footprint/1024.0/1024.0);
}
复制代码
iOS13 开始 <os/proc.h> 中 size_t os_proc_available_memory(void);
能够查看当前可用内存。
Return Value
The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns
0
.Discussion
Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device.
Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently.
Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs.
If you need more detailed information about the available memory resources, you can call
task_info
. However, be aware thattask_info
is an expensive call, whereas this function is much more efficient.
if (@available(iOS 13.0, *)) {
return os_proc_available_memory() / 1024.0 / 1024.0;
}
复制代码
App 内存信息的 API 能够在 Mach 层找到,mach_task_basic_info
结构体存储了 Mach task 的内存使用信息,其中 phys_footprint 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。
#define MACH_TASK_BASIC_INFO 20 /* always 64-bit basic info */
struct mach_task_basic_info {
mach_vm_size_t virtual_size; /* virtual memory size (bytes) */
mach_vm_size_t resident_size; /* resident memory size (bytes) */
mach_vm_size_t resident_size_max; /* maximum resident memory size (bytes) */
time_value_t user_time; /* total user run time for
terminated threads */
time_value_t system_time; /* total system run time for
terminated threads */
policy_t policy; /* default policy for new threads */
integer_t suspend_count; /* suspend count for task */
};
复制代码
因此获取代码为
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count);
if (kr != KERN_SUCCESS) {
return ;
}
CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0);
复制代码
可能有人好奇不该该是 resident_size
这个字段获取内存的使用状况吗?一开始测试后发现 resident_size 和 Xcode 测量结果差距较大。而使用 phys_footprint 则接近于 Xcode 给出的结果。且能够从 WebKit 源码中获得印证。
因此在 iOS13 上,咱们能够经过 os_proc_available_memory
获取到当前能够用内存,经过 phys_footprint
获取到当前 App 占用内存,2者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。
- (CGFloat)limitSizeOfMemory {
if (@available(iOS 13.0, *)) {
task_vm_info_data_t taskInfo;
mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);
if (kernReturn != KERN_SUCCESS) {
return 0;
}
return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0);
}
return 0;
}
复制代码
当前可使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值同样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384*92806)/(1024*1024)=1450.09375M」。
在 XNU 中,有专门用于获取内存上限值的函数和宏,能够经过 memorystatus_priority_entry
这个结构体获得全部进程的优先级和内存限制值。
typedef struct memorystatus_priority_entry {
pid_t pid;
int32_t priority;
uint64_t user_data;
int32_t limit;
uint32_t state;
} memorystatus_priority_entry_t;
复制代码
其中,priority 表明进程优先级,limit 表明进程的内存限制值。可是这种方式须要 root 权限,因为没有越狱设备,我没有尝试过。
相关代码可查阅 kern_memorystatus.h
文件。须要用到函数 int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);
/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE 11 /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12 /* Disable the 'lenient' mode for aggressive jetsam. */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS 13 /* Compute how much a process's phys_footprint exceeds inactive memory limit */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 15 /* Reset the inactive jetsam band for a process to the default band (0)*/
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED 16 /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */
#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED 17 /* Return the 'managed' status of a process */
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., 复制代码
伪代码
struct memorystatus_priority_entry memStatus[NUM_ENTRIES];
size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES;
int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count);
if (rc < 0) {
NSLog(@"memorystatus_control");
return ;
}
int entry = 0;
for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){
printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n",
memstatus[entry].pid,
memstatus[entry].priority,
memstatus[entry].user_data,
memstatus[entry].limit,
state_to_text(memstatus[entry].state));
entry++;
}
复制代码
for 循环打印出每一个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为10的进程,即咱们前台运行的 App。为何是10? 由于 #define JETSAM_PRIORITY_FOREGROUND 10
咱们的目的就是获取前台 App 的内存上限值。
OOM 致使 crash 前,app 必定会收到低内存警告吗?
作2组对比实验:
// 实验1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
NSData *data = [NSData dataWithContentsOfFile:filePath];
[array addObject:data];
}
复制代码
// 实验2
// ViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
NSData *data = [NSData dataWithContentsOfFile:filePath];
[array addObject:data];
}
});
}
- (void)didReceiveMemoryWarning
{
NSLog(@"2");
}
// AppDelegate.m
- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application
{
NSLog(@"1");
}
复制代码
现象:
applicationDidReceiveMemoryWarning
先执行,随后是当前 VC 的 didReceiveMemoryWarning
。结论:
收到低内存警告不必定会 Crash,由于有6秒钟的系统判断时间,6秒内内存降低了则不会 crash。发生 OOM 也不必定会收到低内存警告。
要想精确的定位问题,就须要 dump 全部对象及其内存信息。当内存接近系统内存上限的时候,收集并记录所需信息,结合必定的数据上报机制,上传到服务器,分析并修复。
还须要知道每一个对象具体是在哪一个函数里建立出来的,以便还原“案发现场”。
源代码(libmalloc/malloc),内存分配函数 malloc 和 calloc 等默认使用 nano_zone,nano_zone 是小于 256B 如下的内存分配,大于 256B 则使用 scalable_zone 来分配。
主要针对大内存的分配监控。malloc 函数用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。
使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,由于系统为了有个地方专门统计并管理内存分配状况。这样的设计也知足「收口原则」。
void * malloc(size_t size) {
void *retval;
retval = malloc_zone_malloc(default_zone, size);
if (retval == NULL) {
errno = ENOMEM;
}
return retval;
}
void * calloc(size_t num_items, size_t size) {
void *retval;
retval = malloc_zone_calloc(default_zone, num_items, size);
if (retval == NULL) {
errno = ENOMEM;
}
return retval;
}
复制代码
首先来看看这个 default_zone
是什么东西, 代码以下
typedef struct {
malloc_zone_t malloc_zone;
uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)];
} virtual_default_zone_t;
static virtual_default_zone_t virtual_default_zone
__attribute__((section("__DATA,__v_zone")))
__attribute__((aligned(PAGE_MAX_SIZE))) = {
NULL,
NULL,
default_zone_size,
default_zone_malloc,
default_zone_calloc,
default_zone_valloc,
default_zone_free,
default_zone_realloc,
default_zone_destroy,
DEFAULT_MALLOC_ZONE_STRING,
default_zone_batch_malloc,
default_zone_batch_free,
&default_zone_introspect,
10,
default_zone_memalign,
default_zone_free_definite_size,
default_zone_pressure_relief,
default_zone_malloc_claimed_address,
};
static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone;
static void * default_zone_malloc(malloc_zone_t *zone, size_t size) {
zone = runtime_default_zone();
return zone->malloc(zone, size);
}
MALLOC_ALWAYS_INLINE static inline malloc_zone_t * runtime_default_zone() {
return (lite_zone) ? lite_zone : inline_malloc_default_zone();
}
复制代码
能够看到 default_zone
经过这种方式来初始化
static inline malloc_zone_t * inline_malloc_default_zone(void) {
_malloc_initialize_once();
// malloc_report(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone);
return malloc_zones[0];
}
复制代码
随后的调用以下 _malloc_initialize
-> create_scalable_zone
-> create_scalable_szone
最终咱们建立了 szone_t 类型的对象,经过类型转换,获得了咱们的 default_zone。
malloc_zone_t *
create_scalable_zone(size_t initial_size, unsigned debug_flags) {
return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags);
}
复制代码
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) {
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
void *ptr;
if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
internal_check();
}
if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
return NULL;
}
ptr = zone->malloc(zone, size);
// 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录
if (malloc_logger) {
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
}
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
return ptr;
}
复制代码
其分配实现是 zone->malloc
根据以前的分析,就是szone_t结构体对象中对应的malloc实现。
在建立szone以后,作了一系列以下的初始化操做。
// Initialize the security token.
szone->cookie = (uintptr_t)malloc_entropy[0];
szone->basic_zone.version = 12;
szone->basic_zone.size = (void *)szone_size;
szone->basic_zone.malloc = (void *)szone_malloc;
szone->basic_zone.calloc = (void *)szone_calloc;
szone->basic_zone.valloc = (void *)szone_valloc;
szone->basic_zone.free = (void *)szone_free;
szone->basic_zone.realloc = (void *)szone_realloc;
szone->basic_zone.destroy = (void *)szone_destroy;
szone->basic_zone.batch_malloc = (void *)szone_batch_malloc;
szone->basic_zone.batch_free = (void *)szone_batch_free;
szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect;
szone->basic_zone.memalign = (void *)szone_memalign;
szone->basic_zone.free_definite_size = (void *)szone_free_definite_size;
szone->basic_zone.pressure_relief = (void *)szone_pressure_relief;
szone->basic_zone.claimed_address = (void *)szone_claimed_address;
复制代码
其余使用 scalable_zone 分配内存的函数的方法也相似,因此大内存的分配,无论外部函数如何封装,最终都会调用到 malloc_logger 函数。因此咱们能够用 fishhook 去 hook 这个函数,而后记录内存分配状况,结合必定的数据上报机制,上传到服务器,分析并修复。
// For logging VM allocation and deallocation, arg1 here
// is the mach_port_name_t of the target task in which the
// alloc or dealloc is occurring. For example, for mmap()
// that would be mach_task_self(), but for a cross-task-capable
// call such as mach_vm_map(), it is the target task.
typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);
extern malloc_logger_t *__syscall_logger;
复制代码
当 malloc_logger 和 __syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放经过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,咱们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈能够用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 dsym 解析符号。因此还要记录每一个 image 加载时的偏移 slide,这样 符号表地址 = 堆栈地址 - slide。
小 tips:
ASLR(Address space layout randomization):常见称呼为位址空间随机载入、位址空间配置随机化、位址空间布局随机化,是一种防止内存损坏漏洞被利用的计算机安全技术,经过随机放置进程关键数据区域的定址空间来放置攻击者能可靠地跳转到内存的特定位置来操做函数。现代做业系统通常都具有该机制。
函数地址 add: 函数真实的实现地址;
函数虚拟地址:vm_add
;
ASLR: slide
函数虚拟地址加载到进程内存的随机偏移量,每一个 mach-o 的 slide 各不相同。vm_add + slide = add
。也就是:*(base +offset)= imp
。
因为腾讯也开源了本身的 OOM 定位方案- OOMDetector ,有了现成的轮子,那么用好就能够了,因此对于内存的监控思路就是找到系统给 App 的内存上限,而后当接近内存上限值的时候,dump 内存状况,组装基础数据信息成一个合格的上报数据,通过必定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不一样工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(状况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 问题分析处理后要么发布新版本,要么 hot fix。
图片缩放
WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部份内存,还会生成中间位图 bitmap 消耗大量内存。而 ImageIO 不存在上述2种弊端,只会占用最终图片大小的内存
作了2组对比实验:给 App 显示一张图片
// 方法1: 19.6M
UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"] newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)];
self.imageView.image = imageResult;
// 方法2: 14M
NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]);
UIImage *imageResult = [self scaledImageWithData:data withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp];
self.imageView.image = imageResult;
- (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize
{
UIGraphicsBeginImageContextWithOptions(newSize, NO, 0);
[image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}
- (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation
{
CGFloat maxPixelSize = MAX(size.width, size.height);
CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil);
NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue,
(__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]};
CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options);
UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation];
CGImageRelease(imageRef);
CFRelease(sourceRef);
return resultImage;
}
复制代码
能够看出使用 ImageIO 比使用 UIImage 直接缩放占用内存更低。
合理使用 autoreleasepool
咱们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,咱们若是在不断申请内存,好比各类循环,那么咱们就须要手动添加 autoreleasepool,避免短期内内存猛涨发生 OOM。
对比实验
// 实验1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
[array addObject:resultString];
}
// 实验2
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
@autoreleasepool {
NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
[array addObject:resultString];
}
}
复制代码
实验1消耗内存 739.6M,实验2消耗内存 587M。
UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,否则会形成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。
无论是打开网页,仍是执行 js,都应该使用 WKWebView。UIWebView 会占用大量内存,从而致使 App 发生 OOM 的概率增长,而 WKWebView 是一个多进程组件,Network Loading 以及 UI Rendering 在其它进程中执行,比 UIWebView 占用更低的内存开销。
在作 SDK 或者 App,若是场景是缓存相关,尽可能使用 NSCache 而不是 NSMutableDictionary。它是系统提供的专门处理缓存的类,NSCache 分配的内存是 Purgeable Memory
,能够由系统自动释放。NSCache 与 NSPureableData 的结合使用可让系统根据状况回收内存,也能够在内存清理时移除对象。
其余的开发习惯就不一一描述了,良好的开发习惯和代码意识是须要平时注意修炼的。
移动网络环境一直很复杂,WIFI、2G、3G、4G、5G 等,用户使用 App 的过程当中可能在这几种类型之间切换,这也是移动网络和传统网络间的一个区别,被称为「Connection Migration」。此外还存在 DNS 解析缓慢、失败率高、运营商劫持等问题。用户在使用 App 时由于某些缘由致使体验不好,要想针对网络状况进行改善,必须有清晰的监控手段。
App 发送一次网络请求通常会经历下面几个关键步骤:
DNS 解析
Domain Name system,网络域名名称系统,本质上就是将域名
和IP 地址
相互映射的一个分布式数据库,令人们更方便的访问互联网。首先会查询本地的 DNS 缓存,查找失败就去 DNS 服务器查询,这其中可能会通过很是多的节点,涉及到递归查询和迭代查询的过程。运营商可能不干人事:一种状况就是出现运营商劫持的现象,表现为你在 App 内访问某个网页的时候会看到和内容不相关的广告;另外一种可能的状况就是把你的请求丢给很是远的基站去作 DNS 解析,致使咱们 App 的 DNS 解析时间较长,App 网络效率低。通常作 HTTPDNS 方案去自行解决 DNS 的问题。
TCP 3次握手
关于 TCP 握手过程当中为何是3次握手而不是2次、4次,能够查看这篇文章。
TLS 握手
对于 HTTPS 请求还须要作 TLS 握手,也就是密钥协商的过程。
发送请求
链接创建好以后就能够发送 request,此时能够记录下 request start 时间
等待回应
等待服务器返回响应。这个时间主要取决于资源大小,也是网络请求过程当中最为耗时的一个阶段。
返回响应
服务端返回响应给客户端,根据 HTTP header 信息中的状态码判断本次请求是否成功、是否走缓存、是否须要重定向。
名称 | 说明 |
---|---|
NSURLConnection | 已经被废弃。用法简单 |
NSURLSession | iOS7.0 推出,功能更强大 |
CFNetwork | NSURL 的底层,纯 C 实现 |
iOS 网络框架层级关系以下:
iOS 网络现状是由4层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。
目前业界对于网络监控主要有2种:一种是经过 NSURLProtocol 监控、一种是经过 Hook 来监控。下面介绍几种办法来监控网络请求,各有优缺点。
NSURLProtocol 做为上层接口,使用较为简单,但 NSURLProtocol 属于 URL Loading System 体系中。应用协议的支持程度有限,支持 FTP、HTTP、HTTPS 等几个应用层协议,对于其余的协议则没法监控,存在必定的局限性。若是监控底层网络库 CFNetwork 则没有这个限制。
对于 NSURLProtocol 的具体作法在这篇文章中讲过,继承抽象类并实现相应的方法,自定义去发起网络请求来实现监控的目的。
iOS 10 以后,NSURLSessionTaskDelegate 中增长了一个新的代理方法:
/*
* Sent when complete statistics information has been collected for the task.
*/
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
复制代码
能够从 NSURLSessionTaskMetrics
中获取到网络状况的各项指标。各项参数以下
@interface NSURLSessionTaskMetrics : NSObject
/*
* transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution.
*/
@property (copy, readonly) NSArray<NSURLSessionTaskTransactionMetrics *> *transactionMetrics;
/*
* Interval from the task creation time to the task completion time.
* Task creation time is the time when the task was instantiated.
* Task completion time is the time when the task is about to change its internal state to completed.
*/
@property (copy, readonly) NSDateInterval *taskInterval;
/*
* redirectCount is the number of redirects that were recorded.
*/
@property (assign, readonly) NSUInteger redirectCount;
- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
@end
复制代码
其中:taskInterval
表示任务从建立到完成话费的总时间,任务的建立时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;redirectCount
表示被重定向的次数;transactionMetrics
数组包含了任务执行过程当中每一个请求/响应事务中收集的指标,各项参数以下:
/*
* This class defines the performance metrics collected for a request/response transaction during the task execution.
*/
API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0))
@interface NSURLSessionTaskTransactionMetrics : NSObject
/*
* Represents the transaction request. 请求事务
*/
@property (copy, readonly) NSURLRequest *request;
/*
* Represents the transaction response. Can be nil if error occurred and no response was generated. 响应事务
*/
@property (nullable, copy, readonly) NSURLResponse *response;
/*
* For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding “EndDate” metric will be nil.
* For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics.
*/
/*
* 客户端开始请求的时间,不管是从服务器仍是从本地缓存中获取
* fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources.
*
* The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources:
*
* domainLookupStartDate
* domainLookupEndDate
* connectStartDate
* connectEndDate
* secureConnectionStartDate
* secureConnectionEndDate
*/
@property (nullable, copy, readonly) NSDate *fetchStartDate;
/*
* domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 开始解析的时间
*/
@property (nullable, copy, readonly) NSDate *domainLookupStartDate;
/*
* domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的时间
*/
@property (nullable, copy, readonly) NSDate *domainLookupEndDate;
/*
* connectStartDate is the time immediately before the user agent started establishing the connection to the server.
*
* For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客户端与服务端开始创建 TCP 链接的时间
*/
@property (nullable, copy, readonly) NSDate *connectStartDate;
/*
* If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手开始的时间
*
* For example, this would correspond to the time immediately before the user agent started the TLS handshake.
*
* If an encrypted connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSDate *secureConnectionStartDate;
/*
* If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手结束的时间
*
* If an encrypted connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSDate *secureConnectionEndDate;
/*
* connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客户端与服务器创建 TCP 链接完成的时间,包括 TLS 握手时间
*/
@property (nullable, copy, readonly) NSDate *connectEndDate;
/*
* requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources.
客户端请求开始的时间,能够理解为开始传输 HTTP 请求的 header 的第一个字节时间
*
* For example, this would correspond to the time immediately before the user agent sent an HTTP GET request.
*/
@property (nullable, copy, readonly) NSDate *requestStartDate;
/*
* requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources.
客户端请求结束的时间,能够理解为 HTTP 请求的最后一个字节传输完成的时间
*
* For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request.
*/
@property (nullable, copy, readonly) NSDate *requestEndDate;
/*
* responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources.
客户端从服务端接收响应的第一个字节的时间
*
* For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response.
*/
@property (nullable, copy, readonly) NSDate *responseStartDate;
/*
* responseEndDate is the time immediately after the user agent received the last byte of the resource. 客户端从服务端接收到最后一个请求的时间
*/
@property (nullable, copy, readonly) NSDate *responseEndDate;
/*
* The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301].
* E.g., h2, http/1.1, spdy/3.1.
网络协议名,好比 http/1.1, spdy/3.1
*
* When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol.
*
* For example:
* If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned.
* If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned.
* If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned.
*
*/
@property (nullable, copy, readonly) NSString *networkProtocolName;
/*
* This property is set to YES if a proxy connection was used to fetch the resource.
该链接是否使用了代理
*/
@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection;
/*
* This property is set to YES if a persistent connection was used to fetch the resource.
是否复用了现有链接
*/
@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection;
/*
* Indicates whether the resource was loaded, pushed or retrieved from the local cache.
获取资源来源
*/
@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType;
/*
* countOfRequestHeaderBytesSent is the number of bytes transferred for request header.
请求头的字节数
*/
@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* countOfRequestBodyBytesSent is the number of bytes transferred for request body.
请求体的字节数
* It includes protocol-specific framing, transfer encoding, and content encoding.
*/
@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream.
上传体数据、文件、流的大小
*/
@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* countOfResponseHeaderBytesReceived is the number of bytes transferred for response header.
响应头的字节数
*/
@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* countOfResponseBodyBytesReceived is the number of bytes transferred for response body.
响应体的字节数
* It includes protocol-specific framing, transfer encoding, and content encoding.
*/
@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler.
给代理方法或者完成后处理的回调的数据大小
*/
@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* localAddress is the IP address string of the local interface for the connection.
当前链接下的本地接口 IP 地址
*
* For multipath protocols, this is the local address of the initial flow.
*
* If a connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* localPort is the port number of the local interface for the connection.
当前链接下的本地端口号
*
* For multipath protocols, this is the local port of the initial flow.
*
* If a connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* remoteAddress is the IP address string of the remote interface for the connection.
当前链接下的远端 IP 地址
*
* For multipath protocols, this is the remote address of the initial flow.
*
* If a connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* remotePort is the port number of the remote interface for the connection.
当前链接下的远端端口号
*
* For multipath protocols, this is the remote port of the initial flow.
*
* If a connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection.
链接协商用的 TLS 协议版本号
* It is a 2-byte sequence in host byte order.
*
* Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h
*
* If an encrypted connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection.
链接协商用的 TLS 密码套件
* It is a 2-byte sequence in host byte order.
*
* Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h
*
* If an encrypted connection was not used, this attribute is set to nil.
*/
@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* Whether the connection is established over a cellular interface.
是不是经过蜂窝网络创建的链接
*/
@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* Whether the connection is established over an expensive interface.
是否经过昂贵的接口创建的链接
*/
@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* Whether the connection is established over a constrained interface.
是否经过受限接口创建的链接
*/
@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
/*
* Whether a multipath protocol is successfully negotiated for the connection.
是否为了链接成功协商了多路径协议
*/
@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));
- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
@end
复制代码
网络监控简单代码
// 监控基础信息
@interface NetworkMonitorBaseDataModel : NSObject
// 请求的 URL 地址
@property (nonatomic, strong) NSString *requestUrl;
//请求头
@property (nonatomic, strong) NSArray *requestHeaders;
//响应头
@property (nonatomic, strong) NSArray *responseHeaders;
//GET方法 的请求参数
@property (nonatomic, strong) NSString *getRequestParams;
//HTTP 方法, 好比 POST
@property (nonatomic, strong) NSString *httpMethod;
//协议名,如http1.0 / http1.1 / http2.0
@property (nonatomic, strong) NSString *httpProtocol;
//是否使用代理
@property (nonatomic, assign) BOOL useProxy;
//DNS解析后的 IP 地址
@property (nonatomic, strong) NSString *ip;
@end
// 监控信息模型
@interface NetworkMonitorDataModel : NetworkMonitorBaseDataModel
//客户端发起请求的时间
@property (nonatomic, assign) UInt64 requestDate;
//客户端开始请求到开始dns解析的等待时间,单位ms
@property (nonatomic, assign) int waitDNSTime;
//DNS 解析耗时
@property (nonatomic, assign) int dnsLookupTime;
//tcp 三次握手耗时,单位ms
@property (nonatomic, assign) int tcpTime;
//ssl 握手耗时
@property (nonatomic, assign) int sslTime;
//一个完整请求的耗时,单位ms
@property (nonatomic, assign) int requestTime;
//http 响应码
@property (nonatomic, assign) NSUInteger httpCode;
//发送的字节数
@property (nonatomic, assign) UInt64 sendBytes;
//接收的字节数
@property (nonatomic, assign) UInt64 receiveBytes;
// 错误信息模型
@interface NetworkMonitorErrorModel : NetworkMonitorBaseDataModel
//错误码
@property (nonatomic, assign) NSInteger errorCode;
//错误次数
@property (nonatomic, assign) NSUInteger errCount;
//异常名
@property (nonatomic, strong) NSString *exceptionName;
//异常详情
@property (nonatomic, strong) NSString *exceptionDetail;
//异常堆栈
@property (nonatomic, strong) NSString *stackTrace;
@end
// 继承自 NSURLProtocol 抽象类,实现响应方法,代理网络请求
@interface CustomURLProtocol () <NSURLSessionTaskDelegate>
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue;
@property (nonatomic, strong) NetworkMonitorDataModel *dataModel;
@property (nonatomic, strong) NetworkMonitorErrorModel *errModel;
@end
//使用NSURLSessionDataTask请求网络
- (void)startLoading {
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration
delegate:self
delegateQueue:nil];
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
self.sessionDelegateQueue = [[NSOperationQueue alloc] init];
self.sessionDelegateQueue.maxConcurrentOperationCount = 1;
self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue";
self.dataTask = [session dataTaskWithRequest:self.request];
[self.dataTask resume];
}
#pragma mark - NSURLSessionTaskDelegate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
if (error) {
[self.client URLProtocol:self didFailWithError:error];
} else {
[self.client URLProtocolDidFinishLoading:self];
}
if (error) {
NSURLRequest *request = task.currentRequest;
if (request) {
self.errModel.requestUrl = request.URL.absoluteString;
self.errModel.httpMethod = request.HTTPMethod;
self.errModel.requestParams = request.URL.query;
}
self.errModel.errorCode = error.code;
self.errModel.exceptionName = error.domain;
self.errModel.exceptionDetail = error.description;
// 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲
}
self.dataTask = nil;
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics {
if (@available(iOS 10.0, *) && [metrics.transactionMetrics count] > 0) {
[metrics.transactionMetrics enumerateObjectsUsingBlock:^(NSURLSessionTaskTransactionMetrics *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if (obj.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad) {
if (obj.fetchStartDate) {
self.dataModel.requestDate = [obj.fetchStartDate timeIntervalSince1970] * 1000;
}
if (obj.domainLookupStartDate && obj.domainLookupEndDate) {
self.dataModel. waitDNSTime = ceil([obj.domainLookupStartDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
self.dataModel. dnsLookupTime = ceil([obj.domainLookupEndDate timeIntervalSinceDate:obj.domainLookupStartDate] * 1000);
}
if (obj.connectStartDate) {
if (obj.secureConnectionStartDate) {
self.dataModel. waitDNSTime = ceil([obj.secureConnectionStartDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
} else if (obj.connectEndDate) {
self.dataModel.tcpTime = ceil([obj.connectEndDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
}
}
if (obj.secureConnectionEndDate && obj.secureConnectionStartDate) {
self.dataModel.sslTime = ceil([obj.secureConnectionEndDate timeIntervalSinceDate:obj.secureConnectionStartDate] * 1000);
}
if (obj.fetchStartDate && obj.responseEndDate) {
self.dataModel.requestTime = ceil([obj.responseEndDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
}
self.dataModel.httpProtocol = obj.networkProtocolName;
NSHTTPURLResponse *response = (NSHTTPURLResponse *)obj.response;
if ([response isKindOfClass:NSHTTPURLResponse.class]) {
self.dataModel.receiveBytes = response.expectedContentLength;
}
if ([obj respondsToSelector:@selector(_remoteAddressAndPort)]) {
self.dataModel.ip = [obj valueForKey:@"_remoteAddressAndPort"];
}
if ([obj respondsToSelector:@selector(_requestHeaderBytesSent)]) {
self.dataModel.sendBytes = [[obj valueForKey:@"_requestHeaderBytesSent"] unsignedIntegerValue];
}
if ([obj respondsToSelector:@selector(_responseHeaderBytesReceived)]) {
self.dataModel.receiveBytes = [[obj valueForKey:@"_responseHeaderBytesReceived"] unsignedIntegerValue];
}
self.dataModel.requestUrl = [obj.request.URL absoluteString];
self.dataModel.httpMethod = obj.request.HTTPMethod;
self.dataModel.useProxy = obj.isProxyConnection;
}
}];
// 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲
}
}
复制代码
文章上面 2.1 分析到了 NSURLSessionTaskMetrics 因为兼容性问题,对于网络监控来讲彷佛不太完美,可是自后在搜资料的时候看到了一篇文章。文章在分析 WebView 的网络监控的时候分析 Webkit 源码的时候发现了下面代码
#if !HAVE(TIMINGDATAOPTIONS)
void setCollectsTimingData()
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[NSURLConnection _setCollectsTimingData:YES];
...
});
}
#endif
复制代码
也就是说明 NSURLConnection 自己有一套 TimingData
的收集 API,只是没有暴露给开发者,苹果本身在用而已。在 runtime header 中找到了 NSURLConnection 的 _setCollectsTimingData:
、_timingData
2个 api(iOS8 之后可使用)。
NSURLSession 在 iOS9 以前使用 _setCollectsTimingData:
就可使用 TimingData 了。
注意:
[[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]
。@interface _NSURLConnectionProxy : DelegateProxy
@end
@implementation _NSURLConnectionProxy
- (BOOL)respondsToSelector:(SEL)aSelector
{
if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) {
return YES;
}
return [self.target respondsToSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
[super forwardInvocation:invocation];
if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) {
__unsafe_unretained NSURLConnection *conn;
[invocation getArgument:&conn atIndex:2];
SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]);
NSDictionary *timingData = [conn performSelector:selector];
[[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest];
}
}
@end
@implementation NSURLConnection(tracker)
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(initWithRequest:delegate:);
SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:);
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"];
SEL selector = NSSelectorFromString(selectorName);
[NSURLConnection performSelector:selector withObject:@(YES)];
});
}
- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id<NSURLConnectionDelegate>)delegate
{
if (delegate) {
_NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate];
objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return [self swizzledInitWithRequest:request delegate:(id<NSURLConnectionDelegate>)proxy];
}else{
return [self swizzledInitWithRequest:request delegate:delegate];
}
}
@end
复制代码
iOS 中 hook 技术有2类,一种是 NSProxy,一种是 method swizzling(isa swizzling)
写 SDK 确定不可能手动侵入业务代码(你没那个权限提交到线上代码 😂),因此无论是 APM 仍是无痕埋点都是经过 Hook 的方式。
面向切面程序设计(Aspect-oriented Programming,AOP)是计算机科学中的一种程序设计范型,将横切关注点与业务主体进一步分离,以提升程序代码的模块化程度。在不修改源代码的状况下给程序动态增长功能。其核心思想是将业务逻辑(核心关注点,系统主要功能)与公共功能(横切关注点,好比日志系统)进行分离,下降复杂性,保持系统模块化程度、可维护性、可重用性。常被用在日志系统、性能统计、安全控制、事务处理、异常处理等场景下。
在 iOS 中 AOP 的实现是基于 Runtime 机制,目前由3种方式:Method Swizzling、NSProxy、FishHook(主要用用于 hook c 代码)。
文章上面 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 讨论了从 Webkit 源码中获得的信息,经过私有方法 _setCollectsTimingData:
、_timingData
能够获取到 TimingData。
可是若是须要监所有的网络请求就不能知足需求了,查阅资料后发现了阿里百川有 APM 的解决方案,因而有了方案3,对于网络监控须要作以下的处理
可能对于 CFNetwork 比较陌生,能够看一下 CFNetwork 的层级和简单用法
CFNetwork 的基础是 CFSocket 和 CFStream。
CFSocket:Socket 是网络通讯的底层基础,可让2个 socket 端口互发数据,iOS 中最经常使用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了全部的 BSD 功能,此外加入了 RunLoop。
CFStream:提供了与设备无关的读写数据方法,使用它能够为内存、文件、网络(使用 socket)的数据创建流,使用 stream 能够没必要将全部数据写入到内存中。CFStream 提供 API 对2种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。
简单 Demo
- (void)testCFNetwork
{
CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL);
CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1);
CFRelease(urlRef);
CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef);
CFRelease(httpMessageRef);
CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered);
CFStreamClientContext context = {
0,
NULL,
NULL,
NULL,
NULL
} ;
// Assigns a client to a stream, which receives callbacks when certain events occur.
CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context);
// Opens a stream for reading.
CFReadStreamOpen(readStream);
}
// callback
void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) {
CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0);
CFIndex numberOfBytesRead = 0;
do {
UInt8 buffer[2014];
numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer));
if (numberOfBytesRead > 0) {
CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead);
}
} while (numberOfBytesRead > 0);
CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader);
if (responseBytes) {
if (response) {
CFHTTPMessageSetBody(response, responseBytes);
}
CFRelease(responseBytes);
}
// close and cleanup
CFReadStreamClose(stream);
CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
CFRelease(stream);
// print response
if (response) {
CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response);
CFRelease(response);
printResponseData(reponseBodyData);
CFRelease(reponseBodyData);
}
}
void printResponseData (CFDataRef responseData) {
CFIndex dataLength = CFDataGetLength(responseData);
UInt8 *bytes = (UInt8 *)malloc(dataLength);
CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes);
CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE);
CFShow(responseString);
CFRelease(responseString);
free(bytes);
}
// console
{
"args": {},
"headers": {
"Host": "httpbin.org",
"User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0",
"X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564"
},
"origin": "183.159.122.102",
"url": "https://httpbin.org/get"
}
复制代码
咱们知道 NSURLSession、NSURLConnection、CFNetwork 的使用都须要调用一堆方法进行设置而后须要设置代理对象,实现代理方法。因此针对这种状况进行监控首先想到的是使用 runtime hook 掉方法层级。可是针对设置的代理对象的代理方法没办法 hook,由于不知道代理对象是哪一个类。因此想办法能够 hook 设置代理对象这个步骤,将代理对象替换成咱们设计好的某个类,而后让这个类去实现 NSURLConnection、NSURLSession、CFNetwork 相关的代理方法。而后在这些方法的内部都去调用一下原代理对象的方法实现。因此咱们的需求得以知足,咱们在相应的方法里面能够拿到监控数据,好比请求开始时间、结束时间、状态码、内容大小等。
NSURLSession、NSURLConnection hook 以下。
业界有 APM 针对 CFNetwork 的方案,整理描述下:
CFNetwork 是 c 语言实现的,要对 c 代码进行 hook 须要使用 Dynamic Loader Hook 库 - fishhook。
Dynamic Loader(dyld)经过更新 Mach-O 文件中保存的指针的方法来绑定符号。借用它能够在 Runtime 修改 C 函数调用的函数指针。fishhook 的实现原理:遍历
__DATA segment
里面__nl_symbol_ptr
、__la_symbol_ptr
两个 section 里面的符号,经过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到本身要替换的函数,达到 hook 的目的。
/* Returns the number of bytes read, or -1 if an error occurs preventing any
bytes from being read, or 0 if the stream's end was encountered.
It is an error to try and read from a stream that hasn't been opened first.
This call will block until at least one byte is available; it will NOT block
until the entire buffer can be filled. To avoid blocking, either poll using
CFReadStreamHasBytesAvailable() or use the run loop and listen for the
kCFStreamEventHasBytesAvailable event for notification of data available. */
CF_EXPORT
CFIndex CFReadStreamRead(CFReadStreamRef _Null_unspecified stream, UInt8 * _Null_unspecified buffer, CFIndex bufferLength);
CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式来接受服务器的响应。当回调函数受到
具体步骤及其关键代码以下,以 NSURLConnection 举例
由于要 Hook 挺多地方,因此写一个 method swizzling 的工具类
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSObject (hook)
/**
hook对象方法
@param originalSelector 须要hook的原始对象方法
@param swizzledSelector 须要替换的对象方法
*/
+ (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
/**
hook类方法
@param originalSelector 须要hook的原始类方法
@param swizzledSelector 须要替换的类方法
*/
+ (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
@end
NS_ASSUME_NONNULL_END
+ (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
class_swizzleInstanceMethod(self, originalSelector, swizzledSelector);
}
+ (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
//类方法其实是储存在类对象的类(即元类)中,即类方法至关于元类的实例方法,因此只须要把元类传入,其余逻辑和交互实例方法同样。
Class class2 = object_getClass(self);
class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector);
}
void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL)
{
Method originMethod = class_getInstanceMethod(class, originalSEL);
Method replaceMethod = class_getInstanceMethod(class, replacementSEL);
if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod)))
{
class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
}else {
method_exchangeImplementations(originMethod, replaceMethod);
}
}
复制代码
创建一个继承自 NSProxy 抽象类的类,实现相应方法。
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// 为 NSURLConnection、NSURLSession、CFNetwork 代理设置代理转发
@interface NetworkDelegateProxy : NSProxy
+ (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate;
@end
NS_ASSUME_NONNULL_END
// .m
@interface NetworkDelegateProxy () {
id _originalTarget;
id _NewDelegate;
}
@end
@implementation NetworkDelegateProxy
#pragma mark - life cycle
+ (instancetype)sharedInstance {
static NetworkDelegateProxy *_sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_sharedInstance = [NetworkDelegateProxy alloc];
});
return _sharedInstance;
}
#pragma mark - public Method
+ (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate
{
NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance];
instance->_originalTarget = originalTarget;
instance->_NewDelegate = newDelegate;
return instance;
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
if ([_originalTarget respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:_originalTarget];
[((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation];
}
}
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
return [_originalTarget methodSignatureForSelector:sel];
}
@end
复制代码
建立一个对象,实现 NSURLConnection、NSURLSession、NSIuputStream 代理方法
// NetworkImplementor.m
#pragma mark-NSURLConnectionDelegate
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
NSLog(@"%s", __func__);
}
- (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response {
NSLog(@"%s", __func__);
return request;
}
#pragma mark-NSURLConnectionDataDelegate
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
NSLog(@"%s", __func__);
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
NSLog(@"%s", __func__);
}
- (void)connection:(NSURLConnection *)connection didSendBodyData:(NSInteger)bytesWritten
totalBytesWritten:(NSInteger)totalBytesWritten
totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
NSLog(@"%s", __func__);
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
NSLog(@"%s", __func__);
}
#pragma mark-NSURLConnectionDownloadDelegate
- (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
NSLog(@"%s", __func__);
}
- (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
NSLog(@"%s", __func__);
}
- (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL {
NSLog(@"%s", __func__);
}
// 根据需求本身去写须要监控的数据项
复制代码
给 NSURLConnection 添加 Category,专门设置 hook 代理对象、hook NSURLConnection 对象方法
// NSURLConnection+Monitor.m
@implementation NSURLConnection (Monitor)
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
@autoreleasepool {
[[self class] apm_swizzleMethod:@selector(apm_initWithRequest:delegate:) swizzledSelector:@selector(initWithRequest: delegate:)];
}
});
}
- (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate
{
/*
1. 在设置 Delegate 的时候替换 delegate。
2. 由于要在每一个代理方法里面,监控数据,因此须要将代理方法都 hook 下
3. 在原代理方法执行的时候,让新的代理对象里面,去执行方法的转发,
*/
NSString *traceId = @"traceId";
NSMutableURLRequest *rq = [request mutableCopy];
NSString *preTraceId = [request.allHTTPHeaderFields valueForKey:@"head_key_traceid"];
if (preTraceId) {
// 调用 hook 以前的初始化方法,返回 NSURLConnection
return [self apm_initWithRequest:rq delegate:delegate];
} else {
[rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"];
NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new];
[self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
[self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
[self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
[self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
[self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"];
[self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"];
delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate];
// 调用 hook 以前的初始化方法,返回 NSURLConnection
return [self apm_initWithRequest:rq delegate:delegate];
}
}
- (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id<NSURLConnectionDelegate>)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag
{
if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) {
IMP originalMethodImp = class_getMethodImplementation([originalDelegate class], NSSelectorFromString(methodName));
IMP newMethodImp = class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName));
if (originalMethodImp != newMethodImp) {
[newDelegate registerSelector: methodName];
NSLog(@"");
}
} else {
class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag);
}
}
@end
复制代码
这样下来就是能够监控到网络信息了,而后将数据交给数据上报 SDK,按照下发的数据上报策略去上报数据。
其实,针对上述的需求还有另外一种方法同样能够达到目的,那就是 isa swizzling。
顺道说一句,上面针对 NSURLConnection、NSURLSession、NSInputStream 代理对象的 hook 以后,利用 NSProxy 实现代理对象方法的转发,有另外一种方法能够实现,那就是 isa swizzling。
Method swizzling 原理
struct old_method {
SEL method_name;
char *method_types;
IMP method_imp;
};
复制代码
method swizzling 改进版以下
Method originalMethod = class_getInstanceMethod(aClass, aSEL);
IMP originalIMP = method_getImplementation(originalMethod);
char *cd = method_getTypeEncoding(originalMethod);
IMP newIMP = imp_implementationWithBlock(^(id self) {
void (*tmp)(id self, SEL _cmd) = originalIMP;
tmp(self, aSEL);
});
class_replaceMethod(aClass, aSEL, newIMP, cd);
复制代码
isa swizzling
/// Represents an instance of a class.
struct objc_object {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
};
/// A pointer to an instance of a class.
typedef struct objc_object *id;
复制代码
咱们来分析一下为何修改 isa
能够实现目的呢?
想一想 KVO 的实现原理?结合上面的图
按照这个思路,咱们也能够对 NSURLConnection、NSURLSession 的 load 方法中动态建立子类,在子类中重写方法,好比 - (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately;
,而后将 NSURLSession、NSURLConnection 的 isa 指向动态建立的子类。在这些方法处理完以后还本来身的 isa 指针。
不过 isa swizzling 针对的仍是 method swizzling,代理对象不肯定,仍是须要 NSProxy 进行动态处理。
至于如何修改 isa,我写一个简单的 Demo 来模拟 KVO
- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
//生成自定义的名称
NSString *className = NSStringFromClass(self.class);
NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className];
//1. runtime 生成类
Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0);
// 生成后不能立刻使用,必须先注册
objc_registerClassPair(myclass);
//2. 重写 setter 方法
class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@");
// class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@");
//3. 修改 isa
object_setClass(self, myclass);
//4. 将观察者保存到当前对象里面
objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN);
//5. 将传递的上下文绑定到当前对象里面
objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN);
}
void say(id self, SEL _cmd)
{
// 调用父类方法一
struct objc_super superclass = {self, [self superclass]};
((void(*)(struct objc_super *,SEL))objc_msgSendSuper)(&superclass,@selector(say));
NSLog(@"%s", __func__);
// 调用父类方法二
// Class class = [self class];
// object_setClass(self, class_getSuperclass(class));
// objc_msgSend(self, @selector(say));
}
void setName (id self, SEL _cmd, NSString *name) {
NSLog(@"come here");
//先切换到当前类的父类,而后发送消息 setName,而后切换当前子类
//1. 切换到父类
Class class = [self class];
object_setClass(self, class_getSuperclass(class));
//2. 调用父类的 setName 方法
objc_msgSend(self, @selector(setName:), name);
//3. 调用观察
id observer = objc_getAssociatedObject(self, "observer");
id context = objc_getAssociatedObject(self, "context");
if (observer) {
objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context);
}
//4. 改回子类
object_setClass(self, class);
}
@end
复制代码
本着成本的缘由,因为如今大多数的项目的网络能力都是经过 AFNetworking 完成的,因此本文的网络监控能够快速完成。
AFNetworking 在发起网络的时候会有相应的通知。AFNetworkingTaskDidResumeNotification
和 AFNetworkingTaskDidCompleteNotification
。经过监听通知携带的参数获取网络状况信息。
self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
// 开始
__strong __typeof(weakSelf)strongSelf = weakSelf;
NSURLSessionTask *task = note.object;
NSString *requestId = [[NSUUID UUID] UUIDString];
task.apm_requestId = requestId;
[strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task];
}];
self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
__strong __typeof(weakSelf)strongSelf = weakSelf;
NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey];
NSURLSessionTask *task = note.object;
if (!error) {
// 成功
[strongSelf.networkRecoder recordFinishRequestWithRequestID:task.cmn_requestId task:task];
} else {
// 失败
[strongSelf.networkRecoder recordResponseErrorWithRequestID:task.cmn_requestId task:task error:error];
}
}];
复制代码
在 networkRecoder 的方法里面去组装数据,交给数据上报组件,等到合适的时机策略去上报。
由于网络是一个异步的过程,因此当网络请求开始的时候须要为每一个网络设置惟一标识,等到网络请求完成后再根据每一个请求的标识,判断该网络耗时多久、是否成功等。因此措施是为 NSURLSessionTask 添加分类,经过 runtime 增长一个属性,也就是惟一标识。
这里插一嘴,为 Category 命名、以及内部的属性和方法命名的时候须要注意下。假如不注意会怎么样呢?假如你要为 NSString 类增长身份证号码中间位数隐藏的功能,那么写代码久了的老司机 A,为 NSString 增长了一个方法名,叫作 getMaskedIdCardNumber,可是他的需求是从 [9, 12] 这4位字符串隐藏掉。过了几天同事 B 也遇到了相似的需求,他也是一位老司机,为 NSString 增长了一个也叫 getMaskedIdCardNumber 的方法,可是他的需求是从 [8, 11] 这4位字符串隐藏,可是他引入工程后发现输出并不符合预期,为该方法写的单测没经过,他觉得本身写错了截取方法,检查了几遍才发现工程引入了另外一个 NSString 分类,里面的方法同名 😂 真坑。
下面的例子是 SDK,可是平常开发也是同样。
类名+SDK名称简写_功能名称
。好比当前 SDK 叫 JuhuaSuanAPM,那么该 NSURLSessionTask Category 名称就叫作 NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h
SDK名称简写_属性名称
。好比 JuhuaSuanAPM_requestId`SDK名称简写_方法名称
。好比 -(BOOL)JuhuaSuanAPM__isGzippedData
例子以下:
#import <Foundation/Foundation.h>
@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor)
@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId;
@end
#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h"
#import <objc/runtime.h>
@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor)
- (NSString*)JuhuaSuanAPM_requestId
{
return objc_getAssociatedObject(self, _cmd);
}
- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId
{
objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
@end
复制代码
HTTP 请求报文结构
响应报文的结构
请求报文的格式
<method> <request-URI> <version>
<headers>
<entity-body>
复制代码
响应报文的格式
<version> <status> <reason-phrase>
<headers>
<entity-body>
复制代码
下图是打开 Chrome 查看极课时间网页的请求信息。包括响应行、响应头、响应体等信息。
下图是在终端使用 curl
查看一个完整的请求和响应数据
咱们都知道在 HTTP 通讯中,响应数据会使用 gzip 或其余压缩方式压缩,用 NSURLProtocol 等方案监听,用 NSData 类型去计算分析流量等会形成数据的不精确,由于正常一个 HTTP 响应体的内容是使用 gzip 或其余压缩方式压缩的,因此使用 NSData 会偏大。
Request 和 Response 不必定成对存在
好比网络断开、App 忽然 Crash 等,因此 Request 和 Response 监控后不该该记录在一条记录里
请求流量计算方式不精确
主要缘由有:
HTTPBody.length
,致使不够精确响应流量计算方式不精确
主要缘由有:
exceptedContentLength
致使不够准确Accept-Encoding
字段表明客户端支持的数据压缩方式(代表客户端能够正常使用数据时支持的压缩方法),一样服务端根据客户端想要的压缩方式、服务端当前支持的压缩方式,最后处理数据,在响应头中Content-Encoding
字段表示当前服务器采用了什么压缩方式。第五部分讲了网络拦截的各类原理和技术方案,这里拿 NSURLProtocol 来讲实现流量监控(Hook 的方式)。从上述知道了咱们须要什么样的,那么就逐步实现吧。
先利用网络监控方案将 NSURLProtocol 管理 App 的各类网络请求
在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常状况的接口流量分析足够了,最底层须要 Socket 层)
@property(nonatomic, strong) NSURLConnection *internalConnection;
@property(nonatomic, strong) NSURLResponse *internalResponse;
@property(nonatomic, strong) NSMutableData *responseData;
@property (nonatomic, strong) NSURLRequest *internalRequest;
复制代码
- (void)startLoading
{
NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
self.internalRequest = self.request;
}
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
self.internalResponse = response;
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
[self.responseData appendData:data];
[self.client URLProtocol:self didLoadData:data];
}
复制代码
Status Line 部分
NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,因此要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 能够实现。
思路:将 NSURLResponse 经过 _CFURLResponse
转换为 CFTypeRef
,而后再将 CFTypeRef
转换为 CFHTTPMessageRef
,再经过 CFHTTPMessageCopyResponseStatusLine
获取 CFHTTPMessageRef
的 Status Line 信息。
将读取 Status Line 的功能添加一个 NSURLResponse 的分类。
// NSURLResponse+cm_FetchStatusLineFromCFNetwork.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSURLResponse (cm_FetchStatusLineFromCFNetwork)
- (NSString *)cm_fetchStatusLineFromCFNetwork;
@end
NS_ASSUME_NONNULL_END
// NSURLResponse+cm_FetchStatusLineFromCFNetwork.m
#import "NSURLResponse+cm_FetchStatusLineFromCFNetwork.h"
#import <dlfcn.h>
#define SuppressPerformSelectorLeakWarning(Stuff) \
do { \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \
Stuff; \
_Pragma("clang diagnostic pop") \
} while (0)
typedef CFHTTPMessageRef (*CMURLResponseFetchHTTPResponse)(CFURLRef response);
@implementation NSURLResponse (cm_FetchStatusLineFromCFNetwork)
- (NSString *)cm_fetchStatusLineFromCFNetwork
{
NSString *statusLine = @"";
NSString *funcName = @"CFURLResponseGetHTTPResponse";
CMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]);
SEL getSelector = NSSelectorFromString(@"_CFURLResponse");
if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) {
CFTypeRef cfResponse;
SuppressPerformSelectorLeakWarning(
cfResponse = CFBridgingRetain([self performSelector:getSelector]);
);
if (NULL != cfResponse) {
CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse);
statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef);
CFRelease(cfResponse);
}
}
return statusLine;
}
@end
复制代码
将获取到的 Status Line 转换为 NSData,再计算大小
- (NSUInteger)cm_getLineLength {
NSString *statusLineString = @"";
if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
statusLineString = [self cm_fetchStatusLineFromCFNetwork];
}
NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
return lineData.length;
}
复制代码
Header 部分
allHeaderFields
获取到 NSDictionary,而后按照 key: value
拼接成字符串,而后转换成 NSData 计算大小
注意:key: value
key 后是有空格的,curl 或者 chrome Network 面板能够查看印证下。
- (NSUInteger)cm_getHeadersLength
{
NSUInteger headersLength = 0;
if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
NSDictionary *headerFields = httpResponse.allHeaderFields;
NSString *headerString = @"";
for (NSString *key in headerFields.allKeys) {
headerString = [headerStr stringByAppendingString:key];
headheaderStringerStr = [headerString stringByAppendingString:@": "];
if ([headerFields objectForKey:key]) {
headerString = [headerString stringByAppendingString:headerFields[key]];
}
headerString = [headerString stringByAppendingString:@"\n"];
}
NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
headersLength = headerData.length;
}
return headersLength;
}
复制代码
Body 部分
Body 大小的计算不能直接使用 excepectedContentLength,官方文档说明了其不许确性,只能够做为参考。或者 allHeaderFields
中的 Content-Length
值也是不够准确的。
/*!
@abstract Returns the expected content length of the receiver.
@discussion Some protocol implementations report a content length
as part of delivering load metadata, but not all protocols
guarantee the amount of data that will be delivered in actuality.
Hence, this method returns an expected amount. Clients should use
this value as an advisory, and should be prepared to deal with
either more or less data.
@result The expected content length of the receiver, or -1 if
there is no expectation that can be arrived at regarding expected
content length.
*/
@property (readonly) long long expectedContentLength;
Transfer-Encoding: chunked
,则在 header 中不能有 Content-Length
,有也会被忽视。content-length
字段无关紧要keep alive
,则 Content-Length
和 chunked
必然是二选一。如果非keep alive
,则和 HTTP 1.0同样。Content-Length
无关紧要。什么是 Transfer-Encoding: chunked
数据以一系列分块的形式进行发送 Content-Length
首部在这种状况下不被发送. 在每个分块的开头须要添加当前分块的长度, 以十六进制的形式表示,后面紧跟着 \r\n
, 以后是分块自己, 后面也是 \r\n
,终止块是一个常规的分块, 不一样之处在于其长度为0.
咱们以前拿 NSMutableData 记录了数据,因此咱们能够在 stopLoading
方法中计算出 Body 大小。步骤以下:
在 didReceiveData
中不断添加 data
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
[self.responseData appendData:data];
[self.client URLProtocol:self didLoadData:data];
}
复制代码
在 stopLoading
方法中拿到 allHeaderFields
字典,获取 Content-Encoding
key 的值,若是是 gzip,则在 stopLoading
中将 NSData 处理为 gzip 压缩后的数据,再计算大小。(gzip 相关功能可使用这个工具)
须要额外计算一个空白行的长度
- (void)stopLoadi
{
[self.internalConnection cancel];
PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
model.path = self.request.URL.path;
model.host = self.request.URL.host;
model.type = DMNetworkTrafficDataTypeResponse;
model.lineLength = [self.internalResponse cm_getStatusLineLength];
model.headerLength = [self.internalResponse cm_getHeadersLength];
model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response;
NSData *data = self.dm_data;
if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) {
data = [self.dm_data gzippedData];
}
model.bodyLength = data.length;
}
model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
NSDictionary *networkTrafficDictionary = [model convertToDictionary];
[[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
}
复制代码
先利用网络监控方案将 NSURLProtocol 管理 App 的各类网络请求
在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常状况的接口流量分析足够了,最底层须要 Socket 层)
@property(nonatomic, strong) NSURLConnection *internalConnection;
@property(nonatomic, strong) NSURLResponse *internalResponse;
@property(nonatomic, strong) NSMutableData *responseData;
@property (nonatomic, strong) NSURLRequest *internalRequest;
复制代码
- (void)startLoading
{
NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
self.internalRequest = self.request;
}
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
self.internalResponse = response;
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
[self.responseData appendData:data];
[self.client URLProtocol:self didLoadData:data];
}
复制代码
Status Line 部分
对于 NSURLRequest 没有像 NSURLResponse 同样的方法找到 StatusLine。因此兜底方案是本身根据 Status Line 的结构,本身手动构造一个。结构为:协议版本号+空格+状态码+空格+状态文本+换行
为 NSURLRequest 添加一个专门获取 Status Line 的分类。
// NSURLResquest+cm_FetchStatusLineFromCFNetwork.m
- (NSUInteger)cm_fetchStatusLineLength
{
NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"];
NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
return statusLineData.length;
}
复制代码
Header 部分
一个 HTTP 请求会先构建判断是否存在缓存,而后进行 DNS 域名解析以获取请求域名的服务器 IP 地址。若是请求协议是 HTTPS,那么还须要创建 TLS 链接。接下来就是利用 IP 地址和服务器创建 TCP 链接。链接创建以后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,而后向服务器发送构建的请求信息。
因此一个网络监控不考虑 cookie 😂,借用王多鱼的一句话「那不完犊子了吗」。
看过一些文章说 NSURLRequest 不能完整获取到请求头信息。其实问题不大, 几个信息获取不彻底也没办法。衡量监控方案自己就是看接口在不一样版本或者某些状况下数据消耗是否异常,WebView 资源请求是否过大,相似于控制变量法的思想。
因此获取到 NSURLRequest 的 allHeaderFields
后,加上 cookie 信息,计算完整的 Header 大小
// NSURLResquest+cm_FetchHeaderWithCookies.m
- (NSUInteger)cm_fetchHeaderLengthWithCookie
{
NSDictionary *headerFields = self.allHTTPHeaderFields;
NSDictionary *cookiesHeader = [self cm_fetchCookies];
if (cookiesHeader.count) {
NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields];
[headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader];
headerFields = [headerDictionaryWithCookies copy];
}
NSString *headerString = @"";
for (NSString *key in headerFields.allKeys) {
headerString = [headerString stringByAppendingString:key];
headerString = [headerString stringByAppendingString:@": "];
if ([headerFields objectForKey:key]) {
headerString = [headerString stringByAppendingString:headerFields[key]];
}
headerString = [headerString stringByAppendingString:@"\n"];
}
NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
headersLength = headerData.length;
return headerString;
}
- (NSDictionary *)cm_fetchCookies
{
NSDictionary *cookiesHeaderDictionary;
NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
NSArray<NSHTTPCookie *> *cookies = [cookieStorage cookiesForURL:self.URL];
if (cookies.count) {
cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
}
return cookiesHeaderDictionary;
}
复制代码
Body 部分
NSURLConnection 的 HTTPBody
有可能获取不到,问题相似于 WebView 上 ajax 等状况。因此能够经过 HTTPBodyStream
读取 stream 来计算 body 大小.
- (NSUInteger)cm_fetchRequestBody
{
NSDictionary *headerFields = self.allHTTPHeaderFields;
NSUInteger bodyLength = [self.HTTPBody length];
if ([headerFields objectForKey:@"Content-Encoding"]) {
NSData *bodyData;
if (self.HTTPBody == nil) {
uint8_t d[1024] = {0};
NSInputStream *stream = self.HTTPBodyStream;
NSMutableData *data = [[NSMutableData alloc] init];
[stream open];
while ([stream hasBytesAvailable]) {
NSInteger len = [stream read:d maxLength:1024];
if (len > 0 && stream.streamError == nil) {
[data appendBytes:(void *)d length:len];
}
}
bodyData = [data copy];
[stream close];
} else {
bodyData = self.HTTPBody;
}
bodyLength = [[bodyData gzippedData] length];
}
return bodyLength;
}
复制代码
在 - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response
方法中将数据上报会在 打造功能强大、灵活可配置的数据上报组件 讲
-(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response
{
if (response != nil) {
self.internalResponse = response;
[self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
}
PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
model.path = request.URL.path;
model.host = request.URL.host;
model.type = DMNetworkTrafficDataTypeRequest;
model.lineLength = [connection.currentRequest dgm_getLineLength];
model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie];
model.bodyLength = [connection.currentRequest dgm_getBodyLength];
model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
NSDictionary *networkTrafficDictionary = [model convertToDictionary];
[[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
return request;
}
复制代码