diff --git a/.DS_Store b/.DS_Store index 70de2e6..c98b1cf 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Chapter1 - iOS/1.1.md b/Chapter1 - iOS/1.1.md index b8cf459..593e9ed 100644 --- a/Chapter1 - iOS/1.1.md +++ b/Chapter1 - iOS/1.1.md @@ -35,7 +35,6 @@ Web领域使用IconFont类似的技术已经多年,当我在15年接触BootStr 1. 选择好之后在购物车查看,然后点击下载代码 2. 打开下载好的文件,其机构如下,我们在iOS项目开发过程中使用unicode的形式使用IconFont,所以打开demo\_unicode.html - ![下载文件目录结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-28-iconfontWorkDirectory.png) diff --git a/Chapter1 - iOS/1.43.md b/Chapter1 - iOS/1.43.md index 1d86089..2e09ea9 100644 --- a/Chapter1 - iOS/1.43.md +++ b/Chapter1 - iOS/1.43.md @@ -5,12 +5,90 @@ `NS_BLOCK_ASSERTIONS `: 表明在 Release 状态下过滤 NSAssert,只需要这一个条件就可以过滤掉 NSAssert。 方法:在 “Build Settings” 下搜索 **Preprocessor Macros** ,然后在 Release 下面添加 NS_BLOCK_ASSERTIONS -![](/Users/liubinpeng/Desktop/Github/knowledge-kit/assets/WX20180830-100631@2x.png) +![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180830-100631@2x.png) ### BreakPoint #### 分类 -Breakpoint 分为 Normal Breakpoint、Exception Breakpoint、OpenGL ES Error Breakpoint、Symbolic Breakpoint、Test Failure breakpoint、WatchPoint。可以按照具体的情景使用不同类型的 Breakpoint ,解决问题为根本 +Breakpoint 分为 Normal Breakpoint、Exception Breakpoint、OpenGL ES Error Breakpoint、Symbolic Breakpoint、Test Failure breakpoint、WatchPoint。可以按照具体的情景使用不同类型的 Breakpoint。 +### NSAssert 与 dispatch_once + +开发中非常常见 NSAssert,尤其是在 SDK 和类库的开发中,使用断言帮助在开发阶段发现问题,督促达到预期的结果。 +NSAssert 的本质就是产生一个 Exception,Exception 发生触发 `objc_exception_throw` 这个 c 函数。 + +![NSAssert 断言](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-0311-NSAssert.png) + +callback 信息如下 +```Objective-c +*** First throw call stack: +( + 0 CoreFoundation 0x00007fff23c7127e __exceptionPreprocess + 350 + 1 libobjc.A.dylib 0x00007fff513fbb20 objc_exception_throw + 48 + 2 CoreFoundation 0x00007fff23c70ff8 +[NSException raise:format:arguments:] + 88 + 3 Foundation 0x00007fff256e9b51 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 191 + 4 TEst 0x0000000106edfeef -[AppDelegate application:didFinishLaunchingWithOptions:] + 287 + 5 UIKitCore 0x00007fff48089ad8 -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 232 + 6 UIKitCore 0x00007fff4808b460 -[UIApplication _callInitializationDelegatesWithActions:forCanvas:payload:fromOriginatingProcess:] + 3980 + 7 UIKitCore 0x00007fff48090f05 -[UIApplication _runWithMainScene:transitionContext:completion:] + 1226 + 8 UIKitCore 0x00007fff477c57a6 -[_UISceneLifecycleMultiplexer completeApplicationLaunchWithFBSScene:transitionContext:] + 179 + 9 UIKitCore 0x00007fff4808d514 -[UIApplication _compellApplicationLaunchToCompleteUnconditionally] + 59 + 10 UIKitCore 0x00007fff4808d813 -[UIApplication _run] + 754 + 11 UIKitCore 0x00007fff48092d4d UIApplicationMain + 1621 + 12 TEst 0x0000000106ee0144 main + 116 + 13 libdyld.dylib 0x00007fff5227ec25 start + 1 +) +libc++abi.dylib: terminating with uncaught exception of type NSException +``` + +可以清楚看到当断言失败的时候,Xcode 可以精确定位到 NSAssert 有问题的那行代码,这种情况下是有源代码。 +其实有些场景下发生异常是定位不到真正产生异常的地方。比如当 App 规模较大,为了提高构建速度,很多人将 Pod 打成静态库,但是这种情况下产生的异常不会被精确定位到产生问题的行。如果断言在 GCD 的 block 中,而且上下文中没有源码,则也定位不到。 + +输出信息如下: +```Objective-c +*** First throw call stack: +( + 0 CoreFoundation 0x00007fff23c7127e __exceptionPreprocess + 350 + 1 libobjc.A.dylib 0x00007fff513fbb20 objc_exception_throw + 48 + 2 CoreFoundation 0x00007fff23c70ff8 +[NSException raise:format:arguments:] + 88 + 3 Foundation 0x00007fff256e9b51 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 191 + 4 TEst 0x000000010f242e95 __57-[AppDelegate application:didFinishLaunchingWithOptions:]_block_invoke + 229 + 5 libdispatch.dylib 0x000000010f55fdd4 _dispatch_call_block_and_release + 12 + 6 libdispatch.dylib 0x000000010f560d48 _dispatch_client_callout + 8 + 7 libdispatch.dylib 0x000000010f56ede6 _dispatch_main_queue_callback_4CF + 1500 + 8 CoreFoundation 0x00007fff23bd4049 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9 + 9 CoreFoundation 0x00007fff23bceca9 __CFRunLoopRun + 2329 + 10 CoreFoundation 0x00007fff23bce066 CFRunLoopRunSpecific + 438 + 11 GraphicsServices 0x00007fff384c0bb0 GSEventRunModal + 65 + 12 UIKitCore 0x00007fff48092d4d UIApplicationMain + 1621 + 13 TEst 0x000000010f243134 main + 116 + 14 libdyld.dylib 0x00007fff5227ec25 start + 1 +) +libc++abi.dylib: terminating with uncaught exception of type NSException +``` + +给 Xcode 添加 **Symbolic Breakpoint** 类型的断点,Symbol 为 `objc_exception_throw`,就可以在 Xcode 的左侧堆栈中看到了。 + +![objc_exception_throw](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-03-11-objc_exception_throw.png) + +其实 GCD 的 方法 **_dispatch_client_callout** 去查看下有没有猫腻,再从这里找到 libdispatch 的代码:https://opensource.apple.com/tarballs/libdispatch/。 + +```Objective-c +// object.m +#undef _dispatch_client_callout +void +_dispatch_client_callout(void *ctxt, dispatch_function_t f) +{ + @try { + return f(ctxt); + } + @catch (...) { + objc_terminate(); + } +} +``` +发现 _dispatch_client_callout 把 GCD block 中的 OC Exception try catch 捕获了,然后调用 objc_terminate,导致了 CallStack 断开。 + +有个情景,工作 Crash 到 dispatch_once 这里了,因为 dispatch_once 中的代码 throw OC 异常。一般大公司初期这种情况经常遇到,后期一般都会针对断言专门开发一些代码用来定位 Owner。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.44..md b/Chapter1 - iOS/1.44..md index 212947d..3f48425 100644 --- a/Chapter1 - iOS/1.44..md +++ b/Chapter1 - iOS/1.44..md @@ -57,9 +57,9 @@ Hybrid 架构设计的第一要考虑的问题就是如何设计前端与 Native [iOS调试技巧](https://www.jianshu.com/p/f430caa81fa8) Android 调试技巧: -1. App 中开启 Webview 调试(WebView.setWebContentsDebuggingEnabled(true); ) -2. chrome 浏览器输入 chrome://inspect/#devices 访问可以调试的 webview 列表 -3. 需要翻墙的环境 +- App 中开启 Webview 调试(WebView.setWebContentsDebuggingEnabled(true); ) +- chrome 浏览器输入 chrome://inspect/#devices 访问可以调试的 webview 列表 +- 需要翻墙的环境 ![结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/HybridStructure.jpg) diff --git a/Chapter1 - iOS/1.51.md b/Chapter1 - iOS/1.51.md index 3e69994..94307ab 100644 --- a/Chapter1 - iOS/1.51.md +++ b/Chapter1 - iOS/1.51.md @@ -1,4 +1,4 @@ -# Pod +# cocoapods 相关小技巧 ## 1. 组件的地址 @@ -202,4 +202,69 @@ install! 'cocoapods', generate_multiple_pod_projects: true ```shell sudo gem uninstall cocoapods-core cocoapods cocoapods-deintegrate cocoapods-downloader cocoapods-plugins cocoapods-search cocoapods-stats cocoapods-trunk cocoapods-try coderay colored2 concurrent-ruby cocoapods-clean sudo gem install cocoapods -``` \ No newline at end of file +``` + +## 14. pod 拉取代码太慢 + + +在平时开发的时候,使用 cocoapods 拉取代码经常会比较慢,偶尔一次两次也还可以忍受,但是某些哭每次都很慢,而且 install 的时候工程被修改,没办法编译开发。所以需要想个办法解决这种问题。 + +其实知道 cocoapods 的工作原理的话,我们可以投机取巧。做过 SDK 开发的同学一般都知道,代码开发、测试完毕后需要 lint,将 podspec 文件提交到中心。项目中 Podfile 中添加依赖描述,依赖可以用 分支、tag、path 等形式指定。 + +方法一: + +所以明白怎么工作的,我们可以在本地搭建一个静态服务,专门用来提供较大 SDK 的下载,拿 NodeJS、python、Java、php 都可以,很快写一个静态服务。然后从本地 `.cocoapods` 里找到对应的 SDK 文件夹,修改 podspec 文件,修改 **source** 为本地服务器地址资源地址。 + +由于本地静态服务里面的资源可能会停留在较早版本,所以可以使用定时服务拉取 github 项目最新代码。crontab + shell 做这个很容易。 + + +方法二: + +前端有 jsdeliver,针对 js/css 加速访问。但是它也支持 github 上的仓库。 +但是包大小限制:jsdeliver 规定单个文件不能大于 20M;仓库的某版本不能大于 50M。所以可以对大文件进行 **xz 压缩**。 + +Mac split 命令可以拆分包,cat 可以合并包。 + +```shell +// 分割文件 +split -b 10m xxxx.tar.xz xxxx.tar.xz . +// 查看效果 +ll xxxx.xz* + +// 合并 +cat xxxx.tar.xz.* > xxxx.tar.xz + +// 再次验证大小 +ll xxxx.tar.xz + +// 解压 +mkdir unpackedDir +mv xxxx.tar.xz unpackedDir +cd unpackedDir +tar vxf xxxx.tar.xz +``` + +所以我们可以将较大的库拆分多个,部署到 jsdeliver,然后使用的时候进行加速。 +这一步可以是脚本自动完成,写脚本,将文件夹压缩,拆分,上传。 + +所以使用的时候类似 +```shell +wget https://cdn.jsdelivr.net/mmm/xxxx.tar.xz.aa +wget https://cdn.jsdelivr.net/mmm/xxxx.tar.xz.ab +wget https://cdn.jsdelivr.net/mmm/xxxx.tar.xz.ac +cat xxxx.tar.xz.* > xxxx.tar.xz +tar xvf xxxx.tar.xz +rm xxxx.tar.xz.* xxxx.tar.xz +``` + +结合 cocoapods 的 **prepare_command** 使用。 +比如 +```ruby +Pod::Spec.new do |s| + s.name = 'xxxx' + s.prepare_command = <<-CMD + sh cat.sh + CMD +end +``` +## 15. diff --git a/Chapter1 - iOS/1.55.md b/Chapter1 - iOS/1.55.md index 2d22662..6339768 100644 --- a/Chapter1 - iOS/1.55.md +++ b/Chapter1 - iOS/1.55.md @@ -305,7 +305,7 @@ static char *lbp_viewController_close_time = "lbp_viewController_close_time"; - ```objective-c + ```Objective-c //UIResponder分类 - (NSString *)lbp_identifierKa { diff --git a/Chapter1 - iOS/1.56.md b/Chapter1 - iOS/1.56.md index 4876dab..914b8b8 100644 --- a/Chapter1 - iOS/1.56.md +++ b/Chapter1 - iOS/1.56.md @@ -34,7 +34,7 @@ 1773 -> “1*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “7*7+24” + “3.1415926” + “3*7+24” -> 313.1415926733.1415926733.141592645 02 -> "0*7+24" + "3.1415926" + "2*7+24" -> 243.141592638 20 -> "2*7+24" + "3.1415926" + "0*7+24" -> 383.141592624 -```` +``` #### 2.2 前端拿到数据后再解密,解密后根据自定义的字体 Render 页面 diff --git a/Chapter1 - iOS/1.58.md b/Chapter1 - iOS/1.58.md index f80e764..3e4b041 100644 --- a/Chapter1 - iOS/1.58.md +++ b/Chapter1 - iOS/1.58.md @@ -18,4 +18,23 @@ 2. 当你修改完语法问题的时候,编译工程,发现还是存在一些问题。大体意思是说 Swift 的编译版本不再支持。所以我们需要选中 targets ,切换到 「Build Settings」 下面,搜索 「Swift Language Version」,在后面勾选合适的 Swift 版本。在这里我选择了 Swift 5 - $$ \ No newline at end of file + + +## 手淘 App 落地 Swift,推进生态 + +经过技术调研、基础设施建设、工具链升级、里程碑业务上线和社区培训,得出“拥有一把锤子可以敲一个钉子,拥有一个工具箱可以造一艘航母”。 + +首先是业务视角: +- 在业务需要快速迭代的时候,现有的 iOS 工程师主要以 Objective-C 为主,转战 Swift 需要一定的学习曲线,而且采用 Swift 效率是否一定有提高也有待考证; +- Swift 只能解决 iOS 侧 Native 研发问题,对于高迭代效率的跨平台技术,收益不足。 + +其次是技术视角: +- Swift 早期由于 ABI 不稳定,只能将 Runtime 内置在 IPA 包里面,占用约 3M 的下载空间,苹果还有 150M 的蜂窝网络下载大小的限制,且对启动性能有 150ms 的影响,在各家公司拼体验的时代,这些都会对公司的业务造成负担和损耗; +- 由于语法不固定,每次升级都会造成源码级别的 Break Change,对开发者也会造成负担。淘宝、美团等巨型 App 都采用了二进制组建化研发模块,Swift 只能固定开发工具版本,对大型团队是一种负担和制约,反而极大的降低了研发效率。 + +未来一两年内国内 Swift 生态还是会有巨大改善的,主要有以下几个方面: +- iOS 12.2 以上内置 Swift Runtime, 包大小随着存量旧版本操作系统升级后得到缓解; +- iOS 12.2 以上也没有启动性能的影响; +- Swift 语法不再大变,不会对开发者造成负担; +- 苹果继续强势推进,Swift 社区热度持续提升。 +相信生产力大幅提高后,没有人会放任好用的工具不用而去用一把快要锈掉的锤子(Objective-C )。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.60.md b/Chapter1 - iOS/1.60.md index f450854..7e95e5b 100644 --- a/Chapter1 - iOS/1.60.md +++ b/Chapter1 - iOS/1.60.md @@ -1,4 +1,4 @@ -# iOS 瘦身之道 +# App 瘦身之道 @@ -677,12 +677,4 @@ $ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain 参考文章: - [Humble Assets Catalog](http://lingyuncxb.com/2019/04/14/HumbleAssetCatalog/) -- [关于 Pod 库的资源引用 resource_bundles or resources](http://zhoulingyu.com/2018/02/02/pod-resource-reference/) -- 部分图片或者文字内容引用来自网络(若有引用到,请告诉我地址,及时补充) - - - - -- 线程、队列、runloop 的关系?主串行队列,在 Mach 内核中创建 -- block:堆内存中的结构体变量 -- 全局并发队列:队列(FIFO)。所以最后加入到全局并发队列中的任务最后执行,执行的时候就可以拿到结果 \ No newline at end of file +- [关于 Pod 库的资源引用 resource_bundles or resources](http://zhoulingyu.com/2018/02/02/pod-resource-reference/) \ No newline at end of file diff --git a/Chapter1 - iOS/1.7.md b/Chapter1 - iOS/1.7.md index 4087f3a..58cefb0 100644 --- a/Chapter1 - iOS/1.7.md +++ b/Chapter1 - iOS/1.7.md @@ -2,55 +2,52 @@ # 对象在内存中的存储 -* 栈、堆、BSS、数据段、代码段是什么? +## 一、 栈、堆、BSS、数据段、代码段是什么? - * 栈(stack):又称作堆栈,用来存储程序的局部变量(但不包括static声明的变量,static修饰的数据存放于数据段中)。除此之外,在函数被调用时,栈用来传递参数和返回值。 +栈(stack):又称作堆栈,用来存储程序的局部变量(但不包括static声明的变量,static修饰的数据存放于数据段中)。除此之外,在函数被调用时,栈用来传递参数和返回值。 - * 堆(heap):用于存储程序运行中被动态分配的内存段,它的大小并不固定,可动态的扩张和缩减。操作函数(malloc/free) +堆(heap):用于存储程序运行中被动态分配的内存段,它的大小并不固定,可动态的扩张和缩减。操作函数(malloc/free) - * BSS段(bss segment):通常用来存储程序中未被初始化的全局变量和静态变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段输入静态内存分配 +BSS段(bss segment):通常用来存储程序中未被初始化的全局变量和静态变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段输入静态内存分配 - * 数据段(data segment):通常用来存储程序中已被初始化的全局变量和静态变量和字符串的一块内存区域 +数据段(data segment):通常用来存储程序中已被初始化的全局变量和静态变量和字符串的一块内存区域 - * 代码段(code segment):通常是指用来存储程序可执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量。 +代码段(code segment):通常是指用来存储程序可执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量。 ![内存](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ram.png) -* ##### 搞清楚上面的概念再来研究下对象在内存中如何存储? -``` + + +## 二、研究下对象在内存中如何存储? + +```Objective-C Person *p1 = [Person new] ``` 看这行代码,先来看几个注意点: -* new底层做的事情: - +new底层做的事情: * 在堆内存中申请1块合适大小的空间 - * 在这块内存上根据类模版创建对象。类模版中定义了什么属性就依次把这些属性声明在对象中;对象中还存在一个属性叫做**isa**,是一个指针,指向对象所属的类在代码段中地址 - * 初始化对象的属性。这里初始化有几个原则:a、如果属性的数据类型是基本数据类型则赋值为0;b、如果属性的数据类型是C语言的指针类型则赋值为NULL;c、如果属性的数据类型为OC的指针类型则赋值为nil。 - * 返回堆空间上对象的地址 -* 注意 +注意: * 对象只有属性,没有方法。包括类本身的属性和一个指向代码段中的类isa指针 - * 如何访问对象的属性?指针名->属性名;本质:根据指针名找到指针指向的对象,再根据属性名查找来访问对象的属性值 - * 如何调用方法?[指针名 方法];本质:根据指针名找到指针指向的对象,再发现对象需要调用方法,再通过对象的isa指针找到代码段中的类,再调用类里面方法 -* 为什么不把方法存储在对象中? +为什么不把方法存储在对象中? * 因为以类为模版创建的对象只有属性可能不相同,而方法相同,如果堆区的对象里面也保存方法的话就会很重复,浪费了堆空间,因此将方法存储与代码段 * 所以一个类创建的n个对象的isa指针的地址值都相同,都指向代码段中的类地址 -**做个小实验** +做个小实验 -``` +```Objective-C #import @interface Person : NSObject{ @public @@ -85,20 +82,13 @@ int main(int argc, const char * argv[]) { } ``` -``` -Person *p1 = [Person new]; -``` +`Person *p1 = [Person new];` 这句代码在内存分配原理如下图所示 -**这句代码在内存分配原理如下图所示** - -![解析图](https://fantasticlbp.gitbooks.io/knowledge-kit/assets/Untitled%20Diagram-2.png) +![解析图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Untitled%20Diagram-2.png) **结论** -![p1](https://fantasticlbp.gitbooks.io/knowledge-kit/assets/2017-05-15%20下午5.35.17.png) -![p2](https://fantasticlbp.gitbooks.io/knowledge-kit/assets/2017-05-15%20下午5.35.34.png) +![p1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-15%20下午5.35.17.png) +![p2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-15%20下午5.35.34.png) **可以 看到Person类的3个对象p1、p2、p3的isa的值相同。** - -补充: -- [alloc与init区别](https://www.jianshu.com/p/daf668b76861) diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md index 786753a..106aa10 100644 --- a/Chapter1 - iOS/1.74.md +++ b/Chapter1 - iOS/1.74.md @@ -1,3 +1,7 @@ + + + + # APM > Application Performance Management 应用性能管理对一个应用的持续稳定运行至关重要。所以这篇文章就从一个 iOS App 的性能管理的纬度谈谈如何精确监控以及数据如何上报等技术点 @@ -10,15 +14,10 @@ App 的性能问题是影响用户体验的重要因素之一。性能问题主 ## 监控项目 -- 页面渲染时长 -- 主线程卡顿 -- 网络错误+ -- FPS -- 大文件存储 -- CPU -- 内存使用 -- Crash -- 启动时长 + +- OOM +- crash +- 网络 @@ -36,20 +35,20 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; ``` -代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作。请继续往下看 +代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 #### 1. 屏幕绘制原理 -![老式 CRT 显示器原理](./../assets/2020-02-04-ios_screen_scan.png) +![老式 CRT 显示器原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_screen_scan.png) 讲讲老式的 CRT 显示器的原理。 CRT 电子枪按照上面方式,从上到下一行行扫描,扫面完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;当一帧画面绘制完成后,电子枪恢复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(Vertical synchronization),简称 VSync。显示器通常以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏,但是原理保持不变。 -![显示器和 CPU、GPU 关系](./../assets/2020-02-02-screen_display_gpu.png) +![显示器和 CPU、GPU 关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-02-screen_display_gpu.png) 通常,屏幕上一张画面的显示是由 CPU、GPU 和显示器是按照上图的方式协同工作的。CPU 根据工程师写的代码计算好需要现实的内容(比如视图创建、布局计算、图片解码、文本绘制等),然后把计算结果提交到 GPU,GPU 负责图层合成、纹理渲染,随后 GPU 将渲染结果提交到帧缓冲区。随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过数模转换传递给显示器显示。 @@ -60,9 +59,9 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di 为了解决这个问题,GPU 通常有一个机制叫垂直同步信号(V-Sync),当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新。这样的几个机制解决了画面撕裂的情况,也增加了画面流畅度。但需要更多的计算资源 -![IPC唤醒 RunLoop](./../assets/2020-02-08-ios_vsync_runloop.png) +![IPC唤醒 RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-ios_vsync_runloop.png) -#### 答疑 +答疑 可能有些人会看到「当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新」这里会想,GPU 收到 V-Sync 才进行新的一帧渲染和帧缓冲区的更新,那是不是双缓冲区就失去意义了? @@ -74,7 +73,7 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di 揭秘。请看下图 -![多缓冲区显示原理](./../assets/2020-02-04-Comparison_double_triple_buffering.png) +![多缓冲区显示原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-Comparison_double_triple_buffering.png) @@ -88,11 +87,11 @@ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_di -![卡顿原因](./../assets/2020-02-04-ios_frame_drop.png) +![卡顿原因](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_frame_drop.png) -VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图绘制、图形解码、文本绘制等)。然后将计算的内容提交到 GPU,GPU 经过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示之前渲染好的结果。在垂直同步机制的情况下,如果在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会造成该帧的丢弃,等待下一次机会再显示,这时候屏幕上还是之前渲染的图像,所以这就是 CPU、GPU 层面界面卡顿的原因。 +VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图创建、布局计算、图片解码、文本绘制等)。然后将计算的内容提交到 GPU,GPU 经过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示之前渲染好的结果。在垂直同步机制的情况下,如果在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会造成该帧的丢弃,等待下一次机会再显示,这时候屏幕上还是之前渲染的图像,所以这就是 CPU、GPU 层面界面卡顿的原因。 目前 iOS 设备有双缓存机制,也有三缓冲机制,Android 现在主流是三缓冲机制,在早期是单缓冲机制。 @@ -283,10 +282,10 @@ if (sourceHandledThisLoop && stopAfterHandle) { } ``` -完整且带有注释的 RunLoop 代码见[此处](./../assets/CFRunLoop.c)。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质还是调用 Source0 事件的处理函数。 +完整且带有注释的 RunLoop 代码见[此处](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFRunLoop.c)。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质还是调用 Source0 事件的处理函数。 -![RunLoop 状态](./../assets/2020-02-05-RunLoop.png) +![RunLoop 状态](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-05-RunLoop.png) RunLoop 6个状态 ```Objective-C @@ -315,6 +314,20 @@ RunLoop 在进入睡眠前的方法执行时间过长而导致无法进入睡眠 - 退出(Quit):6s - 后台(Background):3min(在 iOS7 之前可以申请 10min;之后改为 3min;可连续申请,最多到 10min) +通过 `long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)` 方法判断是否阻塞主线程,`Returns zero on success, or non-zero if the timeout occurred.` 返回非0则代表超时阻塞了主线程。 + + + +![RunLoop-ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-APM-RunLoopANR.jpg) + +可能很多人纳闷 RunLoop 状态那么多,为什么选择 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?因为大部分卡顿都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之间。比如 Source0 类型的 App 内部事件等 + +Runloop 检测卡顿流程图如下: + +![RunLoop ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-ANRRunloop.png) + +关键代码如下: + ```Objective-c // 设置Runloop observer的运行环境 @@ -357,9 +370,7 @@ dispatch_async(dispatch_get_global_queue(0, 0), ^{ }); ``` -可能很多人纳闷 RunLoop 状态那么多,为什么选择 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?因为大部分卡顿都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之间。比如 Source0 类型的 App 内部事件等 -# todo:虽然知道 RunLoop 的状态,但是为什么是 dispatch_semaphore_wait @@ -401,7 +412,7 @@ while (self.isCancelled == NO) { 在计算机科学中,调用堆栈是一种栈类型的数据结构,用于存储有关计算机程序的线程信息。这种栈也叫做执行堆栈、程序堆栈、控制堆栈、运行时堆栈、机器堆栈等。调用堆栈用于跟踪每个活动的子例程在完成执行后应该返回控制的点。 维基百科搜索到 “Call Stack” 的一张图和例子,如下 -![调用栈](./../assets/2020-02-08-StackFrame.png) +![调用栈](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-StackFrame.png) 上图表示为一个栈。分为若干个栈帧(Frame),每个栈帧对应一个函数调用。下面蓝色部分表示 `DrawSquare` 函数,它在执行的过程中调用了 `DrawLine` 函数,用绿色部分表示。 可以看到栈帧由三部分组成:函数参数、返回地址、局部变量。比如在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数需要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。 @@ -412,7 +423,42 @@ while (self.isCancelled == NO) { 接下来的步骤就是拿到所有线程的 Stack Pointer 和 Frame Pointer。然后不断回溯,还原案发现场。 -App 在运行的时候,会对应一个 Mach Task,而 Task 下可能有多条线程同时执行任务。《OS X and iOS Kernel Programming》 中描述 Mach Task 为:任务(Task)是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。 + + +#### 5. Mach Task 知识 + +**Mach task:** + +App 在运行的时候,会对应一个 Mach Task,而 Task 下可能有多条线程同时执行任务。《OS X and iOS Kernel Programming》 中描述 Mach Task 为:任务(Task)是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。简单概括为:Mack task 是一个机器无关的 thread 的执行环境抽象。 + +作用: task 可以理解为一个进程,包含它的线程列表。 + +结构体:task_threads,将 target_task 任务下的所有线程保存在 act_list 数组中,数组个数为 act_listCnt + +```c++ +kern_return_t task_threads +( + task_t traget_task, + thread_act_array_t *act_list, //线程指针列表 + mach_msg_type_number_t *act_listCnt //线程个数 +) +``` + + + +thread_info: + +```c++ +kern_return_t thread_info +( + thread_act_t target_act, + thread_flavor_t flavor, + thread_info_t thread_info_out, + mach_msg_type_number_t *thread_info_outCnt +); +``` + +如何获取线程的堆栈数据: 系统方法 `kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);` 可以获取到所有的线程,不过这种方法获取到的线程信息是最底层的 **mach 线程**。 @@ -426,7 +472,10 @@ pthread 的 p 是 **POSIX** 的缩写,表示「可移植操作系统接口」 Unix 系统提供的 `task_threads` 和 `thread_get_state` 操作的都是内核系统,每个内核线程由 thread_t 类型的 id 唯一标识。pthread 的唯一标识是 pthread_t 类型。其中内核线程和 pthread 的转换(即 thread_t 和 pthread_t)很容易,因为 pthread 设计初衷就是「抽象内核线程」。 -`pthread_create` 方法创建线程的回调函数为 **nsthreadLauncher**。 + + +`memorystatus_action_neededpthread_create` 方法创建线程的回调函数为 **nsthreadLauncher**。 + ```Objective-c static void *nsthreadLauncher(void* thread) { @@ -461,8 +510,20 @@ static mach_port_t main_thread_id; ## 二、 App 启动时间监控 + + #### 1. App 启动时间的监控 +应用启动时间是影响用户体验的重要因素之一,所以我们需要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。 + +![App 启动时间](/Users/lbp/Downloads/2020-03-30-APMAppLaunch.png) + +冷启动:App 尚未运行,必须加载并构建整个应用。完成应用的初始化。冷启动存在较大优化空间。冷启动时间从 `application: didFinishLaunchingWithOptions:` 方法开始计算,App 一般在这里进行各种 SDK 和 App 的基础初始化工作。 + +热启动:应用已经在后台运行(常见场景:比如用户使用 App 过程中点击 Home 键,再打开 App),由于某些事件将应用唤醒到前台,App 会在 `applicationWillEnterForeground:` 方法接受应用进入前台的事件 + + + 思路比较简单。如下 - 在监控类的 `load` 方法中先拿到当前的时间值 @@ -470,7 +531,7 @@ static mach_port_t main_thread_id; - 收到通知后拿到当前的时间 - 步骤1和3的时间差就是 App 启动时间。 -`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个基于系统启动后的时钟的“嘀嗒”数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后2个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 +`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后2个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 ```Objective-c mach_timebase_info_data_t g_cmmStartupMonitorTimebaseInfoData = 0; @@ -480,6 +541,7 @@ double timeSpan = (timelapse * g_cmmStartupMonitorTimebaseInfoData.numer) / (g_c ``` + #### 2. 线上监控启动时间就好,但是在开发阶段需要对启动时间做优化。 要优化启动时间,就先得知道在启动阶段到底做了什么事情,针对现状作出方案。 @@ -492,10 +554,10 @@ App 启动过程: - 程序执行:调用 main();调用 UIApplicationMain();调用 applicationWillFinishLaunching(); Pre-Main 阶段 -![Pre-Main 阶段](./../assets/2020-02-10-AppSpeed-PreMain.png) +![Pre-Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-PreMain.png) Main 阶段 -![Main 阶段](./../assets/2020-02-10-AppSpeed-Main.png) +![Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-Main.png) ##### 2.1 加载 Dylib @@ -513,6 +575,8 @@ Main 阶段 - 使用静态库而不是动态库 - 合并非系统动态库为一个动态库 + + ##### 2.2 Rebase && Binding 优化: @@ -521,6 +585,7 @@ Main 阶段 - 转而使用 Swift struct(本质就是减少符号的数量) + ##### 2.3 Initializers 优化: @@ -565,6 +630,8 @@ Main 阶段 ## 三、 CPU 使用率监控 + + #### 1. CPU 架构 CPU(Central Processing Unit)中央处理器,市场上主流的架构有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。区别在于**不同的 CPU 设计理念和方法**。 @@ -657,7 +724,9 @@ for (int i = 0; i < threadCount; i++) { -## 四、App 占有内存 +## 四、 OOM 问题 + + #### 1. 基础知识准备 @@ -667,19 +736,21 @@ for (int i = 0; i < threadCount; i++) { **虚拟内存** 是计算机系统内存管理的一种技术。它使得程序认为它拥有连续的可用内存,而实际上,它通常被分割成多个物理内存碎片,可能部分暂时存储在外部磁盘(硬盘)存储器上(当需要使用时则用硬盘中数据交换到内存中)。Windows 系统中称为 “虚拟内存”,Linux/Unix 系统中称为 ”交换空间“。 +iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手机系统都不支持。因为移动设备的大量存储器是**闪存**,它的读写速度远远小电脑所使用的硬盘,也就是说手机即使使用了**交换空间**技术,也因为闪存慢的问题,不能提升性能,所以索性就没有交换空间技术。 + #### 2. iOS 内存知识 内存(RAM)与 CPU 一样都是系统中最稀少的资源,也很容易发生竞争,应用内存与性能直接相关。iOS 没有交换空间作为备选资源,所以内存资源尤为重要。 -什么是 OOM?是 out-of-memory 的缩写,字面意思是超过了内存限制。它是由 iOS 的 `Jetsam` 机制造成的一种非主流 Crash,它不能通过 Signal 这种监控方案所捕获。 +什么是 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。 -iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手机系统都不支持。因为移动设备的大量存储器是**闪存**,它的读写速度远远小电脑所使用的硬盘,也就是说手机即使使用了**交换空间**技术,也因为闪存慢的问题,不能提升性能,所以索性就没有交换空间技术。 +2种情况触发 OOM:系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "**highg water mark**" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。 内存过大的几种情况 @@ -693,12 +764,9 @@ App 内存不足时,系统会按照一定策略来腾出更多的空间供使 - -##### 2.1 memory page - **Memory page** 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。通常它是 16KB 大小,且有3种类型的 page。 -![内存page种类](./../assets/2020-02-28-iOSMemoryType.png) +![内存page种类](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryType.png) - Clean Memory @@ -706,7 +774,7 @@ App 内存不足时,系统会按照一定策略来腾出更多的空间供使 一开始分配的 page 都是干净的(堆里面的对象分配除外),我们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。 - ![Clean memory](./../assets/2020-02-28-iOSMemoryTypeClean.png) + ![Clean memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeClean.png) - Dirty Memory @@ -714,7 +782,7 @@ App 内存不足时,系统会按照一定策略来腾出更多的空间供使 在使用 framework 的过程中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减少 Dirty memory(因为单例一旦创建就不销毁,一直在内存中,系统不认为是 Dirty memory)。 - ![Dirty memory](./../assets/2020-02-28-iOSMemoryTypeDirty.png) + ![Dirty memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeDirty.png) - Compressed Memory @@ -725,15 +793,688 @@ App 内存不足时,系统会按照一定策略来腾出更多的空间供使 App 运行内存 = pageNumbers * pageSize。因为 Compressed Memory 属于 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize 设备不同,内存占用上限不同,App 上限较高,extension 上限较低,超过上限 crash 到 `EXC_RESOURCE_EXCEPTION`。 -![Memory footprint](./../assets/2020-02-28-iOSMemoryFootprint.png) +![Memory footprint](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryFootprint.png) + +接下来谈一下如何获取内存上限,以及如何监控 App 因为占用内存过大而被强杀。 + + + +#### 3. 获取内存信息 + +##### 3.1 通过 JetsamEvent 日志计算内存限制值 + +当 App 被 Jetsam 机制杀死时,手机会生成系统日志。查看路径:Settings-Privacy-Analytics & Improvements- Analytics Data(设置-隐私- 分析与改进-分析数据),可以看到 `JetsamEvent-2020-03-14-161828.ips` 形式的日志,以 JetsamEvent 开头。这些 JetsamEvent 日志都是 iOS 系统内核强杀掉那些优先级不高(idle、frontmost、suspended)且占用内存超过系统内存限制的 App 留下的。 + +日志包含了 App 的内存信息。可以查看到 日志最顶部有 `pageSize` 字段,查找到 per-process-limit,该节点所在结构里的 `rpages` ,将 rpages * pageSize 即可得到 OOM 的阈值。 + +日志中 largestProcess 字段代表 App 名称;reason 字段代表内存原因;states 字段代表奔溃时 App 的状态( idle、suspended、frontmost...)。 + +为了测试数据的准确性,我将测试2台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码如下 + +```objective-c +- (void)viewDidLoad { + [super viewDidLoad]; + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIImage *image = [UIImage imageNamed:@"AppIcon"]; + imageView.image = image; + [array addObject:imageView]; + } +} +``` + +iPhone 6s plus/13.3.1 数据如下: + +```html +{"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 数据如下: + +```html +{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"} +{ + "crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276", + "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030", + "product" : "iPhone12,3", + "incident" : "7F111601-BC7A-4BD7-A468-CE3370053057", + "date" : "2020-03-19 17:30:28.39 +0800", + "build" : "iPhone OS 13.3.1 (17D50)", + "timeDelta" : 189, + "memoryStatus" : { + "compressorSize" : 66443, + "compressions" : 25498129, + "decompressions" : 15532621, + "zoneMapCap" : 1395015680, + "largestZone" : "APFS_4K_OBJS", + "largestZoneSize" : 41222144, + "pageSize" : 16384, + "uncompressed" : 127027, + "zoneMapSize" : 169639936, + "memoryPages" : { + "active" : 58652, + "throttled" : 0, + "fileBacked" : 20291, + "wired" : 45838, + "anonymous" : 96445, + "purgeable" : 4, + "inactive" : 54368, + "free" : 5461, + "speculative" : 3716 + } +}, + "largestProcess" : "杭城小刘", + "genCounter" : 0, + "processes" : [ + { + "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7", + "states" : [ + "daemon", + "idle" + ], + "lifetimeMax" : 171, + "age" : 5151034269954, + "purgeable" : 0, + "fds" : 50, + "coalition" : 66, + "rpages" : 164, + "pid" : 11276, + "idleDelta" : 3801132318, + "name" : "wcd", + "cpuTime" : 3.430787 + }, + // ... + { + "uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0", + "states" : [ + "frontmost" + ], + "killDelta" : 4345, + "genCount" : 0, + "age" : 654480778, + "purgeable" : 0, + "fds" : 50, + "coalition" : 1718, + "rpages" : 134278, + "reason" : "per-process-limit", + "pid" : 14206, + "cpuTime" : 23.955463999999999, + "name" : "杭城小刘", + "lifetimeMax" : 134278 + }, + // ... + ] +} +``` + +iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384\*134278)/(1024*1024)=2098.09375M + + + +**iOS 系统如何发现 Jetsam ?** + +MacOS/iOS 是一个 BSD 衍生而来的系统,其内核是 Mach,但是对于上层暴露的接口一般是基于 BSD 层对 Mach 的包装后的。Mach 是一个微内核的架构,真正的虚拟内存管理也是在其中进行的,BSD 对内存管理提供了上层接口。Jetsam 事件也是由 BSD 产生的。`bsd_init` 函数是入口,其中基本都是在初始化各个子系统,比如虚拟内存管理等。 + +```c++ +// 1. Initialize the kernel memory allocator, 初始化 BSD 内存 Zone,这个 Zone 是基于 Mach 内核的zone 构建 +kmeminit(); + +// 2. Initialise background freezing, iOS 上独有的特性,内存和进程的休眠的常驻监控线程 +#if CONFIG_FREEZE +#ifndef CONFIG_MEMORYSTATUS + #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS" +#endif + /* Initialise background freezing */ + bsd_init_kprintf("calling memorystatus_freeze_init\n"); + memorystatus_freeze_init(); +#endif> + +// 3. iOS 独有,JetSAM(即低内存事件的常驻监控线程) +#if CONFIG_MEMORYSTATUS + /* Initialize kernel memory status notifications */ + bsd_init_kprintf("calling memorystatus_init\n"); + memorystatus_init(); +#endif /* CONFIG_MEMORYSTATUS */ +``` + +**主要作用就是开启了2个优先级最高的线程,来监控整个系统的内存情况。** + + + +CONFIG_FREEZE 开启时,内核对进程进行冷冻而不是杀死。冷冻功能是由内核中启动一个 `memorystatus_freeze_thread` 进行,这个进程在收到信号后调用 `memorystatus_freeze_top_process` 进行冷冻。 + +iOS 系统会开启优先级最高的线程 `vm_pressure_monitor` 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 进程。iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。有关 Jetsam 也就是 memorystatus 相关的逻辑,可以在 XNU 项目中的 **kern_memorystatus.h** 和 **kern_memorystatus.c **源码中查看。 + +iOS 系统因内存占用过高会强杀 App 前,至少有 6秒钟可以用来做优先级判断,JetsamEvent 日志也是在这6秒内生成的。 + +上文提到了 iOS 系统没有交换空间,于是引入了 **MemoryStatus 机制(也称为 Jetsam)**。也就是说在 iOS 系统上释放尽可能多的内存供当前 App 使用。这个机制表现在优先级上,就是先强杀后台应用;如果内存还是不够多,就强杀掉当前应用。在 MacOS 中,MemoryStatus 只会强杀掉标记为空闲退出的进程。 + +MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 的线程,它负责强杀 App 和记录日志,不会发送消息,所以内存压力检测线程无法获取到强杀 App 的消息。 + +当监控线程发现某 App 有内存压力时,就发出通知,此时有内存的 App 就去执行 `didReceiveMemoryWarning` 代理方法。在这个时机,我们还有机会做一些内存资源释放的逻辑,也许会避免 App 被系统杀死。 + + + +**源码角度查看问题** + +iOS 系统内核有一个数组,专门维护线程的优先级。数组的每一项是一个包含进程链表的结构体。结构体如下: + +```objective-c +#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1) + +typedef struct memstat_bucket { + TAILQ_HEAD(, proc) list; + int count; +} memstat_bucket_t; + +memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT]; +``` + +在 kern_memorystatus.h 中可以看到进行优先级信息 + +```objective-c +#define JETSAM_PRIORITY_IDLE_HEAD -2 +/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */ +#define JETSAM_PRIORITY_IDLE 0 +#define JETSAM_PRIORITY_IDLE_DEFERRED 1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/ +#define JETSAM_PRIORITY_AGING_BAND1 JETSAM_PRIORITY_IDLE_DEFERRED +#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC 2 +#define JETSAM_PRIORITY_AGING_BAND2 JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC +#define JETSAM_PRIORITY_BACKGROUND 3 +#define JETSAM_PRIORITY_ELEVATED_INACTIVE JETSAM_PRIORITY_BACKGROUND +#define JETSAM_PRIORITY_MAIL 4 +#define JETSAM_PRIORITY_PHONE 5 +#define JETSAM_PRIORITY_UI_SUPPORT 8 +#define JETSAM_PRIORITY_FOREGROUND_SUPPORT 9 +#define JETSAM_PRIORITY_FOREGROUND 10 +#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY 12 +#define JETSAM_PRIORITY_CONDUCTOR 13 +#define JETSAM_PRIORITY_HOME 16 +#define JETSAM_PRIORITY_EXECUTIVE 17 +#define JETSAM_PRIORITY_IMPORTANT 18 +#define JETSAM_PRIORITY_CRITICAL 19 + +#define JETSAM_PRIORITY_MAX 21 +``` + +可以明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为10。 + +优先级规则是:内核线程优先级 > 操作系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被降低。 + +在 kern_memorystatus.c 中可以看到 OOM 可能的原因: + +``` +/* For logging clarity */ +static const char *memorystatus_kill_cause_name[] = { + "" , /* kMemorystatusInvalid */ + "jettisoned" , /* kMemorystatusKilled */ + "highwater" , /* kMemorystatusKilledHiwat */ + "vnode-limit" , /* kMemorystatusKilledVnodes */ + "vm-pageshortage" , /* kMemorystatusKilledVMPageShortage */ + "proc-thrashing" , /* kMemorystatusKilledProcThrashing */ + "fc-thrashing" , /* kMemorystatusKilledFCThrashing */ + "per-process-limit" , /* kMemorystatusKilledPerProcessLimit */ + "disk-space-shortage" , /* kMemorystatusKilledDiskSpaceShortage */ + "idle-exit" , /* kMemorystatusKilledIdleExit */ + "zone-map-exhaustion" , /* kMemorystatusKilledZoneMapExhaustion */ + "vm-compressor-thrashing" , /* kMemorystatusKilledVMCompressorThrashing */ + "vm-compressor-space-shortage" , /* kMemorystatusKilledVMCompressorSpaceShortage */ +}; +``` + +查看 memorystatus_init 这个函数中初始化 Jetsam 线程的关键代码 + +```c++ +__private_extern__ void +memorystatus_init(void) +{ + // ... + /* Initialize the jetsam_threads state array */ + jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads); + + /* Initialize all the jetsam threads */ + for (i = 0; i < max_jetsam_threads; i++) { + + result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread); + if (result == KERN_SUCCESS) { + jetsam_threads[i].inited = FALSE; + jetsam_threads[i].index = i; + thread_deallocate(jetsam_threads[i].thread); + } else { + panic("Could not create memorystatus_thread %d", i); + } + } +} +``` + +```shell +/* + * High-level priority assignments + * + ************************************************************************* + * 127 Reserved (real-time) + * A + * + + * (32 levels) + * + + * V + * 96 Reserved (real-time) + * 95 Kernel mode only + * A + * + + * (16 levels) + * + + * V + * 80 Kernel mode only + * 79 System high priority + * A + * + + * (16 levels) + * + + * V + * 64 System high priority + * 63 Elevated priorities + * A + * + + * (12 levels) + * + + * V + * 52 Elevated priorities + * 51 Elevated priorities (incl. BSD +nice) + * A + * + + * (20 levels) + * + + * V + * 32 Elevated priorities (incl. BSD +nice) + * 31 Default (default base for threads) + * 30 Lowered priorities (incl. BSD -nice) + * A + * + + * (20 levels) + * + + * V + * 11 Lowered priorities (incl. BSD -nice) + * 10 Lowered priorities (aged pri's) + * A + * + + * (11 levels) + * + + * V + * 0 Lowered priorities (aged pri's / idle) + ************************************************************************* + */ +``` + +可以看出:用户态的应用程序的线程不可能高于操作系统和内核。而且,用户态的应用程序间的线程优先级分配也有区别,比如处于前台的应用程序优先级高于处于后台的应用程序优先级。iOS 上应用程序优先级最高的是 SpringBoard;此外线程的优先级不是一成不变的。Mach 会根据线程的利用率和系统整体负载动态调整线程优先级。如果耗费 CPU 太多就降低线程优先级,如果线程过度挨饿,则会提升线程优先级。但是无论怎么变,程序都不能超过其所在线程的优先级区间范围。 -#### 2. 获取内存信息 -// todo:APM 下 CPU 可以分析什么?什么场景?参数还是单独 -App 内存信息的 API 可以在 Mach 层找到,`mach_task_basic_info` 结构体存储了 Mach task 的内存使用信息,其中 resident_size 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。 +可以看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(一般情况为1,特殊情况下可能为3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0~127。上文的宏定义是进程优先级,区间为:-2~19)。 + + + +紧接着,分析下 memorystatus_thread 函数,主要负责线程启动的初始化 + +```c++ +static void +memorystatus_thread(void *param __unused, wait_result_t wr __unused) +{ + //... + while (memorystatus_action_needed()) { + boolean_t killed; + int32_t priority; + uint32_t cause; + uint64_t jetsam_reason_code = JETSAM_REASON_INVALID; + os_reason_t jetsam_reason = OS_REASON_NULL; + + cause = kill_under_pressure_cause; + switch (cause) { + case kMemorystatusKilledFCThrashing: + jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING; + break; + case kMemorystatusKilledVMCompressorThrashing: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING; + break; + case kMemorystatusKilledVMCompressorSpaceShortage: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE; + break; + case kMemorystatusKilledZoneMapExhaustion: + jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION; + break; + case kMemorystatusKilledVMPageShortage: + /* falls through */ + default: + jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE; + cause = kMemorystatusKilledVMPageShortage; + break; + } + + /* Highwater */ + boolean_t is_critical = TRUE; + if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) { + if (is_critical == FALSE) { + /* + * For now, don't kill any other processes. + */ + break; + } else { + goto done; + } + } + + jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code); + if (jetsam_reason == OS_REASON_NULL) { + printf("memorystatus_thread: failed to allocate jetsam reason\n"); + } + + if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) { + goto done; + } + + /* + * memorystatus_kill_top_process() drops a reference, + * so take another one so we can continue to use this exit reason + * even after it returns + */ + os_reason_ref(jetsam_reason); + + /* LRU */ + killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors); + sort_flag = FALSE; + + if (killed) { + if (memorystatus_post_snapshot(priority, cause) == TRUE) { + + post_snapshot = TRUE; + } + + /* Jetsam Loop Detection */ + if (memorystatus_jld_enabled == TRUE) { + if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) { + jld_idle_kills++; + } else { + /* + * We've reached into bands beyond idle deferred. + * We make no attempt to monitor them + */ + } + } + + if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) { + /* + * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT + * then we attempt to relieve pressure by purging corpse memory. + */ + task_purge_all_corpses(); + corpse_list_purged = TRUE; + } + goto done; + } + + if (memorystatus_avail_pages_below_critical()) { + /* + * Still under pressure and unable to kill a process - purge corpse memory + */ + if (total_corpses_count() > 0) { + task_purge_all_corpses(); + corpse_list_purged = TRUE; + } + + if (memorystatus_avail_pages_below_critical()) { + /* + * Still under pressure and unable to kill a process - panic + */ + panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages); + } + } + +done: + +} +``` + +可以看到它开启了一个 循环,memorystatus_action_needed() 来作为循环条件,持续释放内存。 + +```c++ +static boolean_t +memorystatus_action_needed(void) +{ +#if CONFIG_EMBEDDED + return (is_reason_thrashing(kill_under_pressure_cause) || + is_reason_zone_map_exhaustion(kill_under_pressure_cause) || + memorystatus_available_pages <= memorystatus_available_pages_pressure); +#else /* CONFIG_EMBEDDED */ + return (is_reason_thrashing(kill_under_pressure_cause) || + is_reason_zone_map_exhaustion(kill_under_pressure_cause)); +#endif /* CONFIG_EMBEDDED */ +} +``` + +它通过 vm_pagepout 发送的内存压力来判断当前内存资源是否紧张。几种情况:频繁的页面换出换进 is_reason_thrashing, Mach Zone 耗尽了 is_reason_zone_map_exhaustion、以及可用的页低于了 memory status_available_pages 这个门槛。 + +继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,如果进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。 + +通常来说单个 App 很难触碰到 high water mark,如果不能结束任何进程,最终走到 memorystatus_act_aggressive,也就是大多数 OOM 发生的地方。 + +```c++ +static boolean_t +memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) +{ + // ... + if ( (jld_bucket_count == 0) || + (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) { + + /* + * Refresh evaluation parameters + */ + jld_timestamp_msecs = jld_now_msecs; + jld_idle_kill_candidates = jld_bucket_count; + *jld_idle_kills = 0; + jld_eval_aggressive_count = 0; + jld_priority_band_max = JETSAM_PRIORITY_UI_SUPPORT; + } + //... +} +``` + +上述代码看到,判断要不要真正执行 kill 是根据一定的时间间判断的,条件是 `jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs`。 也就是在 memorystatus_jld_eval_period_msecs 后才发生条件里面的 kill。 + +```C +/* Jetsam Loop Detection */ +if (max_mem <= (512 * 1024 * 1024)) { + /* 512 MB devices */ +memorystatus_jld_eval_period_msecs = 8000; /* 8000 msecs == 8 second window */ +} else { + /* 1GB and larger devices */ +memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ +} +``` + +其中 memorystatus_jld_eval_period_msecs 取值最小6秒。所以我们可以在6秒内做些处理。**业界所说「当系统收到内存警告时会有6秒钟,再发生 Crash」。本人实测,是4秒钟后发生 Crash** + + + +##### 3.2 开发者们整理所得 + +[stackoverflow](https://stackoverflow.com/questions/5887248/ios-app-maximum-memory-budget/15200855#15200855) 上有一份数据,整理了各种设备的 OOM 临界值 + +| device | crash amount:MB | total amount:MB | percentage of total | +| :--------------------------------: | :-------------: | :-------------: | :-----------------: | +| iPad1 | 127 | 256 | 49% | +| iPad2 | 275 | 512 | 53% | +| iPad3 | 645 | 1024 | 62% | +| iPad4(iOS 8.1) | 585 | 1024 | 57% | +| Pad Mini 1st Generation | 297 | 512 | 58% | +| iPad Mini retina(iOS 7.1) | 696 | 1024 | 68% | +| iPad Air | 697 | 1024 | 68% | +| iPad Air 2(iOS 10.2.1) | 1383 | 2048 | 68% | +| iPad Pro 9.7"(iOS 10.0.2 (14A456)) | 1395 | 1971 | 71% | +| iPad Pro 10.5”(iOS 11 beta4) | 3057 | 4000 | 76% | +| iPad Pro 12.9” (2015)(iOS 11.2.1) | 3058 | 3999 | 76% | +| iPad 10.2(iOS 13.2.3) | 1844 | 2998 | 62% | +| iPod touch 4th gen(iOS 6.1.1) | 130 | 256 | 51% | +| iPod touch 5th gen | 286 | 512 | 56% | +| iPhone4 | 325 | 512 | 63% | +| iPhone4s | 286 | 512 | 56% | +| iPhone5 | 645 | 1024 | 62% | +| iPhone5s | 646 | 1024 | 63% | +| iPhone6(iOS 8.x) | 645 | 1024 | 62% | +| iPhone6 Plus(iOS 8.x) | 645 | 1024 | 62% | +| iPhone6s(iOS 9.2) | 1396 | 2048 | 68% | +| iPhone6s Plus(iOS 10.2.1) | 1396 | 2048 | 68% | +| iPhoneSE(iOS 9.3) | 1395 | 2048 | 68% | +| iPhone7(iOS 10.2) | 1395 | 2048 | 68% | +| iPhone7 Plus(iOS 10.2.1) | 2040 | 3072 | 66% | +| iPhone8(iOS 12.1) | 1364 | 1990 | 70% | +| iPhoneX(iOS 11.2.1) | 1392 | 2785 | 50% | +| iPhoneXS(iOS 12.1) | 2040 | 3754 | 54% | +| iPhoneXS Max(iOS 12.1) | 2039 | 3735 | 55% | +| iPhoneXR(iOS 12.1) | 1792 | 2813 | 63% | +| iPhone11(iOS 13.1.3) | 2068 | 3844 | 54% | +| iPhone11 Pro Max(iOS 13.2.3) | 2067 | 3740 | 55% | + + + +##### 3.3 触发当前 App 的 high water mark + +我们可以写定时器,不断的申请内存,之后再通过 `phys_footprint` 打印当前占用内存,按道理来说不断申请内存即可触发 Jetsam 机制,强杀 App,那么**最后一次打印的内存占用也就是当前设备的内存上限值**。 + +```objective-c +timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES]; + +- (void)allocateMemory { + UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIImage *image = [UIImage imageNamed:@"AppIcon"]; + imageView.image = image; + [array addObject:imageView]; + + memoryLimitSizeMB = [self usedSizeOfMemory]; + if (memoryWarningSizeMB && memoryLimitSizeMB) { + NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB); + } +} + +- (int)usedSizeOfMemory { + task_vm_info_data_t taskInfo; + mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; + kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); + + if (kernReturn != KERN_SUCCESS) { + return 0; + } + return (int)(taskInfo.phys_footprint/1024.0/1024.0); +} +``` + + + +##### 3.4 适用于 iOS13 系统的获取方式 + +iOS13 开始 中 `size_t os_proc_available_memory(void); ` 可以查看当前可用内存。 + +> ## Return Value +> +> The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns `0`. +> +> ## Discussion +> +> Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device. +> +> Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently. +> +> Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs. +> +> If you need more detailed information about the available memory resources, you can call [`task_info`](apple-reference-documentation://hcPGvbcfam). However, be aware that `task_info` is an expensive call, whereas this function is much more efficient. + +```objective-c +if (@available(iOS 13.0, *)) { + return os_proc_available_memory() / 1024.0 / 1024.0; +} +``` + + + +App 内存信息的 API 可以在 Mach 层找到,`mach_task_basic_info` 结构体存储了 Mach task 的内存使用信息,其中 phys_footprint 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。 ```Objective-c #define MACH_TASK_BASIC_INFO 20 /* always 64-bit basic info */ @@ -749,7 +1490,9 @@ struct mach_task_basic_info { integer_t suspend_count; /* suspend count for task */ }; ``` + 所以获取代码为 + ```Objective-c task_vm_info_data_t vmInfo; mach_msg_type_number_t count = TASK_VM_INFO_COUNT; @@ -763,18 +1506,1138 @@ CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0); 可能有人好奇不应该是 `resident_size` 这个字段获取内存的使用情况吗?一开始测试后发现 resident_size 和 Xcode 测量结果差距较大。而使用 phys_footprint 则接近于 Xcode 给出的结果。且可以从 [WebKit 源码](https://github.com/WebKit/webkit/blob/52bc6f0a96a062cb0eb76e9a81497183dc87c268/Source/WTF/wtf/cocoa/MemoryFootprintCocoa.cpp)中得到印证。 -基于近期的发现,可以在线下获取 App 的 high water mark,也就是 oom 内存阈值。 那么就产生了方案3 +所以在 iOS13 上,我们可以通过 `os_proc_available_memory` 获取到当前可以用内存,通过 `phys_footprint` 获取到当前 App 占用内存,2者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。 -所以对于内存的监控思路就是找到系统给 App 的内存上限,然后当接近内存上限值的时候,dump 内存情况,组装基础数据信息成一个合格的上报数据,经过一定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不同工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(情况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 +```objective-c +- (CGFloat)limitSizeOfMemory { + if (@available(iOS 13.0, *)) { + task_vm_info_data_t taskInfo; + mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; + kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); + + if (kernReturn != KERN_SUCCESS) { + return 0; + } + return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0); + } + return 0; +} +``` + +当前可以使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值一样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024*1024)=1450.09375M」。 + + + +##### 3.5 通过 XNU 获取内存限制值 + +在 XNU 中,有专门用于获取内存上限值的函数和宏,可以通过 `memorystatus_priority_entry` 这个结构体得到所有进程的优先级和内存限制值。 + +```objective-c +typedef struct memorystatus_priority_entry { + pid_t pid; + int32_t priority; + uint64_t user_data; + int32_t limit; + uint32_t state; +} memorystatus_priority_entry_t; +``` + +其中,priority 代表进程优先级,limit 代表进程的内存限制值。但是这种方式需要 root 权限,由于没有越狱设备,我没有尝试过。 + +相关代码可查阅 `kern_memorystatus.h` 文件。需要用到函数 `int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);` + +```c +/* Commands */ +#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1 +#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2 +#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3 +#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4 +#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */ +#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */ +#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */ +#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */ +#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */ +#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */ +#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE 11 /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */ +#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12 /* Disable the 'lenient' mode for aggressive jetsam. */ +#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS 13 /* Compute how much a process's phys_footprint exceeds inactive memory limit */ +#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */ +#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 15 /* Reset the inactive jetsam band for a process to the default band (0)*/ +#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED 16 /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */ +#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED 17 /* Return the 'managed' status of a process */ +#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., +``` + + + +伪代码 + +```objective-c +struct memorystatus_priority_entry memStatus[NUM_ENTRIES]; +size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES; +int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count); +if (rc < 0) { + NSLog(@"memorystatus_control"); + return ; +} + +int entry = 0; +for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){ + printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n", + memstatus[entry].pid, + memstatus[entry].priority, + memstatus[entry].user_data, + memstatus[entry].limit, + state_to_text(memstatus[entry].state)); + entry++; +} +``` + +for 循环打印出每个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为10的进程,即我们前台运行的 App。为什么是10?😂 因为 `#define JETSAM_PRIORITY_FOREGROUND 10` 我们的目的就是获取前台 App 的内存上限值。 + + + +#### 4. 如何判定发生了 OOM + +OOM 导致 crash 前,app 一定会收到低内存警告吗? + +做2组对比实验: + +```objective-c +// 实验1 +NSMutableArray *array = [NSMutableArray array]; +for (NSInteger index = 0; index < 10000000; index++) { + NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"]; + NSData *data = [NSData dataWithContentsOfFile:filePath]; + [array addObject:data]; +} +``` + +```objective-c +// 实验2 +// ViewController.m +- (void)viewDidLoad { + [super viewDidLoad]; + dispatch_async(dispatch_get_global_queue(0, 0), ^{ + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"]; + NSData *data = [NSData dataWithContentsOfFile:filePath]; + [array addObject:data]; + } + }); +} +- (void)didReceiveMemoryWarning +{ + NSLog(@"2"); +} + +// AppDelegate.m +- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application +{ + NSLog(@"1"); +} +``` + +现象: + +1. 在 viewDidLoad 也就是主线程中内存消耗过大,系统并不会发出低内存警告,直接 Crash。因为内存增长过快,主线程很忙。 +2. 多线程的情况下,App 因内存增长过快,会收到低内存警告,AppDelegate 中的`applicationDidReceiveMemoryWarning` 先执行,随后是当前 VC 的 `didReceiveMemoryWarning`。 + +结论: + +**1. 收到低内存警告不一定会 Crash,因为有6秒钟的系统判断时间,6秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** + +**2. 业界所说「当系统收到内存警告时会有6秒钟,再发生 Crash」。本人实测,是4秒钟后发生 Crash** + + + +#### 5. 内存信息收集 + +要想精确的定位问题,就需要 dump 所有对象及其内存信息。当内存接近系统内存上限的时候,收集并记录所需信息,结合一定的数据上报机制,上传到服务器,分析并修复。 + +还需要知道每个对象具体是在哪个函数里创建出来的,以便还原“案发现场”。 + +内存分配函数 malloc 和 calloc 等默认使用 nano_zone,nano_zone 是小于 256B 以下的内存分配,大于 256B 则使用 scalable_zone 来分配。 + +主要针对大内存的分配监控。malloc 函数用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。 + +使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,因为系统为了有个地方专门统计并管理内存分配情况。这样的设计也满足「收口原则」。 + +``` +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; +} +``` + +其他使用 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` + + + +由于腾讯也开源了自己的 OOM 定位方案- [OOMDetector](https://github.com/Tencent/OOMDetector) ,有了现成的轮子,那么用好就可以了,所以对于内存的监控思路就是找到系统给 App 的内存上限,然后当接近内存上限值的时候,dump 内存情况,组装基础数据信息成一个合格的上报数据,经过一定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不同工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(情况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 问题分析处理后要么发布新版本,要么 hot fix。 -监控内存增长,在达到  high water mark 附近的时候,dump 内存信息,获取对象名称、对象个数、各对象的内存值;如果稳定可以全量开启,不会有性能问题 -OOMDetector 可以拿到分配内存的堆栈,对于定位到代码层面更加有效;可以灰度开放 + + +#### 6. 开发阶段针对内存我们能做些什么 + +1. 图片缩放 + + WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部分内存,还会生成中间位图 bitmap 消耗大量内存。而 **ImageIO** 不存在上述2种弊端,只会占用最终图片大小的内存 + + 做了2组对比实验:给 App 显示一张图片 + + ```objective-c + // 方法1: 19.6M + UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"] newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)]; + self.imageView.image = imageResult; + + // 方法2: 14M + NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]); + UIImage *imageResult = [self scaledImageWithData:data withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp]; + self.imageView.image = imageResult; + + - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize + { + UIGraphicsBeginImageContextWithOptions(newSize, NO, 0); + [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; + UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return newImage; + } + + - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation + { + CGFloat maxPixelSize = MAX(size.width, size.height); + CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil); + NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue, + (__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]}; + CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options); + UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation]; + CGImageRelease(imageRef); + CFRelease(sourceRef); + return resultImage; + } + ``` + + 可以看出使用 ImageIO 比使用 UIImage 直接缩放占用内存更低。 + +2. 合理使用 autoreleasepool + + 我们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,我们如果在不断申请内存,比如各种循环,那么我们就需要手动添加 autoreleasepool,避免短时间内内存猛涨发生 OOM。 + + 对比实验 + + ```objective-c + // 实验1 + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; + NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; + [array addObject:resultString]; + } + + // 实验2 + NSMutableArray *array = [NSMutableArray array]; + for (NSInteger index = 0; index < 10000000; index++) { + @autoreleasepool { + NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; + NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; + [array addObject:resultString]; + } + } + ``` + + 实验1消耗内存 739.6M,实验2消耗内存 587M。 + +3. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。 + +4. 不管是打开网页,还是执行 js,都应该使用 WKWebView。UIWebView 会占用大量内存,从而导致 App 发生 OOM 的几率增加,而 WKWebView 是一个多进程组件,Network Loading 以及 UI Rendering 在其它进程中执行,比 UIWebView 占用更低的内存开销。 + +5. 在做 SDK 或者 App,如果场景是缓存相关,尽量使用 NSCache 而不是 NSMutableDictionary。它是系统提供的专门处理缓存的类,NSCache 分配的内存是 `Purgeable Memory`,可以由系统自动释放。NSCache 与 NSPureableData 的结合使用可以让系统根据情况回收内存,也可以在内存清理时移除对象。 + + 其他的开发习惯就不一一描述了,良好的开发习惯和代码意识是需要平时注意修炼的。 + + ## 五、 App 网络监控 +移动网络环境一直很复杂,WIFI、2G、3G、4G、5G 等,用户使用 App 的过程中可能在这几种类型之间切换,这也是移动网络和传统网络间的一个区别,被称为「Connection Migration」。此外还存在 DNS 解析缓慢、失败率高、运营商劫持等问题。用户在使用 App 时因为某些原因导致体验很差,要想针对网络情况进行改善,必须有清晰的监控手段。 + + + +###1. App 网络请求过程 + +![网络请求各阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-03-NetworkTime.png) + +App 发送一次网络请求一般会经历下面几个关键步骤: + +- DNS 解析 + + Domain Name system,网络域名名称系统,本质上就是将`域名`和`IP 地址` 相互映射的一个分布式数据库,使人们更方便的访问互联网。首先会查询本地的 DNS 缓存,查找失败就去 DNS 服务器查询,这其中可能会经过非常多的节点,涉及到**递归查询和迭代查询**的过程。运营商可能不干人事:一种情况就是出现运营商劫持的现象,表现为你在 App 内访问某个网页的时候会看到和内容不相关的广告;另一种可能的情况就是把你的请求丢给非常远的基站去做 DNS 解析,导致我们 App 的 DNS 解析时间较长,App 网络效率低。一般做 HTTPDNS 方案去自行解决 DNS 的问题。 + +- TCP 3次握手 + + 关于 TCP 握手过程中为什么是3次握手而不是2次、4次,可以查看这篇[文章](https://draveness.me/whys-the-design-tcp-three-way-handshake/)。 + +- TLS 握手 + + 对于 HTTPS 请求还需要做 TLS 握手,也就是密钥协商的过程。 + +- 发送请求 + + 连接建立好之后就可以发送 request,此时可以记录下 request start 时间 + +- 等待回应 + + 等待服务器返回响应。这个时间主要取决于资源大小,也是网络请求过程中最为耗时的一个阶段。 + +- 返回响应 + + 服务端返回响应给客户端,根据 HTTP header 信息中的状态码判断本次请求是否成功、是否走缓存、是否需要重定向。 + + + +### 2. 监控原理 + +| 名称 | 说明 | +| :-------------: | :---------------------: | +| NSURLConnection | 已经被废弃。用法简单 | +| NSURLSession | iOS7.0 推出,功能更强大 | +| CFNetwork | NSURL 的底层,纯 C 实现 | + +iOS 网络框架层级关系如下: + +![Network Level](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-05-NetworkLevel.png) + +iOS 网络现状是由4层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。 + +目前业界对于网络监控主要有2种:一种是通过 NSURLProtocol 监控、一种是通过 Hook 来监控。 + + + +#### 2.1 NSURLProtocol 监控 App 网络请求 + +NSURLProtocol 作为上层接口,使用较为简单,但 NSURLProtocol 属于 URL Loading System 体系中。应用协议的支持程度有限,支持 FTP、HTTP、HTTPS 等几个应用层协议,对于其他的协议则无法监控,存在一定的局限性。如果监控底层网络库 CFNetwork 则没有这个限制。 + +对于 NSURLProtocol 的具体做法在[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md)中讲过,继承抽象类并实现相应的方法,自定义去发起网络请求来实现监控的目的。 + +iOS 10 之后,NSURLSessionTaskDelegate 中增加了一个新的代理方法: + +```objective-c +/* + * Sent when complete statistics information has been collected for the task. + */ +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)); +``` + +可以从 `NSURLSessionTaskMetrics` 中获取到网络情况的各项指标。各项参数如下 + +```objective-c +@interface NSURLSessionTaskMetrics : NSObject + +/* + * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution. + */ +@property (copy, readonly) NSArray *transactionMetrics; + +/* + * Interval from the task creation time to the task completion time. + * Task creation time is the time when the task was instantiated. + * Task completion time is the time when the task is about to change its internal state to completed. + */ +@property (copy, readonly) NSDateInterval *taskInterval; + +/* + * redirectCount is the number of redirects that were recorded. + */ +@property (assign, readonly) NSUInteger redirectCount; + +- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); ++ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); + +@end +``` + +其中:`taskInterval` 表示任务从创建到完成话费的总时间,任务的创建时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;`redirectCount` 表示被重定向的次数;`transactionMetrics` 数组包含了任务执行过程中每个请求/响应事务中收集的指标,各项参数如下: + +```objective-c +/* + * This class defines the performance metrics collected for a request/response transaction during the task execution. + */ +API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) +@interface NSURLSessionTaskTransactionMetrics : NSObject + +/* + * Represents the transaction request. 请求事务 + */ +@property (copy, readonly) NSURLRequest *request; + +/* + * Represents the transaction response. Can be nil if error occurred and no response was generated. 响应事务 + */ +@property (nullable, copy, readonly) NSURLResponse *response; + +/* + * For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding “EndDate” metric will be nil. + * For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics. + */ + +/* + * 客户端开始请求的时间,无论是从服务器还是从本地缓存中获取 + * fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources. + * + * The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources: + * + * domainLookupStartDate + * domainLookupEndDate + * connectStartDate + * connectEndDate + * secureConnectionStartDate + * secureConnectionEndDate + */ +@property (nullable, copy, readonly) NSDate *fetchStartDate; + +/* + * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 开始解析的时间 + */ +@property (nullable, copy, readonly) NSDate *domainLookupStartDate; + +/* + * domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的时间 + */ +@property (nullable, copy, readonly) NSDate *domainLookupEndDate; + +/* + * connectStartDate is the time immediately before the user agent started establishing the connection to the server. + * + * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客户端与服务端开始建立 TCP 连接的时间 + */ +@property (nullable, copy, readonly) NSDate *connectStartDate; + +/* + * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手开始的时间 + * + * For example, this would correspond to the time immediately before the user agent started the TLS handshake. + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSDate *secureConnectionStartDate; + +/* + * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手结束的时间 + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSDate *secureConnectionEndDate; + +/* + * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客户端与服务器建立 TCP 连接完成的时间,包括 TLS 握手时间 + */ +@property (nullable, copy, readonly) NSDate *connectEndDate; + +/* + * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources. + 客户端请求开始的时间,可以理解为开始传输 HTTP 请求的 header 的第一个字节时间 + * + * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request. + */ +@property (nullable, copy, readonly) NSDate *requestStartDate; + +/* + * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources. + 客户端请求结束的时间,可以理解为 HTTP 请求的最后一个字节传输完成的时间 + * + * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request. + */ +@property (nullable, copy, readonly) NSDate *requestEndDate; + +/* + * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources. + 客户端从服务端接收响应的第一个字节的时间 + * + * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response. + */ +@property (nullable, copy, readonly) NSDate *responseStartDate; + +/* + * responseEndDate is the time immediately after the user agent received the last byte of the resource. 客户端从服务端接收到最后一个请求的时间 + */ +@property (nullable, copy, readonly) NSDate *responseEndDate; + +/* + * The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301]. + * E.g., h2, http/1.1, spdy/3.1. + 网络协议名,比如 http/1.1, spdy/3.1 + * + * When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol. + * + * For example: + * If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned. + * If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned. + * If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned. + * + */ +@property (nullable, copy, readonly) NSString *networkProtocolName; + +/* + * This property is set to YES if a proxy connection was used to fetch the resource. + 该连接是否使用了代理 + */ +@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection; + +/* + * This property is set to YES if a persistent connection was used to fetch the resource. + 是否复用了现有连接 + */ +@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection; + +/* + * Indicates whether the resource was loaded, pushed or retrieved from the local cache. + 获取资源来源 + */ +@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType; + +/* + * countOfRequestHeaderBytesSent is the number of bytes transferred for request header. + 请求头的字节数 + */ +@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfRequestBodyBytesSent is the number of bytes transferred for request body. + 请求体的字节数 + * It includes protocol-specific framing, transfer encoding, and content encoding. + */ +@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream. + 上传体数据、文件、流的大小 + */ +@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseHeaderBytesReceived is the number of bytes transferred for response header. + 响应头的字节数 + */ +@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseBodyBytesReceived is the number of bytes transferred for response body. + 响应体的字节数 + * It includes protocol-specific framing, transfer encoding, and content encoding. + */ +@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler. +给代理方法或者完成后处理的回调的数据大小 + + */ +@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * localAddress is the IP address string of the local interface for the connection. + 当前连接下的本地接口 IP 地址 + * + * For multipath protocols, this is the local address of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * localPort is the port number of the local interface for the connection. + 当前连接下的本地端口号 + + * + * For multipath protocols, this is the local port of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * remoteAddress is the IP address string of the remote interface for the connection. + 当前连接下的远端 IP 地址 + * + * For multipath protocols, this is the remote address of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * remotePort is the port number of the remote interface for the connection. + 当前连接下的远端端口号 + * + * For multipath protocols, this is the remote port of the initial flow. + * + * If a connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection. + 连接协商用的 TLS 协议版本号 + * It is a 2-byte sequence in host byte order. + * + * Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection. + 连接协商用的 TLS 密码套件 + * It is a 2-byte sequence in host byte order. + * + * Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h + * + * If an encrypted connection was not used, this attribute is set to nil. + */ +@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over a cellular interface. + 是否是通过蜂窝网络建立的连接 + */ +@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over an expensive interface. + 是否通过昂贵的接口建立的连接 + */ +@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether the connection is established over a constrained interface. + 是否通过受限接口建立的连接 + */ +@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + +/* + * Whether a multipath protocol is successfully negotiated for the connection. + 是否为了连接成功协商了多路径协议 + */ +@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); + + +- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); ++ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); + +@end +``` + + + +网络监控简单代码 + +```objective-c +// 监控基础信息 +@interface NetworkMonitorBaseDataModel : NSObject +// 请求的 URL 地址 +@property (nonatomic, strong) NSString *requestUrl; +//请求头 +@property (nonatomic, strong) NSArray *requestHeaders; +//响应头 +@property (nonatomic, strong) NSArray *responseHeaders; +//GET方法 的请求参数 +@property (nonatomic, strong) NSString *getRequestParams; +//HTTP 方法, 比如 POST +@property (nonatomic, strong) NSString *httpMethod; +//协议名,如http1.0 / http1.1 / http2.0 +@property (nonatomic, strong) NSString *httpProtocol; +//是否使用代理 +@property (nonatomic, assign) BOOL useProxy; +//DNS解析后的 IP 地址 +@property (nonatomic, strong) NSString *ip; +@end + +// 监控信息模型 +@interface NetworkMonitorDataModel : NetworkMonitorBaseDataModel +//客户端发起请求的时间 +@property (nonatomic, assign) UInt64 requestDate; +//客户端开始请求到开始dns解析的等待时间,单位ms +@property (nonatomic, assign) int waitDNSTime; +//DNS 解析耗时 +@property (nonatomic, assign) int dnsLookupTime; +//tcp 三次握手耗时,单位ms +@property (nonatomic, assign) int tcpTime; +//ssl 握手耗时 +@property (nonatomic, assign) int sslTime; +//一个完整请求的耗时,单位ms +@property (nonatomic, assign) int requestTime; +//http 响应码 +@property (nonatomic, assign) NSUInteger httpCode; +//发送的字节数 +@property (nonatomic, assign) UInt64 sendBytes; +//接收的字节数 +@property (nonatomic, assign) UInt64 receiveBytes; + + +// 错误信息模型 +@interface NetworkMonitorErrorModel : NetworkMonitorBaseDataModel +//错误码 +@property (nonatomic, assign) NSInteger errorCode; +//错误次数 +@property (nonatomic, assign) NSUInteger errCount; +//异常名 +@property (nonatomic, strong) NSString *exceptionName; +//异常详情 +@property (nonatomic, strong) NSString *exceptionDetail; +//异常堆栈 +@property (nonatomic, strong) NSString *stackTrace; +@end + + +// 继承自 NSURLProtocol 抽象类,实现响应方法,代理网络请求 +@interface CustomURLProtocol () + +@property (nonatomic, strong) NSURLSessionDataTask *dataTask; +@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue; +@property (nonatomic, strong) NetworkMonitorDataModel *dataModel; +@property (nonatomic, strong) NetworkMonitorErrorModel *errModel; + +@end + +//使用NSURLSessionDataTask请求网络 +- (void)startLoading { + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration + delegate:self + delegateQueue:nil]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; + self.sessionDelegateQueue = [[NSOperationQueue alloc] init]; + self.sessionDelegateQueue.maxConcurrentOperationCount = 1; + self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue"; + self.dataTask = [session dataTaskWithRequest:self.request]; + [self.dataTask resume]; +} + +#pragma mark - NSURLSessionTaskDelegate +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { + if (error) { + [self.client URLProtocol:self didFailWithError:error]; + } else { + [self.client URLProtocolDidFinishLoading:self]; + } + if (error) { + NSURLRequest *request = task.currentRequest; + if (request) { + self.errModel.requestUrl = request.URL.absoluteString; + self.errModel.httpMethod = request.HTTPMethod; + self.errModel.requestParams = request.URL.query; + } + self.errModel.errorCode = error.code; + self.errModel.exceptionName = error.domain; + self.errModel.exceptionDetail = error.description; + // 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](./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 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](./1.80.md) 讲 + } +} +``` + + + +#### 2.2 骚操作篇 + +在 2.1 分析到了 NSURLSessionTaskMetrics 由于兼容性问题,对于网络监控来说似乎不太完美,但是自后在搜资料的时候看到了一篇[文章](https://www.jianshu.com/p/1c34147030d1)。文章在分析 WebView 的网络监控的时候分析 Webkit 源码的时候发现了下面代码 + +```objective-c +#if !HAVE(TIMINGDATAOPTIONS) +void setCollectsTimingData() +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + [NSURLConnection _setCollectsTimingData:YES]; + ... + }); +} +#endif +``` + +也就是说明 NSURLConnection 本身有一套 `TimingData` 的收集 API,只是没有暴露给开发者,苹果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 `_setCollectsTimingData:` 、`_timingData` 2个 api(iOS8 以后可以使用)。 + +NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 TimingData 了。 + +注意: + +- 因为是私有 API,所以在使用的时候注意混淆。比如 `[[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]`。 +- 不推荐私有 API,一般做 APM 的属于公共团队,你想想看虽然你做的 SDK 达到网络监控的目的了,但是万一给业务线的 App 上架造成了问题,那就得不偿失了。一般这种投机取巧,不是百分百确定的事情可以在玩具阶段使用。 + +```objective-c +@interface _NSURLConnectionProxy : DelegateProxy + +@end + +@implementation _NSURLConnectionProxy + +- (BOOL)respondsToSelector:(SEL)aSelector +{ + if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) { + return YES; + } + return [self.target respondsToSelector:aSelector]; +} + +- (void)forwardInvocation:(NSInvocation *)invocation +{ + [super forwardInvocation:invocation]; + if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) { + __unsafe_unretained NSURLConnection *conn; + [invocation getArgument:&conn atIndex:2]; + SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]); + NSDictionary *timingData = [conn performSelector:selector]; + [[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest]; + } +} + +@end + +@implementation NSURLConnection(tracker) + ++ (void)load +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class class = [self class]; + + SEL originalSelector = @selector(initWithRequest:delegate:); + SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:); + + Method originalMethod = class_getInstanceMethod(class, originalSelector); + Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); + method_exchangeImplementations(originalMethod, swizzledMethod); + + NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]; + SEL selector = NSSelectorFromString(selectorName); + [NSURLConnection performSelector:selector withObject:@(YES)]; + }); +} + +- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id)delegate +{ + if (delegate) { + _NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate]; + objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return [self swizzledInitWithRequest:request delegate:(id)proxy]; + }else{ + return [self swizzledInitWithRequest:request delegate:delegate]; + } +} + +@end +``` + + + +#### 2.3 Hook + +写 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 的解决方案,对于网络监控需要做如下的 hook + +![network hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-network_monitor.png) + + + +可能对于 CFNetwork 比较陌生,可以看一下 CFNetwork 的层级和简单用法 + +![CFNetwork Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-CFNetworkStructure.png) + + + +CFNetwork 的基础是 CFSocket 和 CFStream。 + +CFSocket:Socket 是网络通信的底层基础,可以让2个 socket 端口互发数据,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了所有的 BSD 功能,此外加入了 RunLoop。 + +CFStream:提供了与设备无关的读写数据方法,使用它可以为内存、文件、网络(使用 socket)的数据建立流,使用 stream 可以不必将所有数据写入到内存中。CFStream 提供 API 对2种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。 + +简单 Demo + +```objective-c +- (void)testCFNetwork +{ + CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL); + CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1); + CFRelease(urlRef); + + CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef); + CFRelease(httpMessageRef); + + CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); + + CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered); + CFStreamClientContext context = { + 0, + NULL, + NULL, + NULL, + NULL + } ; + // Assigns a client to a stream, which receives callbacks when certain events occur. + CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context); + // Opens a stream for reading. + CFReadStreamOpen(readStream); +} +// callback +void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) { + CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0); + CFIndex numberOfBytesRead = 0; + do { + UInt8 buffer[2014]; + numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer)); + if (numberOfBytesRead > 0) { + CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead); + } + } while (numberOfBytesRead > 0); + + + CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader); + if (responseBytes) { + if (response) { + CFHTTPMessageSetBody(response, responseBytes); + } + CFRelease(responseBytes); + } + + // close and cleanup + CFReadStreamClose(stream); + CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); + CFRelease(stream); + + // print response + if (response) { + CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response); + CFRelease(response); + + printResponseData(reponseBodyData); + CFRelease(reponseBodyData); + } +} + +void printResponseData (CFDataRef responseData) { + CFIndex dataLength = CFDataGetLength(responseData); + UInt8 *bytes = (UInt8 *)malloc(dataLength); + CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes); + CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE); + CFShow(responseString); + CFRelease(responseString); + free(bytes); +} +// console +{ + "args": {}, + "headers": { + "Host": "httpbin.org", + "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", + "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564" + }, + "origin": "183.159.122.102", + "url": "https://httpbin.org/get" +} +``` + +Method Swizzling 方法替换需要知道类名,我们在开发阶段使用 NSURLConnection、NSURLSession 都需要指定代理对象,且代理对象在该阶段没有办法确定,所以在此处的思路是在 NSURLConnection、NSURLSession 设置代理的时候替换掉代理对象, + + + + + + + + + +#### 2.4 监控 App 常见网络请求 + +本着成本的原因,由于现在大多数的项目的网络能力都是通过 [AFNetworking](https://github.com/AFNetworking/AFNetworking) 完成的,所以本文的网络监控可以快速完成。 + +AFNetworking 在发起网络的时候会有相应的通知。`AFNetworkingTaskDidResumeNotification` 和 `AFNetworkingTaskDidCompleteNotification`。通过监听通知携带的参数获取网络情况信息。 + +```Objective-c + self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { + // 开始 + __strong __typeof(weakSelf)strongSelf = weakSelf; + NSURLSessionTask *task = note.object; + NSString *requestId = [[NSUUID UUID] UUIDString]; + task.apm_requestId = requestId; + [strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task]; +}]; + +self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { + + __strong __typeof(weakSelf)strongSelf = weakSelf; + + NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey]; + NSURLSessionTask *task = note.object; + if (!error) { + // 成功 + [strongSelf.networkRecoder recordFinishRequestWithRequestID:task.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,但是日常开发也是一样。 + +- Category 类名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加当前分类的功能,也就是`类名+SDK名称简写_功能名称`。比如当前 SDK 叫 JuhuaSuanAPM,那么该 NSURLSessionTask Category 名称就叫做 `NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h` +- Category 属性名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加属性名,也就是`SDK名称简写_属性名称`。比如 JuhuaSuanAPM_requestId` +- Category 方法名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加方法名,也就是`SDK名称简写_方法名称`。比如 `-(BOOL)JuhuaSuanAPM__isGzippedData` + +例子如下: +```Objective-c +#import + +@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor) + +@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId; + +@end + +#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h" +#import + +@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor) + +- (NSString*)JuhuaSuanAPM_requestId +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId +{ + objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC); +} +@end +``` + + + + +## 六、 Crash 监控 + +对于奔溃 + + + + + + ## 参考资料 @@ -785,4 +2648,12 @@ OOMDetector 可以拿到分配内存的堆栈,对于定位到代码层面更 - [获取任意线程调用栈的那些事](https://bestswifter.com/callstack/) - [iOS启动时间优化](https://www.zoomfeng.com/blog/launch-time.html) - [WWDC2019之启动时间与Dyld3](https://www.zoomfeng.com/blog/launch-optimize-from-wwdc2019.html) +- [Apple-libmalloc](https://opensource.apple.com/tarballs/libmalloc/) +- [Apple-XNU](https://opensource.apple.com/tarballs/xnu/) +- [OOM探究:XNU 内存状态管理](https://www.jianshu.com/p/4458700a8ba8) +- [Reducing FOOMs in the Facebook iOS app](https://engineering.fb.com/ios/reducing-fooms-in-the-facebook-ios-app/) +- [iOS内存abort(Jetsam) 原理探究](https://satanwoo.github.io/2017/10/18/abort/) +- [iOS微信内存监控](https://wetest.qq.com/lab/view/367.html?from=coop_gad) +- [iOS堆栈信息解析(函数地址与符号关联)](https://www.jianshu.com/p/df5b08330afd) +- [Apple-CFNetwork Programming Guide](https://developer.apple.com/library/archive/documentation/Networking/Conceptual/CFNetwork/Introduction/Introduction.html#//apple_ref/doc/uid/TP30001132-CH1-DontLinkElementID_30) diff --git a/Chapter1 - iOS/1.76.md b/Chapter1 - iOS/1.76.md index bb33815..8f4b6d8 100644 --- a/Chapter1 - iOS/1.76.md +++ b/Chapter1 - iOS/1.76.md @@ -34,4 +34,28 @@ Thread 12 Crashed: 为了搞懂这种 Crash,这篇文章就诞生了. -## \ No newline at end of file +## crash 后的 backtrace 符号化 + +iOS 的奔溃日志结合 dsym 文件可以找到奔溃时的 backtrace,这是解决奔溃的关键线索。如果是同一台 Mac 进行打包,导入 crash log 会自动将 backtrace 进行符号化,这样我们就可以看到类名、文件名、方法名和行号。 + +如果打包不是在同一台 Mac,xcode 找不到对应的符号表,backtrace 是不会被解析成功的。 + +``` +Last Exception Backtrace: +0 CoreFoundation 0x2cb535f2 __exceptionPreprocess + 122 +1 libobjc.A.dylib 0x3a3c5c72 objc_exception_throw + 34 +2 CoreFoundation 0x2ca67152 -[__NSArrayM objectAtIndex:] + 226 +3 myapp 0x004fe736 0x9b000 + 4601654 +4 myapp 0x00507ed4 0x9b000 + 4640468 +5 myapp 0x004fd112 0x9b000 + 4595986 +6 myapp 0x003275c6 0x9b000 + 2672070 +``` + +方法:将 crash 对应的包比如 Test.app 文件和 crash 文件放在一个文件夹下。执行下面命令 +```shell + atos -arch arm64 Test.app/Test -l 0x9b000 0x004fe736 +``` + + +其中: +arch 后代表指令架构集;第一个数字,取backtrace的要解析的行的第4列;第二个数字取第3列, 就会得到对应的方法名,文件名,行号. \ No newline at end of file diff --git a/Chapter1 - iOS/1.8.md b/Chapter1 - iOS/1.8.md index 9d61f12..a339224 100644 --- a/Chapter1 - iOS/1.8.md +++ b/Chapter1 - iOS/1.8.md @@ -1,5 +1,5 @@ -# 长按UIWebView上的图片保存到相册 +# 教你实现微信公众号效果:长按图片保存到相册 > 不知道各位对于这个需求要如何解决? > @@ -17,11 +17,11 @@ #### 开发步骤 -*给UIWebView添加长按手势 -*监听手势动作,拿到坐标点(x,y) -*UIWebView注入js:Document.elementFromPoint(x,y).src拿到img标签的src -*判断拿到的src是否有值,有值则代表点击的网页上的img标签,此时弹出对话框,是否保存到相册。如果src为空,则代表点击网页上的非img标签,则不需要弹出对话框。 -*拿到图片的url,生成UIImage。再将图片保存到相册 +- 给UIWebView添加长按手势 +- 监听手势动作,拿到坐标点(x,y) +- `UIWebView注入js:Document.elementFromPoint(x,y).src` 拿到 img 标签的 src +- 判断拿到的 src 是否有值,有值则代表点击的网页上的 img 标签,此时弹出对话框,是否保存到相册。如果 src为空,则代表点击网页上的非img标签,则不需要弹出对话框 +- 拿到图片的 url,生成 UIImage,再将图片保存到相册 #### 有巨坑 diff --git a/Chapter1 - iOS/1.82.md b/Chapter1 - iOS/1.82.md new file mode 100644 index 0000000..c7b73b5 --- /dev/null +++ b/Chapter1 - iOS/1.82.md @@ -0,0 +1,93 @@ +# Runtime + +> 做很多需求或者是技术细节验证的时候会用到 Runtime 技术,用了挺久的了,本文就写一些场景和源码分析相关的文章。 + +## 场景 + +最简单的一个场景就是防止按钮多次点击吧,比如短时间内点击了多次按钮,可以用「节流」来实现。用到的技术是 runtime。再举一个例子,比如无痕埋点的实现里面对各种控件的点击、页面的跳转等也需要用到 runtime,想看无痕埋点的设计与实现,可以看我这篇[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.55.md)。 + +这里不得不提的一个知识点就是为什么给类或者对象进行 hook 的操作,要放到 load 方法中进行了。 + + + +## 一、 load 和 initialize 方法 + +**load 方法** + +对于加入运行期系统中的每个类及分类来说,必定会调用 load 方法,而且仅调用1次。当包含类或分类的程序库载入系统时(通常指应用程序启动的时候),就会执行 load 方法。 + +- 类的 load 方法会在它所有父类 load 方法后调用 +- 分类的 load 方法会在类本身的 load 方法后调用 +- load 方法不遵循继承 +- load 方法内部的实现必须简单,如果逻辑太复杂有可能会导致阻塞 + +load 方法有个需要注意的地方:执行该方法时,运行期系统处于脆弱状态。在执行子类的 load 方法之前,必须先执行完所有的超类的 load 方法,假如代码中还依赖了其他的程序库,那么程序库里的相关 load 方法也会先执行。在开发中,load 方法中使用其他类是不安全的。 + +```objective-c +@implentation ClassB ++ (void)load +{ + ClassA *classA = [[ClassA alloc] init]; + [classA setUp]; +} +@end +``` + +上面的代码不太推荐且不太安全,因为你没办法确定在执行 ClassB 的 load 方法的时候, ClassA 是否已经被加载到系统中。 + +load 方法并不像普通方法那样具备继承规则。普通的方法在面向对象程序设计中,父类的方法、属性等都会在子类中存在,假如 Person 类有 eat、sleep 方法,Student 类继承子 Person 类,虽然 Person 类没有重写 eat 方法,但是你给 Student 类发送 eat 消息是可以响应的,因为方法被继承了。 Load 方法就不会,假如子类没有 load 方法,不管超类是否有 load 方法,子类都不会调用 load 方法。 + +load 方法内代码逻辑必须精简。因为整个应用程序在执行 load 方法的时候会被阻塞。如果 load 方法中包含繁杂的代码,那么应用程序可能会变得无法响应。更加不要使用锁。想通过 load 方法在类加载之前做些操作的,都属于错误的打开方式。它真正的用法应该是 Debug 吧,比如在 Category 中判断当前分类是否被成功 load 进去。 + + + +**inintialize 方法** + +对于每个类来说,该方法会在程序首次调用该类之前调用,且只调用一次。它是由运行时系统来调用的,不应该通过代码的方式直接调用。 + + + +与 load 的区别: + +- 惰性调用的。也就是说某个类的 initialize 方法也许永远不会被调用,当且仅当程序用到了该类的时候才会调用。 load 是加载进 runtime 肯定会调用,initizlize 第一次使用前会被调用。对 load 来说,应用程序必须阻塞并且等着所有类 load 方法执行完毕才可以继续。 +- 运行期系统在执行 initialize 方法时,是处于正常状态的,因此从运行期完整度方面来讲,此时可以安全使用并调用任何类的任意方法,而且 runtime 保证了在 initizlize 方法时期一定会在一个线程安全的环境中执行,也就是说执行 initialize 方法的这个线程可以操作类或者实例,其他线程先阻塞,等待执行完毕 +- initialize 同其他方法一样,如果某个类并未实现它,而其实现了,那么就会运行超类实现的代码。 + +```objective-c +@implentation AClass ++ (void)initialize +{ + NSLog(@"%@ initialize", self); +} +@end + +@interface BClass:AClass + +@end +@implentation BClass + +@end + +// log +AClass initialize +BClass initialize +``` + +基于上述特点,我们一般需要 initialize 方法中做些判断,如下 + +```objective-c ++ (void)initialize +{ + if (self == [ACLass class]) { + // 确定了我是 AClass 的 initialize 方法执行时期 + } +} +``` + + + +经常说不要在 App 中写太多的 load 方法,会影响 App 的启动时间。原因就是 load 方法的执行特点决定的。某个类的 load 方法执行是在它所有父类 load 方法执行后执行的,该类的分类的 load 方法执行是在当前类 load 方法之后执行的。如果某个类的 load 方法中引用了其他的类或者其他库的代码,则该类的 load 方法必须是其他类或者其他库中类的 load 方法执行后执行,所以类的 load 方法中最好做本类相关的逻辑,比如 runtime method swizzling。 + + +TagPointerString 不走消息转发 +CFString 走消息转发 \ No newline at end of file diff --git a/Chapter1 - iOS/1.83.md b/Chapter1 - iOS/1.83.md new file mode 100644 index 0000000..0e6bed0 --- /dev/null +++ b/Chapter1 - iOS/1.83.md @@ -0,0 +1,439 @@ +# NSURLProtocol 应用场景 + +> 在做 Hybrid 的时候就使用到 NSURLProtocol,对于网络监控依旧可以使用它,所以本文就总结下 NSURLProtocol 的应用场景和如何用 + +## 一、 NSURLProtocol 是什么 + +NSURLProtocol 是 Foundation 框架中 URL Loading System 的一部分。它可以让开发者可以在不修改应用内原始请求代码的情况下,去改变 URL 加载的全部细节。换句话说,NSURLProtocol 是一个被 Apple 默许的中间人攻击。 + +虽然 NSURLProtocol 叫“Protocol”,却不是协议,而是一个抽象类。 + +既然 NSURLProtocol 是一个抽象类,说明它无法被实例化,那么它又是如何实现网络请求拦截的? + +答案就是通过子类化来定义新的或是已经存在的 URL 加载行为。如果当前的网络请求是可以被拦截的,那么开发者只需要将一个自定义的 NSURLProtocol 子类注册到 App 中,在这个子类中就可以拦截到所有请求并进行修改。 + + + + +## 二、NSURLProtocol 使用场景 + +### 1. 技术层面 + +NSURLProtocol 是 URL Loading System 的一部分,所以它可以拦截所有基于 URL Loading System 的网络请求: +- NSURLSession +- NSURLConnection +- NSURLDownload +- NSURLResponse +- NSHTTPURLResponse +- NSURLRequest +- NSMutableURLRequest +所以,基础这些基础技术开发的网络框架比如 AFNetworking、Alamofire 也可以拦截。 + +想到了2种场景不能拦截: +- 早期使用 CFNetwork 实现的 ASIHTTPRequest 框架就无法拦截 +- UIWebView 也是可以被拦截的。但是 WKWebView 是基于 webkit,不走底层 c socket。 + +### 2. 需求层面 + +#### 2.1 Hybrid + +- 对 webview 上运行的资源进行监控(大小、时间等) +- Native 代理 WebView 上面的图片资源,使用和客户端一致的图片管理策略(比如 SDWebImage 管理) +- 访问提速。App 在 cd 阶段打包内置了项目技术栈的框架库、样式库、一些业务频道的基础包。配合一定的资源更新策略就可以给 Hybrid 提速 +- 代理 WebView 上的网络请求。Native 针对网络请求具备更高的安全性和灵活性、网络请求收口策略 + +#### 2.2 针对网络进行监控 + +- 对 App 内的网络请求进行重定向,解决 DNS 域名劫持问题 +- 针对全局网络请求设置。比如缓存管理、请求地址修改、header +- App 网络安全性。设置 App 网络白名单 +- 自定义网络请求,过滤垃圾内容 +- H5 加速,请求走本地离线包 + + + + +## 三、NSURLProtocol 的相关方法 + +创建协议对象 + +```Objective-c +// 创建一个 URL 协议实例来处理 request 请求 +- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id)client; +// 创建一个 URL 协议实例来处理 session task 请求 +- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id)client; +``` + +注册和注销协议类 +```Objective-c +// 尝试注册 NSURLProtocol 的子类,使之在 URL 加载系统中可见 ++ (BOOL)registerClass:(Class)protocolClass; +// 注销 NSURLProtocol 的指定子类 ++ (void)unregisterClass:(Class)protocolClass; +``` + +确定子类是否可以处理请求 + +子类化 NSProtocol 的首要任务就是告知它,需要控制什么类型的网络请求。 + +```objective-c +// 确定协议子类是否可以处理指定的 request 请求,如果返回 YES,请求会被其控制,返回 NO 则直接跳入下一个 protocol ++ (BOOL)canInitWithRequest:(NSURLRequest *)request; +// 确定协议子类是否可以处理指定的 task 请求 ++ (BOOL)canInitWithTask:(NSURLSessionTask *)task; +``` + +获取和设置请求属性 + +NSURLProtocol 允许开发者去获取、添加、删除 request 对象的任意元数据。这几个方法常用来处理请求无限循环的问题。 + +```Objective-c +// 在指定的请求中获取与指定键关联的属性 ++ (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request; +// 设置与指定请求中的指定键关联的属性 ++ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request; +// 删除与指定请求中的指定键关联的属性 ++ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request; +``` + +提供请求的规范版本 + +如果你想要用特定的某个方式来修改请求,可以用下面这个方法。 +```Objective-c +// 返回指定请求的规范版本 ++ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request; +``` + +确定请求是否相同 + +```Objective-c +// 判断两个请求是否相同,如果相同可以使用缓存数据,通常只需要调用父类的实现 ++ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b; +``` + +启动和停止加载 + +这是子类中最重要的两个方法,不同的自定义子类在调用这两个方法时会传入不同的内容,但共同点都是围绕 protocol 客户端进行操作。 +```Objective-c +// 开始加载 +- (void)startLoading; +// 停止加载 +- (void)stopLoading; +``` + +获取协议属性 + +```Objective-c +// 获取协议接收者的缓存 +- (NSCachedURLResponse *)cachedResponse; +// 接受者用来与 URL 加载系统通信的对象,每个 NSProtocol 的子类实例都拥有它 +- (id)client; +// 接收方的请求 +- (NSURLRequest *)request; +// 接收方的任务 +- (NSURLSessionTask *)task; +``` + + + + +## 四、 如何利用 NSProtocol 拦截网络请求 +NSURLProtocol 在实际应用中,主要是完成两步:拦截 URL 和 URL 转发。先来看如何拦截网络请求。 + + + +**创建 NSURLProtocol 子类** + +这里创建一个名为 HTCustomURLProtocol 的子类。 +```Objective-c +@interface HTCustomURLProtocol : NSURLProtocol +@end +``` + + + +**注册 NSURLProtocol 的子类** + +在合适的位置注册这个子类。对基于 NSURLConnection 或者使用 [NSURLSession sharedSession] 初始化对象创建的网络请求,调用 registerClass 方法即可。 + +```objective-c +[NSURLProtocol registerClass:[NSClassFromString(@"CustomURLProtocol") class]]; +// or +// [NSURLProtocol registerClass:[HTCustomURLProtocol class]]; +``` + + +如果需要全局监听,可以设置在 `AppDelegate.m` 的 `didFinishLaunchingWithOptions` 方法中。如果只需要在单个 UIViewController 中使用,记得在合适的时机注销监听: + +```objective-c +[NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]]; +``` + + + + +如果是基于 `NSURLSession` 的网络请求,且不是通过 `[NSURLSession sharedSession]` 方式创建的,就得配置 `NSURLSessionConfiguration` 对象的 `protocolClasses` 属性。 + +```objective-c + NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; + config.protocolClasses = @[[NSClassFromString(@"CustomProtocol") class]]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; +``` + + + +**实现 NSURLProtocol 子类** + + + +> 注册 → 拦截 → 转发 → 回调 → 结束 + + + +以拦截 UIWebView 为例,这里需要重写父类的这五个核心方法。 + +```objective-c +// 定义一个协议 key +static NSString * const HTCustomURLProtocolHandledKey = @"HTCustomURLProtocolHandledKey"; + +// 在拓展中定义一个 NSURLConnection 属性。通过 NSURLSession 也可以拦截,这里只是以 NSURLConnection 为例。 +@property (nonatomic, strong) NSURLConnection *connection; +// 定义一个可变的请求返回值, +@property (nonatomic, strong) NSMutableData *responseData; + +// 方法 1:在拦截到网络请求后会调用这一方法,可以再次处理拦截的逻辑,比如设置只针对 http 和 https 的请求进行处理。 ++ (BOOL)canInitWithRequest:(NSURLRequest *)request { + // 只处理 http 和 https 请求 + NSString *scheme = [[request URL] scheme]; + if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || + [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) { + // 看看是否已经处理过了,防止无限循环 + if ([NSURLProtocol propertyForKey:HTCustomURLProtocolHandledKey inRequest:request]) { + return NO; + } + // 如果还需要截取 DNS 解析请求中的链接,可以继续加判断,是否为拦截域名请求的链接,如果是返回 NO + return YES; + } + return NO; +} + +// 方法 2:【关键方法】可以在此对 request 进行处理,比如修改地址、提取请求信息、设置请求头等。 ++ (NSURLRequest *) canonicalRequestForRequest:(NSURLRequest *)request { + // 可以打印出所有的请求链接包括 CSS 和 Ajax 请求等 + NSLog(@"request.URL.absoluteString = %@",request.URL.absoluteString); + NSMutableURLRequest *mutableRequest = [request mutableCopy]; + return mutableRequest; +} + +// 方法 3:【关键方法】在这里设置网络代理,重新创建一个对象将处理过的 request 转发出去。这里对应的回调方法对应 协议方法 +- (void)startLoading { + // 可以修改 request 请求 + NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; + // 打 tag,防止递归调用 + [NSURLProtocol setProperty:@YES forKey:HTCustomURLProtocolHandledKey inRequest:mutableRequest]; + // 也可以在这里检查缓存 + // 将 request 转发,对于 NSURLConnection 来说,就是创建一个 NSURLConnection 对象;对于 NSURLSession 来说,就是发起一个 NSURLSessionTask。 + self.connection = [NSURLConnection connectionWithRequest:mutableRequest delegate:self]; +} + +// 方法 4:主要判断两个 request 是否相同,如果相同的话可以使用缓存数据,通常只需要调用父类的实现。 ++ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b { + return [super requestIsCacheEquivalent:a toRequest:b]; +} + +// 方法 5:处理结束后停止相应请求,清空 connection 或 session +- (void)stopLoading { + if (self.connection != nil) { + [self.connection cancel]; + self.connection = nil; + } +} + +// 按照在上面的方法中做的自定义需求,看情况对转发出来的请求在恰当的时机进行回调处理。 +#pragma mark- NSURLConnectionDelegate + +- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { + [self.client URLProtocol:self didFailWithError:error]; +} + +#pragma mark - NSURLConnectionDataDelegate + +// 当接收到服务器的响应(连通了服务器)时会调用 +- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { + self.responseData = [[NSMutableData alloc] init]; + self.internalResponse = response; + // 可以处理不同的 statusCode 场景 + // NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; + // 可以设置 Cookie + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; +} + +// 接收到服务器的数据时会调用,可能会被调用多次,每次只传递部分数据 +- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { + [self.responseData appendData:data]; + [self.client URLProtocol:self didLoadData:data]; +} + +// 服务器的数据加载完毕后调用 +- (void)connectionDidFinishLoading:(NSURLConnection *)connection { + [self.client URLProtocolDidFinishLoading:self]; +} + +// 请求错误(失败)的时候调用,比如出现请求超时、断网,一般指客户端错误 +- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { + [self.client URLProtocol:self didFailWithError:error]; +} + +- (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response { + if (response != nil) { + [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; + } + return request; +} + +- (BOOL)connectionShouldUseCredentialStorage:(NSURLConnection *)connection { + return YES; +} + +- (BOOL)connection:(NSURLConnection *)connection canAuthenticateAgainstProtectionSpace:(NSURLProtectionSpace *)protectionSpace { + return [protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]; +} + +- (void)connection:(NSURLConnection *)connection didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { + if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { + [[challenge sender] useCredential:[NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust] forAuthenticationChallenge:challenge]; + [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge]; + } + [self.client URLProtocol:self didReceiveAuthenticationChallenge:challenge]; +} + +- (void) connection:(NSURLConnection *)connection +didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { + [self.client URLProtocol:self +didCancelAuthenticationChallenge:challenge]; +} + +- (NSCachedURLResponse *)connection:(NSURLConnection *)connection + willCacheResponse:(NSCachedURLResponse *)cachedResponse { + return cachedResponse; +} +``` + + +注意:NSURLConnection 已经被废弃,推荐使用 NSURLSession 进行网络请求,它好处多多,具体的自行查阅官方介绍。 + + + +## 五、 补充内容 + +### 1. 使用 NSURLSession 时的注意事项 + +如果在 NSURLProtocol 中使用 NSURLSession,需要注意: + +• 拦截到的 request 请求的 HTTPBody 为 nil,但可以借助 HTTPBodyStream 来获取 body; + +• 如果要用 registerClass 注册,只能通过 ` [NSURLSession sharedSession] `的方式创建网络请求。 + + + +### 2. 注册多个 NSURLProtocol 子类 + +当有多个自定义 NSURLProtocol 子类注册到系统中的话,会按照他们注册的反向顺序依次调用 URL 加载流程,也就是最后注册的 NSURLProtocol 会被优先判断。 + +对于通过配置 NSURLSessionConfiguration 对象的 protocolClasses 属性来注册的情况,protocolClasses 数组中只有第一个 NSURLProtocol 会起作用,后续的 NSURLProtocol 就无法拦截到了。 + + + +### 3. 如何拦截 WKWebview + +WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol 无法拦截请求。苹果开源的 webKit2 源码暴露了 私有API : + +```objective-c ++ [WKBrowsingContextController registerSchemeForCustomProtocol:] +``` + +通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求。涉及到的私有 api :`WKBrowsingContextControoler` 、`registerSchemeForCustomProtocol` + +```objective-c +Class cls = NSClassFromString(@"WKBrowsingContextController"); +SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:"); +if ([cls respondsToSelector:sel]) { + // 通过 http 和 https 的请求,同理可通过其他的 Scheme 但是要满足 URL Loading System + [cls performSelector:sel withObject:@"http"]; + [cls performSelector:sel withObject:@"https"]; +} +``` + +因为使用了私有 api,所以会无法过审,我们可以对字符串进行处理,比如对方法名进行加密。 +该方案还存在2个严重缺陷: + +1. post 请求 body 数据被清空 + + 由于 WKWebview 在独立的进程中执行网络请求,一旦注册 registerSchemeForCustomProtocol http(https) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。在 Webkit2 的设计里使用 **MessageQueue** 进行进程间通信, Network Process 会将请求 encode 成一个 message,然后通过 IPC 发送给 App Process,出于性能角度的考虑,encode 的时候 HTTPBody 和 HTTPBodyStream 这2个字段被丢弃掉了。可以查看 [webkit2 源码](https://github.com/WebKit/webkit/blob/fe39539b83d28751e86077b173abd5b7872ce3f9/Source/WebKit2/Shared/mac/WebCoreArgumentCodersMac.mm#L61-L88)。 + +2. 对 ATS 支持不足 + + info.plist 中打开 ATS 开关,设置 Allow Arbitrary Loads 选项为 NO,设置 registerSchemeForCustomProtocol 注册了 http(https) scheme,WKWebView 发起的所有 http 网络请求将被阻塞(即使 Allow Arbitrary Loads in Web Content 选项为 YES)。 + + WKWebView 可以注册 customScheme,比如自定义 scheme:`Hybrid://`,因此使用离线包但不使用 post 方式的请求可以通过 customScheme 发起。比如 `Hybrid://www.xxx.com/`,然后在 App 进程被 NSURLProtocol 拦截这个请求,然后加载离线包资源。 + + 不足:使用 post 方式的请求需要修改 h5 侧代码(scheme)。 + +### 4. WKWebView loadRequest 问题 + +在 WKWebView 上通过 loadRequest 发起的 post 请求,会丢失 body 数据。 + +```objective-c +//同样是由于进程间通信性能问题,HTTPBody字段被丢弃 +[request setHTTPMethod:@"POST"]; +[request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]]; +[wkwebview loadRequest: request]; +``` + +通过上面的基础条件,其实可以麻烦一点解决 post 请求的 body 丢失问题。 + +假如 WKWebView loadRequest 要加载 post 请求 request1: http://h5.xxx.com/mobile/index。步骤如下: + +1. 子类继承自抽象类 NSURLProtocol,并向 App 注册。 + +2. 将 request1 的 scheme 进行替换,生成新的请求 request2:`post://h5.xxx.com/mobile/index`。同时将 rquest1 的 body 字段添加到 header 信息中(webkit 不会丢弃 header 信息)。 + +3. WKWebView 加载新的 request2。 `[WKWebView loadRequest:request2];` + +4. 通过 `[WKBrowsingContextController registerSchemeForCustomProtocol:]` 注册 scheme:**post://**。 + +5. NSURLProtocl 拦截请求 ``post://h5.xxx.com/mobile/index`。做 scheme 还原的操作,也就是替换 scheme。生成新的请求 request3 `http://h5.xxx.com/mobile/index`,同时将 request2 header 中的 body 字段添加到 request3 的 body 中,并使用 NSURLConnection 加载 request3 + +6. 网络请求完成后,通过 NetworkProtocolClient 将请求结果返回给 WKWebView。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1. +关于 WKWebview 的各种问题可以查看这篇[文章](https://www.tuicool.com/articles/QbE3Mb7)。 + diff --git a/Chapter1 - iOS/1.84.md b/Chapter1 - iOS/1.84.md new file mode 100644 index 0000000..7578d5b --- /dev/null +++ b/Chapter1 - iOS/1.84.md @@ -0,0 +1,2 @@ +# WKWebView 技巧集 + diff --git a/Chapter1 - iOS/1.9.md b/Chapter1 - iOS/1.9.md index f96ff1f..9891bc2 100644 --- a/Chapter1 - iOS/1.9.md +++ b/Chapter1 - iOS/1.9.md @@ -1,10 +1,14 @@ -# hittest方法 +# 事件响应者判定原理之 hittest 方法 +## 一、 hittest 介绍 * 就是用来寻找最合适的view * 当一个事件传递给一个控件,就会调用这个控件的hitTest方法 * 点击了白色的view: 触摸事件 -> UIApplication -> UIWindow 调用 \[UIWindow hitTest\] -> 白色view \[WhteView hitTest\] -实验1: + +## 二、 实验 + +### 2.1 实验一 定义 BaseView,在里面实现方法touchBegan,监听当前哪个类调用了该方法。 @@ -15,9 +19,8 @@ ![UI效果图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Simulator%20Screen%20Shot%20-%20iPhone%206s%20Plus%20-%202017-10-11%20at%2010.14.37.png) -``` +```Objective-C //KeyWindow - -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ UIView *view = [super hitTest:point withEvent:event]; NSLog(@"fittest->%@",view); @@ -25,11 +28,9 @@ } ``` -结果: - 点击了白色1: -``` +```shell 2017-10-11 16:48:52.882547+0800 主流App框架[16295:358790] BrownView--hitTest withEvent 2017-10-11 16:48:59.646610+0800 主流App框架[16295:358790] GreenView--hitTest withEvent 2017-10-11 16:48:59.647145+0800 主流App框架[16295:358790] fittest->> @@ -40,7 +41,7 @@ 点击了蓝色3: -``` +```shell 2017-10-11 16:49:56.331024+0800 主流App框架[16295:358790] BrownView--hitTest withEvent 2017-10-11 16:49:56.331335+0800 主流App框架[16295:358790] BView--hitTest withEvent 2017-10-11 16:49:56.331617+0800 主流App框架[16295:358790] BlueView--hitTest withEvent diff --git a/Chapter1 - iOS/chapter1.md b/Chapter1 - iOS/chapter1.md index 8f69ea7..f8c5a52 100644 --- a/Chapter1 - iOS/chapter1.md +++ b/Chapter1 - iOS/chapter1.md @@ -10,7 +10,7 @@ * [5、事件响应者链](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.5.md) * [6、外卖App双列表联动](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.6.md) * [7、在内存剖析对象](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.7.md) - * [8、长按UIWebView上的图片保存到相册](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.8.md) + * [8、教你实现微信公众号效果:长按图片保存到相册](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.8.md) * [9、hitTest和pointInside方法你真的熟吗?](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.9.md) * [10、HyBrid探索(一)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.10.md) * [11、iOS中的事件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.11.md) @@ -54,7 +54,7 @@ * [48、OC类别(Catrgory)和拓展(Extension)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.48.md) * [49、MVC、MVP、MVVM](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.49.md) * [50、“静态库”和“动态库”](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.50.md) - * [51、cocopod](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.51.md) + * [51、cocoapods 相关小技巧](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.51.md) * [52、如何打造团队的代码风格统一以及开发效率的提升](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.52.md) * [53、iOS 数据持久化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.53.md) * [54、Xcode 设置作者信息](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.54.md) @@ -85,4 +85,6 @@ * [79、深入理解各种锁](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.79.md) * [80、打造功能强大的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) * [81、__asm__ 重命名符号](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.81.md) - \ No newline at end of file + * [82、runtime](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.82.md) + * [83、NSURLProtocol 应用场景](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md) + * [84、WKWebView 技巧集合](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.84.md) \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/2.24.md b/Chapter2 - Web FrontEnd/2.24.md index 8cb0c8d..5279b43 100644 --- a/Chapter2 - Web FrontEnd/2.24.md +++ b/Chapter2 - Web FrontEnd/2.24.md @@ -218,8 +218,8 @@ Chrome开发者工具和Firebug都提供了书签功能,用于显示你在元 ``` var arr = [{ num: 0 }]; setInterval(function(){ -console.log(arr); -arr[0].num += 1; + console.log(arr); + arr[0].num += 1; }, 1000); ``` diff --git a/Chapter2 - Web FrontEnd/2.3.md b/Chapter2 - Web FrontEnd/2.3.md index c3fe1fc..9918c89 100644 --- a/Chapter2 - Web FrontEnd/2.3.md +++ b/Chapter2 - Web FrontEnd/2.3.md @@ -126,8 +126,8 @@ ?> ``` -![css点击统计](/assets/2287777-c1d479c5171de2d0.png) -![php代码统计](/assets/2287777-a50bd17a33290204.png) +![css点击统计](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-c1d479c5171de2d0.png) +![php代码统计](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-a50bd17a33290204.png) diff --git a/Chapter2 - Web FrontEnd/2.6.md b/Chapter2 - Web FrontEnd/2.6.md index c2262f4..c2f54fd 100644 --- a/Chapter2 - Web FrontEnd/2.6.md +++ b/Chapter2 - Web FrontEnd/2.6.md @@ -1,6 +1,6 @@ # h5自定义对象 -### 一、方式一 +## 一、方式一 在很早以前我们自定义元素的属性要通过 `user-defined-attribute="value"`的方式来设置自己需要的属性 diff --git a/Chapter5 - Network/5.3.md b/Chapter5 - Network/5.3.md index 0bd9653..3a297aa 100644 --- a/Chapter5 - Network/5.3.md +++ b/Chapter5 - Network/5.3.md @@ -50,8 +50,8 @@ |:- |:-|:-| |↓ |0x100| 67| |↓ |0x101| 45| - |↓ |0x102| 23| jvyyhr - |↓|0x103| 01| + |↓ |0x102| 23 | + |↓ |0x103| 01 | diff --git a/Chapter7 - Geek Talk/7.20.md b/Chapter7 - Geek Talk/7.20.md index 5a9e64f..b4a057d 100644 --- a/Chapter7 - Geek Talk/7.20.md +++ b/Chapter7 - Geek Talk/7.20.md @@ -45,7 +45,7 @@ 2. 安装所需依赖。`npm install --save-dev @commitlint/config-conventional @commitlint/cli husky` 3. 在工程根目录下新建配置文件,名称为 `commitlint.config.js`。 4. 在 commitlint.config.js 中添加配置信息 - ```shell + ```javascript const types = [ 'build', 'ci', diff --git a/SUMMARY.md b/SUMMARY.md index 982b431..7c80e6b 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -10,7 +10,7 @@ * [5、事件响应者链](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.5.md) * [6、外卖App双列表联动](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.6.md) * [7、在内存剖析对象](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.7.md) - * [8、长按UIWebView上的图片保存到相册](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.8.md) + * [8、教你实现微信公众号效果:长按图片保存到相册](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.8.md) * [9、hitTest和pointInside方法你真的熟吗?](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.9.md) * [10、HyBrid探索(一)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.10.md) * [11、iOS中的事件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.11.md) @@ -53,7 +53,7 @@ * [48、OC类别(Catrgory)和拓展(Extension)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.48.md) * [49、MVC、MVP、MVVM](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.49.md) * [50、“静态库”和“动态库”](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.50.md) - * [51、cocopod](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.51.md) + * [51、cocoapods 相关小技巧](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.51.md) * [52、如何打造团队的代码风格统一以及开发效率的提升](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.52.md) * [53、iOS 数据持久化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.53.md) * [54、Xcode 设置作者信息](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.54.md) @@ -84,7 +84,9 @@ * [79、深入理解各种锁](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.79.md) * [80、打造功能强大的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) * [81、__asm__ 重命名符号](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.81.md) - + * [82、runtime](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.82.md) + * [83、NSURLProtocol 应用场景](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md) + * [84、WKWebView 技巧集合](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.84.md) * [Chapter2 - Web FrontEnd](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/chapter2.md) * [1、-last-child与-last-of-type你只是会用,有研究过区别吗?](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/2.1.md) @@ -170,8 +172,7 @@ * [15、区块链技术和比特币](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.16.md) * [16、如何写一份夺目的简历](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.17.md) * [17、一套开发规范](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.18.md) - * [18、云服务器靠谱推荐](https://github.com/FantasticLBP/knowledge-kit/blob/master/ - Chapter7%20-%20Geek%20Talk/7.19.md) + * [18、云服务器靠谱推荐](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.19.md) * [19、规范化团队 git 提交信息](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter7%20-%20Geek%20Talk/7.20.md) diff --git a/assets/2020-03-11-objc_exception_throw.png b/assets/2020-03-11-objc_exception_throw.png new file mode 100644 index 0000000..9c44169 Binary files /dev/null and b/assets/2020-03-11-objc_exception_throw.png differ diff --git a/assets/2020-03-30-APMAppLaunch.png b/assets/2020-03-30-APMAppLaunch.png new file mode 100644 index 0000000..d4fb5f7 Binary files /dev/null and b/assets/2020-03-30-APMAppLaunch.png differ diff --git a/assets/2020-0311-NSAssert.png b/assets/2020-0311-NSAssert.png new file mode 100644 index 0000000..75829fe Binary files /dev/null and b/assets/2020-0311-NSAssert.png differ diff --git a/assets/2020-04-03-Network-NSURLSessionTaskMetrics.png b/assets/2020-04-03-Network-NSURLSessionTaskMetrics.png new file mode 100644 index 0000000..4a77499 Binary files /dev/null and b/assets/2020-04-03-Network-NSURLSessionTaskMetrics.png differ diff --git a/assets/2020-04-03-NetworkTime.png b/assets/2020-04-03-NetworkTime.png new file mode 100644 index 0000000..b2e242a Binary files /dev/null and b/assets/2020-04-03-NetworkTime.png differ diff --git a/assets/2020-04-04-ANRRunloop.png b/assets/2020-04-04-ANRRunloop.png new file mode 100644 index 0000000..ae65b34 Binary files /dev/null and b/assets/2020-04-04-ANRRunloop.png differ diff --git a/assets/2020-04-04-APM-RunLoopANR.jpg b/assets/2020-04-04-APM-RunLoopANR.jpg new file mode 100644 index 0000000..9ecb3b0 Binary files /dev/null and b/assets/2020-04-04-APM-RunLoopANR.jpg differ diff --git a/assets/2020-04-04-CFNetworkStructure.png b/assets/2020-04-04-CFNetworkStructure.png new file mode 100644 index 0000000..ddf738e Binary files /dev/null and b/assets/2020-04-04-CFNetworkStructure.png differ diff --git a/assets/2020-04-04-network_monitor.png b/assets/2020-04-04-network_monitor.png new file mode 100644 index 0000000..a8ff410 Binary files /dev/null and b/assets/2020-04-04-network_monitor.png differ diff --git a/assets/2020-04-05-NetworkLevel.png b/assets/2020-04-05-NetworkLevel.png new file mode 100644 index 0000000..655361d Binary files /dev/null and b/assets/2020-04-05-NetworkLevel.png differ