diff --git a/.DS_Store b/.DS_Store index 4e3941c..35f6121 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index b512c09..ea479ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -node_modules \ No newline at end of file +node_modules +.DS_Store +prompt.txt +unused_assets.py +/t diff --git a/Chapter1 - iOS/1.1.md b/Chapter1 - iOS/1.1.md deleted file mode 100644 index 7f09fe0..0000000 --- a/Chapter1 - iOS/1.1.md +++ /dev/null @@ -1,91 +0,0 @@ -# 工程大小优化之图片资源 - -> 一点点iOS项目本身功能较多,导致应用体积也比较大。一个Xcode工程下图片资源占用了很大的空间,且如果有些App需要一键换肤功能,呵呵,不知道得做多少图片。每套图片还需要设置1x@,2x@,3x@等 - -## 简介 - -IconFont技术起源于Web领域的Web Font技术。随着时间的推移,网页设计越来越漂亮。但是电脑预装的字体远远无法满足设计者的要求,于是Web Font技术诞生了。一个英文字库并不大,通过网络下载字体,完成网页的显示。有了Web Font技术,大大提升了设计师的发挥空间。 - -网页设计中图标需要适配多个分辨率,每个图标需要占用一次网络请求。于是有人想到了用Web Font的方法来解决这两个问题,就是IconFont技术。将矢量的图标做成字体,一次网络请求就够了,可以保真缩放。解决这个问题的另一个方式是图片拼合的Sprite图。 - -Web领域使用IconFont类似的技术已经多年,当我在15年接触BootStrap的时候Font Awesome技术大行其道。最近IconFont技术在iOS图片资源方面得以应用,最近有点时间自己研究整理了一番,在此记录学习点滴。 - -## 优点 - -* 减小体积,字体文件比图片要小 -* 图标保真缩放,解决2x/3x乃至将来的nx图问题 -* 方便更改颜色大小,图片复用 - -## 缺点 - -* 只适用于 - `纯色icon` -* 使用unicode字符难以理解 -* 需要维护字体库 - -网上说了一大堆如何制作IconFont的方法,在此不做讨论。 - -## 我们说说怎么用 - -1. 首先选取一些有丰富资源的网站,我使用阿里的IconFont多年,其他的没去研究,所以此处直接使用阿里的产品。地址:[http://www.iconfont.cn/plus](http://www.iconfont.cn/plus) - -2. 打开网站在线挑选好合适的图标加入购物车,如图 -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-28-iconfontPickUp.png) - -1. 选择好之后在购物车查看,然后点击下载代码 - -2. 打开下载好的文件,其机构如下,我们在iOS项目开发过程中使用unicode的形式使用IconFont,所以打开demo\_unicode.html -![下载文件目录结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-28-iconfontWorkDirectory.png) - - -**注意:** 创建 UIFont 使用的是字体名,而不是文件名;文本值为 8 位的 Unicode 字符,我们可以打开 demo.html 查找每个图标所对应的 HTML 实体 Unicode 码,比如: "店" 对应的 HTML 实体 Unicode 码为:0x3439 转换后为:\U00003439 就是将 0x 替换为 \U 中间用 0 填补满长度为 8 个字符 - -# Xcode中使用IconFont - -初步尝试使用 - -1. 首先看看如何简单实用IconFont -2. 首先将下载好的文件夹中的 **iconfont.ttf** 加入到Xcode工程中,确保加入成功在Build检查 - -![Xcode检查引入结果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-28-iconfintWorkSetting.png) - -1. 怎么用? - -```Objective-c - -NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:@"\U0000e696 \U0000e6ab \U0000e6ac \U0000e6ae"]; -[attributedStr addAttribute:NSForegroundColorAttributeName value:[UIColor redColor] range:NSMakeRange(0, 1)]; -[attributedStr addAttribute:NSForegroundColorAttributeName value:[UIColor orangeColor] range:NSMakeRange(3, 1)]; -[attributedStr addAttribute:NSForegroundColorAttributeName value:[UIColor blackColor] range:NSMakeRange(9, 1)]; -self.label.attributedText = attributedStr; -[self.view addSubview:self.label]; - -pragma mark - getter and setter --(UILabel *)label{ - if (!_label) { - _label = [[UILabel alloc] initWithFrame:CGRectMake(100, 100, BoundWidth-200, 40)]; - _label.font = [UIFont fontWithName:@"iconfont" size:24]; - _label.textColor = [UIColor purpleColor]; - } - return _label; - } -``` - -#### 做进一步封装,实用更加方便 - -利用IconFont生成1个UIImage只需要 LBPIconFontmake(par1, par2, par3),par1:iconfont的unicode值;par2:图片大小;par3:图片的颜色值。其中,LBPIconFontmake是一个宏,#define LBPIconFontmake(text,size,color) [[LBPFontInfo alloc] initWithText:text withSize:size andColor:color]。 - -```Objective-c -self.latestImageView.image = [UIImage iconWithInfo:LBPIconFontmake(@"\U0000e6ac", 60, @"000066") ]; -``` -![封装后的工程目录结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2017-05-28-iconfont.png) - -1. LBPFontInfo来封装字体信息 -2. UIColor+picker根据十六进制字符串来设置颜色 -3. LBPIconFont向系统中注册IconFont字体库,并使用 -4. UIImage+LBPIconFont封装一个使用IconFont的Image分类 - - -# [Demo地址](https://github.com/FantasticLBP/IconFont_Demo) - - diff --git a/Chapter1 - iOS/1.10.md b/Chapter1 - iOS/1.10.md deleted file mode 100644 index 4779544..0000000 --- a/Chapter1 - iOS/1.10.md +++ /dev/null @@ -1,206 +0,0 @@ -# UIWebView加载网页内容 - -可以通过本地文件、url等方式。 - -```objective-c -NSString *htmlPath = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"]; -NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL fileURLWithPath:htmlPath]]; -[self.webView loadRequest:request]; -``` - -## Native调用JavaScript - -Native调用JS是通过UIWebView的stringByEvaluatingJavaScriptFromString 方法实现的,该方法返回js脚本的执行结果。 - -```objective-c -[webView stringByEvaluatingJavaScriptFromString:@"Math.random();"]; -``` - -实际上就是调用了网页的Window下的一个对象。如果我们需要让native端调用js方法,那么这个js方法必须在window下可以访问到。 - - -## JavaScript调用Native - -反过来,JavaScript调用Native,并没有现成的API可以调用,而是间接地通过一些其它手段来实现。UIWebView有个代理方法:在UIWebView内发起的任何网络请求都可以通过delegate函数在Native层得到通知。由此思路,我们就可以在UIWebView内发起一个自定义的网络请求,通常是这样的格式:**jsbridge://methodName?param1=value1¶m2=value2...** - -在UIWebView的delegate函数中,我们判断请求的scheme,如果request.URL.scheme是jsbridge,那么就不进行网页内容的加载,而是去执行相应的方法。方法名称就是request.URL.host。参数可以通过request.URL.query得到。 - -问题来了?? - -发起这样1个网络请求有2种方式。1:location.href .2:iframe。通过location.href有个问题,就是如果js多次调用原生的方法也就是location.href的值多次变化,Native端只能接受到最后一次请求,前面的请求会被忽略掉。 - -使用ifrmae方式,以调用Native端的方法。 - -```javascript -var iFrame; -iFrame = document.createElement("iframe"); -iFrame.style.height = "1px"; -iFrame.style.width = "1px"; -iFrame.style.display = "none"; -iFrame.src = url; -document.body.appendChild(iFrame); -setTimeout(function(){ - iFrame.remove(); -},100); -``` - -举个🌰: - -需求: - -原生端提供一个UIWebView,加载一个网页内容。还有1个按钮,按钮点击一下网页增加一段段落文本。网页上有2个输入框,用户输入数字,点击按钮,js将用户输入的参数告诉native端,native去执行加法,计算完成后将结果返回给js - -```html - - - - - - - - + - - - - -``` - -```objective-c --(void)addContentToWebView{ - NSString *jsString = @" var pNode = document.createElement(\"p\"); pNode.innerText = \"我是由原生代码调用js后将一段文件添加到html上,也就是注入\";document.body.appendChild(pNode);"; - [self.webView stringByEvaluatingJavaScriptFromString:jsString]; -} - - --(NSInteger)plusparm:(NSInteger)par1 parm2:(NSInteger)par2{ - return par1 + par2; -} - - -#pragma mark -- UIWebViewDelegate -- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType{ - NSURL *url = request.URL; - NSString *scheme = url.scheme; - NSString *method = url.host; - NSString *parms = url.query; - NSArray *pars = [parms componentsSeparatedByString:@"&"]; - NSInteger par1 = [[pars[0] substringFromIndex:5] integerValue]; - NSInteger par2 = [[pars[1] substringFromIndex:5] integerValue]; - if ([scheme isEqualToString:@"jsbridge"]) { - //发现scheme是JSBridge,那么就是自定义的URLscheme,不去加载网页内容而拦截去处理事件。 - - if ([method isEqualToString:@"plus"]) { - NSInteger result = [self plusparm:par1 parm2:par2]; - [self.webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:@"receiveValue(%@);",@(result)]]; - } - - return NO; - } - return YES; -} -``` - - -## Android 端如何与 JS 通信(2种方法) - -- webview.loadUrl() -- Webview.evaluateJavascript() - -> 2者区别: -> -> 1. loadUrl() 会刷新页面,evaluateJavascript() 则不会刷新页面,效率高 -> 2. loadUrl() 得不到 JS 的返回值;evaluateJavascrip() 则可以获取返回值 -> 3. evaluateJavascrip() 在 Android 4.4 之后才可以使用 - -注意:Android 可以直接调用 JS 的 alert() 方法是因为 alert 方法直接挂载在 window 对象上。但是 Native 与 JS 可能不止一个方法、多个方法多个属性去访问,这样都直接挂载在 window 对象上不是明智之举。因为后期维护很不方便。所以我们在 Native 和 JS 之间会设置一个桥接对象,像一个中间层一样,让2端互调。 - -Android 需要在页面加载完,也就是 webview 的 onPageFinished 方法中写调用逻辑,否则不会执行 - -```java -webView.loadUrl("javascript:callJsFunction('soloname')") -webView.evaluateJavascript("javascript:callJsFunction('soloname')" -``` - - -### JS 如何与 Android 通信 - -- 通过 Webview 的 addJavascriptInterface() 进行对象映射 -- 通过 WebviewClient 的 shouldOverrideUrlLoading() 方法回调拦截 Url -- 通过 webChromeClient 的 onJsAlert()、onJSPrompt() 方法回调拦截 JS 对话框 alert()、confirm()、prompt() 等消息 - -第一种最简洁,但是在 Android 4.2 以下存在漏洞。 - -实验:Android webview 上跑一个网页,点击网页的按钮,让 Native 弹出一个字符串。 - -```vue -methods: { - showAndroidToast() { - $App.showToast("哈哈,我是js调用的") - } -} -``` - -``` -public class JsJavaBridge { - - private Activity activity; - private WebView webView; - - public JsJavaBridge(Activity activity, WebView webView) { - this.activity = activity; - this.webView = webView; - } - - @JavascriptInterface - public void onFinishActivity() { - activity.finish(); - } - - @JavascriptInterface - public void showToast(String msg) { - ToastUtils.show(msg); - } -} - -``` - -然后通过 webview 设置 Android 类与 JS 代码的映射 - -``` -webView.addJavascriptInterface(new JsJavaBridge(this, tbsWebView), "$App"); -``` - -这里将类 JsJavaBridge 在 JS 中映射为了 $App,所以在 Vue 中可以这样调用 `$App.showToast("哈哈,我是js调用的")`。 - - - -## 同步和异步问题 - -js调用native是通过在一个网页上插入一个iframe,这个iframe插入完了就完了,执行的结果需要native另外调用stringByEvaluatingJavaScriptString 方法通知js。这明显是1个异步的调用。而stringByEvaluatingJavaScriptString方法会返回执行js脚本的结果。本质上是一个同步调用 - -所以js call native是异步,native call js是同步。 diff --git a/Chapter1 - iOS/1.100.md b/Chapter1 - iOS/1.100.md deleted file mode 100644 index 5fbc2e8..0000000 --- a/Chapter1 - iOS/1.100.md +++ /dev/null @@ -1,217 +0,0 @@ -# iOS 端底层网络错误 - -> 本篇文章主要记录在 iOS 侧,一些底层网络问题的产生和解决。包括一些 socket 的疑难杂症 - -## 典型案例 - -### 1. Socket 断开后会收到 SIGPIPE 类型的信号,如果不处理会 crash - -同事问了我一个问题,说收到一个 crash 信息,去 mpaas 平台看到如下的 crash 信息 - -![2021-04-06-NetworkFatlError.png](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-04-06-NetworkFatlError.png) - -看了代码,显示在某某文件的313行代码,代码如下 - -![2021-04-06-NetworkFatlError.png](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-04-06-NetworkFatalError2.png) - -Socket 属于网络最底层的实现,一般我们开发不需要用到,但是用到了就需要小心翼翼,比如 Hook 网络层、长链接等。查看官方文档会说看到一些说明。 - -当使用 socket 进行网络连接时,如果连接中断,在默认情况下, 进程会收到一个 `SIGPIPE` 信号。如果你没有处理这个信号,app 会 crash。 - -Mach 已经通过异常机制提供了底层的陷进处理,而 BSD 则在异常机制之上构建了信号处理机制。硬件产生的信号被 Mach 层捕捉,然后转换为对应的 UNIX 信号,为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为 Mach 异常,然后再转换为信号。 - -Mach 异常都在 host 层被 `ux_exception` 转换为相应的 unix 信号,并通过 `threadsignal` 将信号投递到出错的线程。 - -![Mach 异常处理以及转换为 Unix 信号的流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-19-BSDCatchSignal.png) - -有2种解决办法: - -- Ignore the signal globally with the following line of code.(在全局范围内忽略这个信号 。缺点是所有的 `SIGPIPE` 信号都将被忽略) - - ```objective-c - signal(SIGPIPE, SIG_IGN); - ``` - -- Tell the socket not to send the signal in the first place with the following lines of code (substituting the variable containing your socket in place of `sock`)(告诉 socket 不要发送信号:SO_NOSIGPIPE) - - ```c++ - int value = 1; - setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &value, sizeof(value)); - ``` - -`SO_NOSIGPIPE` 是一个宏定义,跳过去看一下实现 - -```c++ -#define SO_NOSIGPIPE 0x1022 /* APPLE: No SIGPIPE on EPIPE */ -``` - -什么意思呢?没有 SIGPIPE 信号在 EPIPE。那啥是 `EPIPE`。 - -其中:**EPIPE** 是 socket send 函数可能返回的错误码之一。如果发送数据的话会在 Client 端触发 RST(指Client端的 FIN_WAIT_2 状态超时后连接已经销毁的情况),导致send操作返回 `EPIPE`(errno 32)错误,并触发 `SIGPIPE` 信号(默认行为是 **Terminate**)。 - -> What happens if the client ignores the error return from readline and writes more data to the server? This can happen, for example, if the client needs to perform two writes to the server before reading anything back, with the first write eliciting the RST. -> -> The rule that applies is: When a process writes to a socket that has received an RST, the SIGPIPE signal is sent to the process. The default action of this signal is to terminate the process, so the process must catch the signal to avoid being involuntarily terminated. -> -> If the process either catches the signal and returns from the signal handler, or ignores the signal, the write operation returns EPIPE. - -UNP(unix network program) 建议应用根据需要处理 `SIGPIPE`信号,至少不要用系统缺省的处理方式处理这个信号,系统缺省的处理方式是退出进程,这样你的应用就很难查处处理进程为什么退出。对 UNP 感兴趣的可以查看:http://www.unpbook.com/unpv13e.tar.gz。 - -下面是2个苹果官方文档,描述了 socket 和 SIGPIPE 信号,以及最佳实践: - -[Avoiding Common Networking Mistakes](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/NetworkingOverview/CommonPitfalls/CommonPitfalls.html) - -[Using Sockets and Socket Streams](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/NetworkingTopics/Articles/UsingSocketsandSocketStreams.html) - -但是线上的代码还是存在 Crash。查了下代码,发现奔溃堆栈在 PingFoundation 中的 `sendPingWithData`。也就是虽然在 AppDelegate 中设置忽略了 SIGPIPE 信号,但是还是会在某些函数下「重置」掉。 - -``` -- (void)sendPingWithData:(NSData *)data { - int err; - NSData * payload; - NSData * packet; - ssize_t bytesSent; - id strongDelegate; - // ... - // Send the packet. - if (self.socket == NULL) { - bytesSent = -1; - err = EBADF; - } else if (!CFSocketIsValid(self.socket)) { - //Returns a Boolean value that indicates whether a CFSocket object is valid and able to send or receive messages. - bytesSent = -1; - err = EPIPE; - } else { - [self ignoreSIGPIPE]; - bytesSent = sendto( - CFSocketGetNative(self.socket), - packet.bytes, - packet.length, - SO_NOSIGPIPE, - self.hostAddress.bytes, - (socklen_t) self.hostAddress.length - ); - err = 0; - if (bytesSent < 0) { - err = errno; - } - } - // ... -} - -- (void)ignoreSIGPIPE { - int value = 1; - setsockopt(CFSocketGetNative(self.socket), SOL_SOCKET, SO_NOSIGPIPE, &value, sizeof(value)); -} - -- (void)dealloc { - [self stop]; -} - -- (void)stop { - [self stopHostResolution]; - [self stopSocket]; - - // Junk the host address on stop. If the client calls -start again, we'll - // re-resolve the host name. - self.hostAddress = NULL; -} -``` - -也就是说在调用 `sendto()` 的时候需要判断下,调用 `CFSocketIsValid` 判断当前通道的质量。该函数返回当前 Socket 对象是否有效且可以发送或者接收消息。之 -前的判断是,当 self.socket 对象不为 NULL,则直接发送消息。但是有种情况就是 Socket 对象不为空,但是通道不可用,这时候会 Crash。 - -> Returns a Boolean value that indicates whether a CFSocket object is valid and able to send or receive messages. - -``` -if (self.socket == NULL) { - bytesSent = -1; - err = EBADF; -} else { - [self ignoreSIGPIPE]; - bytesSent = sendto( - CFSocketGetNative(self.socket), - packet.bytes, - packet.length, - SO_NOSIGPIPE, - self.hostAddress.bytes, - (socklen_t) self.hostAddress.length - ); - err = 0; - if (bytesSent < 0) { - err = errno; - } -} -``` - -### 2. 设备无可用空间问题 - -![设备无可用空间问题](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/NoSpaceLeftOnDevice.png) -最早遇到这个问题,直观的判断是某个接口所在的服务器机器,出现了存储问题(因为查了代码是网络回调存在 Error 的时候会调用我们公司基础),因为不是稳定必现,所以也就没怎么重视。直到后来发现线上有商家反馈这个问题最近经常出现。经过排查该问题该问题 `Error Domain=NSPOSIXErrorDomain Code=28 "No space left on device"` 是系统报出来的,开启 Instrucments Network 面板后看到显示 Session 过多。为了将问题复现,定时器去触发“切店”逻辑,切店则会触发首页所需的各个网络请求,则可以复现问题。工程中查找 NSURLSession 创建的代码,将问题定位到某几个底层库,HOOK 网络监控的能力上。一个是 APM 网络监控,确定 APMM 网路监控 Session 创建是收敛的,另一个库是动态域名替换的库,之前出现过线上故障。所以思考之下,暂时将这个库发布热修代码。之前是采用“悲观策略”,99%的概率不会出现故障,然后牺牲线上每个网络的性能,增加一道流程,而且该流程的实现还存在问题。思考之下,采用乐观策略,假设线上大概率不会出现故障,保留2个方法。线上出现故障,马上发布热修,调用下面的方法。 - -``` -+ (BOOL)canInitWithRequest:(NSURLRequest *)request { - return NO; -} - -//下面代码保留着,以防热修复使用 -+ (BOOL)open_canInitWithRequest:(NSURLRequest *)request { - // 代理网络请求 -} -``` - -问题临时解决后,后续动态域名替换的库可以参考 WeexSDK 的实现。见 [WXResourceRequestHandlerDefaultImpl.m](https://github.com/apache/incubator-weex/blob/master/ios/sdk/WeexSDK/Sources/Network/WXResourceRequestHandlerDefaultImpl.m#L37)。WeexSDK 这个代码实现考虑到了多个网络监听对象的问题、且考虑到了 Session 创建多个的问题,是一个合理解法。 - -``` -- (void)sendRequest:(WXResourceRequest *)request withDelegate:(id)delegate -{ - if (!_session) { - NSURLSessionConfiguration *urlSessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration]; - if ([WXAppConfiguration customizeProtocolClasses].count > 0) { - NSArray *defaultProtocols = urlSessionConfig.protocolClasses; - urlSessionConfig.protocolClasses = [[WXAppConfiguration customizeProtocolClasses] arrayByAddingObjectsFromArray:defaultProtocols]; - } - _session = [NSURLSession sessionWithConfiguration:urlSessionConfig - delegate:self - delegateQueue:[NSOperationQueue mainQueue]]; - _delegates = [WXThreadSafeMutableDictionary new]; - } - - NSURLSessionDataTask *task = [_session dataTaskWithRequest:request]; - request.taskIdentifier = task; - [_delegates setObject:delegate forKey:task]; - [task resume]; -} -``` - -### NSURLProtocol 主意事项 - -使用 NSURLProtocol 的时候,如果是代理 NSURLSession 的网络请求,则需要重写 protocolClasses 方法。但是在你往给方法设置 protocolClasses 的时候可能全局也有其他 SDK、工具类也做了修改。这样子需要注意不能丢弃别人的,也不能丢弃自己的。参考 OHHTTPStubs 在注册 NSURLProtocol 子类的处理 - -``` -+ (void)setEnabled:(BOOL)enable forSessionConfiguration:(NSURLSessionConfiguration*)sessionConfig -{ - // Runtime check to make sure the API is available on this version - if ( [sessionConfig respondsToSelector:@selector(protocolClasses)] - && [sessionConfig respondsToSelector:@selector(setProtocolClasses:)]) - { - NSMutableArray * urlProtocolClasses = [NSMutableArray arrayWithArray:sessionConfig.protocolClasses]; - Class protoCls = HTTPStubsProtocol.class; - if (enable && ![urlProtocolClasses containsObject:protoCls]) - { - [urlProtocolClasses insertObject:protoCls atIndex:0]; - } - else if (!enable && [urlProtocolClasses containsObject:protoCls]) - { - [urlProtocolClasses removeObject:protoCls]; - } - sessionConfig.protocolClasses = urlProtocolClasses; - } - else - { - NSLog(@"[OHHTTPStubs] %@ is only available when running on iOS7+/OSX9+. " - @"Use conditions like 'if ([NSURLSessionConfiguration class])' to only call " - @"this method if the user is running iOS7+/OSX9+.", NSStringFromSelector(_cmd)); - } -} -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.101.md b/Chapter1 - iOS/1.101.md deleted file mode 100644 index 0db407b..0000000 --- a/Chapter1 - iOS/1.101.md +++ /dev/null @@ -1,117 +0,0 @@ -# 离屏渲染 - -## 什么是离屏渲染 -什么是在屏渲染? -在当前屏幕渲染,指的是 GPU 的渲染操作是在当前用于显示的屏幕缓冲区中进行的。 - -如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的 Frame Buffer(帧缓冲区),作为像素数据存储区域,然后由显示控制器把帧缓冲区的数据显示到屏幕上。如果因为面临一些限制,比如阴影、光栅、遮罩等,CPU 无法把渲染结果直接写入 Frame Buffer,而是先暂时把中间的临时状态保存在额外的内存区域,之后再写入 Frame Buffer,那么这个过程被称为离屏渲染。 -系统如果没有直接把渲染结果直接写入到 GPU FrameBuffer 中,则认为发生了一次离屏渲染。(离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作) - - -## 哪些 case 会触发离屏渲染 -- 光栅化(shouldRasterize) -属于系统的优化机制,当开启光栅化的时候,系统会将该图片以 BitMap 的形式缓存起来,缓存时间为100ms,当下次需要显示这张图片的时候,系统会从缓存中获取出这张图片传递给 GPU,不需要 GPU 再次渲染这部分图层,达到减少 GPU 运算量的目的。 -应用场景非常小,因为时间仅为100ms,且会触发离屏渲染。 -``` -self.imageView.layer.shouldRasterize = YES -``` -- 遮罩(mask) -遮罩 Mask 相当于增加了 GPU 绘制复杂度,无法一次计算完成,需要增加一块新的帧缓冲区计算。 -``` -CALayer *layer = [CALayer layer]; -layer.frame = // ...; -self.imageView.layer.mask = layer; -``` -- 阴影(shadow) -因为阴影位于视图下层,绘制完阴影,界面还没有绘制完主要内容,所以需要一块额外的帧缓冲区中转。 -``` -self.imageView.layer.shadowColor = [UIColor redColor].CGColor; -self.imageView.layer.shadowOpacity = 0.2; -``` -如果给阴影设置 shadowPath 则不会触发离屏幕渲染。因为 shadowPath 预先告诉 CoreAnimation 框架阴影的几何形状,因此不需要依赖 layer 本体,可以独立渲染。 -- 抗锯齿(竖直图片旋转后会出现锯齿) -可能会触发离屏渲染,假如 UIImageView 控件的尺寸和图片素材的大小不一致,比如设置旋转,则会触发离屏渲染,否则不会。 -抗锯齿的计算量很大,因此需要额外的帧缓冲区保存计算结果,则触发离屏渲染。 -``` -CGFloate angle = M_PI/60.0; -self.iamgeView.layer setTransform3DRotate(self.imageView.layer.transform, angle, 0, 0, 1); -self.iamgeView.layer.allowsEdgeAntialiasing = YES; -``` -- 不透明 -当 alpha 为1的时候,不会触发离屏渲染。 -当 alpha 不为1的时候,且设置了父视图的 allowsGroupOpacity 为 YES,则会触发离屏渲染。因为父视图的 allowsGroupOpacity 为 YES,则代表子视图的透明度是否和父视图一样,一样则需要额外的帧缓冲区计算。 -``` -UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0,0, 10, 20)]; -view.backgroundColor = [UIColor redColor]; -[self.imageView addSubview: view]; -self.iamgeView.alpha = 1; -self.iamgeView.layer.allowsGroupOpacity = YES; -``` -- 圆角 -当给视图设置了背景颜色且设置了圆角,则会触发离屏渲染。 -UILabel 比较特殊,给 UILabel 设置 backgroundColor 其实就是给 UILabel 的 contents 设置背景颜色,contents 层级比 layer 高,所以 UILabel 整体显示为红色。 -下面显示结果为:红绿 - -``` -self.label.backgroundColor = [UIColor redColor]; -self.label.layer.backgroundColor = [UIColor greenColor].CGColor; -self.label.layer.cornerRadius = YES; - -self.iamgeView.backgroundColor = [UIColor redColor]; -self.iamgeView.layer.backgroundColor = [UIColor greenColor].CGColor; -self.imageView.layer.cornerRadius = YES; -``` - - - -## 离屏渲染的影响? - -触发离屏渲染后,会增加 GPU 的工作量,CPU + GPU 工作时间可能会超过一次渲染周期,会发生 UI 卡顿。 - -离屏渲染,指的是 GPU 在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。由上面的一个结论视图和圆角的大小对帧率并没有什么影响,数量的多少才显著影响性能。可以知道离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。 -上下文切换,不管是在GPU渲染过程中,还是广为人知的进程切换,上下文切换都是一个相当耗时的操作。首先要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到On-Screen Rendering 或者再开始一个新的离屏渲染重复之前的操作。 - -一次 mask 发生了两次离屏渲染和一次主屏渲染。即使忽略昂贵的上下文切换,一次mask需要渲染三次才能在屏幕上显示,这已经是普通视图显示3陪耗时,若再加上下文环境切换,一次 mask 就是普通渲染的n(n>3)倍以上耗时操作 - -正常流程:App Source Code -> CPU -> Frame Buffer -> Dispaly -离屏渲染流程:App Source Code -> CPU -> Off Screen Frame Buffer -> Frame Buffer -> Dispaly - - -## 如何优化? -- 针对 shadow 可以增加 shadowPath -- 针对圆角可以增加贝塞尔曲线或者一张图片实现(类似遮罩)。 - -## 特殊的离屏渲染 -- drawRect 是一种特殊情况,因为是依赖 Core Graphics 将绘制结果保存在 backing store 中,是 CPU 层面的操作,离屏渲染是 GPU 层面的。 - -1. 重写 drawRect 方法的时候系统会为该 View 创建一块内存区域,渲染结果先保存到该内存区域,最后将该内存区域的结果写入到 FrameBuffer 中,但是该现象不属于严格意义的离屏渲染。 - -## 画家算法 -画家算法,也叫做优先填充算法,是计算机三维图形学中处理可见性问题的一种解法(三维场景投影到二维平面上)。画家算法先将场景中的多边形按照深度进行排序,然后按照由远及近的顺序进行描述,这样可以将不可见的部分覆盖,解决“可见性”问题(也就是一层层画布进行绘制,最后叠加)。 - -GPU 利用片元将整个图片分为一个个像素,并且并行计算了每一个像素的颜色。在同一个栅格内可能存在多个视图,根据距离眼睛的远近,存在多个不同的物体。显而易见,我们应该将最近物体的颜色作为该栅栏的颜色,后面物体的颜色应该被遮挡(如果后面物体的颜色被传递给片元着色器,这时候就是一个显示错误,比如我们打游戏的时候可以看到墙后的人) - -画家算法带来2个问题。第一个问题是相互交错的物体(类似于死循环,无法pick出谁应该最先被渲染),按照画家算法,这样的情况,GPU 会无从下手。所以早期的时候,设计师总是避免这样相互交错的设计。 -第二个问题是过度绘制,因为画家算法总是一层层绘制,所以存在重合叠加的情况,层级较低的物体总是会被过度绘制,浪费资源。 - -因为 GPU 的设计是并发、无序的,所以我们期望的画家算法是不希望浪费、等待,同时为了绘制速度,所以在此基础上引入了 Depth Buffer 和 Early-Z 和深度缓冲。 - -画家算法有个缺点,就是当后面的图层开始渲染时,是无法回过头去处理之前的图层,这就对于一些前后依赖的图层时,无法实现,因此需要申请一块额外的帧缓冲区来完成,比如阴影、圆角。 - -明白了画家算法的工作原理,也就明白了为什么会发生离屏渲染。 -- 离屏渲染需要创建额外的帧缓冲区 -- 渲染相关的上下文对象、帧缓冲区都比较大,切换会带来性能损耗 -- 内存拷贝,需要将临时帧缓冲区的内容拷贝到真正的帧缓冲区。单帧渲染都会比较耗费性能了,如果屏幕上多个视图渲染都存在离屏渲染,整个界面会发生卡顿。 - - -## 如何检测离屏渲染 -Xcode 就提供了检测功能,打开路径为: Xcode -> Debug -> View Debugging -> Rendering -> Color Offscreen-Renderer Yellow - - - - - -## 引申阅读 -- [实时渲染管线中的光源渲染问题](https://zhuanlan.zhihu.com/p/392748735) -- [蒙特卡罗方法](https://wiki.mbalib.com/wiki/蒙特卡罗方法) -- [抗锯齿方法](https://zhuanlan.zhihu.com/p/56385707) \ No newline at end of file diff --git a/Chapter1 - iOS/1.102.md b/Chapter1 - iOS/1.102.md deleted file mode 100644 index de878d7..0000000 --- a/Chapter1 - iOS/1.102.md +++ /dev/null @@ -1,906 +0,0 @@ -# LLVM - -[LLVM](https://llvm.org/) 项目是模块化、可重用的编译器以及工具链技术的集合 - -> The LLVM Project is a collection of modular and reusable compiler and toolchain technologies. - -LLVM 不是 low level virtual machine 的缩写,就是项目名称。 - - - -## 结构 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LLVM-segment.png) - -LLVM 由三部分构成: - -- FrontEnd(前端):词法分析、语法分析、语义分析、生成中间代码 - -- Optimizer(优化器):优化中间代码 - -- Backend(后端):生成目标程序(机器码)。比如编写好的 Swift 代码,在编译后端这一步根据在手机上运行,则生成 arm64 的代码,如果运行在 windows 平台上,则生成 x86_64 的代码。 - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LLVM-Structure.png) - -正是由于这样的设计,使得 LLVM 具备很多有点: - -- 不同的前端后端使用统一的中间代码 LLVM Intermediate Representation (LLVM IR) - -- 如果需要支持一种新的编程语言,那么只需要实现一个新的前端 - -- 如果需要支持一种新的硬件设备,那么只需要实现一个新的后端 - -- 优化阶段是一个通用的阶段,它针对的是统一的 LLVM IR,不论是支持新的编程语言,还是支持新的硬件设备,都不需要对优化阶段做修改 - -- 相比之下,GCC 的前端和后端没分得太开,前端后端耦合在了一起。所以 GCC 为了支持一门新的语言,或者为了支持一个新的目标平台,就变得特别困难 - -LLVM 现在被作为实现各种静态和运行时编译语言的通用基础结构(GCC 家族、Java、.NET、Python、Ruby、Scheme、Haskell、D 等) - - - -广义上来讲,LLVM 说的是一种架构。狭义上来讲,LLVM 强调的是偏后端部分,如下图的除了 clang 编译前端外的部分,包括优化器和编译后端,统称为 LLVM 后端。 - - - - - -## Clang - -[Clang](http://clang.llvm.org/) 是 LLVM 的一个子项目,基于 LLVM 架构的 c/c++/Objective-C 语言的编译器前端 - -GCC 是 c/c++ 等的编译器 - -Clang 相较于 GCC,具备下面优点: - -- 编译速度快:在某些平台上,Clang的编译速度显著的快过GCC(Debug模式下编译OC速度比GGC快3倍) - -- 占用内存小:Clang 生成的 AST 所占用的内存是 GCC 的五分之一左右 - -- 模块化设计:Clang 采用基于库的模块化设计,易于 IDE 集成及其他用途的重用 - -- 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告 - -- 设计清晰简单,容易理解,易于扩展增强 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LLVM-phase.png) - - - -## 各个编译阶段 - -Demo - -```c++ -#import -#define AGE 29 - -int main(int argc, const char * argv[]) { - int a = 10; - int b = 20; - int sum = a + b + AGE; - return 0; -} -``` - -查看 `main.m` 的整个编译过程 - -```shell -clang -ccc-print-phases main.m -``` - -展示如下: - - - - - -可以看到经历了:**输入、预处理、编译、LLVM Backend、汇编、链接、绑定架构**7个阶段。 - - - -### 预处理 - -查看 preprocessor (预处理)的结果:`clang -E main.m`。预处理主要做的事情就是头文件导入( include、import)、宏定义替换等。展示如下: - - - - - -### 词法分析 - -词法分析阶段,主要生成 Token。使用指令 `clang -fmodules -E -Xclang -dump-tokens main.m` 查看具体做了什么 - - - - - -### 语法分析 - -语法分析阶段,生成语法树(AST,Abstract Syntax Tree)。使用指令 `clang -fmodules -fsyntax-only -Xclang -ast-dump main.m` 查看 - - - -对 main.m 的代码进行改造 - -``` -#import -#define AGE 29 - -int main(int argc, const char * argv[]) { - int a = 10; - int b = 20; - int sum = a + b + AGE; - return 0; -} - -void test(int a, int b) { - int c = a + b - 4; -} -``` - -再次查看 AST 可以加深理解 - - - -其中: - -- `FunctionDecl` 节点下存在2个 `ParamVarDecl` 和1个 `CompoundStmt` 也就是2个参数和1个函数体 -- 函数体 `CompoundStmt` 内部存在一个变量声明 `VarDecl` -- `-`是一个操作符。 -- 红色框框内的是第一层树形结构。操作符 `-` 有2个参数。首先是最下面的字面量 `IntegerLiteral` 4。另一个就是蓝色框内的运算结果 -- 蓝色框内操作符 `+` 也有2个 `DeclRefExpr` - -也就是先运算蓝色框内的值,然后用结果和红色框内的进行相减。所以这是很标准的树形结构。 - - - - - -### LLVM IR - -IR 作为中间语言具有语言无关的特性,下面是 IR 中与语言无关的类型信息: - -- 语言共有的基础类型(void、bool、signed 等) -- 复杂类型,pointer、array、structure、function -- 弱类型的支持,用 cast 来实现一种类型到另一种任意类型的转换 -- 支持地址运算,getelmentptr 指令用于获取结构体子元素,比如 a.b 或 [a b] - -LLVM IR 有3种表示格式: - -- text:便于阅读的文本格式,类似于汇编语言,推展名为 `.ll`。使用指令 `clang -S -emit-llvm main.m` 进行转换 - - - - 学过 arm64 汇编的话看这段 IR 很眼熟,汇编里 `load` 相关的指令都是从内存中装载数据,比如 `ldr`、`ldur` 、`ldp`。`store` 相关的指令是往内存中写入数据,比如 `str`、 `stur`、 `stp` - - 一些读 IR 的 tips: - - - 注释以分号 `;` 开头 - - 全局变量以 `@` 开头 - - 局部变量以 `%` 开头 - - `alloca` 在当前函数栈帧中分配内存,为当前执行的函数分配内存,当该函数执行完毕时自动释放内存 - - `i32`,表示整数占几位,例如 i32 就代表 32 bit,4个字节的意思 - - `align` 内存对齐。比如单个 int 占4字节,为了对齐,只占1字节的 char 要对齐,就需要占用 4 字节 - - `store` ,写入数据 - - `load` ,读取数据 - - `icmp`,2个整数值比较,返回布尔值 - - `br`,选择分支,根据条件跳转到对应的 label - - `label`,代码标签 - - 更多的可以参考[官方文档](https://llvm.org/docs/LangRef.html) - -- memory 格式:内存格式 - -- bitcode:二进制格式,拓展名为 `.bc`.使用指令 `clang -c -emit-llvm main.m` 进行转换。 - - - -## 调试 LLVM -选择 Edit Scheme. - - - - - - -最后就可以加断点进行 Debug 了。但为了让调试更有意义,类似 `nm -a /Users/unix_kernel/Library/Developer/Xcode/DerivedData/LDExploreDemo-ehvvtxafpkdkubgrswvvsudzhqbb/Build/Products/Debug-iphonesimulator/LDExploreDemo.app/LDExploreDemo` 一样可以查看到更有意义的信息,可以在 Edit Scheme 面板中 `Run -> Arguments -> Arguments Passed On Launch` section 中的 **+** 点击,添加一些参数,如下图: - - - -最后允许测试。注意:LLVM 项目较大,可以选择顶部 "Product -> Perform Action -> Run Without Building". - - -## 用途 - -LLVM 的一些插件,比如 libclang、libTooling,可以查看官方文档:https://clang.llvm.org/docs/Tooling.html,可以做一些**语法树解** - -**析、语言转换**等工作。 - -应用场景分为3大类: - -- Clang 插件开发,可以参考官方文档: - - - https://clang.llvm.org/docs/ClangPlugins.html - - - https://clang.llvm.org/docs/RAVFrontendAction.html - - - https://clang.llvm.org/docs/ExternalClangExamples.html - - 应用场景是:代码检查(命名规范、代码规范)等。 - -- Pass 开发,可以参考官方文档: - - - https://llvm.org/docs/WritingAnLLVMPass.html - - 应用场景是:代码优化、代码混淆、精准测试等 - -- [libclang](https://clang.llvm.org/doxygen/group__CINDEX.html)、[Clang plugins](https://clang.llvm.org/docs/ClangPlugins.html)、[libTooling](https://clang.llvm.org/docs/LibTooling.html) 做语法树分析,实现语言转换 OC 转 Swift、JS 等其它语言;字符串加密;开发新的语言,例如 Swift 语言。可以参考博客: - - - https://kaleidoscope-llvm-tutorial-zh-cn.readthedocs.io/zh-cn/latest/ - - https://llvm-tutorial-cn.readthedocs.io/en/latest/index.html - - - - 其中: - - libclang 供了一个相对较小的 API,它将用于解析源代码的工具暴露给抽象语法树(AST),加载已经解析的 AST,遍历 AST,将物理源位置与 AST 内的元素相关联。 - - libclang 是一个稳定的高级 C 语言接口,隔离了编译器底层的复杂设计,拥有更强的 Clang 版本兼容性,以及更好的多语言支持能力,对于大多数分析 AST 的场景来说,libclang 是一个很好入手的选择。 - - ##### 优点 - - 1. 可以使用 C++ 之外的语言与 Clang 交互。 - 2. 稳定的交互接口和向后兼容。 - 3. 强大的高级抽象,比如用光标迭代 AST,并且不用学习 Clang AST 的所有细节。 - - ##### 缺点:不能完全控制 Clang AST。 - - - - Clang Plugin 允许你在编译过程中对 AST 执行其他操作。Clang Plugin 是动态库,由编译器在运行时加载,并且它们很容易集成到构建环境中。 - - - - LibTooling 是一个独立的库,它允许使用者很方便地搭建属于你自己的编译器前端工具,它的优点与缺点一样明显,它基于 C++ 接口,读起来晦涩难懂,但是提供给使用者远比 libclang 强大全面的 AST 解析和控制能力,同时由于它与 Clang 的内核过于接近导致它的版本兼容能力比 libclang 差得多,Clang 的变动很容易影响到 LibTooling。libTooling 还提供了完整的参数解析方案,可以很方便的构建一个独立的命令行工具。这是 libclang 所不具备的能力。一般来说,如果你只需要语法分析或者做代码补全这类功能,libclang 将是你避免掉坑的最佳的选择。 - - - -### 编写 Xcode 插件 - -比如检查类名的合法性,Xcode 默认认为类名带有下划线或者小写开头的类名是合法的。但是这个不符合团队代码规范,使用 LLVM 就可以编写 Xcode 插件,来检查类名的合法性。 - -判断类名是否合法,这肯定是编译前端做的事情。搞清楚这点,就好办了 - -接下来就一步步实现该功能。 - - - -#### 下载 - -创建文件夹 `llvm_explore` ,shell 进入到文件夹执行指令 `git clone https://github.com/llvm/llvm-project.git` - - - -#### 编译 - -用 brew 安装 cmake 和 ninja:`brew install cmake` 、`brew install ninja` - -Tips:ninja 如果安装失败,可以直接从 [github]( https://github.com/ninja-build/ninja/releases) 获取 release 版放入`/usr/local/bin`中 - - - -编译方式有2种: - -- ninja 编译 - - 在 LLVM 源码同层目录下创建一个 `llvm_build` 目录,最终会在 `llvm_build` 目录下生成 `build.ninja` - - ```shell - cd llvm_build - cmake -G Ninja ../llvm -DCMAKE_INSTALL_PREFIX=LLVM的安装路径 - ``` - - 然后执行编译指令,使用 `ninja` - - 再执行安装指令,使用 `ninja install` - -- Xcode 编译 - - 在 LLVM 源码同层目录下创建一个 `llvm_xcode_build` 目录 - - ```shell - mkdir llvm_xcode_build - cd llvm_xcode_build - cmake -S ../../llvm-project/llvm -B ./ -G Xcode -DLLVM_ENABLE_PROJECTS="clang" - ``` - - -因为要编写 Clang 插件,是 c++ 代码,所以需要借助 IDE 的能力,我们选用 Xcode 进行编译。如下图所示,代表编译成功 - - - - - - - -#### LLVM 角色说明 - -- LLVM Core:包含一个现在的源代码/目标设备无关的优化器,一集一个针对很多主流(甚至于一些非主流)的 CPU 的汇编代码生成支持。 -- Clang:一个 C/C++/Objective-C 编译器,致力于提供令人惊讶的快速编译,极其有用的错误和警告信息,提供一个可用于构建很棒的源代码级别的工具 -- dragonegg: gcc 插件,可将 GCC 的优化和代码生成器替换为 LLVM 的相应工具。 -- LLDB:基于 LLVM 提供的库和 Clang 构建的优秀的本地调试器。 -- libc++、libc++ ABI:符合标准的,高性能的 C++ 标准库实现,以及对 C++11 的完整支持 -- compiler-rt:针对 __fixunsdfdi 和其他目标机器上没有一个核心 IR(intermediate representation) 对应的短原生指令序列时,提供高度调优过的底层代码生成支持 -- OpenMP:Clang 中对多平台并行编程的 runtime 支持 -- vmkit:基于 LLVM 的 Java 和 .NET 虚拟机 -- polly: 支持高级别的循环和数据本地化优化支持的 LLVM 框架。 -- libclc: OpenCL 标准库的实现 -- klee:基于L LVM 编译基础设施的符号化虚拟机 -- SAFECode:内存安全的 C/C++ 编译器 -- lld: clang/llvm 内置的链接器 - - - -#### 添加插件目录 - -进入目录 `/Users/unix_kernel/Desktop/LLVM_Explore/llvm-project/clang/tools`: - -- 先创建一个插件文件夹 `code-style-validate-plugin` - - - -- 编辑 `CMakeLists.txt` 文件,在最后添加 `add_clang_subdirectory(code-style-validate-plugin)` - - - - - -#### 配置插件 - -在上一步创建的 `code-style-validate-plugin` 文件夹下: - -- 创建插件代码文件 `CodeStyleValidatePlugin.cpp` - -- 创建 `CMakeLists.txt` ,添加配置代码,其中 `FANPlugin` 是插件名,CodeStyleValidatePlugin 是插件源码文件名 - - ```shell - add_llvm_library(CodeStyleValidatePlugin MODULE BUILDTREE_ONLY - CodeStyleValidatePlugin.cpp - ) - ``` - - -由于新做了配置,并且要开发 `CodeStyleValidatePlugin.cpp` ,所以重新生成 `cmake -S .https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/llvm-project/llvm -B ./ -G Xcode -DLLVM_ENABLE_PROJECTS="clang"` - - - -#### 编写插件代码 - -Xcode 打开项目,选择自动创建 Schemes - - - - - -选择 Target 为 `CodeStyleValidatePlugin`,源代码所在文件夹为 `Sources/Loadable modules`,然后选中 CodeStyleValidatePlugin.cpp` 文件进行编写逻辑 - - - - - -初步编写后 Command + B 进行编译,在 Products 下可以看到编译产物:`CodeStyleValidatePlugin.dylib` 动态库。 - - - - - -#### 编译 clang/clang++ - -此步骤前需要做一步编译 Clang 的动作。Xcode 打开 LLVM 项目,选中 `ALL_BUILD` target,进行编译,此过程耗时较长(1h+) - -此步骤的目的是:在 testLLVM 项目中,加载 `CodeStyleValidatePlugin.dylib` 插件可以成功。因为默认的 Xcode 使用的 clang/clang++ 编译器和编译 `CodeStyleValidatePlugin.dylib` 动态库不是一个版本。不做修改的话,Xcode 加载 `CodeStyleValidatePlugin.dylib` 会报错。所以需要先编译出同一个 LLVM 版本的 clang/clang++。 - - - - - -#### Xcode 加载插件 - -新建一个名字叫做 ` TestLLVM` 的 Xcode 项目。要在 Xcode 中加载指定的动态库,需要修改 Build Settings 配置,操作路径为:`Build Settings -> Other C Flags`。 - -添加: - -- `-Xclang` -- `-load` -- `-Xclang` -- 动态库路径 -- `-Xclang` -- `-add-plugin` -- `-Xclang` -- 插件名称 - - - - - -#### 设置编译器 - -在新创建的 TestLLVM Xcode 项目中加载创建的 `CodeStyleValidatePlugin.dylib` 会报错。原因是:由于 Clang 插件需要使用对应的版本去加载,如果版本不一致则会导致编译错误。如下所示: - - - - - -解决方案是在 Build Setiings 中增加2项用户自定义的设置: - -- `CC`:对应的是自己编译的 clang 的绝对路径 - -- `CXX`:对应的是自己编译的 clang++ 绝对路径 - -如下所示: - - - - - -继续编译还是会报错,报错如下: - - - -解决方案为:在 `Build Settings` 栏目中搜索 `index`,将 `Enable Index-Wihle-Building Functionality` 的 ` Default` 改为 `NO`。 - - - - - -#### 编译插件,验证正确性 - -编译项目后,会在编译日志看到 `FANPlugin` 插件的打印信息,说明前面的配置没有问题,接下去就是继续编写 `FANPlugin.cpp` 的逻辑代码,继续验证。 - -Tips: 由于重新修改了插件的源码,所以每次 Build 构建完 FANPlugin 之后,在 `TestLLVM` Xcode 项目中,最好每次都执行一下 Clean 操作。 - -编译成功,可以看到在日志中输出了我们编写的日志信息。 - - - - - -#### Clang 插件编写说明 - -- `AnalysisConsumer`:`AnalysisConsumer` 是 clang AST 中做实事儿的接口,根据具体情况 `ASTFrontendAction` 可能对应一个或多个 `AnalysisConsumer` -- `RecursiveASTVisitor` & `StmtVisitor`:`RecursiveASTVisitor `是顶层的遍历 clang AST 的工具,虽然也能处理 `stmt` 级别的处理,但是终归没有 `StmtVisitor` 用的顺手 -- `PluginASTAction`:clang 插件的关键组件之一。通过 PluginASTAction,可以在编译过程中运行额外的用户定义操作。这个类允许创建 AST 消费者对象,并处理插件命令行参数,以便根据需要执行特定操作。您可以通过实现 `ParseArgs` 方法来处理插件的命令行选项,以及通过覆盖 `getActionType` 方法来确定插件的执行时机,例如在主要操作之前或之后执行。这样的灵活性使得开发人员能够根据需求定制 clang 插件的行为 -- `ASTConsumer` :用于处理抽象语法树(AST)的重要组件。ASTConsumer 负责遍历和处理由 clang 前端生成的 AST 节点,执行特定的操作或分析。通过实现 ASTConsumer,开发人员可以访问和处理 AST 中的各种节点,例如函数、变量声明、表达式等,以便进行静态分析、代码转换或其他编译器任务 -- `MatchFinder`:提供类似 DSL 的方式用于匹配 AST 节点,用于做进一步的检验,获取节点来做判断或者进一步的处理。 -- `MatchFinder::MatchCallback`:用于在 MatchFinder 中处理匹配结果的回调函数。当 MatchFinder 在抽象语法树(AST)中找到与匹配器描述的模式相匹配的节点时,会调用注册的 MatchCallback 来处理这些匹配结果。MatchCallback 通常包含一些虚拟方法,如 `run()`、`onStartOfTranslationUnit()`、`onEndOfTranslationUnit()` 等,开发人员可以根据需要重写这些方法来实现自定义的处理逻辑。例如,在 `run()` 方法中处理每个匹配结果,在 `onStartOfTranslationUnit()` 方法中处理每个翻译单元的开始,在 `onEndOfTranslationUnit()` 方法中处理每个翻译单元的结束。 - - - -#### 继续完善代码 - -类名不符合规范的情况。 - -```objective-c -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface workaholic_person : NSObject - -@end - -NS_ASSUME_NONNULL_END -``` - -利用 Clang 查看 AST 指令为 `clang -fmodules -fsyntax-only -Xclang -ast-dump workaholic_person.m` - - - -核心思路为:我们要分析类名不符合规范的情况,要精确报错,首先要识别到类名,利用 AST 的能力可以办到(类名在 AST 的 `ObjCInterfaceDecl` 节点上)。然后获取到类名的行号信息,精确报错。 - -步骤为: - -- 注册插件,需要指定 Action 是什么。这里我们指定自定义的继承自 `PluginASTAction` 的 `PluginASTAction` -- Action 内部会调用 `CreateASTConsumer` 方法,所以需要创建一个继承自 `ASTConsumer` 的 consumer,即 ·`FANCounsumer` -- Consumer 在 Xcode 解析完 AST 后会调用 `HandleTranslationUnit` 方法,`HandleTranslationUnit` 方法的参数是一个类行为 `ASTContext` 的对象,携带了 AST 的全部信息 -- 然后创建一个 `MatchFinder ` 对象。在构造器里指定 Macther 找什么 `matcher.addMatcher(objcInterfaceDecl().bind("ObjCInterfaceDecl"), &handler)`,以及找到后做什么事情,将找到后的逻辑交给了一个 CallBack,即 `handler` 的 `void run(const MatchFinder::MatchResult &Result)` 方法 -- `size_t pos = decl->getName().find('_')` 用来找类名中有没有下划线 `_`。 -- `pos != StringRef::npos` 不等于 `StringRef::npos` 则说明找到了下划线,则执行括号里面的逻辑 -- `DiagnosticsEngine &D = ci.getDiagnostics()` 对象具有报错能力,`D.Report()` -- 为了精确报错,需要找到具体的位置信息 `SourceLocation loc = decl->getLocation().getLocWithOffset(pos)` - - - -完整代码 - -```c++ -#include -#include "clang/AST/AST.h" -#include "clang/AST/ASTConsumer.h" -#include "clang/ASTMatchers/ASTMatchers.h" -#include "clang/ASTMatchers/ASTMatchFinder.h" -#include "clang/Frontend/CompilerInstance.h" -#include "clang/Frontend/FrontendPluginRegistry.h" - -#include -#include -#include - -using namespace clang; -using namespace std; -using namespace llvm; -using namespace clang::ast_matchers; - -#define CodeStyleValidateMethodDeclaration "ObjCMethodDecl" -#define CodeStyleValidatePropertyDeclaration "ObjcPropertyDecl" -#define CodeStyleValidateInterfaceDeclaration "ObjCInterfaceDecl" - -namespace CodeStyleValidatePlugin { - // 自定义 handler - class CodeStyleValidateHandler : public MatchFinder::MatchCallback { - private: - CompilerInstance &ci; // 编译器实例 - - // 判断是否为开发者写的代码 - bool isDeveloperSourceCode (string filename) { - if (filename.empty()) - return false; - if(filename.find("/Applications/Xcode.app/") == 0) - return false; - return true; - } - - // 判断属性是否需要用 Copy - bool isShouldUseCopyAttribute(const string typeStr) { - if (typeStr.find("NSString") != StringRef::npos || - typeStr.find("NSArray") != StringRef::npos || - typeStr.find("NSDictionary") != StringRef::npos - ) { - return true; - } - return false; - } - - // 检测类名 - void validateInterfaceDeclaration(const ObjCInterfaceDecl *decl) { - StringRef className = decl->getName(); - // 判断首字母不能以小写开头 - char c = className[0]; - if (isLowercase(c)) { - std::string tempName = decl->getNameAsString(); - tempName[0] = toUppercase(c); - StringRef replacement(tempName); - SourceLocation nameStart = decl->getLocation(); - SourceLocation nameEnd = nameStart.getLocWithOffset(static_cast(className.size() - 1)); - FixItHint fixItHint = FixItHint::CreateReplacement(SourceRange(nameStart, nameEnd), replacement); - - //报告警告 - SourceLocation location = decl->getLocation(); - showWaringReport(location, "☠️ 杭城小刘提示你:Class 名不能以小写字母开头 ⚠️", &fixItHint); - } - - // 判断下划线不能在类名有没有包含下划线 - size_t pos = decl->getName().find('_'); - if (pos != StringRef::npos) { - std::string tempName = decl->getNameAsString(); - std::string::iterator end_pos = std::remove(tempName.begin(), tempName.end(), '_'); - tempName.erase(end_pos, tempName.end()); - StringRef replacement(tempName); - SourceLocation nameStart = decl->getLocation(); - SourceLocation nameEnd = nameStart.getLocWithOffset(static_cast(className.size() - 1)); - FixItHint fixItHint = FixItHint::CreateReplacement(SourceRange(nameStart, nameEnd), replacement); - - //报告警告 - SourceLocation loc = decl->getLocation().getLocWithOffset(static_cast(pos)); - showWaringReport(loc, "☠️ 杭城小刘提示你:Class 名中不能带有下划线 ⚠️", &fixItHint); - } - } - - // 检测属性 - void validatePropertyDeclaration(const clang::ObjCPropertyDecl *propertyDecl) { - - StringRef name = propertyDecl -> getName(); - // 名称必须以小写字母开头 - bool checkUppercaseNameIndex = 0; - if (name.find('_') == 0) { - // 以下划线开头则首字母位置变为1 - checkUppercaseNameIndex = 1; - } - char c = name[checkUppercaseNameIndex]; - if (isUppercase(c)) { - // 修正提示 - std::string tempName = name.str(); - tempName[checkUppercaseNameIndex] = toLowercase(c); - StringRef replacement(tempName); - SourceLocation nameStart = propertyDecl->getLocation(); - SourceLocation nameEnd = nameStart.getLocWithOffset(static_cast(name.size() - 1)); - FixItHint fixItHint = FixItHint::CreateReplacement(SourceRange(nameStart, nameEnd), replacement); - SourceLocation location = propertyDecl->getLocation(); - // 报告警告 - showWaringReport(location, "☠️ 杭城小刘提示你:@property 名称必须以小写字母开头 ⚠️", &fixItHint); - } - - // 检测属性 - if (propertyDecl->getTypeSourceInfo()) { - ObjCPropertyAttribute::Kind attrKind = propertyDecl->getPropertyAttributes(); - SourceLocation location = propertyDecl->getLocation(); - string typeStr = propertyDecl->getType().getAsString(); - string propertyName = propertyDecl->getNameAsString(); - - // 判断 Property 需要使用 copy - if (isShouldUseCopyAttribute(typeStr) && !(attrKind & ObjCPropertyAttribute::Kind::kind_copy)) { - showWaringReport(location, "☠️ 杭城小刘提示你:建议使用 copy 代替 strong ⚠️", NULL); - } - - // 判断int需要使用NSInteger - if(!typeStr.compare("int")){ - showWaringReport(location, "☠️ 杭城小刘提示你:建议使用 NSInteger 替换 int ⚠️", NULL); - } - // 判断delegat使用weak - if ((typeStr.find("<")!=string::npos && typeStr.find(">")!=string::npos) && (typeStr.find("Array")==string::npos) && !(attrKind & ObjCPropertyAttribute::Kind::kind_weak)) { - showErrorReport(location, "☠️ 杭城小刘提示你:建议使用 weak 定义 Delegate ⚠️", NULL); - } - } - } - - // 检测方法 - void validateMethodDeclaration(string fileName, const clang::ObjCMethodDecl *methodDecl) { - // 检查名称的每部分,都不允许以大写字母开头 - Selector sel = methodDecl -> getSelector(); - int selectorPartCount = methodDecl -> getNumSelectorLocs(); - for (int i = 0; i < selectorPartCount; i++) { - StringRef selName = sel.getNameForSlot(i); - char c = selName[0]; - if (isUppercase(c)) { - // 修正提示 - std::string tempName = selName.str(); - tempName[0] = toLowercase(c); - StringRef replacement(tempName); - SourceLocation nameStart = methodDecl -> getSelectorLoc(i); - SourceLocation nameEnd = nameStart.getLocWithOffset(static_cast(selName.size() - 1)); - FixItHint fixItHint = FixItHint::CreateReplacement(SourceRange(nameStart, nameEnd), replacement); - - // 报告警告 - SourceLocation location = methodDecl->getLocation(); - showWaringReport(location, "☠️ 杭城小刘提示你:方法名要以小写开头 ⚠️", &fixItHint); - } - } - - // 检测方法中定义的参数名称是否存在大写开头 - for (ObjCMethodDecl::param_const_iterator it = methodDecl->param_begin(); it != methodDecl->param_end(); it++) { - const ParmVarDecl *parmVarDecl = *it; - StringRef name = parmVarDecl -> getName(); - char c = name[0]; - if (isUppercase(c)) { - // 修正提示 - std::string tempName = name.str(); - tempName[0] = toLowercase(c); - StringRef replacement(tempName); - SourceLocation nameStart = parmVarDecl -> getLocation(); - SourceLocation nameEnd = nameStart.getLocWithOffset(static_cast(name.size() - 1)); - FixItHint fixItHint = FixItHint::CreateReplacement(SourceRange(nameStart, nameEnd), replacement); - - //报告警告 - SourceLocation location = methodDecl->getLocation(); - showWaringReport(location, "☠️ 杭城小刘提示你:参数名称要小写开头 ⚠️", &fixItHint); - } - } - } - - - template - /// 抛出警告 - /// @param Loc 位置 - /// @param Hint 修改提示 - void showWaringReport(SourceLocation Loc, const char (&FormatString)[N], FixItHint *Hint) { - DiagnosticsEngine &diagEngine = ci.getDiagnostics(); - unsigned DiagID = diagEngine.getCustomDiagID(clang::DiagnosticsEngine::Warning, FormatString); - (Hint!=NULL) ? diagEngine.Report(Loc, DiagID) << *Hint : diagEngine.Report(Loc, DiagID); - } - - template - /// 抛出错误 - /// @param Loc 位置 - /// @param Hint 修改提示 - void showErrorReport(SourceLocation Loc, const char (&FormatString)[N], FixItHint *Hint) { - DiagnosticsEngine &diagEngine = ci.getDiagnostics(); - unsigned DiagID = diagEngine.getCustomDiagID(clang::DiagnosticsEngine::Error, FormatString); - (Hint!=NULL) ? diagEngine.Report(Loc, DiagID) << *Hint : diagEngine.Report(Loc, DiagID); - } - - public: - CodeStyleValidateHandler(CompilerInstance &ci) :ci(ci) {} - - // 主要方法,分配 类、方法、属性 做不同处理 - void run(const MatchFinder::MatchResult &Result) override { - if (const ObjCInterfaceDecl *interfaceDecl = Result.Nodes.getNodeAs(CodeStyleValidateInterfaceDeclaration)) { - string filename = ci.getSourceManager().getFilename(interfaceDecl->getSourceRange().getBegin()).str(); - if(isDeveloperSourceCode(filename)){ - // 类的检测 - validateInterfaceDeclaration(interfaceDecl); - } - } - - if (const ObjCPropertyDecl *propertyDecl = Result.Nodes.getNodeAs(CodeStyleValidatePropertyDeclaration)) { - string filename = ci.getSourceManager().getFilename(propertyDecl->getSourceRange().getBegin()).str(); - if(isDeveloperSourceCode(filename)) { - // 属性的检测 - validatePropertyDeclaration(propertyDecl); - } - } - - if (const ObjCMethodDecl *methodDecl = Result.Nodes.getNodeAs(CodeStyleValidateMethodDeclaration)) { - string filename = ci.getSourceManager().getFilename(methodDecl->getSourceRange().getBegin()).str(); - if(isDeveloperSourceCode(filename)) { - // 方法的检测 - validateMethodDeclaration(filename, methodDecl); - } - } - } - }; - - // 自定义的处理工具 - class CodeStyleValidateASTConsumer: public ASTConsumer { - private: - MatchFinder matcher; - CodeStyleValidateHandler handler; - public: - //调用 CreateASTConsumer 方法后就会加载 Consumer 里面的方法 - CodeStyleValidateASTConsumer(CompilerInstance &ci) :handler(ci) { - matcher.addMatcher(objcInterfaceDecl().bind(CodeStyleValidateInterfaceDeclaration), &handler); - matcher.addMatcher(objcMethodDecl().bind(CodeStyleValidateMethodDeclaration), &handler); - matcher.addMatcher(objcPropertyDecl().bind(CodeStyleValidatePropertyDeclaration), &handler); - } - - // 遍历完一次语法树就会调用一次下面方法。该方法通常被用来处理整个翻译单元的 AST,进行进一步的分析、处理或者其他操作。在处理完整个 AST 后,开发者可以在这个方法中执行他们需要的操作,比如生成代码、执行静态分析、进行重构等。 - void HandleTranslationUnit(ASTContext &context) override { - matcher.matchAST(context); - } - }; - - // 入口,解析 AST 后的动作 - class ValidateCodeStyleAction: public PluginASTAction { - std::set ParsedTemplates; - public: - // 需要返回一个 Consumer,所以继续创建一个继承自 ASTConsumer 的 Consumer - unique_ptr CreateASTConsumer(CompilerInstance &ci, StringRef iFile) override { - return unique_ptr (new CodeStyleValidateASTConsumer(ci)); // 使用自定义的处理工具 - } - - bool ParseArgs(const CompilerInstance &ci, const std::vector &args) override { - return true; - } - }; -} - -// 注册插件,告诉 LLVM 插件对应的 Action 是 FANAction -static FrontendPluginRegistry::Add -X("CodeStyleValidatePlugin", "This plugin is designed for scanning code styles, powered by @FantasticLBP"); -``` - -效果如下: - -- 可以对类名检测,如果带下划线,则报错提示并给出修改意见 -- 可以对 Category 名做检测,如果带下划线,则报错提示并给出修改意见 -- 编写的 `CodeStyleValidatePlugin` Demo 中对不符合规范的做了 `DiagnosticsEngine::Warning` 级别的警告。如果遇到1个警告则不影响,继续编译。如果是 `DiagnosticsEngine::Error` 级别的编译报错,遇到1个则终止编译,请注意该区别,按需编写自己的插件逻辑。 - - - - - - - - - -#### 有没有其他方式? - -利用 LLVM 编译前端 Clang + AST 的能力可以解决大多数编译器相关的问题,但是过程可能较为复杂。还有个思路是利用脚本能力,各种脚本语言,比如 Python、Node 都具备 `glob` 模块。`glob` 可以快速匹配并实现字符串的查找能力。 - -利用关键词 `@interface 类名 : 父类名` 的特点,找到到所有的类名,判断类名带有 "_",然后将类名保存起来,最后输出有问题的类信息。 - - - -### 检查 Category 中重名的方法 - -- 使用开源库 [LIEF](https://github.com/lief-project/LIEF) 的能力 -- 脚本 Python、Node glob 模块的快速匹配能力 -- 添加 Xcode 环境变量 `OBJC_PRINT_REPLACED_METHODS`,运行时候会打印出来。参考[官方文档](https://developer.apple.com/library/archive/qa/qa1908/_index.html) - - 此处再引申聊聊命名规范的事情。[官方文档](https://developer.apple.com/library/archive/qa/qa1908/_index.html) 也说了 Category 命名的的最佳实践 - - > ## Category Method Name Best Practice - > - > It is not possible to tell whether a given method name will conflict with an existing method defined by the original class because classes often contain private methods that are not listed in the classes interface. Further, a future version of the class may add new methods that clash with methods previously defined in your category. In order to avoid undefined behavior, it’s best practice to add a prefix to method names in categories on framework classes, just like you should add a prefix to the names of your own classes. You might choose to use the same three letters you use for your class prefixes, but lowercase to follow the usual convention for method names, then an underscore, before the rest of the method name. - - 简单来说,虽然有些类的方法在 `.m` 中可能存在10个方法,但在 `.h` 中公开了3个方法,然后在迭代的过程中,可能另一个对象也新增了3个方法,这3个方法可能是公开的也可能是私有方法,由于大家都遵循常见的 OC 命名策略(见名知意)所以很容易造成命名 冲突。给 Category 或者动态库、静态库命名最好带前缀,以避免方法冲突。这个好处不只是命名规范上的,更是代码逻辑安全出发的,由于 OC 强大的 Runtime 消息机制,重名的方法容易被调用。 - - 官方给的例子 - - ```objective-c - @interface UIView (MyCategory) - - // CORRECT: The method name is prefixed. - - (BOOL)wxyz_isOccludedByView:(UIView*)otherView; - - // INCORRECT: The method name is not prefixed. This method may clash with an existing method in UIView. - - (BOOL)isOccludedByView:(UIView*)otherView; - - @end - ``` - - 除了 CI、CD 最后一道防线的拦截外,事前,团队内宣讲统一代码风格,Code Review 阶段看到 Category 方法命名不合理的地方,即使给出严厉的 Comment,也能拦截和规范一部分情况。 - -- 使用 LLVM 编写 Clang 插件,解析 AST 拿到所有的 `ObjCInterfaceDecl` 信息,然后结合 `ObjCMethodDecl` 信息便可获取 Category 中的 所有方法,再判断方法是否同名 - - - - - -### Pass 插桩,实现精准测试 - -这部分涉及到 Objective-C 和 Swift 的代码插桩逻辑的不同实现,篇幅较大,可以查看这篇文章 [精准测试最佳实践](./1.108.md) - - - -### 静态检测、静态分析 - -通过语法树进行代码静态分析,找出非语法性错误。模拟代码执行路径,分析出 control-flow graph(CFG)。 - -LLVM 项目中 clang 内置了一堆 checker,用于实现 lint。 - -具体的使用可以查看这篇文章:[质量检测](./1.137.md) - - - -### CodeGen - IR 代码生成与 OC Runtime 桥接 - -- Class/Meta Class/Protocol/Category 内存结构生成,并存放在指定的 section 中(如 Class:`_DATA, _objc_classrefs`) - -- Non-Fragile ABI:为每个 Ivar 合成 `OBJC_IVAR_$_` 偏移值常量 - -- 存取 Ivar 的语句(_ivar = 123; int a = _ivar) 转成 base + `OBJC_IVAR_$_` 的形式 - -- 将语法树中的 `ObjcMessageExpr` 翻译成相应版本的 `objc_msgSend`,super 翻译成 `objc_msgSendSuper` - -- 根据修饰符 strong、weak、copy、atomic 合成 @property 自动实现的 setter/getter。处理 `@synthesize` - -- ARC:分析对象引用关系,将 `objc_storeStrong` `objc_storeWeak` 等 ARC 代码插入 - -- 将 ObjcAutoreleasePoolStmt 翻译成 `objc_autoreleasePoolPush`、`objc_autoreleasePoolPop` - -- 自动调用 `[super dealloc]` - - - -- 为每个拥有 ivar 的 Class 合成` .cxx_destructor` 方法来自动释放类的成员变量,代替 MRC 时代的 `self.xxx = nil` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.103.md b/Chapter1 - iOS/1.103.md deleted file mode 100644 index 28ec154..0000000 --- a/Chapter1 - iOS/1.103.md +++ /dev/null @@ -1 +0,0 @@ -# 设计模式及其场景 diff --git a/Chapter1 - iOS/1.104.md b/Chapter1 - iOS/1.104.md deleted file mode 100644 index 22d3589..0000000 --- a/Chapter1 - iOS/1.104.md +++ /dev/null @@ -1,906 +0,0 @@ -# NSNotification底层原理 - -> 有人聊起来 NSNotification 可以在不同的线程发和接收吗?对于不知道或者不确定的知识,有必要探究记录下 - - - -## NSNotificationCenter - -```objectivec -@property (class, readonly, strong) NSNotificationCenter *defaultCenter; -// 添加 Observer -- (void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject; -// 发送通知 -- (void)postNotification:(NSNotification *)notification; -- (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject; -- (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject userInfo:(nullable NSDictionary *)aUserInfo; -// 移除通知 -- (void)removeObserver:(id)observer; -- (void)removeObserver:(id)observer name:(nullable NSNotificationName)aName object:(nullable id)anObject; -// 添加 Observer -- (id )addObserverForName:(nullable NSNotificationName)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block API_AVAILABLE(macos(10.6), ios(4.0), watchos(2.0), tvos(9.0)); -@end -``` - -通过官方 API 可以窥探得到,系统内部应该是维护了 NSNotificationName、Observer、selector、object 之间的关系。 - -直接上 GUNStep 源码探索下 - - - -### Observation - -```c -typedef struct Obs { - id observer; /* Object to receive message. */ - SEL selector; /* Method selector. */ - struct Obs *next; /* Next item in linked list. */ - int retained; /* Retain count for structure. */ - struct NCTbl *link; /* Pointer back to chunk table */ -} Observation; -``` - -结构体存储了 observer、selector 信息。此外可以看出,是一个链表结构(next),指向注册了同一个通知的下一个观察者。 - - - -### NCTbl - -```c -typedef struct NCTbl { - Observation *wildcard; /* Get ALL messages. */ - GSIMapTable nameless; /* Get messages for any name. */ - GSIMapTable named; /* Getting named messages only. */ - // ... -} NCTable; -``` - -查看 NCTbl 结构体定义,发现其内部存在2张 MapTable。 - -- named:用于保存添加观察者的时候传入 NotificationName 的情况 - -- nemeless:同于保存添加观察者时没有传递 NotificationName 的情况 - - - -### named Table - -该表用于存储添加观察者时传了 NotificationName 的情况。也就是 Named Table 中,NotificationName 作为 key。在使用系统 API 注册观察者的时候还可以传入 object 参数,表示只监听该对象发出的通知,所以还需要一张表存储 object 和 observer 的对应关系,object 为 key,observer 为 value。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/notification-namedTable.png) - -- 第一个 MapTable key 为 notificationName,value 为另一个 MapTable(子 Table) - -- 子 Table 以 object 为 key,value 为链表,存储所有的观察者 - -- object 为 nil 的时候,系统会根据 nil 自动生成一个 key,相当于这个 key 对应的值链表保存的就是当前通知传入了 NotificationName 且没有 object 的所有观察者。 - -### nameless Table - -nameless Table 结构较为简单,因为没有 notificationName,所以就一层 MapTable。key 为 object,value 为链表,存储所有的观察者。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Notification-namelessTable.png) - -### wildcard - -wildcard 也是链表结构。如果在添加 Observer 的时候没有传递 notificationName、object 则会将 Observer 添加到 wildcard 链表中,注册到这里的观察者可以响应所有的系统通知。 - -### 添加通知 - -添加通知的所有 API,最终都会调用该 API。 - -```c -- (void)addObserver:(id)observer - selector:(SEL)selector - name:(NSString *)name - object:(id)object { - Observation *list; - Observation *o; - GSIMapTable m; - GSIMapNode n; - // 参数合法性校验 - if (observer == nil) - [NSException raise:NSInvalidArgumentException - format:@"Nil observer passed to addObserver ..."]; - - if (selector == 0) - [NSException raise:NSInvalidArgumentException - format:@"Null selector passed to addObserver ..."]; - - if ([observer respondsToSelector:selector] == NO) - { - [NSException raise:NSInvalidArgumentException - format:@"[%@-%@] Observer '%@' does not respond to selector '%@'", - NSStringFromClass([self class]), NSStringFromSelector(_cmd), - observer, NSStringFromSelector(selector)]; - } - - lockNCTable(TABLE); - // 调用 obsNew 方法,创建 observation 对象,持有 SEL、object - o = obsNew(TABLE, selector, observer); - - /* - * Record the Observation in one of the linked lists. - * - * NB. It is possible to register an observer for a notification more than - * once - in which case, the observer will receive multiple messages when - * the notification is posted... odd, but the MacOS-X docs specify this. - */ - // notificationName 存在的逻辑 - if (name) { - /* - * Locate the map table for this name - create it if not present. - */ - // NAMED 是一个宏定义,表示名为 named 的字典,key 为 name,从 named 表中获取对应的 mapTable - n = GSIMapNodeForKey(NAMED, (GSIMapKey)(id)name); - // 不存在,则创建 - if (n == 0) { - m = mapNew(TABLE); // 先从缓存中取,如果没有则新建 mapTable - /* - * As this is the first observation for the given name, we take a - * copy of the name so it cannot be mutated while in the map. - */ - name = [name copyWithZone:NSDefaultMallocZone()]; - GSIMapAddPair(NAMED, (GSIMapKey)(id)name, (GSIMapVal)(void *)m); - GS_CONSUMED(name) - } else { - // 存在 mapTable 则取出值也就是 named MapTable - m = (GSIMapTable)n->value.ptr; - } - - /* - * Add the observation to the list for the correct object. - */ - n = GSIMapNodeForSimpleKey(m, (GSIMapKey)object); - // 从 named MapTable 中以 key 为 object 获取存储着观察者的链表对象。不存在则创建 - if (n == 0) { - o->next = ENDOBS; - GSIMapAddPair(m, (GSIMapKey)object, (GSIMapVal)o); - } else { - list = (Observation *)n->value.ptr; - o->next = list->next; - list->next = o; - } - } else if (object) { - // 走到这里代表 name 为空,但 object 不为空。此时从 nameless MapTable 中以 object 获取链表对象值。 - n = GSIMapNodeForSimpleKey(NAMELESS, (GSIMapKey)object); - // 不存在则创建新的链表,并存入 nameless MapTable - if (n == 0) { - o->next = ENDOBS; - GSIMapAddPair(NAMELESS, (GSIMapKey)object, (GSIMapVal)o); - } else { - // 存在,则将新的观察者添加到链表 - list = (Observation *)n->value.ptr; - o->next = list->next; - list->next = o; - } - } else { -// name、object 都为空,则将添加的 observation 观察者添加到 WILDCARD 链表 - o->next = WILDCARD; - WILDCARD = o; - } - unlockNCTable(TABLE); -} - -static Observation *obsNew(NCTable *t, SEL s, id o) -{ - Observation *obs; - if (t->freeList == 0) - { - Observation *block; - - if (t->chunkIndex == CHUNKSIZE) - { - unsigned size; - - t->numChunks++; - - size = t->numChunks * sizeof(Observation *); - t->chunks = (Observation **)NSReallocateCollectable( - t->chunks, size, NSScannedOption); - - size = CHUNKSIZE * sizeof(Observation); - t->chunks[t->numChunks - 1] = (Observation *)NSAllocateCollectable(size, 0); - t->chunkIndex = 0; - } - block = t->chunks[t->numChunks - 1]; - t->freeList = &block[t->chunkIndex]; - t->chunkIndex++; - t->freeList->link = 0; - } - obs = t->freeList; - t->freeList = (Observation *)obs->link; - obs->link = (void *)t; - obs->retained = 0; - obs->next = 0; - // 持有 observer 和 selector - obs->selector = s; - obs->observer = o; - return obs; -} -``` - -通过源码,我们可以发现和设想差不多,得出以下结论: - -- 在调用 `(void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject;`方法后,系统内部会调用 `obsNew` 方法,创建 Observation 对象(内部结构是一个结构体),内部保存了观察者 Observer、接收通知时会执行的方法 selector - -- 如果传递了 notificationName 则会去 Named MapTable 中,以 notificationName 为 key,查找对应的 value(子 MapTable)。如果不存在则创建一个新的 MapTable - - - 在子 MapTable 中,以 object 为 key,去取对应的 Observation 链表。如果没有 object 则会生成一个默认的 key,表示所有的通知都会被监听 - - - 通过 object 生成的 key 查找 Observation 链表,如果不存在,则创建一个节点,并作为头节点。如果有链表,则将 Observation 插入尾部(链表的基础操作) - -- 如果 notificationName 不存在,且 object 不为空,则通过 object 生成的 key,查找 Observation 链表,如果没有则创建一个节点,且作为头节点,如果有链表,则插入到尾部 - -- 如果 notificationName、object 都为空,则直接把创建的 Observation 对象存储在 wildcard 链表结构中 - -### 发送通知 - -postNotification 相关的 API 最终都会调用到 `_postAndRelease` 方法,源码如下 - -```c -- (void) _postAndRelease: (NSNotification*)notification { - Observation *o; - unsigned count; - NSString *name = [notification name]; - id object; - GSIMapNode n; - GSIMapTable m; - GSIArrayItem i[64]; - GSIArray_t b; - GSIArray a = &b; - // 参数合法性判断 - if (name == nil) { - RELEASE(notification); - [NSException raise: NSInvalidArgumentException - format: @"Tried to post a notification with no name."]; - } - object = [notification object]; - // 创建 Array 来存储 Observation - GSIArrayInitWithZoneAndStaticCapacity(a, _zone, 64, i); - lockNCTable(TABLE); - // 遍历 WILDCARD 链表,将其中的 Observation 对象都添加到 Array 中的 既没有 notificationName,又没有 object) - for (o = WILDCARD = purgeCollected(WILDCARD); o != ENDOBS; o = o->next) { - GSIArrayAddItem(a, (GSIArrayItem)o); - } - // 拿到 object,在 nameless 表中,以 object 为 key,查找对应的链表 - if (object) { - n = GSIMapNodeForSimpleKey(NAMELESS, (GSIMapKey)object); - // 链表存在,则遍历链表,将 Observation 对象添加到 Array 中 - if (n != 0){ - o = purgeCollectedFromMapNode(NAMELESS, n); - while (o != ENDOBS) { - GSIArrayAddItem(a, (GSIArrayItem)o); - o = o->next; - } - } - } - - // 如果 NotificationName 存在 - if (name) { - //则以 NotificationName 为 key,在 Named MapTable 中寻找对应的子 MapTable - n = GSIMapNodeForKey(NAMED, (GSIMapKey)((id)name)); - if (n) { - m = (GSIMapTable)n->value.ptr; - } else { - m = 0; - } - if (m != 0) { - n = GSIMapNodeForSimpleKey(m, (GSIMapKey)object); - // 当子 MapTable 存在的时候,以 object 为 key,获取对应的链表 - if (n != 0) { - o = purgeCollectedFromMapNode(m, n); - // 当链表存在的时候,遍历链表,将其中的 Observation 对象添加到数组 - while (o != ENDOBS) { - GSIArrayAddItem(a, (GSIArrayItem)o); - o = o->next; - } - } - - if (object != nil) { - // 以 nil 为 object key,查找对应的 Observation,添加到数组中 - n = GSIMapNodeForSimpleKey(m, (GSIMapKey)nil); - if (n != 0) { - o = purgeCollectedFromMapNode(m, n); - while (o != ENDOBS) { - GSIArrayAddItem(a, (GSIArrayItem)o); - o = o->next; - } - } - } - } - } - - /* Finished with the table ... we can unlock it, - */ - unlockNCTable(TABLE); - - // 最后遍历数组,调用 Observation 结构体中的 selector 方法。 - count = GSIArrayCount(a); - while (count-- > 0) { - o = GSIArrayItemAtIndex(a, count).ext; - if (o->next != 0) { - NS_DURING - { - [o->observer performSelector: o->selector - withObject: notification]; - } - NS_HANDLER - { - BOOL logged; - - /* Try to report the notification along with the exception, - * but if there's a problem with the notification itself, - * we just log the exception. - */ - NS_DURING - NSLog(@"Problem posting %@: %@", notification, localException); - logged = YES; - NS_HANDLER - logged = NO; - NS_ENDHANDLER - if (NO == logged){ - NSLog(@"Problem posting notification: %@", localException); - } - } - NS_ENDHANDLER - } - } - lockNCTable(TABLE); - GSIArrayEmpty(a); - unlockNCTable(TABLE); - // 释放 NSNotification 对象 - RELEASE(notification); -} -``` - -通过源码分析,得出以下结论: - -- 调用 `-(void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject` 的时候,内部会生成一个 Array 来存储 Observation 信息 - -- 先将 WILDCARD 中的所有 Observation 添加到 Array 中去,通过分析发送通知源码知道,WILDCARD 中 Observation 都是没有 Object、NotificationName 的,也就是可以接收所有的通知 - -- 然后在 NAMELESS MapTable 中,以 Object 为 key,获取对应的链表,遍历链表,将其中的 Observation 添加到数组中 - -- 在 NAMED MapTable 中,先以 NotificationName 为 key,获取对应的子 MapTable - - - 在子 MapTable 中根据 object 为 key,获取对应的链表。遍历链表,将其中的 Observation 对象添加到数组 - - - 如果 object 不为 nil,则以 nil 为 object key,查找对应的 Observation,添加到 Array 中 - -- 最后遍历 Array,调用 Observation 结构体成员变量 observer 的 selector 方法。以 `performSelector: withObject:` 形式调用的,所以可以看出都是在同一个线程同步执行的 - -- 释放 NSNootification 对象 - -### 移除通知 - -```c -// 遍历 Observation 链表,判断节点的 observer 等于传递来的参数,则删除该节点 -static Observation *listPurge(Observation *list, id observer) -{ - Observation *tmp; - - while (list != ENDOBS && list->observer == observer) - { - tmp = list->next; - list->next = 0; - obsFree(list); - list = tmp; - } - if (list != ENDOBS) - { - tmp = list; - while (tmp->next != ENDOBS) - { - if (tmp->next->observer == observer) - { - Observation *next = tmp->next; - - tmp->next = next->next; - next->next = 0; - obsFree(next); - } - else - { - tmp = tmp->next; - } - } - } - return list; -} - -- (void) removeObserver: (id)observer - name: (NSString*)name - object: (id)object{ - // 参数合法性校验 - if (name == nil && object == nil && observer == nil) - return; - - /* - * NB. The removal algorithm depends on an implementation characteristic - * of our map tables - while enumerating a table, it is safe to remove - * the entry returned by the enumerator. - */ - - lockNCTable(TABLE); - // NotificationName、object 都为 nil,则说明被加入到了 WILDCARD 链表中,调用 listPurge 方法,遍历链表,删除节点中 observer 等于换入参数的节点 - if (name == nil && object == nil) - { - WILDCARD = listPurge(WILDCARD, observer); - } - // NotficationName 为空 - if (name == nil) - { - GSIMapEnumerator_t e0; - GSIMapNode n0; - - /* - * First try removing all named items set for this object. - */ - e0 = GSIMapEnumeratorForMap(NAMED); - n0 = GSIMapEnumeratorNextNode(&e0); - // 现在 NAMED MapTable 中遍历所有子 MapTable - while (n0 != 0) - { - GSIMapTable m = (GSIMapTable)n0->value.ptr; - NSString *thisName = (NSString*)n0->key.obj; - - n0 = GSIMapEnumeratorNextNode(&e0); - // object 为空,则遍历 MapTable 中的节点 - if (object == nil) - { - GSIMapEnumerator_t e1 = GSIMapEnumeratorForMap(m); - GSIMapNode n1 = GSIMapEnumeratorNextNode(&e1); - - /* - * Nil object and nil name, so we step through all the maps - * keyed under the current name and remove all the objects - * that match the observer. - */ - while (n1 != 0) - { - GSIMapNode next = GSIMapEnumeratorNextNode(&e1); - // 清空与 observer 相同的节点 - purgeMapNode(m, n1, observer); - n1 = next; - } - } - else - // 如果 object 不为空 - { - GSIMapNode n1; - // 则以 object 为 key,在子 MapTable 中找到链表,清空链表中所有与 observer 相同的节点 - n1 = GSIMapNodeForSimpleKey(m, (GSIMapKey)object); - if (n1 != 0) - { - purgeMapNode(m, n1, observer); - } - } - /* - * If we removed all the observations keyed under this name, we - * must remove the map table too. - */ - if (m->nodeCount == 0) - { - mapFree(TABLE, m); - GSIMapRemoveKey(NAMED, (GSIMapKey)(id)thisName); - } - } - - // 处理 NAMELESS MapTable - if (object == nil) - { - e0 = GSIMapEnumeratorForMap(NAMELESS); - n0 = GSIMapEnumeratorNextNode(&e0); - // object 为空,则遍历链表,删除与 observer 相同的节点 - while (n0 != 0) - { - GSIMapNode next = GSIMapEnumeratorNextNode(&e0); - - purgeMapNode(NAMELESS, n0, observer); - n0 = next; - } - } - else - { - // 如果 object 不为空,则根据 object 从 NAMELESS MapTable 中以 object 为 key,找到对应的链表 - n0 = GSIMapNodeForSimpleKey(NAMELESS, (GSIMapKey)object); - if (n0 != 0) - { - // 删除与 Observer 相同的节点 - purgeMapNode(NAMELESS, n0, observer); - } - } - } - else - // NotificationName 不为空美好 - { - GSIMapTable m; - GSIMapEnumerator_t e0; - GSIMapNode n0; - - // 则从 NAMED MapTable 中以 name 为 key,获取对应的子 MapTable - n0 = GSIMapNodeForKey(NAMED, (GSIMapKey)((id)name)); - if (n0 == 0) - { - unlockNCTable(TABLE); - return; /* Nothing to do. */ - } - m = (GSIMapTable)n0->value.ptr; - // object 为空,则在子 MapTable 中删除节点(节点值与 observer 相同) - if (object == nil) - { - e0 = GSIMapEnumeratorForMap(m); - n0 = GSIMapEnumeratorNextNode(&e0); - - while (n0 != 0) - { - GSIMapNode next = GSIMapEnumeratorNextNode(&e0); - - purgeMapNode(m, n0, observer); - n0 = next; - } - } - else - { - // 如果 object 不为空,则以 object 为 key,取出对应的链表,然后将链表中与 observer 相同的节点删除 - n0 = GSIMapNodeForSimpleKey(m, (GSIMapKey)object); - if (n0 != 0) - { - purgeMapNode(m, n0, observer); - } - } - if (m->nodeCount == 0) - { - mapFree(TABLE, m); - GSIMapRemoveKey(NAMED, (GSIMapKey)((id)name)); - } - } - unlockNCTable(TABLE); -} -``` - -查看源码,得出以下结论: - -- 调用删除通知观察者最后都会收敛到该API `(void)removeObserver:(id)observer name:(nullable NSNotificationName)aName object:(nullable id)anObject;` 。 - -- 内部会先判断 NotificationName、object 如果都是 nil,则移除 WILDCARD 链表中与 observer 相同的节点 - -- 如果 NotificationName 为 nil - - - 先在 NAMED MapTable 中遍历子 MapTable - - - 如果 object 为 nil,则遍历子 MapTable,且删除与 observer 相同的节点 - - - object 不为 nil,则以 object 为 key,获取子 MapTable 中对应的链表,然后清空链表中与 observer 相同的节点 - - - 再处理 NAMLESS MapTable - - - 如果 object 为 nil,则直接遍历并删除 table 中所有与 observer 相同的节点 - - - 如果 object 不为 nil,则以 object 为 key,获取对应的链表,遍历并删除 table 中所有与 observer 相同的节点 - -- 如果 NotificationName 不为 nil,则在 NAMED MapTable 中以 NotificationName 为 key,获取链表 - - - 如果 object 为 nil,则遍历 MapTable 中所有的节点,清空与 observer 相同的节点 - - - 如果 object 不为 nil,则以 object 为 key,获取子 MapTable 中对应的链表,然后清空链表中与 observer 相同的节点 - -## 如何异步发送通知 - -这个 case 就需引入 `NSNotificationQueue` 也就是通知队列了。 - -简单来说 NSNotificationQueue 是 NSNotificationCenter 的缓冲池。当我们调用 `-(void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject` 的这种方式的时候就是同步发送通知。通知会直接发送到 NSNotificationCenter,然后 NSNotificationCenter 会直接将其发送给注册了该通知的观察者。 - -使用 NSNotificationQueue ,则通知不是直接发送给 NSNotificationCenter,而是先存储在 NSNotificationQueue 中,然后再由 notification 转发给注册的观察者。 - -且可以实现合并相同 NSNotificationName 的通知功。NSNotificationQueue 遵循队列先进先出的特性(FIFO),当一个通知处于对头的时候,它会被发送给 NSNotificationCenter,然后 NSNotificationCenter 再将该 notiication 转发给注册了该通知的所以监听者。 - -每一个线程都有一个默认的 NSNotificationQueue,该队列与通知中心联系在一起。也可以为一个线程创建多个 NSNotificationQueue。 - -其所有 API - -```objectivec -@interface NSNotificationQueue : NSObject { -@private - id _notificationCenter; - id _asapQueue; - id _asapObs; - id _idleQueue; - id _idleObs; -} -@property (class, readonly, strong) NSNotificationQueue *defaultQueue; -// 创建 -- (instancetype)initWithNotificationCenter:(NSNotificationCenter *)notificationCenter NS_DESIGNATED_INITIALIZER; -// 添加观察者(入队) -- (void)enqueueNotification:(NSNotification *)notification postingStyle:(NSPostingStyle)postingStyle; -- (void)enqueueNotification:(NSNotification *)notification postingStyle:(NSPostingStyle)postingStyle coalesceMask:(NSNotificationCoalescing)coalesceMask forModes:(nullable NSArray *)modes; -// 移除观察者(出队) -- (void)dequeueNotificationsMatching:(NSNotification *)notification coalesceMask:(NSUInteger)coalesceMask; -@end -``` - -其中 postingStyle 是枚举参数 - -```objectivec -typedef NS_ENUM(NSUInteger, NSPostingStyle) { - NSPostWhenIdle = 1, - NSPostASAP = 2, - NSPostNow = 3 -}; -``` - -- NSPostWhenIdle:代表在空闲时发送 notification 到 NSNotificationCenter。也就是本线程 RunLoop 空闲时即发送通知到通知中心 - -- NSPostASAP:as soon as possible,尽可能快。即当前通知或者 timer 回调执行结束就发送通知到通知中心,还是需要依赖 RunLoop - -- NSPostNow:马上发送 - -postingStyle 也是枚举 - -```objectivec -typedef NS_OPTIONS(NSUInteger, NSNotificationCoalescing) { - NSNotificationNoCoalescing = 0, - NSNotificationCoalescingOnName = 1, - NSNotificationCoalescingOnSender = 2 -}; -``` - -- NSNotificationNoCoalescing:不管是否 NSNotificationName 是否重复,都不合并 - -- NSNotificationCoalescingOnName:NSNotificationName 相同的多个 NSNotification 会被合并为一个。 - -- NSNotificationCoalescingOnSender:按照发送方,如果多个通知发送方相同,则保留一个 - -测试异步发送通知 - -```objectivec -- (void)mockNotificationQueue -{ - //每个进程默认有一个通知队列,默认是没有开启的,底层通过队列实现,队列维护一个调度表 - NSNotification *notifi = [NSNotification notificationWithName:@"Notification" object:nil]; - NSNotificationQueue *queue = [NSNotificationQueue defaultQueue]; - //FIFO - NSLog(@"notifi before"); - [queue enqueueNotification:notifi postingStyle:NSPostASAP coalesceMask:NSNotificationCoalescingOnName forModes:[NSArray arrayWithObjects:NSDefaultRunLoopMode, nil]]; - NSLog(@"notifi after"); - NSPort *port = [[NSPort alloc] init]; - [[NSRunLoop currentRunLoop] addPort:port forMode:NSRunLoopCommonModes]; - [[NSRunLoop currentRunLoop] run]; - NSLog(@"runloop over"); -} -- (void)viewDidLoad -{ - [super viewDidLoad]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotifi:) name:@"Notification" object:nil]; -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event -{ - [self mockNotificationQueue]; -} -- (void)handleNotifi:(NSNotification *)notification -{ - NSLog(@"%@", [NSThread currentThread]); -} -// NSPostWhenIdle、NSPostASAP -2022-05-07 01:18:01.859643+0800 DDD[62783:2383065] notifi before -2022-05-07 01:18:01.859924+0800 DDD[62783:2383065] notifi after -2022-05-07 01:18:01.860887+0800 DDD[62783:2383065] <_NSMainThread: 0x600003530840>{number = 1, name = main} -2022-05-07 01:18:02.005840+0800 DDD[62783:2383065] notifi before -2022-05-07 01:18:02.006072+0800 DDD[62783:2383065] notifi after -2022-05-07 01:18:02.006882+0800 DDD[62783:2383065] <_NSMainThread: 0x600003530840>{number = 1, name = main} -// NSPostNow -2022-05-07 01:35:21.387512+0800 DDD[63186:2401325] notifi before -2022-05-07 01:35:21.387748+0800 DDD[63186:2401325] <_NSMainThread: 0x60000315c880>{number = 1, name = main} -2022-05-07 01:35:21.387917+0800 DDD[63186:2401325] notifi after -2022-05-07 01:35:21.532892+0800 DDD[63186:2401325] notifi before -2022-05-07 01:35:21.533130+0800 DDD[63186:2401325] <_NSMainThread: 0x60000315c880>{number = 1, name = main} -2022-05-07 01:35:21.533292+0800 DDD[63186:2401325] notifi after -``` - -改变参数发现: - -NSPostWhenIdle:异步 - -NSPostASAP: 异步 - -NSPostNow: 同步 - -所以要实现异步发送通知,则必须使用 NSNotificationQueue 相关 API,且 `postingStyle` 参数必须为 NSPostASAP、NSPostWhenIdle,不能为 NSPostNow、不能为 NSPostNow、不能为 NSPostNow。 - -## 通知和 RunLoop 的关系 - -```objectivec -- (void)notifiWithRunloop -{ - CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - - if(activity == kCFRunLoopEntry){ - NSLog(@"进入 Runloop"); - }else if(activity == kCFRunLoopBeforeWaiting){ - NSLog(@"即将进入等待状态"); - }else if(activity == kCFRunLoopAfterWaiting){ - NSLog(@"结束等待状态"); - } - }); - CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode); - - CFRelease(observer); - - NSNotification *notification1 = [NSNotification notificationWithName:@"notify" object:nil userInfo:@{@"key":@"1"}]; - NSNotification *notification2 = [NSNotification notificationWithName:@"notify" object:nil userInfo:@{@"key":@"2"}]; - NSNotification *notification3 = [NSNotification notificationWithName:@"notify" object:nil userInfo:@{@"key":@"3"}]; - - [[NSNotificationQueue defaultQueue] enqueueNotification:notification1 postingStyle:NSPostWhenIdle coalesceMask:NSNotificationNoCoalescing forModes:@[NSDefaultRunLoopMode]]; - - [[NSNotificationQueue defaultQueue] enqueueNotification:notification2 postingStyle:NSPostASAP coalesceMask:NSNotificationNoCoalescing forModes:@[NSDefaultRunLoopMode]]; - - [[NSNotificationQueue defaultQueue] enqueueNotification:notification3 postingStyle:NSPostNow coalesceMask:NSNotificationNoCoalescing forModes:@[NSDefaultRunLoopMode]]; - - NSPort *port = [[NSPort alloc] init]; - [[NSRunLoop currentRunLoop] addPort:port forMode:NSDefaultRunLoopMode]; - [[NSRunLoop currentRunLoop] run]; -} -- (void)viewDidLoad -{ - [super viewDidLoad]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotifi:) name:@"notify" object:nil]; -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event -{ - [self notifiWithRunloop]; -} - -- (void)handleNotifi:(NSNotification *)notification -{ - NSLog(@"%@", notification.userInfo); -} -2022-05-07 01:43:51.470047+0800 DDD[63370:2409134] { - key = 3; -} -2022-05-07 01:43:51.470312+0800 DDD[63370:2409134] 进入Runloop -2022-05-07 01:43:51.470522+0800 DDD[63370:2409134] { - key = 2; -} -2022-05-07 01:43:51.470962+0800 DDD[63370:2409134] 进入Runloop -2022-05-07 01:43:51.471223+0800 DDD[63370:2409134] 即将进入等待状态 -2022-05-07 01:43:51.471422+0800 DDD[63370:2409134] { - key = 1; -} -2022-05-07 01:43:51.471613+0800 DDD[63370:2409134] 结束等待状态 -2022-05-07 01:43:51.471759+0800 DDD[63370:2409134] 即将进入等待状态 -2022-05-07 01:43:51.479267+0800 DDD[63370:2409134] 结束等待状态 -2022-05-07 01:43:51.480009+0800 DDD[63370:2409134] 进入Runloop -2022-05-07 01:43:51.480172+0800 DDD[63370:2409134] 即将进入等待状态 -2022-05-07 01:43:51.842003+0800 DDD[63370:2409134] 结束等待状态 -2022-05-07 01:43:51.842938+0800 DDD[63370:2409134] 即将进入等待状态 -2022-05-07 01:44:33.109154+0800 DDD[63370:2409134] 结束等待状态 -``` - -通过 Demo 和对应的打印可以看出,NSNotificationQueue 相关的 API 和 RunLoop 有关系,当 `postingStyle` 参数为 NSPostNow 的时候则说明通知没有进入 RunLoop,而是直接立即执行。参数为 NSPostASAP、NSPostWhenIdle 的时候都和 RunLoop 有关,NSPostASAP 通知快于 NSPostWhenIdle。 - -## 通知重定向 - -```objectivec -- (void)viewDidLoad { - [super viewDidLoad]; - NSLog(@"dispatch thread = %@", [NSThread currentThread]); - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:@"TESTNOTIFICATION" object:nil]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [[NSNotificationCenter defaultCenter] postNotificationName:@"TESTNOTIFICATION" object:nil userInfo:nil]; - }); -} -- (void)handleNotification:(NSNotification *)notification -{ - NSLog(@"receive thread = %@", [NSThread currentThread]); -} - -2022-05-07 01:55:21.042542+0800 DDD[63607:2419849] dispatch thread = <_NSMainThread: 0x600001b44840>{number = 1, name = main} -2022-05-07 01:55:21.042835+0800 DDD[63607:2419937] receive thread = {number = 5, name = (null)} -``` - -虽然我们在主线程中注册了通知的观察者,但在全局队列中 postNotification 并不是在主线程处理的。如果我们想在回调中处理与 UI 相关的操作,需要确保是在主线程中执行回调。 - -为什么不直接在处理通知事件的地方强制切回主线程? - -不推荐。假如子线程发送多个通知,注册多个不同的观察者,那你是否要在每一个通知处理的地方都去切主线程,不够收口 - -> For example, if an object running in a background thread is listening for notifications from the user interface, such as a window closing, you would like to receive the notifications in the background thread instead of the main thread. In these cases, you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread. - -这里谈到重定向。一种重定向的实现思路是自定义一个通知队列(注意,不是NSNotificationQueue 对象,而是一个数组),让这个队列去维护那些我们需要重定向的Notification。我们仍然是像平常一样去注册一个通知的观察者,当 Notification 来了时,判断 postNotification 的线程是不是所期望的线程,如果不是,则将这个 Notification 存储到我们的队列中,并发送一个信号 signal 到期望的线程中,来告诉这个线程需要处理一个Notification。指定的线程在收到信号后,将 Notification 从队列中移除,并进行处理。 - -官方 Demo 如下 - -```objectivec -@interface ViewController () -@property (nonatomic) NSMutableArray *notifications; // 通知队列 -@property (nonatomic) NSThread *notificationThread; // 期望线程 -@property (nonatomic) NSLock *notificationLock; // 用于对通知队列加锁的锁对象,避免线程冲突 -@property (nonatomic) NSMachPort *notificationPort; // 用于向期望线程发送信号的通信端口 - -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - NSLog(@"current thread = %@", [NSThread currentThread]); - - // 初始化 - self.notifications = [[NSMutableArray alloc] init]; - self.notificationLock = [[NSLock alloc] init]; - - self.notificationThread = [NSThread currentThread]; - self.notificationPort = [[NSMachPort alloc] init]; - self.notificationPort.delegate = self; - - // 往当前线程的run loop添加端口源 - // 当Mach消息到达而接收线程的run loop没有运行时,则内核会保存这条消息,直到下一次进入run loop - [[NSRunLoop currentRunLoop] addPort:self.notificationPort - forMode:(__bridge NSString *)kCFRunLoopCommonModes]; - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(processNotification:) name:@"TestNotification" object:nil]; - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - - [[NSNotificationCenter defaultCenter] postNotificationName:TEST_NOTIFICATION object:nil userInfo:nil]; - - }); -} - -- (void)handleMachMessage:(void *)msg { - [self.notificationLock lock]; - while ([self.notifications count]) { - NSNotification *notification = [self.notifications objectAtIndex:0]; - [self.notifications removeObjectAtIndex:0]; - [self.notificationLock unlock]; - [self processNotification:notification]; - [self.notificationLock lock]; - }; - [self.notificationLock unlock]; -} - -- (void)processNotification:(NSNotification *)notification { - if ([NSThread currentThread] != _notificationThread) { - // Forward the notification to the correct thread. - [self.notificationLock lock]; - [self.notifications addObject:notification]; - [self.notificationLock unlock]; - [self.notificationPort sendBeforeDate:[NSDate date] - components:nil - from:nil - reserved:0]; - } - else { - // Process the notification here; - NSLog(@"current thread = %@", [NSThread currentThread]); - NSLog(@"process notification"); - } -} -@end -``` - -这种方式先将当前线程存储下来,在收到通知的时候去遍历当前数组(数组代替队列),判断当前线程是不是目标线程,不是则将对头元素移动到对尾。 - -## FAQ - -### 通知的发送是同步还是异步? - -通过 postNotification 源码可以看到,通知的发送是同步的,且在同一个线程中 - -### 页面销毁时,不移除通知会 crash 吗? - -通过这段文档,我们可以看出 - -- 使用 `addObserverForName:object:queue:usingBlock` 必须自己手动移除 -- 使用 `addObserver:selector:name:object:` ios9后系统会自动移除 - -如何自动移除 -ios9以后,系统使用weak指针修饰observe,当observe被释放后,再次发送消息给nil发送不会引起崩溃,并且根据描述中提到,系统会下次发送通知时,移除这些oboserve为nil的观察者 - -### 多次添加同一个通知的观察者会出现什么问题?多次移除同一个通知会有问题吗? - -查看 addObserver 源码会发现,针对同一个 NSNotificationName 进行多次添加,系统并不会过滤,假设有 object,则会维护 NAMED MapTable,key 为 NSNotificationName,value 为子 MapTable,子 MapTable 中 object 为 key,value 为 observer。所以多次添加则会造成当 postNotification 的时候会有多次响应。 - -查看 removeObserver 源码发现,移除都会针对 NSNotificationName 进行操作,从 NAMED MapTable 中,以 NSNofiticationName 为 key,获取 value 为子 MapTable 。子 MapTable 根据 object 为 key,获取对应的链表,然后根据参数 observer 移除链表中所有 observer 都为传递的 observer 的节点。所以多次调用不会存在问题。 diff --git a/Chapter1 - iOS/1.105.md b/Chapter1 - iOS/1.105.md deleted file mode 100644 index 60da1dc..0000000 --- a/Chapter1 - iOS/1.105.md +++ /dev/null @@ -1,538 +0,0 @@ -# iOS 界面渲染流程 - -> 下面几个问题你熟悉吗? -> -> - 为什么调用 `[UIView serNeedsDisplay]` 并没有立刻发生当前视图的绘制工作? - - - -## 视图显示原理 - -为什么调用 `[UIView setNeedsDisplay]` 并没有立刻发生当前视图的绘制工作? - -UIView 绘制流程。 - - - - - -当调用 UIView `[UIView setNeedsDisplay]` 方法时,系统会立刻调用其 Layer 的同名方法 `[view.layer setNeedsDisplay]` 方法,之后相当于给当前 Layer 打上一个脏标记,之后会在当前 RunLoop 快要结束的时候才会调用 Layer 的 `[CALayer display]` 方法。然后进入当前 UIView 真正的绘制流程中。 - -其次,会判断 CALayer 的代理,有没有实现 `displayLayer:` 方法 - -- 如果没有实现,则进入系统的绘制流程:比如:创建绘制上下文、调用 `drawInContext:`、生成内容并赋值给 `contents` -- 如果实现了,则可能是异步绘制或者自定义渲染的实现。是**代理自定义绘制的入口**。代理可以在这个方法里直接设置`layer.contents`(比如异步绘制生成 UIImage 后赋值给`contents`),完全接管 layer 的内容渲染 - - - -Demo1: - -自定义 View,不实现 `displayInContext` 方法 - -```objective-c -#import - -@interface CustomDrawView : UIView -@end - -@implementation CustomDrawView - -// 重写drawRect: —— 系统绘制流程的上层入口 -- (void)drawRect:(CGRect)rect { - // 1. 系统自动创建绘制上下文,这里可以直接获取 - CGContextRef context = UIGraphicsGetCurrentContext(); - - // 2. 绘制操作(对应系统流程的「调用drawInContext:」阶段) - // 设置填充色为红色 - CGContextSetFillColorWithColor(context, [UIColor redColor].CGColor); - // 绘制一个矩形 - CGContextFillRect(context, CGRectMake(20, 20, 100, 100)); - - NSLog(@"执行drawRect: → 底层对应系统调用CALayer的drawInContext:"); -} - -// 关键:不实现 displayLayer: 代理方法 -// - (void)displayLayer:(CALayer *)layer {} // 注释掉,模拟「未实现」场景 - -@end -``` - -在 ViewController 中使用 - -```objective-c -#import "ViewController.h" -#import "CustomDrawView.h" - -@interface ViewController () -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor whiteColor]; - - // 1. 创建自定义View并添加到界面 - CustomDrawView *drawView = [[CustomDrawView alloc] initWithFrame:CGRectMake(50, 100, 150, 150)]; - drawView.backgroundColor = [UIColor lightGrayColor]; // 浅灰色背景,方便区分绘制区域 - [self.view addSubview:drawView]; - - // 2. 触发绘制(打脏标记,RunLoop阶段系统会执行layer.display) - [drawView setNeedsDisplay]; -} - -@end -``` - -结果:屏幕上会显示「浅灰色背景 + 红色矩形」;控制台打印: `执行drawRect: → 底层对应系统调用CALayer的drawInContext:`; - -分析: - -- `[drawView setNeedsDisplay]` → 内部调用 `layer.setNeedsDisplay`,给 layer 打 “脏标记” -- 当前 RunLoop 的 CATransaction 阶段,系统调用 `[layer display]` -- layer 检查代理(CustomDrawView)有没有实现代理方法 → 未实现`-(void)displayLayer:(CALayer *)layer`; -- 系统**自动创建绘制上下文** → 调用 `[layer drawInContext:]`(UIView 的`drawRect:`是对这个方法的封装,所以`drawRect:`被执行) -- 系统将绘制结果生成位图 → 赋值给 `layer.contents` -- 最终 layer 把`contents`内容渲染到屏幕 - -Demo2: - -自定义 Layer,实现 `displatLayer:` 代理方法的 Layer - -```objective-c -#import - -@interface CustomLayer : CALayer -@end - -@implementation CustomLayer - -// 重写CALayer的drawInContext: —— 系统绘制流程的核心方法 -- (void)drawInContext:(CGContextRef)ctx { - // 系统创建的上下文会传入这个方法 - NSLog(@"系统调用drawInContext: → 进入核心绘制阶段"); - - // 绘制操作:画一个蓝色圆形 - CGContextSetFillColorWithColor(ctx, [UIColor blueColor].CGColor); - CGContextFillEllipseInRect(ctx, CGRectMake(20, 20, 100, 100)); -} - -@end -``` - -在 VC 中使用 - -```objective-c -#import "ViewController.h" -#import "CustomLayer.h" - -@interface ViewController () -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor whiteColor]; - - // 1. 创建自定义Layer - CustomLayer *customLayer = [CustomLayer layer]; - customLayer.frame = CGRectMake(50, 250, 150, 150); - customLayer.backgroundColor = [UIColor lightGrayColor].CGColor; - [self.view.layer addSublayer:customLayer]; - - // 2. 触发绘制(打脏标记) - [customLayer setNeedsDisplay]; -} - -@end -``` - -结果:屏幕上显示「浅灰色背景 + 蓝色圆形」;并且输出:`系统调用drawInContext: → 进入核心绘制阶段` - -分析: - -- 调用 CALayer 的 setNeedsDisplay 方法,内部会调用 display 方法 -- 系统会将其 CALayer 打上 dirty 标记 -- RunLoop 会在一次 loop 的末尾,提交 CATranscation。然后去绘制 layer 的 displayLayer 方法 -- 判断没有实现 displayLayer 方法,然后自动创建渲染上下文。 -- 然后调用 `drawInContext:(CGContextRef)ctx` ,方法的 ctx 参数就是系统自动创建的上下文对象 -- 该方法内创建的渲染内容,最后会合成一张 bitmap,最后交给 layer.contents 属性 -- 屏幕渲染 contents 内容 - - - -下面来个 Demo 展示下简单的异步绘制一个 String。 - - - - - - - -接下来看看系统的绘制实现流程: - - - -如何实现异步绘制? - -`[layer.delegate displayPlayer:]` - -- 代理负责生成对应的 bitmap -- 设置该 bitmap 作为 layer.contents 属性的值 - - - - - - - - - - - - - - - -## 渲染机制 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RenderStructure.png) - -iOS 渲染框架可以分为4层,顶层是 UIKit,包括图形界面的高级 API 和常用的各种 UI 控件。UIKit 下层是 Core Animation,不要被名字误解了,它不光是处理动画相关,也在做图形渲染相关的事情(比如 UIView 的 CALayer 就处于 Core Animation 中)。Core Animation 之下就是由 OpenGL ES 和 CoreGraphics 组成的图形渲染层,OpenGL ES 主要操作 GPU 进行图形渲染,CoreGraphics 主要操作 CPU 进行图形渲染。上面3层都属于渲染图形软件层,再下层就是图形显示硬件层。 - -iOS 图形界面的显示是一个复杂的流程,一部分数据通过 Core Graphics、Core Image 有 CPU 预处理,最终通过 OpenGL ES 将数据传输给 GPU,最终显示到屏幕上。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RenderPipeline.png) - -- Core Animation 提交会话(事务),包括自己和子树(view hierarchy) 的布局状态 - -- Render Server 解析所提交的子树状态,生成绘制指令 - -- GPU 执行绘制指令 - -- 显示器显示渲染后的数据 - -## Core Animation - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/APM-CoreAnimationPipeline.png) - -可以看到 Core Animation pipeline 由4部分组成:Application 层的 Core Animation 部分、Render Server 中的 Core Animation 部分、GPU 渲染、显示器显示。 - -### Application 层 Core Animation 部分 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationCommit.png) - -- 布局(Layout):`layoutSubviews`、`addSubview`,这里通常是 CPU、IO 繁忙 - -- 显示(Display):调用 view 重写的 `drawRect` 方法,或者绘制字符串。这里主要是 CPU 繁忙、消费较多内存。每个 UIView 都有 CALayer,同时图层又一个像素存储控件,存储视图,调用 `setNeedsDisplay` 仅会设置图层为 dirty。当渲染系统准备就绪,调用视图的 `display` 方法,同时装配像素存储空间,建立一个 Core Graphics 上下文(CGContextRef),将上下文 push 进上下文堆栈,绘图程序进入对应的内存存储空间。 - -- 准备(Prepare):图片解码、图片格式转换。GPU 不支持某些图片格式,尽量使用 GPU 能支持的图片格式 - -- 提交(Commit):打包 layers 并发送给 Render Server,递归提交子树的 layers。如果子树层级较多(复杂),则对性能造成影响 - -### Render Server 中 Core Animation 部分 - -Render Server 是一个独立的渲染进程,当收到来自 Application 的 (IPC) 事务时,首先解析 layer 层级关系,然后 Decode。最后执行 Draw Calls(执行对应的 OpenGL ES 命令) - -### GPU 渲染 - -- OpenGL ES 的 command buffer 进行定点变换,三角形拼接、光栅话变为 parameter buffer - -- parameter buffer 进行像素变化,testing、blending 生成 frame buffe - -### 显示器显示 - -视频控制器从 frame buffer 中读取数据显示在显示屏上。 - -## UIView 绘制流程 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIRenderPipeline.png) - -- 每个 UIView 都有一个 CALayer,layer 属性都有 contents,contents 其实是一块缓存,叫做 backing store - -- 当 UIView 被绘制时,CPU 执行 drawRect 方法,通过 context 将数据写入 backing store 中(位图 bitmap) - -- 当 backing store 写完后,通过 Render Server 交给 GPU 去渲染,最后显示到屏幕上 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIViewRenderPipeline.png) - -- 调用 `[UIView setNeedsDisplay]` 方法时,并没有立即执行绘制工作,而是马上调用 `[view.layer setNeedsDisplay]` 方法,给当前 layer 打上标记 - -- 在当前 RunLoop 快要结束的时候调用 layer 的 display 方法,来进入到当前视图真正的绘制流程 - -- 在 layer 的 display 方法内部,系统会判断 layer 的 layer.delegate 是否实现了 `displayLayer` 方法 - - - 如果没有,则执行系统的绘制流程 - - - 如果实现了,则会进入异步绘制流程 - -- 最后把绘制完的 backing store 提交给 GPU - -### 系统绘制流程 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSRenderProcess.png) - -- 首先 CALayer 内部会创建一个 CGContextRef,在 drwaRect 方法中,可以通过上下文堆栈取出 context,拿到当前视图渲染上下文也就是 backing store - -- 然后 layer 会判断是否存在代理,若没有,则调用 CALayer 的 drawInContext - -- 如果存在代理,则调用代理方法。然后做当前视图的绘制工作,然后调用 view 的 drawRect 方法 - -- 最后由 CALayer 上传对应的 backing store(可以理解为位图)提交给 GPU。 - -### 异步绘制流程 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSAsyncRender.png) - -- 如果 layer 有代理对象,且代理对象实现了代理方法,则可以进入异步绘制流程 - -- 异步绘制流程中主要生成对应的 bitmap。目的是最后一步,需要将 bitmap 设置为 layer.contents 的值 - - - 左侧是主队列,右侧是全局并发队列 - - - 调用了setNeedsDiaplay 方法后,在当前 Runloop 将要结束的时候,会有系统调用视图所对应 layer 的 display 方法 - - - 通过在子线程中去做位图的绘制,此时主线程可以去做些其他的工作。在子线程中:主要通过 CGBitmapContextCreate 方法,来创建一个位图的上下文、通过CoreGraphic API,绘制 UI、通过 CGBitmapContextCreatImage 方法,根据所绘制的上下文,生成一张 CGImage 图片 - - - 然后再回到主队列中,提交这个位图,设置给 CALayer 的 contents 属性 - -## 图片加载库都做了什么事 - -众所周知,iOS应用的渲染模式,是完全基于Core Animation和CALayer的(macOS上可选,另说)。因此,当一个UIImageView需要把图片呈现到设备的屏幕上时候,其实它的Pipeline是这样的: - -1. 一次Runloop完结 -> -2. Core Animation提交渲染树CA::render::commit -> -3. 遍历所有Layer的contents -> -4. UIImageView的contents是CGImage -> -5. 拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上 -> -6. Surface(Metal或者OpenGL ES)渲染到硬件管线上 - -这个流程看起来没有什么问题,但是注意,Core Animation库自身,虽然支持异步线程渲染(在macOS上可以手动开启),但是UIKit的这套内建的pipeline,全部都是发生在主线程的。 - -因此,当一个CGImage,是采取了惰性解码(通过Image/IO生成出来的),那么将会在主线程触发先前提到的惰性解码callback(实际上Core Animation的调用,触发了一个`CGDataProviderRetainBytePtr`),这时候Image/IO的具体解码器,会根据先前的图像元信息,去分配内存,创建Bitmap Buffer,这一步骤也发生在主线程。 - -这个流程带来的问题在于,主线程过多的频繁操作,会造成渲染帧率的下降。实验可以看出,通过原生这一套流程,对于一个1000*1000的PNG图片,第一次滚动帧率大概会降低5-6帧(iPhone 5S上当年有人的测试)。后续帧率不受影响,因为是惰性解码,解码完成后的Bitmap Buffer会复用。 - -所以,最早不知是哪个团队的人(可能是[FastImageCache](https://github.com/path/FastImageCache),不确定)发现,并提出了另一种方案:通过预先调用获取Bitmap,强制Image/IO产生的CGImage解码,这样到最终渲染的时候,主线程就不会触发任何额外操作,带来明显的帧率提升。后面的一系列图片库,都互相效仿,来解决这个问题。 - -具体到解决方案上,目前主流的方式,是通过CGContext开一个额外的画布,然后通过`CGContextDrawImage`来画一遍原始的空壳CGImage,由于在`CGContextDrawImage`的执行中,会触发到`CGDataProviderRetainBytePtr`,因此这时候Image/IO就会立即解码并分配Bitmap内存。得到的产物用来真正产出一个CGImage-based的UIImage,交由UIImageView渲染。 - -## ForceDecode的优缺点 - -上面解释了ForceDecode具体解决的问题,当然,这个方案肯定存在一定的问题,不然苹果研发团队早已经改变了这套Pipeline流程了 - -优点:可以提升,图像第一次渲染到屏幕上时候的性能和滚动帧率 - -缺点:提前解码会立即分配Bitmap Buffer的内存,增加了内存压力。举例子对于一张大图(2048*2048像素,32位色)来说,就会立即分配16MB(2048 * 2048 * 4 Bytes)的内存。 - -由此可见,这是一个拿空间换时间的策略。但是实际上,iOS设备早期的内存都是非常有限的,UIKit整套渲染机制很多地方采取的都是时间换空间,因此最终苹果没有使用这套Pipeline,而是依赖于高性能的硬件解码器+其他优化,来保证内存开销稳定。当然,作为图片库和开发者,这就属于仁者见仁的策略了。如大量小图渲染的时候,开启Force Decode能明显提升帧率,同时内存开销也比较稳定。 - - - - - -## iOS 图片解压缩到渲染过程 - -- 假设我们使用 `+imageWithContentsOfFile:` 方法从磁盘中加载一张图片,这个时候的图片并没有解压缩 - -- 然后将生成的 `UIImage` 赋值给 `UIImageView` -- 接着一个隐式的 `CATransaction` 捕获到了 `UIImageView` 图层树的变化 -- 在主线程的下一个 `runloop` 到来时,`Core Animation` 提交了这个隐式的 `transaction` ,这个过程可能会对图片进行 `copy` 操作,而受图片是否字节对齐等因素的影响,这个 `copy` 操作可能会涉及以下部分或全部步骤 - - 分配内存缓冲区用于管理文件 IO 和解压缩操作 - - 将文件数据从磁盘读到内存中 - - 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作 - - 最后 `Core Animation` 中 `CALayer` 使用未压缩的位图数据渲染 `UIImageView` 的图层 - - CPU 计算好图片的 Frame,对图片解压之后.就会交给 GPU 来做图片渲染 -- 渲染流程 - - GPU 获取图片的坐标 - - 将坐标交给顶点着色器(顶点计算) - - 将图片光栅化(获取图片对应屏幕上的像素点) - - 片元着色器计算(计算每个像素点的最终显示颜色值) - - 从帧缓存区中渲染到屏幕上 - -我们提到了图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出 - - - -## 为什么要解压缩图片 - -既然图片的解压缩很耗费 CPU 时间,那么为什么还要对图片进行解压缩?是否可以不解压缩直接显示图片?不能 - - - -其实位图,就是一个像素数组,数组中的每个像素就代表图片中的一个点。平时遇到的 png、jpeg 就是位图。 - - - -```objective-c -UIImage *image = [UIImage imageNamed:@"text.png"]; -CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage)); -``` - -rawData 就是图片原始数据。 - -jpg、png 都是一种压缩格式,只不过 png 是无损压缩,支持 alpha 通道。而 jpeg 是有损压缩,可以指定0~100%压缩比。iOS 提供2个函数来生成 png、jpeg 图片。 - -```objective-c -// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format -UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image); - -// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least) -UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality); -``` - -所以,在磁盘的图片渲染到屏幕之前,必须先得到图片的原始像素数据,才可以执行后续的操作。所以必须先解压缩。 - - - -## 图片解压缩原理 - -既然图片的解压缩不可避免,而我们也不想让它在主线程执行,影响 App 性能,那么是否有比较好的解决方案呢? - -我们前面已经提到了,当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在**子线程提前对图片进行强制解压缩**。 - -而强制解压缩的原理就是**对图片进行重新绘制,得到一张新的解压缩后的位图**。其中,用到的最核心的函数是 `CGBitmapContextCreate` - -```objective-c -CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data, - size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow, - CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo) - CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0); -``` - -参数说明: - -- data:如果不为 `NULL` ,那么它应该指向一块大小至少为 `bytesPerRow * height` 字节的内存;如果 为 `NULL`,那么系统就会为我们自动分配和释放所需的内存,所以一般指定 `NULL` 即可; -- width 和 height :位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可; -- bitsPerComponent:像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可; -- bytesPerRow :位图的每一行使用的字节数,大小至少为 `width * bytes per pixel` 字节。当我们指定 0/NULL 时,系统不仅会为我们自动计算,而且还会进行 `cache line alignment` 的优化 -- space :就是我们前面提到的颜色空间,一般使用 RGB 即可; -- bitmapInfo :位图的布局信息.`kCGImageAlphaPremultipliedFirst` - - - -参考 YYImage/SDWebImage 都有图片解压缩的实现 - -```objective-c -CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) { - ... - - if (decodeForDisplay) { // decode with redraw (may lose some precision) - CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask; - - BOOL hasAlpha = NO; - if (alphaInfo == kCGImageAlphaPremultipliedLast || - alphaInfo == kCGImageAlphaPremultipliedFirst || - alphaInfo == kCGImageAlphaLast || - alphaInfo == kCGImageAlphaFirst) { - hasAlpha = YES; - } - - // BGRA8888 (premultiplied) or BGRX8888 - // same as UIGraphicsBeginImageContext() and -[UIView drawRect:] - CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host; - bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst; - - CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo); - if (!context) return NULL; - - CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode - CGImageRef newImage = CGBitmapContextCreateImage(context); - CFRelease(context); - - return newImage; - } else { - ... - } -} -``` - -自己也可以实现 - -```objective-c -- (void)setImage { - SP_BEGIN_LOG(custome, gl_log, imageSet); - [self decodeImage:[UIImage imageNamed:@"peacock"] completion:^(UIImage *image) { - self.imageView.image = image; - SP_END_LOG(imageSet); - }]; -} - -- (void)decodeImage:(UIImage *)image completion:(void(^)(UIImage *image))completionHandler { - if (!image) return; - //在子线程执行解码操作 - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - CGImageRef imageRef = image.CGImage; - //获取像素宽和像素高 - size_t width = CGImageGetWidth(imageRef); - size_t height = CGImageGetHeight(imageRef); - if (width == 0 || height == 0) return ; - CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask; - BOOL hasAlpha = NO; - //判断颜色是否含有alpha通道 - if (alphaInfo == kCGImageAlphaPremultipliedLast || - alphaInfo == kCGImageAlphaPremultipliedFirst || - alphaInfo == kCGImageAlphaLast || - alphaInfo == kCGImageAlphaFirst) { - hasAlpha = YES; - } - //在iOS中,使用的是小端模式,在mac中使用的是大端模式,为了兼容,我们使用kCGBitmapByteOrder32Host,32位字节顺序,该宏在不同的平台上面会自动组装换成不同的模式。 - /* - #ifdef __BIG_ENDIAN__ - # define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big - # define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big - #else //Little endian. - # define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little - # define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little - #endif - */ - - CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host; - //根据是否含有alpha通道,如果有则使用kCGImageAlphaPremultipliedFirst,ARGB否则使用kCGImageAlphaNoneSkipFirst,RGB - bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst; - //创建一个位图上下文 - CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo); - if (!context) return; - //将原始图片绘制到上下文当中 - CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); - //创建一张新的解压后的位图 - CGImageRef newImage = CGBitmapContextCreateImage(context); - CFRelease(context); - UIImage *originImage =[UIImage imageWithCGImage:newImage scale:[UIScreen mainScreen].scale orientation:image.imageOrientation]; - //回到主线程回调 - dispatch_async(dispatch_get_main_queue(), ^{ - !completionHandler ?: completionHandler(originImage); - }); - }); -} -``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.106.md b/Chapter1 - iOS/1.106.md deleted file mode 100644 index 23df224..0000000 --- a/Chapter1 - iOS/1.106.md +++ /dev/null @@ -1,256 +0,0 @@ -# NSUserDefault 底层原理探究 - -最近看到字节一篇文章 [卡死崩溃监控原理及最佳实践](https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488080&idx=1&sn=39d0386b97b9ac06c6af1966f48387fc&chksm=e9d0d9b2dea750a4a7d21fd383aefa014d63f0dc79f2e3a13c97ad52bba1578dca8b50d6a40a&scene=21&cur_album_id=1590407423234719749#wechat_redirect) ,里面写到 NSUserDefault 底层实现存在直接或者间接跨进程通信,在主线程同步调用容易卡死。以前只是用过,但是没有仔细研究,看到这里就有必要研究下底层实现啦。 - -## 回顾 - -NSUserDefault 不安全。因为数据自动保存在沙盒的 `Libarary/Preferences` 目录下。 - -数据按照 plist (property list)格式存储在沙盒中。当攻击者破解 App 就可轻而易举拿到里面的数据(可能有些人会将 token、password、secret 明文存在里面) - -另外 App 卸载重装会导致之前存储的数据丢失。这里推荐使用 Keychain。Keychain 是 iOS 提供的安全存储数据的方案,用于存储一些账号、密码等敏感信息。数据也不在沙盒中,即使删除 App,重新安装则可以继续从 Keychain 中获取数据。 - -NSUserDefaults 的原理和 plist 序列化不同。 - -iOS 上应用必须被沙盒化,各个不同 App 之间的 Defaults Domain 不一样,通常是 Bundle Identifier,或者是 App Group 中约定的 Suite Name。当使用 NSUserDefaults 的时候会按照下面 Domain 顺序: - -- NSArgumentDomain - -- 应用的 Bundle Identifier - -- NSGlobalDomain - -- 系统语言的标识符 - -- NSRegistrationDomain - -任何应用,通过 NSUserDefaults 访问值都需要经历从上到下搜索各个 Domain 的过程,期间如何某个 Domain 有这个值,就会取出其对应的值。如果全部访问完还是没找到,则返回 undefined result。 - -## 如何保证多线程安全 - -通过设置符号断点可以看出, NSUserDefaults 内部在读写时会通过 `os_unfair_lock` 加锁进行多线程安全保护。 - -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/NSUserDfault-lock.png) - -## 存储性能如何 - -查看 GUN 源码 - -```c -- (BOOL) synchronize -{ - BOOL isLocked = NO; - BOOL wasLocked = NO; - BOOL shouldLock = NO; - BOOL defaultsChanged = NO; - BOOL hasLocalChanges = NO; - // 合法性校验 - if ([removed count] || [added count] || [modified count]) - { - hasLocalChanges = YES; - } - if (YES == hasLocalChanges && NO == [owner _readOnly]) - { - shouldLock = YES; - } - if (YES == shouldLock && YES == [owner _lockDefaultsFile: &wasLocked]) - { - isLocked = YES; - } - NS_DURING - { - NSFileManager *mgr; - NSMutableDictionary *disk; - // 利用 NSFileManager 读取文件 - mgr = [NSFileManager defaultManager]; - disk = nil; - if (YES == [mgr isReadableFileAtPath: path]) - { - NSData *data; - // 文件存在,则将里面的内容读取出来 - data = [NSData dataWithContentsOfFile: path]; - if (nil != data) - { - id o; - // 将文件数据利用 NSPropertyListSerialization 序列化为 NSDictionary 信息 - o = [NSPropertyListSerialization - propertyListWithData: data - options: NSPropertyListImmutable - format: 0 - error: 0]; - // 将之前已经持久化好的本地文件数据写入到 disk 可变字典 - if ([o isKindOfClass: [NSDictionary class]]) - { - disk = AUTORELEASE([o mutableCopy]); - } - } - } - if (nil == disk) - { - disk = [NSMutableDictionary dictionary]; - } - loaded = YES; - // 判断是否有新数据 - if (NO == [contents isEqual: disk]) - { - defaultsChanged = YES; - if (YES == hasLocalChanges) - { - NSEnumerator *e; - NSString *k; - // 从标记为待删除的数据中遍历,删除 disk 可变字典中的数据 - e = [removed objectEnumerator]; - while (nil != (k = [e nextObject])) - { - [disk removeObjectForKey: k]; - } - // 遍历需要添加的数据,添加到 disk 中 - e = [added objectEnumerator]; - while (nil != (k = [e nextObject])) - { - [disk setObject: [contents objectForKey: k] forKey: k]; - } - // 遍历需要修改的数据,添加到 disk 中 - e = [modified objectEnumerator]; - while (nil != (k = [e nextObject])) - { - [disk setObject: [contents objectForKey: k] forKey: k]; - } - } - // 将 disk 数据拷贝到 contents - ASSIGN(contents, disk); - } - if (YES == hasLocalChanges) - { - BOOL written = NO; - - if (NO == [owner _readOnly]) - { - if (YES == isLocked) - { - // 判断 contents 字典是否有值,没有则给指定路径写入 nil - if (0 == [contents count]) - { - /* Remove empty defaults dictionary. - */ - written = writeDictionary(nil, path); - } - else - { - /* Write dictionary to file. - */ - // 判断 contents 字典有值,则将 contents 给指定路径写入 - written = writeDictionary(contents, path); - } - } - } - // 写入成功删除内存缓存 - if (YES == written) - { - [added removeAllObjects]; - [removed removeAllObjects]; - [modified removeAllObjects]; - } - } - if (YES == isLocked && NO == wasLocked) - { - isLocked = NO; - [owner _unlockDefaultsFile]; - } - } - NS_HANDLER - { - fprintf(stderr, "problem synchronising defaults domain '%s': %s\n", - [name UTF8String], [[localException description] UTF8String]); - if (YES == isLocked && NO == wasLocked) - { - [owner _unlockDefaultsFile]; - } - } - NS_ENDHANDLER - return defaultsChanged; -} -``` - -会发现:性能也就那么回事,底层实现通过内存缓存 `contents` 来缓存数据写入文件。 - -## NSUserDefaults 为什么触发 XPC 通信 - -通过对代码添加符号断点 `xpc_connection_send_message_with_reply_sync` 可以看到下面的堆栈 - -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/NSUserDefault-XPC.png) - -执行 `[NSUserDefaults standardUserDefaults];` 可以发现是调用了 XPC,创建名称为 “com.apple.cfprefsd.daemon” 的 XPC Connection,且会发送一个 `xpc_connection_send_message_with_reply_sync` 的消息。 - -执行 `[defaults setObject:@"杭城小刘" forKey:@"name"];` 会调用 `xpc_connection_send_message_with_reply_sync`,发送一个消息。 - -通过 Demo 得出结论: - -- `NSUserDefaults` 调用 `set...forKey:`, 会触发 XPC 通信,调用 `...ForKey:` 、`synchronized` 不会调用 XPC 通信 - -- 为了提高性能,尽量减少调用 `set...forKey:` - -## 异步持久化 - -XPC 该`xpc_connection_send_message_with_reply_sync` API 因为 XPC 同步通信,所以在主线程容易存在卡死。那么有没有异步调用的能力? - -发现2个 API 可以用于异步发送 - -- xpc_connection_send_message - -- xpc_connection_send_message_with_reply - -所以想异步持久化,则需要自定义 XPC Connection,然后将数据用 xpc_dictionary_create 创造出的 Dictionary 去接,最后调用 `xpc_connection_send_message_with_reply` 去持久化数据 - -```objectivec - xpc_connection_t conn = xpc_connection_create_mach_service("com.apple.cfprefsd.daemon", NULL, XPC_CONNECTION_MACH_SERVICE_PRIVILEGED); -#pragma mark - 开始构建信息 -// (lldb) po $rsi -// { count = 8, transaction: 0, voucher = 0x0, contents = -// "CFPreferencesHostBundleIdentifier" => { length = 9, contents = "test.demo" } -// "CFPreferencesUser" => { length = 25, contents = "kCFPreferencesCurrentUser" } -// "CFPreferencesOperation" => : 1 -// "Value" => { length = 16, contents = "ÈÖ∑ÈÖ∑ÁöÑÂìÄÊÆø2" } -// "Key" => { length = 3, contents = "key" } -// "CFPreferencesContainer" => { length = 169, contents = "/private/var/mobile/Containers/Data/Application/0C224166-1674-4D36-9CDB-9FCDB633C7E3/" } -// "CFPreferencesCurrentApplicationDomain" => : true -// "CFPreferencesDomain" => { length = 9, contents = "test.demo" } -// }> - - xpc_object_t hello = xpc_dictionary_create(NULL, NULL, 0); - - // 注释1:test.demo 是 bundleid。测试代码时,需要根据需要修改 - xpc_dictionary_set_string(hello, "CFPreferencesHostBundleIdentifier", "test.demo"); - xpc_dictionary_set_string(hello, "CFPreferencesUser", "kCFPreferencesCurrentUser"); - // 注释2:存储值 - xpc_dictionary_set_int64(hello, "CFPreferencesOperation", 1); - // 注释3:存储的内容 - xpc_dictionary_set_string(hello, "Value", "this is a test"); - xpc_dictionary_set_string(hello, "Key", "key"); - - // 注释4:存储的位置 - CFURLRef url = CFCopyHomeDirectoryURL(); - const char *container = CFStringGetCStringPtr(CFURLCopyPath(url), kCFStringEncodingASCII); - xpc_dictionary_set_string(hello, "CFPreferencesContainer", container); - xpc_dictionary_set_bool(hello, "CFPreferencesCurrentApplicationDomain", true); - xpc_dictionary_set_string(hello, "CFPreferencesDomain", "test.demo"); - - - xpc_connection_set_event_handler(conn, ^(xpc_object_t object) { - printf("xpc_connection_set_event_handler:收到返回消息: %sn", xpc_copy_description(object)); - }); - xpc_connection_resume(conn); -#pragma mark - 异步方案一 (没有回应) -// xpc_connection_send_message(conn, hello); -#pragma mark - 异步方案二 (有回应) - xpc_connection_send_message_with_reply(conn, hello, NULL, ^(xpc_object_t _Nonnull object) { - printf("xpc_connection_send_message_with_reply:收到返回消息: %sn", xpc_copy_description(object)); - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; // mach_msg_trap - printf("从 NSUserDefaults 读取:%s\n", [defaults stringForKey:@"key"].UTF8String); - }); -#pragma mark - 同步方案 -// xpc_object_t obj = xpc_connection_send_message_with_reply_sync(conn, hello); -// NSLog(@"xpc_connection_send_message_with_reply_sync:收到返回消息:%s", xpc_copy_description(obj)); - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; // mach_msg_trap - printf("从 NSUserDefaults 读取:%s\n", [defaults stringForKey:@"key"].UTF8String); -``` diff --git a/Chapter1 - iOS/1.107.md b/Chapter1 - iOS/1.107.md deleted file mode 100644 index b675da4..0000000 --- a/Chapter1 - iOS/1.107.md +++ /dev/null @@ -1 +0,0 @@ -# IM技术 \ No newline at end of file diff --git a/Chapter1 - iOS/1.108.md b/Chapter1 - iOS/1.108.md deleted file mode 100644 index 41ea0ae..0000000 --- a/Chapter1 - iOS/1.108.md +++ /dev/null @@ -1,829 +0,0 @@ -# 精准测试最佳实践 - - - -## 背景 - -下面这张图是22年整理的我们移动中台对于质量的一些把控手段,也是一个有效的 checklist。对于一个业务项目或者技术项目来说,QA 给的测试用例全部通过不能说明代码没有问题。单测覆盖率的最佳实践是针对于基础 SDK,对于业务侧的代码来说,由于经常变化,所以还是以人工测试为主,一些核心的不变的核心链路,沉淀出 UI 自动化用例,每周迭代的时候,交付测试后,开始 UI 自动化回归。 - - - -但,这些回归还是不能 cover 所有问题,我们需要为我们写的每行代码买单,如何衡量每行代码的效果呢?这就是精准测试要做的事情。 - -iOS 工程来说,跨端项目暂时不在本文范畴,Native 侧主要是 OC 和 Swift 为主。本文将会从 OC/ Swift 2个技术栈展开说说如何获取精准测试覆盖率报告。 - - - -## Objective-C 代码覆盖率 - -### 理论分析 - -#### 覆盖率检测原理 - -统计代码覆盖率的实现抓手就是对代码进行插桩,OC 是 C 语言的一个超集,而 LLVM 诞生自 GCC,我们可以使用 GCC 的插桩器对 OC 代码进行编译插桩,具体流程如下: - - - -在编译阶段指定 `-fprofile-arcs` `-ftest-coverage` 等测试选项,LLVM 会做这么几件事: - -- 在输出目标文件中留出一段存储区保存统计数据 - - 打开一个插桩工程,查看 MachO 文件可以印证。可以看到 `__llvm_prf_cnts`、`__llvm_prf_data` 、`__llvm_prf_names`、`__llvm_prf_vnds`、`__llvm_covfun`、`__llvm_covmap` 等 section 就是存储插桩信息的空间。 - - - -- 在源代码中为每个 Basic Block 进行插桩(Basic Block 下文会讲) - - 可以看到 `showAssets` 方法内存在一个 if,即2个 Basic Block,所以通过汇编查看的话,存在2个插桩点。 - - - -- 产生 `.gcno` 文件,它包含 Basic Block 和相应的源码行号信息 - -- 在最终可执行文件中,进入 main 函数之前调用 `gcov_init` 内部函数初始化统计数据区,并将 `gcov_init` 内部函数注册为`exit_handers`,用户代码调用 exit 正常结束时,`gcov_exit` 函数得到调用,并继续调用 `__gcov_flush` 输出统计数据到 `.gcda` 文件。 - - - -生成覆盖率报告,首先需要在 Xcode 中配置编译选项,编译后会为每个可执行文件生成对应的 **`.gcno`** 文件;之后在代码中调用覆盖率分发函数,会生成对应的 **`.gcda`** 文件。 - -其中,`.gcno` 包含了代码计数器和源码的映射关系, `.gcda` 记录了每段代码具体的执行次数。覆盖率解析工具需要结合这两个文件给出最后的检测报表。 - - - -#### .gcno - -利用 Clang 分别生成源文件的 AST 和 IR 文件,对比发现,AST 中不存在计数指令,而 IR 中存在用来记录执行次数的代码。查看 LLVM 源码可以看到 `GCDAProfiling.c` ,该文件主要作用是:覆盖率映射关系生成源码。 - -覆盖率映射关系生成源码是 LLVM 的一个 Pass,用来向 IR 中插入计数代码并生成 .gcno 文件(关联计数指令和源文件)。 - - - - - - - -#### Basic Block - -从编译器角度出发,基本块(Basic Block,BB)是代码执行的基本单元,LLVM 基于 BB 进行覆盖率计数指令的插入。BB 特点是: - -- 只有1个入口 -- 只有1个出口 -- 只要 BB 中第一条指令被执行,那么 BB 中所有指令都会按顺序执行1次 - -1个 BB 中,不包含其他的 jump/return/if/switch 等流程控制语句,也就是一个最小可执行单元。 - -基本块 BB 是程序中一个顺序执行的**语句序列**,同一个 BB 中所有语句的执行次数一定相同,一般由多个顺序执行语句后跟一个跳转语句组成。 - -从一个 BB 到另一个 BB 的跳转称为一个 ARC。 - - - -#### GCOV 工作原理 - -如果跳转语句是有条件的,就产生了一个分支(ARC),该基本块就有2个基本块作为目的地。如果把每个基本块当作一个节点,那么一个函数中 的所有基本块就构成了一个有向图, 称之为基本块图. 只要知道 BB 或 ARC 的执行次数就可以推算出所有 的 BB 和所有的 ARC 的执行次数. GCOV 根据 BB 和 ARC 的统计情况来统计各 BB 内各行代码执行情况, 从而计算整个程序的覆盖率情况。 - - - - - - - - 也就是说插桩的数量和函数内的代码行数、函数数量都不是一一对应的关系。**插桩数量和 BB 个数一一对应**。 - -这样设计的好处是:BB 的概念存在已久,利用现有能力进行功能拓展(插桩分析覆盖率),而不是为每行原始代码都插桩,从而大大减少了可执行文件的大小并且提高了执行的速度,同时还能够精确分析到所有代码的执行情况。x - -覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 `.gcno` 中写入函数位置信息。 - -对下面方法展示控制流程图展示: - -```objective-c -- (void)showAssets { - NSLog(@"I am a rich man"); - if (self.name) { - [self.cat play]; - } else { - NSLog(@"I am nobody"); - } -} -``` - - - - - - - -#### .gcon 计数符号和文件位置关联信息 - -`.gcon` 文件存储着计数插桩位置和源文件之间的关联信息。`GCOVPass` 通过2层循环插入计数指令的同时,会将文件及 BB 信息写入 `.gcon` 文件。 - -- 创建 `.gcno` 文件,写入 Magic number(oncg + version) -- 随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数) -- 随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系) -- 写入函数中BB对应行号信息(标注基本块与源码行数关系) - - - -`.gcon` 文件由4部分组成: - -- 文件结构 -- 函数结构 -- BB 结构 -- BB 行结构 - - - - - -#### .gcda - -关于 `.gcda` 的逻辑可以查看源码的 [GCDAProfiling.c 文件](https://github.com/llvm/llvm-project/blob/main/llvm/lib/Transforms/Instrumentation/GCOVProfiling.cpp),也是覆盖率相关的核心逻辑。 - -```c++ -void GCOVProfiler::emitGlobalConstructor( - SmallVectorImpl> &CountersBySP) { - Function *WriteoutF = insertCounterWriteout(CountersBySP); - Function *ResetF = insertReset(CountersBySP); - - // Create a small bit of code that registers the "__llvm_gcov_writeout" to - // be executed at exit and the "__llvm_gcov_reset" function to be executed - // when "__gcov_flush" is called. - FunctionType *FTy = FunctionType::get(Type::getVoidTy(*Ctx), false); - Function *F = createInternalFunction(FTy, "__llvm_gcov_init", "_ZTSFvvE"); - F->addFnAttr(Attribute::NoInline); - - BasicBlock *BB = BasicBlock::Create(*Ctx, "entry", F); - IRBuilder<> Builder(BB); - - FTy = FunctionType::get(Type::getVoidTy(*Ctx), false); - auto *PFTy = PointerType::get(FTy, 0); - FTy = FunctionType::get(Builder.getVoidTy(), {PFTy, PFTy}, false); - - // Initialize the environment and register the local writeout, flush and - // reset functions. - FunctionCallee GCOVInit = M->getOrInsertFunction("llvm_gcov_init", FTy); - Builder.CreateCall(GCOVInit, {WriteoutF, ResetF}); - Builder.CreateRetVoid(); - - appendToGlobalCtors(*M, F, 0); -} -``` - -二进制代码加载时,调用了 `llvm_gcov_init(fn_ptr wfn, fn_ptr rfn)` 函数,传入了 `__llvm_gcov_writeout` 方法用于写 `.gcov` 文件,`__llvm_gcov_reset` 方法用于 reset 保存的数据。 - -然后 `emitGlobalConstructor ` 函数调用 `insertGlobalConstructorCode` 函数,后者负责插入全局构造函数所需的代码。`insertGlobalConstructorCode` 函数进一步调用 `initializeGCOVDataStructures` 函数和 `setupCodeCoverageEnvironment` 函数,分别用于初始化 `.gcov` 数据结构和设置代码覆盖率测试环境。 - -```c++ -COMPILER_RT_VISIBILITY -void llvm_gcov_init(fn_ptr wfn, fn_ptr rfn) { - static int atexit_ran = 0; - - if (wfn) - llvm_register_writeout_function(wfn); - - if (rfn) - llvm_register_reset_function(rfn); - - if (atexit_ran == 0) { - atexit_ran = 1; - - /* Make sure we write out the data and delete the data structures. */ - atexit(llvm_delete_reset_function_list); -#ifdef _WIN32 - atexit(llvm_writeout_and_clear); -#endif - } -} -``` - -代码注释是 `__gcov_flush`(LLVM 老版本的 `__gcov_flush` )已经更新为 `__gcov_dump` ,调用 `__gcov_dump` 会将覆盖率信息写入文件。 - -```c++ -void __gcov_dump(void) { - for (struct fn_node *f = writeout_fn_list.head; f; f = f->next) - f->fn(); -} -``` - -`.gcda` 文件/函数结构和 `.gcno` 基本一致,包含了弧跳变的次数和其他概要信息。利用 `gcov -f Person.gcda` 就可以可视化查看 `.gcda` 文件内容 - - - - - -Xcode 导出 `.gcda` 的时候,断点查看汇编如下 - - - - - -#### .info 文件 - -拿到 `.gcno` 和 `.gcda` 文件后,我们可以使用 LCOV 工具(基于 gcov )来生成这个源代码文件的覆盖率信息。 - -覆盖率信息 `.info` 文件包含以下内容: - -1. TN:测试用例名称 -2. SF:源码文件路径 -3. FN:函数名及行号 -4. FNDA:函数名及执行次数 -5. FNF:函数总数 -6. FNH:函数执行数 -7. DA:代码行及执行次数 -8. LF:代码总行数 -9. LH:代码执行行数 - -在增量覆盖率信息统计的步骤中,覆盖率信息文件新增了用于统计增量信息的字段: - -1. CA:差异代码行及执行次数 -2. CF:差异代码行总数 -3. CH:差异代码行执行数 - - - -#### 完整流程 - - - -- 编译前, 在编译器中加入编译器参数 `-fprofile-arcs` `-ftest-coverage` -- 源码经过编译预处理, 在生成汇编文件的阶段完成插桩,生成可执行文件,并且生成关联 BB 和跳转次数 ARC 的 `.gcno` 文件 -- 运行可执行文件,随着功能被执行,打点插桩的计数值不断更新,收集程序的执行信息 -- 生成具有 BB 和 ARC 的执行统计次数等数据的 `.gcda` 文件 -- 通过 lcov、genhtml 将代码覆盖率信息生成 html 格式的报告 - - - -### 工程实践 - -第一步,在 Xcode Build Settings 中,修改 Clang 编译参数 `Instrument Program Flow`、 `Generate Legacy Test Coverage File` 为 true,打开后即**开启插桩能力**。 - - - -第二步,为了控制代码覆盖率保存的位置和文件名,需要我们设置一下 GCC 提供的环境变量 - -- `GCOV_PREFIX` 环境变量用于指定代码覆盖率文件的存储路径 -- `GCOV_PREFIX_STRIP `环境变量用于指定在存储路径中去除的前缀部分。 - -```objective-c -NSString *covFilePath = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/coverage_files"]; -setenv("GCOV_PREFIX", [covFilePath cStringUsingEncoding: NSUTF8StringEncoding], 1); -setenv("GCOV_PREFIX_STRIP", "100", 1); -``` - -第三步,开启插桩后即拥有了原始 BB 信息,也开启了插桩。等待用户操作 App 后,即记录了 BB 执行信息,这些信息需要被写入 `.gcda` 中。早期版本是 `gcov_flush()`。可以看到 `_gcov_flush` 已经不能用了,发现官方已经是 `_gcov_dump` 。修改后编译通过。 - -```c++ -extern void __gcov_dump(void); -__gcov_dump(); -``` - - - - - -第四步,运行代码。完成测试后,我在屏幕点击事件里,将 BB 执行情况写入到 `.gcda` 中。 - - - -第五步,获取 `.gcno` 信息。编译器生成与源代码同名的 `.gcno` 文件(note file),这种文件含有重建基本块依赖图和将源代码关联至基本块及源代码行号的必要信息。 - -Xcode 选择 products,show In Finder。然后上上层的 `Intermediates.noindex` 目录存储,继续往下寻找,我个人电脑上路径为:`/Users/unix_kernel/Library/Developer/Xcode/DerivedData/CodeCoverageDemo-enpprvshxhvihgavktgzcmeoertf/Build/Intermediates.noindex/CodeCoverageDemo.build/Debug-iphonesimulator/CodeCoverageDemo.build/Objects-normal/x86_64`,存储了 `.gcno` 信息。 - - - - - - - - - -第六步,将 `.gcno` 和 `.gcda` 文件,保存到一个文件夹下 - - - - - -第七步,利用 `lcov` 指令可以将 `.gcno` 文件和 `.gcda` 文件结合生成代码覆盖率结果 info 文件 - -指令格式为:`lcov -c -d . -o CodeCoverage.info` ,其中 `.` 代表当前目录 - -`CodeCoverage.info` 文件内容大概如下(各个字段代表什么上面 info 文件这一节有说明)。 - - - - - -第八步,利用指令 `genhtml -o html CodeCoverage.info` 将 info 文件和源代码文件结合转化为可视化网页形式。 - -注意:执行 genhtml 指令必须保证和项目源代码(Xcode 项目叫 CodeCoverageDemo,源码则在 CodeCoverageDemo/CodeCoverageDemo 下)在同一文件夹下否则会报错。 - - - -访问覆盖率路径为 html 目录下,和项目同名的文件夹里面的 `index.html` - - - -第九步,通过类的列表,针对覆盖率低的文件,点进去看看,看看那些代码没有被执行。思考是什么原因造成的: - -- if...else 代码是由于测试条件不满足,测试 case 不充足,导致另一个 case 没有被覆盖?? -- 某些兜底代码太多,根本走不到??? - - - -其中:蓝色部分代码已经执行的代码,橘色代表未执行的代码 - -第十步,假设我们在另一台设备上进行了测试,对剩余的测试任务内容进行完善,这个时候该怎么处理?Demo 以针对 Person 类的覆盖率完善为例。 - -1. 在另一台测试剩余 case 的机器上,执行测试流程。得到测试结果,即 `.gcda` 文件 - -2. 新建测试数据分析文件夹 `CodeCoverageAnalysis2` - -3. 将上一步得到的 `.gcda` 文件拷贝到 ``CodeCoverageAnalysis2` 里面 - -4. 进入打包产物 App 所在文件夹,进入文件夹 `Build/Intermediates.noindex/CodeCoverageDemo.build/Debug-iphonesimulator/CodeCoverageDemo.build/Objects-normal/x86_64`,可以看到一堆类似 `AppDelegate.d`、 `AppDelegate.dia` `AppDelegate.gcno` 、`AppDelegate.o` 这样的文件。同样移动到 ``CodeCoverageAnalysis2` 里面 - - - -5. 在 `CodeCoverageAnalysis2` 目录下利用指令 `lcov -c -d . -o CodeCoverage2.info` 生成新的一份覆盖率信息 `CodeCoverage2.info` - -6. 然后利用 `locv -a` 指令合并2个 `.info` 文件。指令为 `lcov -a CodeCoverage2.info -a https://github.com/FantasticLBP/knowledge-kit/raw/master/CodeCoverageAnalysis/CodeCoverage.info -o CodeCoverageCombined.info` - -7. 然后利用 `genhtml` 生成合并后的覆盖率可视化 html 文件 `genhtml -o html CodeCoverageCombined.info` - -8. 查看分析最新的覆盖率报告 - - - - - -### 缺陷 - -Person 类的 showAssets 方法,内部有 Cat 相关逻辑,且 Cat 是 Swift 代码。为什么在代码覆盖列表上看不到 Cat? - - - -因为,clang 是一个基于 LLVM 的编译器前端,它可以编译多种编程语言,包括 C、C++、Objective-C 和 Objective-C++。然而,虽然 clang 本身基于 LLVM,但它并不是 Swift 语言的默认编译器。Swift 语言的官方编译器是 **swiftc**,是基于 LLVM 但专门为 Swift 语言设计的。 - -接下来看看 Swift 代码,如何获取代码覆盖率 - - - -### 工程化 - -工程化要解决的3个问题是: - -- 一般来说,iOS 现在采用模块化的方式:壳工程 + 各个业务域子工程 + 3方模块。可通过 ruby 脚本修改壳工程和相应的业务工程的编译配置,开启编译插桩能力。一般对于 Debug 包来说不插桩,所以需要有个配置文件,来对各个模块进行配置。 -- 单个版本不断测试,生成的代码覆盖率信息如何合并 -- 多版本增量覆盖率 -- 打包平台及其服务侧 - - - -### 模块化配置 - -对于各个模块在什么模式下插桩的配置, `CodeCoverageConfig.rb` - -```ruby -ENABLE_PROJECTS = { - "XXX/XXXPhone.xcodeproj" => "Enterprise", - "XXXHD/XXXHD.xcodeproj" => "Enterprise", - "Pods/XXXGoods.xcodeproj" => "Enterprise", - // ... -} -``` - -Ruby 脚本利用 [xcodeproj](https://github.com/CocoaPods/Xcodeproj) 对每个 target 的编译参数 `GCC_INSTRUMENT_PROGRAM_FLOW_ARCS` 、`GCC_GENERATE_TEST_COVERAGE_FILES`进行修改以开启插桩能力 - -```ruby -require 'xcodeproj' -CONFIG_DIR = Pathname.new(File.join(File.dirname(__FILE__), ".https://github.com/FantasticLBP/knowledge-kit/raw/master/../..")).realpath -CONFIG_FILE = File.join(CONFIG_DIR, "CodeCoverageConfig.rb") - -def update(args) - enable = args[0] == "true" ? "YES" : "NO" - debug = args[1] == "true" ? true : false - load "#{CONFIG_FILE}" - projects = ENABLE_PROJECTS - projects.each do | proj, conf | - proj_file = File.join(CONFIG_DIR, proj) - project = Xcodeproj::Project.open(proj_file) - project.build_configurations.each do |config| - next if debug && config.name != "Debug" - next if !debug && config.name != conf - config.build_settings['GCC_INSTRUMENT_PROGRAM_FLOW_ARCS'] = enable - config.build_settings['GCC_GENERATE_TEST_COVERAGE_FILES'] = enable - end - project.save - end -end - -update(ARGV) -``` - - - -### 单版本覆盖率 - -代码不变的情况下,发现 QA 或者开发自己测试的情况下,发现代码覆盖率不高,测试没有全面,则继续测试。这样生成多分 `.gcda` 文件, - -- 生成覆盖率:`lcov -c -d {$SOURCE} -o {$DEST_INFO}`,比如 `lcov -c -d . -o CodeCoverage2.info` -- 合并覆盖率:`lcov -a {$SOURCE_INFO_1} -a {$SOUCE_INFO_2} -o {$DEST_INFO}`,比如 `lcov -a CodeCoverage2.info -a https://github.com/FantasticLBP/knowledge-kit/raw/master/CodeCoverageAnalysis/CodeCoverage.info -o CodeCoverageCombined.info` - - - -### 多版本增量覆盖率 - -一个常见的场景是,开发同学基于业务需求 A 做完功能,QA 测试后导出覆盖率报告,发现覆盖率较低;或者 QA 提了3个测试 Bug,开发针对这2个情况,去修改了代码,重新打包让 QA 回归。这个时候 QA 不会重新点点,较好的做法是只回归遗漏或者有问题的代码。 - - - -核心思路是:基于上个版本的覆盖率数据,利用 git diff 查找出变化的部分,然后将旧版本覆盖率 `.info` 里面喝 git diff 得出的变化的部分关联,将值更新到新测试后的覆盖率 `.info` 里面。 - - - -git diff 如何解读 - - - - - -其中 - -- `index.txt` 是文件名 -- `@@ -3,6 +3,6 @@` ,- 代表删除,+ 代表增加。整体意思为从第3行开始,删除了6行,从第3行开始,增加了6行 - -所以步骤如下: - -- 解析 git diffFile: - - 根据文件名匹配规则 `diff --git (.*)` 将 diffFile 解析为若干个文件的数组集合 diffInfoList,并且保存文件信息 - - 根据 diff 块匹配规则 `@@(.*)@@` 将每个文件的 diffInfo 解析为若干个 diff 块的 blockInfoList,并且保存块信息 - - 根据增 / 删代码匹配规则 `(\+|\-)(.*)` 将每个块的 blockInfo 解析为若干个修改行号的增 / 删行数,并保存增 / 删信息 `{'delLine': 3, 'delCount': 6, 'addLine': 3, 'addCount': 6}` - -- 解析 info 文件 - - 根据文件名匹配 `SF:*end_of_record:` 规则将 info 解析为若干个文件的 fileInfoList,并且保存文件信息 - - 根据函数行、函数执行次数、代码行及执行次数匹配规则 `FN、FNDA、DA` 将每个文件的 fileInfo 解析为若干个执行信息的 daList,并且保存数据信息 `{'lineNo': 12, 'exeCount': 1, 'funName': 'eat'}` - -- 生成 info 文件 - - 根据 diffFile 解析结果,遍历 blockInfo 匹配起始修改行号 `delLine` 及修改行数 `diffline = addCount - delCount`,将 info 的解析结果进行行号匹配和增 / 删操作 `if (lineNo > delLine) lineNo += diffLine`,修改 fileInfoList 。这一步其实就是根据 git diff 信息,将新的覆盖率中的 lineNo 进行更新 - - 将新的 fileInfoList 中的数据根据 info 的结构进行写入文件操作 - -完成行号平移之后,两个版本的 .info 文件中的数据已经对齐了行号,可以用上述 LCOV 工具进行合并,合并完成后,用行号标记来统计差异的代码覆盖率数据。 - - - -### 打包平台及其服务侧 - -- 编写脚本在打包插桩后,将 `.gcno` 和源代码等信息上传到文件服务器上 -- 移动端各个测试设备测试后,App 可视化导出精准测试覆盖率报告,一键将 `.gcda` 文件上传到文件服务器上 -- 上传 `.gcda` 触发任务,利用 lcov 处理展示报告,同时也保存到文件服务器上 -- 最后 lark、企业微信通知能力,发送报告链接给开发、QA和相关人员 -- 同时 mPass 项目平台,买票上高铁的项目列表也有入口可以展示查看精准覆盖率报告 - - - -## Swift 代码覆盖率 - -这部分我将介绍: - ->- 如何生成 `.profraw` 文件并通过命令行测量代码覆盖率 ->- 如何在 Swift 项目里调用 c/c++ 方法 -> ->- 如何在 Xcode 中测量完整 Swift App 项目的代码覆盖率 - - - -### 理论支撑 - -#### 编译器参数支持 - -思路同 Objective-C 一样,参看 swiftc 编译器的编译参数 `swiftc --help` 可以看到 - - - -可以看到这2个参数是大概收集代码覆盖率相关的。 - - - -#### MachO 和汇编插桩验证 - -利用 MachOView 查看产物里的 Mach-O 文件发现,MachO 多了一些和 LLVM 相关的 section,这些 section 看名字猜出来都是用来统计覆盖率的。 - - - -当 Xcode 开启 Swift 插桩统计后,打断点查看汇编代码可以发现,在 sayHi 方法,也就是只有1个 Basic Block 的情况下,编译器只插入1个桩,插桩1次。 - - - -可以把 `__profc_xxx` 理解为打点计数信息,具体的地址保存在 MachO 文件的 `__DATA` 段 `__llvm_prf_cnts` 节点中。在程序刚启动时,所有的计数器信息为0,每当该代码(BB块)被执行1次,其计数值会加一。 - -重要的2个参数: - -- `-profile-generate`:负责插桩代码的生成,是统计插桩信息用来的。`__llvm_prf` 段。 -- `-profile-coverage-mapping` :则生成一些 LLVM 相关的 `__LLVM_COV` 段。 - -之所以要做这样的拆分,猜测可能的原因是,插桩信息除了可以用于覆盖率分析以外,还可以用来进行 PGO 优化。什么是 PGO?即 Profile Guided Optimization ,是编译器用于提升 Application 的性能的一项技术。具体可以查看这篇文章[编译器利用 PGO 优化 App 性能](./1.133.md) - - - -#### 导出原理 - -`llvm-cov` 如何生成报告的?因为 `.profdata` 文件只有 BB 计数器的调用次数,在生成覆盖率的时候传入了源码,那计数器信息和源码关联应该就是靠 MachO 文件了。 - -[LLVM Code Coverage Mapping Format](https://llvm.org/docs/CoverageMappingFormat.html#high-level-overview) 也说明了该细节 - -> LLVM’s code coverage mapping format is designed to be a self contained data format that can be embedded into the LLVM IR and into object files. It’s described in this document as a **mapping** format because its goal is to store the data that is required for a code coverage tool to map between the specific source ranges in a file and the execution counts obtained after running the instrumented version of the program. -> -> The mapping data is used in two places in the code coverage process: -> -> 1. When clang compiles a source file with `-fcoverage-mapping`, it generates the mapping information that describes the mapping between the source ranges and the profiling instrumentation counters. This information gets embedded into the LLVM IR and conveniently ends up in the final executable file when the program is linked. -> 2. It is also used by *llvm-cov* - the mapping information is extracted from an object file and is used to associate the execution counts (the values of the profile instrumentation counters), and the source ranges in a file. After that, the tool is able to generate various code coverage reports for the program. - -LLVM 的代码覆盖率映射格式被设计为一种自包含的数据格式,可以嵌入 LLVM IR 和 `.o` 文件中。在本文档中,它被描述为映射格式,因为它的目标是存储代码覆盖率工具在文件中的特定源范围和运行插入指令的程序版本后获得的执行计数之间进行映射所需的数据。 -在代码覆盖过程中,映射数据用于两个位置: - -- 当 clang 使用 `-fcoverage-mapping` 编译源文件时,它会生成描述源范围和分析检测计数器之间映射的映射信息。这些信息被嵌入LLVM IR中,并在链接程序时方便地最终出现在最终的可执行文件中。 -- 它也被 `llvm-cov` 使用-映射信息从对象文件中提取,用于关联文件中的执行计数(配置文件检测计数器的值)和源范围 - -在此之后,该工具能够为程序生成各种代码覆盖率报告。 - - - -完整流程为: - - - -覆盖率生成流程为:编译阶段使用 `-profile-generate` 和 `-profile-coverage-mapping ` 参数,其中` -profile-generate` 会开启插桩能力,为每个 BB 增加插桩代码,`-profile-coverage-mapping` 将记录 BB、计数器值、和文件源码的关联映射信息,并将这些信息存储在编译产物,也就是 `__LLVM_COV` 段中。编译产物运行的过程中, 随着 BB 被执行,计数器的值会不断增加,并且写入 `__DATA` 段。运行结束后生成 `.profraw` 文件,可以处理成 `.profdata` 文件,该文件记录了每个计数器以及调用次数。 - -覆盖率解析流程为:利用指令提供的源代码路径,和可执行文件信息,结合 `.profdata` 信息,产出覆盖率报告。具体原理是:遍历 `profdata` 中的每一个计数器,先根据可执行文件中存储的映射关系,找到这个计数器所对应统计的那一段源码,从而生成行级别的覆盖率信息。 - - - -### 实验 - -用简单的单个 Swift 文件进行理论分析。 - -第一步,创建一个名为 `test.swift` 的文件,内容如下: - -```swift -func sayHi() { - print("Hello swift world") -} - -func add(_ x: Int, _ y: Int) -> Int { - return x + y -} - -func minuse(_ x: Int, _ y: Int) -> Int { - return x - y -} - -sayHi() -print(add(2, 4)) -``` - -第二步,在终端命令行,`test.swift` 所在路径下执行下面指令 `swiftc -profile-generate -profile-coverage-mapping test.swift` - -传递给编译器的选项 `-profile-generate` 和 `-profile-coverage-mapping` 将在编译源码时启用覆盖率特性。基于源码的代码覆盖功能直接对 AST 和预处理器信息进行操作。 - -第三步,运行二进制文件 `./test`。然后在当前目录执行 `ls`,可以看到多出了一个名为 `default.profraw` 的文件。该文件由 llvm 生成,目的是衡量代码覆盖率。我们必须使用配套工具 llvm-profdata 来组合多个原始配置文件并同时对其进行索引。 - -第四步,终端运行指令 `xcrun llvm-profdata merge -sparse default.profraw -o coverage.profdata`,得到一个名为 `coverage.profdata` 的文件,进一步处理,它可以用来展示覆盖率报告。 - -第五步,在终端运行指令得到覆盖率信息 - -```shell -xcrun llvm-cov show ./test -instr-profile=coverage.profdata -xcrun llvm-cov export ./test -instr-profile=coverage.profdata -``` - -整个步骤也可以看这张图 - - - -在 `test.swift` 中编写的3个函数,只有2个执行了。查看覆盖率可以证实这一点,minuse 函数没有被执行。 - - - -### 工程实践 - -第一步,创建 Swift 项目,编写测试代码 - -```swift -// Cat.swift -import Foundation -class Cat { - var kind: String - init(kind: String) { - self.kind = kind - } - - func play() { - print("I am a \(kind) cat, I am playing now.") - } -} - -// Person.swift -import Foundation -class Person { - var name: String - var cat: Cat? - - init(name: String, cat: Cat? = nil) { - self.name = name - self.cat = cat - } - - func sayHi() { - print("Hello world, I am \(name), I have a \(String(describing: cat?.kind)) cat") - } - - func eat() { - print("eat") - } - - func sleep() { - print("sleep") - } - - func play() { - cat?.play() - } -} -``` - -第二步,选择 ` Build Settings -> Swift Compiler — Custom Flags`,在 Other Swift Flags 添加 `-profile-generate` 和 `-profile-coverage-mapping` 选项。 - - - - - -第三步,开启覆盖率收集选项 - - - -第四步,要将覆盖率信息导出前,必须要调用 llvm 的一些 c/c++ api,所以要将需要用到的方法,导出为一个模块。 - -创建一个名为 `InstrProfiling.h` 的头文件。内容为: - -```c++ -#ifndef PROFILE_INSTRPROFILING_H_ -#define PROFILE_INSTRPROFILING_H_int __llvm_profile_runtime = 0;void __llvm_profile_initialize_file(void); - -const char *__llvm_profile_get_filename(); -void __llvm_profile_set_filename(const char *); -int __llvm_profile_write_file(); -int __llvm_profile_register_write_file_atexit(void); -const char *__llvm_profile_get_path_prefix(); - -#endif /* PROFILE_INSTRPROFILING_H_ */ -``` - -创建一个 `module.modulemap` 文件并将所有内容导出为一个模块(创建的时候 Xcode 选择 empty 模版) - -```shell -module InstrProfiling { - header "InstrProfiling.h" - export * -} -``` - -第五步,判断时机,在需要导出覆盖率的地方编写函数。我在 ViewController 点击屏幕的时候导出: - -- 导入模块 `import InstrProfiling` -- 编写导出方法 `__llvm_profile_set_filename` 和 `__llvm_profile_write_file` - -```` -import UIKit -import InstrProfiling - -class ViewController: UIViewController { - var cat: Cat? - var person: Person? - - override func viewDidLoad() { - super.viewDidLoad() - - self.cat = Cat(kind: "Ragdoll") - self.person = Person(name: "FantasticLBP", cat: cat) - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - self.person?.sayHi() - self.person?.play() - // Do any additional setup after loading the view. - print("File Path Prefix: \(String(cString: __llvm_profile_get_path_prefix()) )") - print("File Name: \(String(cString: __llvm_profile_get_filename()) )") - let name = "SwiftCodeCoverage.profraw" - let fileManager = FileManager.default - - do { - let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor:nil, create:false) - let filePath: NSString = documentDirectory.appendingPathComponent(name).path as NSString - __llvm_profile_set_filename(filePath.utf8String) - print("File Name: \(String(cString: __llvm_profile_get_filename()))") - __llvm_profile_write_file() - } catch { - print(error) - } - } -} - -```` - -第六步, 运行代码,生成 `.profraw` 格式的文件。 - - - -第七步,因为产出覆盖率的时候需要用到 MachO 文件。所以在项目根目录下创建名为 `DataAnalysis` 的文件夹。在终端利用 mv 将产物里的 MachO 移动到 `DataAnalysis` 文件夹下。也将 `.profraw` 移动进去。 - - - -第八步,利用指令 `xcrun llvm-profdata merge -sparse SwiftCodeCoverage.profraw -o SwiftCodeCoverage.profdata`,将 `.profraw` 转换成 `.profdata` 文件 - -第九步,利用指令 `xcrun llvm-cov show ./SwiftCodeCoverage.app/SwiftCodeCoverage -instr-profile=SwiftCodeCoverage.profdata` 在终端查看代码的覆盖情况 - - - -第十步,终端查看代码执行情况还是不够直观,可以用 `llvm-cov` 命令生成 HTML 格式的覆盖率报告,指令格式为: - -```shell -xcrun llvm-cov show\ - -use-color\ # 彩色报告 - -format=html\ # HTML 格式 - -arch=x86_64\ # 架构指令集 - -instr-profile=${.profdata 路径}\ # 指定 .profdata 文件路径 - ${MachO 文件路径}\ # 指定 MachO文件路径 - ${SourceCode 路径} # 项目源代码路径 - -output-dir ${Swift覆盖率报告路径} # 指定覆盖率报告保存的路径 -``` - -我这边具体指令为: - -`xcrun llvm-cov show -use-color -format=html -arch=x86_64 -instr-profile=SwiftCodeCoverage.profdata SwiftCodeCoverage https://github.com/FantasticLBP/knowledge-kit/raw/master/ -output-dir ./SwiftCodeCoverageReport ` - -第十一步,查看整体的覆盖率信息与单个文件的覆盖率,查看代码执行情况 - -其中 `index.html` 是所有文件的覆盖率数据汇总,而每个文件精确到行级别的覆盖率信息,则保存在 `coverage` 文件夹中,每个文件对应一个 HTML 文件。 - - - -第十二步,假设我们在另一台 CI 机器上也在执行测试任务。那不同机器上的测试结果如何合并? - -生成覆盖率报告是基于插桩实现的,最后 `xcrun llvm-cov` 生成 html 需要的是:Mach-O 文件、源代码路径、`.profdata` 文件。 - -看得出来不同 CI 机器上,不同的只有 `.profdata` 文件,所以处理 `.profdata` 即可。所幸 `llvm-profdata` 就支持不同的 `.profraw` 的合并。 - -比如第一台机器生成的是 `SwiftCodeCoverage.profraw` 得到的覆盖率如上图所示。第二台机器生成的是 `SwiftCodeCoverage.profraw`。 - -接下去利用指令 ` xcrun llvm-profdata merge SwiftCodeCoverage.profraw SwiftCodeCoverage2.profraw -o SwiftCodeCoverageCombined.profdata` 将2份测试原始文件进行合并,然后再利用 `llvm-cov` 生成 html 报告 - - - -第十三步,我们有时候有需求会更改操作生成的测试文件,`.profdata` 是没办法修改的,但 `llvm-profdata` 指令可以传递参数生成 `.text` 格式的文件,里面的内容可以修改。修改后再从 `.text` 转换为 `.profdata`,最后再利用 `llvm-cov` 生成 html 报告。 - -下面演示下如何修改生成的覆盖率数据(注意:不修改 html,而是修改 BB 的计数值) - -1. 利用 ` xcrun llvm-profdata merge SwiftCodeCoverage.profraw SwiftCodeCoverage2.profraw -text -o SwiftCodeCoverageCombined.txt` 将2份 `.profraw` 数据合并为 `.txt` 格式的文件(记录了 BB 和技术值信息) - - - -2. 编辑修改 `.txt` BB 的计数值,此处,故意把 `Person:sleep` 的1改为0 - -3. 利用指令将 `.txt` 改为 `.profdata` 格式。`xcrun llvm-profdata merge SwiftCodeCoverageCombined.txt -o SwiftCodeCoverageCombinedFromText.profdata` - -4. 再根据合并后的 `.profdata` 生成 html 覆盖率报告。`xcrun llvm-cov show -use-color -format=html -arch=x86_64 -instr-profile=SwiftCodeCoverageCombinedFromText.profdata SwiftCodeCoverage https://github.com/FantasticLBP/knowledge-kit/raw/master/ -output-dir ./SwiftCodeCoveragCombinedReportFromText` - -效果如下: - - - - - - - -## 心得感悟 - -下面是一个工作中的实际例子,冒烟用例也全部通过了,代码在 CR 后 MR 了,然后买票上车,开始高铁回归阶段。 - - - - - - - -QA 去回归测试,然后会给开发一个精准测试报告。就是原始的本次业务开发分支上的代码执行情况。程序员去分析,覆盖率低的原因是什么,是兜底代码太多、还是某些技术实现是类似夸端的 Weex、RN、Flutter、还是测试 case 不充分以至于看上去用例通过,但是某些代码还是没有测试到,往往这些没有测试到、执行到的代码是线上用户在极端情况下容易走到的 case。所以需要根据精准测试覆盖率反推 QA 完善用例,或者开发自己优化代码。 - -精准测试的价值很明显,但 ROI 就见仁见智了,有些人觉得要开发一套 CI 需要耗时耗力,每个项目完成后需要分析精准测试报告、反推 QA 完善用例很麻烦,但有些决策者就觉得这样能 cover 一些平时难以发现的问题。 - - - -## 参考文章 - -[Source-based Code Coverage](https://clang.llvm.org/docs/SourceBasedCodeCoverage.html#source-based-code-coverage) - -[llvm-cov - emit coverage information](https://llvm.org/docs/CommandGuide/llvm-cov.html) - -[LLVM Code Coverage Mapping Format](https://llvm.org/docs/CoverageMappingFormat.html#llvm-code-coverage-mapping-format) - - - diff --git a/Chapter1 - iOS/1.109.md b/Chapter1 - iOS/1.109.md deleted file mode 100644 index f14415e..0000000 --- a/Chapter1 - iOS/1.109.md +++ /dev/null @@ -1,1023 +0,0 @@ -# 汇编学习 - -## 基础知识回顾 - -地址总线:它的宽度决定了 CPU 的寻址能力。比如8086地址总线宽度为20,则寻址能力为1M(2的20次方) -数据总线:它的宽度决定了 CPU 单次数据传送量,也就是数据传输速度。比如8086的数据总线宽度为16,所以单次最大传递2个字节的数据/ -控制总线:它的宽度决定了 CPU 对其他其间的控制能力,能有多少种控制。 - -内存的分段管理:起始地址+偏移地址=物理地址,计算出物理地址再去访问内存。 - -偏移地址为16位,16位地址的寻址能力位64kb,所以一个段的长度最大为64kb。 - - - -## CPU 的典型构成 - -- 寄存器:信息存储 - -- 运算器:信息处理 - -- 控制器:控制其他器件进行工作 - -对开发同学来说,CPU 中最主要的部件就是寄存器,“可以通过改变寄存器的内容来实现对 CPU 的控制”。 - -不同的 CPU,寄存器个数、结构是不同的(比如8086是16为结构的 CPU,8086有14个寄存器) - - - -## 说明 - -- 汇编中,小括号内存放的一定是内存地址。 - -- 指令后面的字母代表操作数长度。比如 b = byte(8-bit),s = short(16-bit integer or 32-bit floating point)、w = word(16-bit)、l=long(32-bit integer or 64-bit floating point)、q=quad(64 bit)、t=tem bytes(80-bit floating point)。比如 ` movq $0xa, 0x86c1(%rip)` 是 `let a:Int = 10` 的汇编实现。 - -- rip 存储的说指令的地址。CPU 要执行的下一条指令地址就存储在 rip 中 - -- rax、rdx 寄存器一般作为函数返回值使用 - - ```swift - func getValue() -> Int { - return 10 - } - var v = getValue() - ``` - - - - - - 在第5行代码加断点,第4行汇编遇到 call 函数调用,LLDB 输入 `si` 进去,可以看到将十六进制 `0xa` 也就是10,保存到寄存器 `%eax` 也就是`%rax` 中。 - - ```assembly - SwiftDemo`getValue(): - -> 0x100003b20 <+0>: pushq %rbp - 0x100003b21 <+1>: movq %rsp, %rbp - 0x100003b24 <+4>: movl $0xa, %eax - 0x100003b29 <+9>: popq %rbp - 0x100003b2a <+10>: retq - ``` - - LLDB 输入 `finsh` 结束函数调用这段汇编,可以看到在汇编的第5行,将 `%rax` 保存的 10 赋值到 `%rip + 0x86d0 ` 地址。可以看 `%rip + 0x86d0` 是个全局变量,大概就是 v 的地址(可以继续用汇编验证,绝对是 v)。 - - - -- rdi、rsi、rdx、rcx、r8、r9 寄存器一般用来存储函数参数。 - - - - 可以看到第四行汇编的 `%edi` ... `%r9d` 和上面描述的寄存器顺序一致。 - -- rsp、rbp 寄存器用于栈操作。栈顶指针,指向栈的顶部 -- leaq 和 movq 是有区别的。`leaq 0xd(%rip), %rax` 是从 `%rip + 0xd` 算出来的地址值赋值给 `%rax` ,`movq 0xd(%rip), %rax` 是从 `%rip + 0xd ` 算出来的地址值,取8个字节给 `%rax`。 -- `xorl` 异或运算。 - -## 寄存器的高低位兼容设计 - -汇编中高位对于低位寄存器的兼容性设计: -%r 开头的寄存器都是64位(8 Byte) -%e 开头的寄存器都是32位的(4 Byte) -那如果所有的寄存器再去分 %r、%e 那就会存在很多寄存器了,使用和记忆很难了。 - -同时早期的寄存器之下写的汇编代码,升级的时候要改写,成本太大了。如何设计才可以兼容升级呢? - -设计很巧妙。假设一个 %rax 的64位寄存器(0~63位) - -- 64位:则 all in 全部使用 -- 32位:为了兼容低的32位寄存器,则拿出低的4字节(0~31位)当作 %eax 32位寄存器来使用 -- 16位:为了兼容16位的寄存器,则拿出低的2个字节(0~15位)当作 %ax 16位寄存器来使用; -- 8位:为了兼容8位的寄存器,则拿出低的2个字节(0~15位)分为2段,高8位、低8位来使用,分别是 %ah、%al 寄存器。 - -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/RegisterHighAndLow1.png) -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/RegisterHighAndLow2.png) - -寄存器: -- r 开头:64 bit,8 Byte -- e 开头:32 bit,4 Byte -- ax、bx、cx、dx:16 bit,2 Byte -- ah、al、bh、bl...:8 bit,1 Byte - - - -## 通用寄存器 - -AX、BX、CX、DX 这4个寄存器通常用来存放一般性的数据,成为通用寄存器(有时候也有特定用途) - -通常 CPU 会把内存中的数据存储到通用寄存器中,然后再对通用寄存器中的数据进行运算 - -例如:在内存中有一个内存空间a值为3,需要将它的值加1,然后将结果存储到内存空间b上。 - -- `mov ax, a`。CPU 首先会将内存空间 a 的值放到寄存器 ax 中 - -- `add ax, 1`。调用 add 指令,将 ax + 1 - -- `mov b, ax`。调用 mov 将 ax 中的值赋值给内存空间 b - - - -### CS和IP - -CS 为代码段,IP 为指令指针寄存器,它们代表 CPU 当前要读取指令的地址 - - 任意时刻,8086 CPU 都会将 `CS:IP` 指向的指令作为下一条需要取出执行的指令。 - -IP 只为 CS 提供服务。 - -8086 CPU 工作过程如下: - -- 从 `CS:IP` 指向的内存单元读取指令,读取的指令进入指令缓冲器 - -- IP = IP + 当前所读取指令的长度。从而指向 IP 指向下一条指令 - -- 执行指令,转到步骤1,重复执行 - -在8086CPU 加电启动或者复位后(即 CPU 刚开始工作时)CS 被设置位 CS=FFFFH,IP 被设置为 IP=0000H,即在8086PC 机刚启动时,CPU 从内存 FFFF0H 单元中读取指令执行,FFFF0H 单元中的指令是 8086PC 机开机后执行的第一条指令。 - -注意:在内存或者磁盘上,指令和数据其实没有差别,都是二进制信息。CPU 在工作时把有的信息看成指令,有些信息看数据,为同样的信息赋予了不同意义。 - -那么 CPU 根据什么来判断这块内存上的信息是指令还是数据? - -- CPU 将 `CS:IP` 所指向的内存单元的内容看作指令 - -- 如果内存中的某段内容曾被 CPU 执行过,那么它所在的内存单元肯定被 `CS:IP` 指向过 - - - -### jmp 指令 - -mov 指令不能用于设置 CS、IP 的值,8086没有提供该功能。可以通过 jmp 指令来实现修改 CS、IP 的值,这些指令被成为转移指令。 - - `jmp 段地址:偏移地址` 可以实现同时修改 CS、IP 的值,表示用指令中给出的段地址修改 CS,偏移地址 IP。 - -`jmp 2AE3:3` 执行后表示:CS=2AE3H,IP=0003H,CPU 将从2AE33H处读取指令。 - -QA:下面3条指令执行完毕后,CPU 修改了几次 IP 寄存器? - -```shell -mov ax, bx -sub ax, ax -jmp ax -``` - -修改了4次。每执行一条指令,IP 都会被修改1次(IP=IP+该条指令的长度),最后一条指令执行后,IP 寄存器的值也会被修改1次,共3+1=4次。 - -`jmp *%rax` jmp 后面如果跟寄存器地址,则一定要加 `*`,地址存放在 `%rax` 中 - - - -### ds 寄存器 - -CPU 要读写一个内存单元时,必须要给出这个内存单元的地址,在8086中,内存地址由段地址和偏移地址组成。 - -8086中有一个 DS 段寄存器,通常用来存放要访问数据的段地址 - -```shell -mov bx, 1000H -mov ds, bx -mov al, [0] -``` - -上面3条指令的意思是将 10000H (1000:0)中的内存数据,赋值到 al 寄存器中。 - -`mov al, [address]` 的意思是将 DS:address 该地址中的内存数据赋值到 al 寄存器中。 - -由于 al 是8位寄存器,所以上述命令是将一个字节的数据赋值给 al 寄存器。 - -tips:8086 不支持将数据直接送入段寄存器,所以 `mov ds, 1000H` 是错误的。 - -QA:写指令来实现将 al 中的数据写入到内存单元 10000H 中 - -```shell -mov ax, 1000 -mov ds, ax -mov [0], al -``` - -QA:内存中有如下数据,写出下面指令执行后寄存器 ax 的值? - -| 10000H | 23 | -| ------ | --- | -| 10001H | 11 | -| 10002H | 22 | -| 10003H | 66 | - -```shell -mov ax, 1000H -mov ds, ax -mov ax, [2] -``` - -代码分析: - -- 第一条指令,ax 寄存器存放了 1000H 这个地址 - -- 第二条指令,将访问 ds 数据段,在 1000H 这个地址出访问 - -- 第三条指令,将数据段中 1000H 这个地址处,偏移 2,也就是内存中 10002H 这个的值22写入到 ax 中,由于 ax 寄存器是16位,所以会取2个单位的数据,22和66。所以 ax 的值为2266。 - -8086 CPU 下,AX、BX、CX、DX 等通用寄存器均被分为高位和低位,AX = AH + AL,其中高位寄存器和低位寄存器。高位和低位都是16位。所以会从10002H 开始取2个16位的数据赋值给 ax。 - -思考:如果代码改变下呢,如下 - -```shell -mov ax, 1000H -mov ds, ax -mov al, [2] -``` - -此时 al 的值为多少?al 和 ax 的区别在于 ax = ah + al,所以 al 的情况下直接从 10002H 开始取1个16位的数据,所以 al 为 0022。 - - - -### 大小端序 - -小端序,指的是数据的高字节保存在内存的高地址中,数据的低字节保存在内存的低地址中 - -大端序,指的是数据的高字节保存在内存的低地址中,数据的低字节保存在内存的高地址中。 - -注意:这里的大小端序还存在网络大小端序 NBO 和主机大小端序 HBO,详细可以查看我[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter5%20-%20Network/5.3.md) - -Big Endian:PowerPC、IBM、Sun - -Little Endian:x86、DEC - -ARM:大小端序模式下均可工作。 - -16bit 宽的数0x1234 分别在大小端序模式的 CPU 内存存放形式为 - -| 内存地址 | 小端序 | 大端序 | -| ------ | ---- | ---- | -| 0x4000 | 0x34 | 0x12 | -| 0x4001 | 0x12 | 0x34 | - -32bit宽的 0x12345678 分别在大小端序模式的 CPU 内存存放形式为 - -| 内存地址 | 小端序 | 大端序 | -| ------ | ---- | ---- | -| 0x4000 | 0x78 | 0x12 | -| 0x4001 | 0x56 | 0x34 | -| 0x4002 | 0x34 | 0x56 | -| 0x4003 | 0x12 | 0x78 | - -QA:将 0x1122 存放在 0x40002 中,如何存储? - -分析 0x1122需要2个字节,0x40002 是1个字节,所以肯定需要在 0x40002和向后的一个字节中存储。然后考虑主机序的大小端情况。假设小端模式下: - -0x40000 - -0x40001 - -0x40002 0x22 - -0x40003 0x11 - - - -### 指令操作明确 CPU 操作的内存 - -```shell -mov ax, 1000H -mov ds, ax -mov word ptr [0], 66h -``` - -上述代码先把 1000H 写入 ax 寄存器,然后访问数据段的 1000H 内存,然后将66h写入到数据段的0位置,但是 word 告诉了 CPU 需要操作2个字节,也就是 00 66 - -指令执行前:1000: 0000 11 22 00 00 00 00 00 00 - -指令执行后:1000: 0000 00 66 00 00 00 00 00 00 - -如果将第三行代码改为 `mov byte ptr [0], 66h`,意味着明确告诉计算机需要操作1个字节,也就是66 。 - -指令执行前:1000: 0000 11 22 00 00 00 00 00 00 - -指令执行后:1000: 0000 66 22 00 00 00 00 00 00 - - - -## 栈 - -栈是一种后进先出特点的数据存储空间(LIFO) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Stack.png) - -- 8086 会将 CS 作为代码段的段地址,将 `CS:IP` 指向的指令作为下一条需要取出执行的指令 - -- 8060会将 DS 作为数据段的段地址,`mov ax, [address]` 就是取出 `DS:address` 内存区域上的数据放到 ax 寄存器中 - -- 8086会将 SS 作为栈段的段地址,`SS:SP` 指向栈顶元素 - -- 8086提供了 PUSH 指令用来入栈,POP 出栈。PUSH ax 是将 ax 的数据入栈,pop ax 是将栈顶的数据送入 ax - -SS: 栈的段地址 - -SP:堆栈寄存器存放栈的偏移地址 - - - -### push - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/StackPush.png) - -`push ax` 指令执行,会拆解为: - -- `SP = SP-2` ,`SS:SP` 指向当前栈顶前面的单元,更新栈顶指针 - -- 将 ax 中的数据送入到 `SS:SP` 所指向的内存单元处 - -ax = ah + al,所以 ax 中的数据入栈需要占据2个单位(sp = sp - 2) - - - -### pop - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/stackPop.png) - -`pop ax` 指令执行,会拆解为: - -- 将 `SS:SP` 栈顶指向的内存单元中的数据(2个单位)写入到 ax 寄存器中 - -- `SP = SP + 2`,更新 `SS:SP` 栈顶的地址 - -注意: - -当一个栈空间是空的时候,`SS:SP` 指向栈空间最高地址单元的下一个单元。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/emptyStack.png) - -当一个栈空或者满的时候,执行 PUSH、POP 指令需要注意,因为 `SP = SP + 2`、`SP = SP - 2` 都会导致将错误的数据入栈或者错误的数据出栈,导致发生不可预期的事情。 - -QA:将10000H~1000FH 这段空间当作栈,初始栈为空,AX = 001AH,BX=001BH,利用栈,交换 AX、BX 中的数据 - -```shell -push ax -push bx -pop ax -pop bx -``` - -### 段总结 - -数据段:存放数据的段 - -代码段:存放代码的段 - -栈段:将一个段当作栈 - -对于数据段,将它的段地址放在 DS 中,用 mov、add、sub 等访问内存单元的指令时,CPU 则认为数据段中的内容是数据来访问 - -对于代码段,将它的段地址存放在 CS 中,将段中的第一条指令的偏移地址放在 IP 中,这样 CPU 就将执行我们定义的代码段的指令(每执行一条指令之前,就会将 IP 的值更新,规则为 IP = IP + 当前指令的长度,以保证该条指令执行完可以根据 段地址 + 偏移地址获取到下条指令的地址) - -对于栈段,将它的段地址存放在 SS 中,将栈顶单元的偏移地址存放在 SP 中,这样 CPU 在进行栈操作(LIFO)的时候比如 push、pop 指令,就可以操作 SP,将我们定义的栈段当作栈空间来使用 - - - -## 中断 - -中断是由于软件或者硬件的信号,使得 CPU 暂停当前的任务,转而去执行另一段子程序。 - -在程序运行过程中,系统出现了一个必须由 CPU 立即处理的情况,此时,CPU 暂时终止当前程序的执行转而处理这个新情况的过程就叫中断。 - -中断分为: - -- 硬中断(外中断):由外部设备(网卡、硬盘)随机引发的,比如当网卡收到数据包的时候,就会发出一个中断 - -- 软中断(内中断):由执行中断指令产生,可以通过程序控制触发 - -汇编中主要指的是软中断,可以通过指令 `int n` 产生中断,其中 `n` 表示中断码,内存中有一张中断向量表,用来存放中断码对应中断处理程序的入口地址。 - -Demo:写一个打印 Hello World 的汇编代码 - -```powershell -; 提醒开发者每个段的定义(增加可读性) -assume cs:code, ds:data -;------ 数据段 begin -------- -data segment - age db 20h - no dw 30h - db 10 dup(6) ; 生成连续10个6 - string db 'Hello world!$' -data ends -;------ 数据段 end -------- - -;------ 代码段 begin -------- -code sgement -start: - ; 设置 ds 的值 - mov ax, data - mov ds, ax - - mov ax, no - mov bl, age - - ; 打印字符串 - mov dx, offset string ; offset string 代表 string 的偏移地址(将地址赋值给 dx) - mov ah, 9h - int 21h ; 打印字符串其实也是一次中断 - - ; 退出程序 - mov ax, 4c00h - int 21h -code ends -end start -;------ 代码段 end -------- -``` - -- start 代表汇编程序的入口 - -- `mov ax, 4c00h` 和 `int 21h` 代表程序正常中断 - -QA:“全局变量的地址在编译那一刻就确定好了”怎么理解? - -全局变量存放在数据段,我们开发者写的代码存放在代码段,位置不一样,编译期就可以确定全局变量的地址。 - - - -## call 和 ret 指令 - -实现打印3次 "Hello" - -方法1 - -```powershell -assume ds:data, ss: stack, cs: code -; 栈段 -stack segment - -ends stack - -; 数据段 -data segment - - -ends data - -; 代码段 -code segment -start: - ; 设置 ds、ss - mov ax, data - mov ds, ax - mov ax, stack - mov ss, ax - - ; 业务逻辑 - ; 打印 - ; ds:dx 告诉字符串地址 - mov dx, offset string - mov ah, 9h - int 21h - - mov dx, offset string - mov ah, 9h - int 21h - - mov dx, offset string - mov ah, 9h - int 21h - - ; 程序正常退出 - mov ax, 4c00h - int 21h -code ends -end start -``` - -有没有问题?重复出现2次以及以上,需要封装为函数,汇编也遵循这个原则 - -方法2 - -```shell -assume ds:data, ss: stack, cs: code -; 栈段 -stack segment - -ends stack - -; 数据段 -data segment - - -ends data - -; 代码段 -code segment -start: - ; 设置 ds、ss - mov ax, data - mov ds, ax - mov ax, stack - mov ss, ax - - ; 业务逻辑 - call print - call print - call print - - ; 程序正常退出 - mov ax, 4c00h - int 21h -print: - ; 打印 - ; ds:dx 告诉字符串地址 - mov dx, offset string - mov ah, 9h - int 21h - ; 函数正常退出 - ret - -code ends -end start -``` - -说明: - -call 会将下一条指令的偏移地址入栈;会转到标号(print:) 处执行指令 - -ret 会将栈顶的值出栈,赋值给 `CS:IP` ,ret 即 return - -## 函数调用的本质 - -函数的3要素:参数、返回值、局部变量 - - - -### 返回值 - -函数运算的结果,一般是放在 ax 通用寄存器中。可以拿 Xcode 将下面的代码执行下,断点开启在 test 方法内的 return 处(Debug - Debug WorkFlow - Always show Disassembly) - -```objectivec -#import -int test (void) { - return 9; -} -int main(int argc, const char * argv[]) { - int res = test(); - printf("%d", res); - return 0; -} -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AssembleReturn.png) - -可以看到 return 的值是保存在 eax 寄存器中。为什么是 e,e是32位的意思(环境:老款 MBP 电脑运行)。 - -### 参数 - -需要用的时候 push,最后不用则 pop,所以用栈来传参。 - -注意利用栈传递参数,在函数内部计算之后(return出来),需要保证**栈平衡**,也就是函数调用前后的栈顶指针要一致。 - -栈不平衡会导致栈空间迟早溢出,发生不可预期的错误。 - -Demo - -```shell -push 1122h -push 2233h -call sum -add sp, 4 - -sum: - ; 访问栈中的参数 - mov bp, sp - mov ax, ss:[bp + 2] - add ax, ss:[bp + 4] - ret -``` - -- 上面代码调用2次 push 将方法参数入栈,调用 sum 方法(call sum),call 本质会将下一行指令的地址压入栈,所以目前栈有3个元素。 - -- 函数执行完毕,调用 ret 指令会将栈顶的指令 pop 出来,更改 `CS:IP` 然后马上执行 - -- 访问栈中的参数的时候,由于 call 指令会将下一条指令地址也入栈,所以访问需要 +2 - -- 访问栈中参数 +2 的时候不能直接 `sp + 2 `,需要用 bp - -但目前函数调用结束了,栈里面还存在2个局部变量,导致空间浪费了,会存在“栈平衡”问题。所以在函数调用完毕,需要告诉内存,将栈顶指针恢复原位 `sp = sp + 4`(类比程序员告诉计算机,这块内存我不用了,后续其他人的代码可以用这块内存存某个值) - -QA:stack overflow? - -清楚函数调用原理 call、ret、stack 就知道函数调用函数,常见的递归或者循环,其实函数都在 stack 上进行操作,比如函数参数、函数下一条指令也会入栈,在递归或者函数内不断调用函数的过程中,stack 不及时”栈平衡“,很容易出现栈溢出的情况,也就是 stack overflow。 - - - -### 内平栈/外平栈 - -外平栈 - -```shell -push 1122h -push 2233h -call sum -add sp, 4 - -sum: - ; 访问栈中的参数 - mov bp, sp - mov ax, ss:[bp + 2] - add ax, ss:[bp + 4] - ret -``` - -内平栈 - -```shell -push 1122h -push 2233h -call sum - -sum: - ; 访问栈中的参数 - mov bp, sp - mov ax, ss:[bp + 2] - add ax, ss:[bp + 4] - ret 4 -``` - -内平栈的好处是函数调用者不用去处理“栈平衡” - - - -### 函数调用的约定 - -`__cdecl` 外平栈,参数从右到左入栈 - -`_stdcall` 内平栈,参数从右到左入栈 - -`_fastcall` 内平栈,ecx、edx 分别传递前面2个参数,其他参数从右到左入栈 - -寄存器传递参数效率更高,速度更快,iOS 平台函数采用6到8个 寄存器传参,剩余的从右到左入栈。 - - - -### c 代码可与汇编混合开发 - - 验证函数的返回值是存放在 eax 寄存器中(eax 和 ax 区别在于位数) - -```c -#import -int test (int a, int b) { - return a + b; -} -int main(int argc, const char * argv[]) { - test(2, 8); - int c = 0; - __asm { - mov c, eax - } - printf("%d", c); - return 0; -} -// 10 -``` - - - -## 函数局部变量 - -大多数情况下函数内部会存在局部变量,但是不知道局部变量到底有多少,如何保证局部变量不会被污染呢? - - CPU 会在栈内部,将局部变量的地方,临时分配10字节大小空间用来存储局部变量。这个怎么实现呢?`SP = SP - 10` 这条指令用来将栈顶指针改变,留出10字节大小空间。但是留出的空间是空的,万一 `CS:IP` 指向这块区域会把里面的数据当作指令去执行,则可能发生一些不可预知的错误。Windows 平台,针对预留的局部变量空间,会走动填充 cc,也就是 `int 3 ` 断点中断,只要 `CS:IP` 去执行就会断点中断,更安全。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AssembleFunctionStack.png) - -关键代码如下: - -```powershell -; 返回值放在 ax 寄存器中 -; 传递2个参数(放入栈中) -sum: - ; 保护 bp - push bp - ; 保存 sp 之前的值;指向 bp 以前的值 - mov bp, sp - ; 预留10个字节的空间用来存放局部变量(栈,内部是高地址向) - sub sp, 10 - - ; 保护可能用到的寄存器 - push si - push di - push bx - - ; 给局部变量空间填充 int 3(cccc):调试中断,以增加 `CS:IP` 安全性 - ; stosw 的作用:将 ax 的值拷贝到 es:di 中 - mov ax, 0cccch - ; 让 es 等于 ss - mov es, ss - mov es, bx - ; 让 di = bp - 10(局部变量地址的最小处) - mov di, bp - sub di, 10 - ; cx 的值决定了 rep 的执行次数 - mov cx, 5 - ; rep 重复执行某条指令(次数由 cx 的值决定) - rep stosw - - ; 业务逻辑 - ; 定义2个局部变量 - mov word ptr ss:[bp-2], 3 - mov word ptr ss:[bp-4], 4 - mov ax, ss:[bp-2] - add ax, ss:[bp-4] - mov ss:[bp-6], ax - - ; 访问栈中的参数 - mov ax, ss:[bp+4] - add ax, ss:[bp+6] - add ax, ss:[bp-6] - - ; 恢复寄存器中的值 - pop bx - pop di - pop si - - ; 恢复 sp - mov sp, bp - - ; 恢复 bp - - pop -``` - - - -## 栈帧 - -Stack Frame Layout,代表一个函数的执行环境。包括:参数、返回地址、局部变量和包括在本函数内部执行的所有内存操作等 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/StackFrame.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CSStackFrame.png) - - - -## iOS 调用汇编 - -1. 在 Xcode 工程中创建文件,选择 Other -> empty,保存为 `.s` 拓展名 - - - -2. 编写汇编代码 - - ```assembly - .text - .global _test - - _test: - movq $0x8, %rax; - ret; - ``` - - 创建一个名为 `test` 的函数,内部给 rax 寄存器赋值为8,然后 ret 返回。 - - `.text` 是保存在 _TEXT 段上。并将函数暴露给全局,函数名为 test,暴露的时候就要写 _test - -3. 汇编函数给外部调用,就需要声明一个头文件 `Asm.h`,写好需要暴露的方法声明 - -4. 最后在使用的地方,引入暴露的汇编头文件 `Asm.h`,正常调用函数即可。 - - - - - - - - - -## 汇编编写“函数” - -上面的例子也顺带看了汇编是如何编写“函数”的,为什么加引号,因为这个概念是不存在的,汇编只有指令,这个函数概念是方便组织代码,参考定义的。类似给一段代码打了个标签。 - -1. 创建汇编文件 - -2. 编写代码 - - ```assembly - .text - .global _test, _add, _sub - - _test: - movq $0x8, %rax; - ret; - - _add: - movq %rsi, %rax - movq %rdi, %rbx - addq %rbx, %rax - retq - _sub: - movq %rdi, %rax - movq %rsi, %rbx - subq %rbx, %rax - ret - ``` - - - - 说明:笔者编写平台是老款 MBP,Xcode 连接模拟器跑的代码,也就是 X86_64 架构的汇编。真机运行一般跑 arm64 汇编语法,会 X86_64 的话 arm64 类似,翻译下写法就好。 - - 看这2个函数,都是从 `rsi` `rdi` 寄存器里面获取函数参数,内部调用系统指令,做了减加运算逻辑后,将函数返回值保存到 `rax` 寄存器中,直接 return。不需要显示声明 `return rax`,汇编会自动将 `rax` 寄存器里的值,交给函数调用者。 - -3. 汇编函数给外部调用,就需要声明一个头文件 `Asm.h`,写好需要暴露的方法声明 - -4. 最后在使用的地方,引入暴露的汇编头文件 `Asm.h`,正常调用函数即可。 - - - - - -## iOS 源码探索 - -经常需要将黑盒的 iOS 代码结合 GNU 之外,还需要将源文件编译成汇编代码去分析。格式为: - -`xcrun --sdk iphoneos clang -S -arch arm64 main.m -o main.s` - - - -## arm64 汇编 - -### 寄存器 - -#### 通用寄存器 - -- 64位: x0~x28 -- 32位:w0~w28(属于 x0~x28 的低32位) -- x0~x7 经常用来存放函数的参数,更多的函数参数用堆栈来传递 -- x0 经常用来存放函数的返回值 - - - -Demo:汇编定义加减法,OC 去调用 - -```assembly -// Asm.s -.text -.global _add, _sub - -_add: - add x0, x0, x1 - ret -_sub: - sub x0, x0, x1 - ret - -// ViewContoller.m -#import "Asm.h" -NSInteger sum = add(2, 4) // 6 -NSInteger res = sub(4, 2) // 2 -``` - - - - - -#### 程序计数器 - -pc(Program Counter) - - - -#### 堆栈指针 - -- sp(Stack Pointer) -- fp(Frame Pointer),也就是 x29 - -#### 链接寄存器 - -lr(link register),也就是 x30 - - - -#### 程序状态寄存器 - -- cpsr(Current Program Status Register) -- spsr(Saved Program Status Register),异常状态下使用 - - - -### 指令 - -- ret:函数返回 - -- cmp:将2个寄存器的值相减,结果会影响 cpsr 寄存器的标志位 - -- b:跳转指令。格式为:`b{条件} 目标地址` 。b 指令是最简单的跳转指令,一旦遇到一个 B 指令,ARM 处理器将立即跳转到给定的目标地址,从那里继续执行。 - - 条件跳转一般搭配 cmp 使用。条件跳转对应 `if...else...` - - Demo:定义一段汇编代码一个标签,然后跳转执行。跳转前传递参数,跳转后读取并相加 - - ```assembly - .text - .global _jump - - _jump: - movq $0x1, %rsi - jmp myCode - myCode: - movq %rsi, %rax - movq $0x2, %rbx - addq %rbx, %rax - ret - ``` - - - - - - 上面是 x86_64 的汇编,`jmp` 跳转指令在 arm64 中对应 `b` 指令。类似下面代码 - - ```assembly - .text - .global _jump: - - _jump: - // ... - b myCode - myCode: - // ... - ``` - - 条件跳转:`bgt conditionJump` - - ```assembly - .text - .global _jump - - _jump: - mov x0, #0x5 - mov x1, #0x5 - cmp x0, x1 - bgt conditionJump - conditionJump: - mov x1, #0x6 - ret - ``` - -- bl:带返回值的跳转指令。格式为:`bl{条件} 目标地址`。bl 跳转前,会在寄存器 r14 中保存 pc 的当前内容,因此,可以通过将 r14 的内容重新加载到 pc 中,来返回到跳转指令之后的那个指令处执行。该指令是实现子程序调用的一个基本但常用的手段。在 x86_64 中就是 call 指令。 - - - - - -### 条件域 - -- EQ:equal 相等 -- NE:not equal 不想等 -- GT:great than 大于 -- GE:greater equal 大于等于 -- LT:less than 小于 -- LE:less equal 小于等于 - - - -### 内存操作 - -- load 从内存中装载数据 - - - ldr - - `ldr x0, [x1]` 代表从地址 x1 处,取8个字节的数据,赋值给 x0(会将 x1 寄存器中存储的内存地址所指向的值加载到 x0 寄存器中)。`ldr w0, [x1]` 代表从地址 x1 处,取4个字节的数据,赋值给 w0。一般会搭配 CPU 寻址能力一起使用。 - - - ldur - - 和 ldr 一样,作用都是从一个寄存器中存储的内存地址所指向的值加载到某个寄存器上。ldr 搭配正数地址,如 `ldr x1, [sp, #0x28]` ,ldur 搭配负数地址,如 `ldur w8, [x29, #-0x8]` - - - ldp, `ldp w0, w1, [x2, #0x10]` 代表从 x2 + 0x10 计算结果对应的内存出,取出前4个字节的值赋值给寄存器 w0,后4个字节对应的值赋值给寄存器 w1 - -- store 往内存中存储数据 - - - str。`str w0, [x1, #0x5]` 将 w0 寄存器的值赋值给 `x1 + #0x5` 地址开始,4个字节处。str 搭配正数地址偏移 - - - stur。`str w0, [x1, #-0x5]` 将 w0 寄存器的值赋值给 `x1 - #0x5` 地址开始,4个字节处。stur 搭配正数地址偏移 - - - stp。`stp w0, w1, [x1, #0x5]` 将 w0 寄存器的值赋值给 `x1 + #0x5` 地址开始,前4个字节处,w1 寄存器的值赋值给后4个字节 - - - 零寄存器 - - - wzr(32bit)即 word zero register。 - - xzr(64bit) - - ```objective-c - int a = 0; - long b = 0; - ``` - - 转换为 arm64 汇编就是 - - ```assembly - stur wzr, [x29, #-0x14] - stur xzr, [x29, #-0x24] - ``` - - - -## 经验小结 - -- 内存地址格式为:`0x7ab60(%rip)` 一般是全局变量 -- 内存地址格式为:`-0x50(%rbp)` 一般是局部变量 -- 源代码 -> 汇编 -> 机器码,从机器码到汇编是可逆的。但是无法做到汇编到源代码的反编译,因为不同的源代码可能生成的汇编代码是一样的。 - - - diff --git a/Chapter1 - iOS/1.11 b/Chapter1 - iOS/1.11 deleted file mode 100644 index e8c608b..0000000 --- a/Chapter1 - iOS/1.11 +++ /dev/null @@ -1,2 +0,0 @@ -//print(MemoryLayout.stride(ofValue: str1)) -//print(Mems.memStr(ofVal: &str1)) \ No newline at end of file diff --git a/Chapter1 - iOS/1.11.md b/Chapter1 - iOS/1.11.md deleted file mode 100644 index bfe0c99..0000000 --- a/Chapter1 - iOS/1.11.md +++ /dev/null @@ -1,51 +0,0 @@ -# iOS中的事件 - - - -* 用户在使用App的时候会产生各种事件 -* 触摸事件、重力加速计事件、远程遥控事件 -* 只有继承自UIResponder才可以响应事件 -* UIView、UIApplication、UIViewController都可以响应事件 -* ## UIResponder -* UIResponder内部提供了一些方法处理事件 - -``` -//触摸事件 --(void)touchBegan:(NSSet *)touches withEvent:(UIEvent *)event; --(void)touchMoved:(NSSet *)touches withEvent:(UIEvent *)event; --(void)touchEnded:(NSSet *)touches withEvent:(UIEvent *)event; --(void)touchCanceled:(NSSet *)touches withEvent:(UIEvent *)event; - -//加速计事件 --(void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event; --(void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event; --(void)motionCanceled:(UIEventSubtype)motion withEvent:(UIEvent *)event; - -//远程控制事件 --(void)remoteControlReceivedWithEvent:(UIEvent *)event; -``` - -# 事件的产生和传递 - -* 发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中去 -* UIApplication会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常先分发事件给应用程序的主窗口(keyWindow) -* 主窗口会在视图层次结构中寻找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程中最重要的一步。 - - - -找到合适的视图控件后,就会调用视图控件的touch方法来做具体的事件处理逻辑 - - - -## UIView不接收事件的3种情况 - -1. 不接收用户交互。view.userInteractionEnabled = NO -2. 隐藏。view.hidden = YES -3. 透明度很低。view.alpha = 0.0 ~ 0.01 - - - -注意:UIImageView的userInteractionEnabled默认为NO,因此UIImageView及其它上面的子控件默认是不能接受触摸事件的。 - - - diff --git a/Chapter1 - iOS/1.110.md b/Chapter1 - iOS/1.110.md deleted file mode 100644 index 504c403..0000000 --- a/Chapter1 - iOS/1.110.md +++ /dev/null @@ -1,431 +0,0 @@ -## 妙用设计模式来设计一个客户端校验器 - - -> 业务逻辑千变万化,弹窗优先级不断改变,代码冗余问题和难以维护问题如何解决? -> 本篇文章从设计模式角度出发,讨论责任链设计模式和工厂设计模式2个方式,如何去设计一个校验器,同时解决代码冗余和难以维护的问题 - - -## 问题背景 - -订单在提交的时候会面临不同的校验规则,不同的校验规则会有不同的处理。假设这个处理就是弹窗。 -有的时候会命中规则1,则弹窗1,有的时候同时命中规则1、2、3,但由于存在规则的优先级,则会处理优先级最高的弹窗1。 - -老的业务背景下,弹窗优先级或者说校验规则是统一的。直接用函数翻译实现,写多个 if 问题不大。 -但在新业务背景下,不同的条件,弹窗优先级不一致,之前的写法需要写大量的嵌套判断,代码难以维护。 - -所以问题抽象为:如何设计一个校验器 - - -为了清晰说明问题,假设线上的弹窗校验规则为:A -> B -> C - -```Plain -typedef NS_ENUM(NSUInteger, OrderSubmitReminderType) { - OrderSubmitReminderTypeNormal = 0, // 没有命中校验规则 - OrderSubmitReminderTypeA, // 命中校验规则 A - OrderSubmitReminderTypeB, // 命中校验规则 B - OrderSubmitReminderTypeC, // 命中校验规则 C -} -``` - -老规则比较简单,不存在不同的校验规则,所以需求可以直接用代码翻译,不需要额外设计 - -```Shell -+ (OrderSubmitReminderType)acquireOrderValidateType:(id)params { - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - return OrderSubmitReminderTypeNormal; -} -``` - -假设只有2个弹窗条件:是否是 VIP 账户(isVIP)、是否是付费用户(isChargedAccount)。 - -- isVIP & isChargedAccount: A -> B -> C -- isVIP & !isChargedAccount:B -> C-> A -- !isVIP: C -> B -> A - -如果直接改,代码就是一坨垃圾了 - -```Shell -+ (OrderSubmitReminderType)acquireOrderValidateType:(id)params { - if (isVIP) { - if (isChargedAccount) { - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - return OrderSubmitReminderTypeNormal; - } else { - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - return OrderSubmitReminderTypeNormal; - } - } else { - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - return OrderSubmitReminderTypeNormal; - } -} -``` - - - -## 思路 - -可能有些人会觉得,那不简单,我将不同组合条件下的弹窗抽取为3个方法,照样很简洁 - -```Shell -+ (OrderSubmitReminderType)acquireOrderValidateTypeWhenIsVIPAndChargedAccount:(id)params { - // A->B->C - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - return OrderSubmitReminderTypeNormal; -} - -+ (OrderSubmitReminderType)acquireOrderValidateTypeWhenIsVIPAndNotChargedAccount:(id)params { - // B -> C-> A - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - return OrderSubmitReminderTypeNormal; -} - -+ (OrderSubmitReminderType)acquireOrderValidateTypeWhenIsNotVIP:(id)params { - // C -> B-> A - if ([OrderSubmitUtils validateC:params]) { - return OrderSubmitReminderTypeC; - } - if ([OrderSubmitUtils validateB:params]) { - return OrderSubmitReminderTypeB; - } - if ([OrderSubmitUtils validateA:params]) { - return OrderSubmitReminderTypeA; - } - return OrderSubmitReminderTypeNormal; -} -``` - -其实不然,问题还是很多: - -- 虽然抽取为不同方法,但是每个方法内部存在大量冗余代码,因为每个校验规则的代码是一样的,重复存在,只不过先后顺序不同 -- 存在隐含逻辑。 return 顺序决定了弹窗优先级的高低(这一点不够痛) - - - -## 方案 - -那能不能优化呢?有3个思路:责任链设计模式、工厂设计模式、策略模式 - -策略模式:当需要根据客户端的条件选择算法、策略时,可用该模式,客户端会根据条件选择合适的算法或策略,并将其传递给使用它的对象。典型设计前端 Vue-Validator form 各种 rules - -职责链模式:当需要根据请求的内容选择处理器时,可用该模式,请求会沿着链传递,直到被处理,如 Node 洋葱模型 - -不过目前来看,策略模式被 Pass 了 - -### 责任链设计模式 - -责任链模式即 Chain Of Responsibility,属于行为型模式。行为型模式不仅描述对象或类的模式,还描述他们之间的通信模式,比如对操作的处理该如何传递等等。 - -为什么会有这个思路? - -主要来源于2个方向:Node 的洋葱模式、移动端的点击事件传递。 - -移动端的事件响应模型:点击 view 看看能不能响应,不能响应则继续向上抛,直到抛到 window 为止; - -前端 JS 事件冒泡机制:点击事件假设是动态绑定到 DOM 节点上的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 DOM 拥有对该点击事件的平等处理权,所以就诞生了事件冒泡和组织冒泡的能力 `event.stopPropagation()` - - - -Node 洋葱模式:发送一个 Request 一层层中间件去处理,比如添加日志、添加请求拦截转发、处理核心业务逻辑、添加日志、添加自定义 response header等,一个中间件层只关注聚焦自己层需要做的事情,处理完继续向下一层抛。 - -设想下如果没有中间价模型,假设实现一个记录请求事件和自定义 HTTP Header 的需求,业务逻辑 curd 代码和记录请求时间和自定义 Header 代码全都杂糅在一起,难以维护。 - -责任链的核心就是:**使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。** - -- 降低处理者对象之间的耦合度。一个对象无须知道到底是哪一个对象处理其请求以及链的结构,发送者和接收者也无须拥有对方的明确信息。 - -- 增强了系统的可扩展性。可以根据业务需求增加或者调整新的请求处理类,满足开闭原则(类似维护链表的节点信息) - -- 可插拔,增强了给对象指派职责的灵活性。当工作流程发生变化,可以动态地改变链内的成员或者调动它们的次序,也可动态地新增或者删除责任。 - -- 责任链简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。 - -- 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。 - - - -采用责任链设计模式。基类 `OrderSubmitBaseValidator` 声明接口,是一个抽象类: - -- 有一个属性 `nextValidator` 用于指向下一个校验器 -- 有一个方法 `- (void)validate:(id)params;` 用于处理校验,内部利用模版模式,默认实现是传递给下一个校验器 - -```Shell -//.h -OrderSubmitBaseValidator { - @property nextValidator; - - - (void)validate:(id)params; - - (BOOL)isValidate:(id)params; - - (void)handleWhenCapture; -} - - -// .m -#pragma mark - public Method -- (BOOL)isValidate:(id)params { - Assert(0, @"must override by subclass"); - return NO; -} -- (void)validate:(id)params { - BOOL isValid = [self isValidate:params]; - if (isValid) { - [self.nextValidator validate:params]; - } else { - [self handleWhenCapture]; - } -} - -- (void)handleWhenCapture { - Assert(0, @"must override by subclass"); -} -``` - -然后针对不同的校验规则声明不同的子类,继承自 `OrderSubmitBaseValidator`。根据A、B、C 3个校验规则,有:OrderSubmitAValidator、OrderSubmitBValidator、OrderSubmitCValidator。 - -子类去重写父类方法 - -```Shell -OrderSubmitAValidator { - - (BOOL)isValidate:(id)params { - // 处理是否满足校验规则A - } - - - (void)handleWhenCapture { - // 当不满足条件规则的时候的处理逻辑 - displayDialogA(); - } -} -``` - -为了设计的健壮,假设没有命中任何校验规则,需要如何处理?这个能力需要有兜底默认的行为,比如打印日志:`NSLog(@"暂无命中任何弹窗类型,参数为:%@",params);` 也可以由业务方传递 - -```Shell -OrderSubmitDefaultValidator *defaultValidator = [OrderSubmitDefaultValidator validateWithBloock:^ { - SafeBlock(self.deafaultHandler, params); - if (!self.deafaultHandler) { - NSLog(@"暂无命中任何弹窗类型,参数为:%@",params); - } -}]; -``` - -初始化多个校验规则 - -```Shell -OrderSubmitAValidator *aValidator = [[OrderSubmitAValidator alloc] initWithParams:params]; -OrderSubmitBValidator *bValidator = [[OrderSubmitBValidator alloc] initWithParams:params]; -OrderSubmitCValidator *cValidator = [[OrderSubmitCValidator alloc] initWithParams:params]; -``` - -不同优先级的校验如何指定: - -```Shell -if (isVIP) { - if (isChargedAccount) { - aValidator.nextValidator = bValidator; - bValidator.nextValidator = cValidator; - } else { - bValidator.nextValidator = cValidator; - cValidator.nextValidator = aValidator; - } -} else { - cValidator.nextValidator = bValidator; - bValidator.nextValidator = aValidator; -} -``` - -但还是不够优雅,这个优先级需要用户感知。能不能做到业务方只传递参数,内部判断命中什么弹窗优先级组合。所以接口可以设计为 - -```Shell -[OrderSubmitValidator validateWithParams:params handleWhenNotCapture:^{ - NSLog(@"暂无命中任何弹窗类型,参数为:%@",params); -}]; -``` - -上述方法其实等价于 - -```Shell -let validateType = [OrderSubmitValidator generateTypeWithParams:params]; -[OrderSubmitValidator validateWith:validateType]; -``` - -利用策略模式 `validateWith` 方法内部根据 validateType 去组装 Map 的 key,然后从 Map 中取出具体规则组合,然后依次迭代遍历执行 - -``` -let rulesMap = { - isVIP && isCharged : [a-b-c-d], - isVIP && !isCharged: [a-b-d-c], - !isVIP: [a-c-d-b], -} -``` -这部分策略的生成也可以单独抽取出去,比如 ValidateStrategyFactory 去根据不同的信息,生成不同的策略。 - -优点: - -1. 解决了现在的错误弹窗的隐含逻辑,后续人接手,弹窗优先级清晰可见,提高可维护性,减少出错概率 -2. 对于判断(校验)的增减都无需关心其他的校验规则。类似维护链表,仅在一开始指定即可,符合“开闭原则” -3. 对于现有校验规则的修改足够收口,每个规则都有自己的 validator 和 validate 方法 -4. 目前弹窗优先级针对 isVIP、isCharged 存在不同优先级顺序,如果按照现有的方案实施,则会存在很多冗余代码 -5. 按照策略模式,不同的校验规则,组装不同的策略,也可以单独抽取出去,独立维护,更清晰 -6. validate 内部按照模版模式,调用 `isValidate` 方法,每个单独的 Validator 不需要额外去调用 next,设计更加健壮,防止别人漏写 - - - -### 工厂设计模式 - -设计基类 - -```Shell -OrderSubmitBaseValidator { - - (void)validate; - - - (BOOL)validateA; - - (BOOL)validateB; - - (BOOL)validateC; -} - -- (void)validate { - Assert(0, @"must override by subclass"); -} - -- (BOOL)validateA { - // 判断是否命中规则 A -} -- (BOOL)validateB { - // 判断是否命中规则 B -} - -- (BOOL)validateC { - // 判断是否命中规则 C -} -``` - -根据不同的弹窗优先级条件,声明3个不同的子类:`OrderSubmitAValidator`、`OrderSubmitBValidator`、`OrderSubmitCValidator`。各自重写 `validate` 方法 - -```Shell -OrderSubmitAValidator { - - (void)validate { - [self validateA]; - [self validateB]; - [self validateC]; - } -} - -OrderSubmitBValidator { - - (void)validate { - [self validateB]; - [self validateC]; - [self validateA]; - } -} - -OrderSubmitCValidator { - - (void)validate { - [self validateC]; - [self validateB]; - [self validateA]; - } -} -``` - -设计工厂类`OrderSumitValidatorFactory`,提供工厂初始化方法 - -```Shell -OrderSumitValidatorFactory { - + (OrderSubmitBaseValidator *)generateValidatorWithParams:(id)params; -} - -+ (OrderSubmitBaseValidator *)generateValidatorWithParams:(id)params { - if (isVIP) { - if (isChargedAccount) { - return [[OrderSubmitAValidator alloc] initWithParams:params]; - } else { - return [[OrderSubmitBValidator alloc] initWithParams:params]; - } - } else { - return [[OrderSubmitCValidator alloc] initWithParams:params]; - } -} -``` - -优点: - -- 没有重复逻辑,判断方法都守口在基类中 -- 优先级的关系维护在不同的子类中,各司其职,独立维护 - - -最后选什么?组合优于继承,个人倾向使用责任链模式去组织代码。关于责任链设计模式的文章也可以看这篇[文章](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/Chapter6%20-%20Design%20Pattern/6.23.md) - - - -## 拓展 - -如果业务真的是高频迭代变化,但校验顺序不变的话,甚至可以做成多端协定后,对应业务校验编号和业务关联,动态下发 - -```json -// Version1 -{ - "validatorRuleOrder": ["1", "4", "3", "2"] -} - -// Version2 -{ - "validatorRuleOrder": ["1", "3", "4", "2"] -} -``` - -App 动态请求,然后执行业务逻辑。需思考一些问题: - -- 网络请求慢怎么处理? -- 需不需要缓存? -- 有缓存的话,更新策略是什么? -- 需不需要内置的产品逻辑? - -当然,这不在本篇文章范畴内,不做展开。 diff --git a/Chapter1 - iOS/1.111.md b/Chapter1 - iOS/1.111.md deleted file mode 100644 index 13494ab..0000000 --- a/Chapter1 - iOS/1.111.md +++ /dev/null @@ -1,364 +0,0 @@ -## 写给 iOSer 的鸿蒙开发 tips - -## 下载问题 -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/DevEco-Studio-DownloadNetworkErrror.png) -The other possible cause is that the system language of the PC is English and the region code is US. You could try to perform the following operations to change the region code to CN. Before changing the region code, close DevEco Studio. - - -For Mac OS: ~/Library/Application Support/Huawei/DevEcoStudio3.0/options/country.region.xml - -修改为 CN - -```xml - - - - - -``` - - - -## 鸿蒙开发任务拆分 - -### 依赖梳理 - -- 梳理依赖的二方、三方 SDK,根据对应 SDK 的鸿蒙化排期调整整体项目的排期 -- 暂时不能鸿蒙化的 SDK 功能降级 - -### 优先级对齐 - -- 根据依赖梳理的排期,倒推二方 SDK 的优先级 -- 梳理各个团队的 P1、P2、P3 任务 - -### 功能定版 - -- 考虑到部分基础能力无法实现和人力资源排期的问题,无法完全对齐 Android 版本 -- 根据前期梳理,确定每个节点的功能范围 - -### 架构设计 - -- App 模块架构基本对齐 Android -- 鸿蒙特性部分微调 -- 基础能力下沉到 C++,提升复用 - -## 规范 - -### 代码规范制定 - -除了常规流程的 MR,但对于鸿蒙,大家都属于摸着石头过河的状态。开发也是摸着石头过河,所以会变开发,定期制定、更新代码规范文档,并定期 review 项目代码,完善代码规范,避免糟糕代码野蛮生长 - -### 踩坑分享 - -及时收集整理开发过程中的踩坑点,定期在团队内分享,减少或避免相同问题的再次发生 - -### 最佳实践分享 - -定期梳理最佳实践,鼓励分享。快速提高、拉齐大家的鸿蒙水平 - - - -## 鸿蒙背景下的跨端方案 - -业务使用 TS 开发,公用一套渲染引擎 - - - -### 引擎选择 - -| 引擎 | | 优点 | 缺点 | -| -------------- | --------------- | --------------------------------- | ----------------------------- | -| JavaScriptCore | Apple | 在 iOS 平台上有非常明显的主场优势 | 在 Android 缺乏优化,适配不好 | -| V8 | Google | 性能优异,支持 JIT,支持调试 | 体积大、内存占用大 | -| quickJS | Fabrice Bellard | 体积小、内存小 | 无法进行 JIT,不支持调试 | -| panda 熊猫 | 鸿蒙 | 内置 JS 引擎,支持 JIT | 没有太多关于引擎的介绍 | - -所以 iOS 侧旋 JavascriptCore 引擎,Android 选 quickJS 作为 JS 引擎,后续增加 V8 作为本地开发调试的引擎。 - - - -## 公共能力 - -### 公共桥能力 - -业务方可以通过公共桥直接使用 Native 侧的能力: - -- modal 弹窗:Modal 桥主要用来展示 toast、dialog、window 等一些原生系统弹窗能力 -- keyboard:keyboard 桥主要用来控制键盘相关的操作 -- Navigator:Navigator 桥主要用来负责导航以及对导航栏的定制操作 -- Network:Network 桥主要用于网络相关的操作 -- Storage:Storage 桥主要用来持久化存储和获取数据 -- Broadcast:Broadcast 桥主要用于接收和发送通知 - -### 自定义桥能力开发 - -- 定义桥协议:定义需要实现的功能、确定桥所属模块与方法名、参数等 -- TS 侧开发:在 TS 侧实现桥协议的方法,确定以同步还是异步的方式回调 -- Native 侧开发:通过公共通信层解析 TS 侧透传的方法与参数,实现 Native 侧功能与回调 -- 桥注册:桥开发完毕后,需要在 Native 侧通过 registerCustomModule 方法,注册后才可以使用 -- 桥使用:TS 侧业务代码通过调用桥协议,来使用自定义桥功能 - -### 渲染 - -将 js 引擎返回的 UI 数据通过解析进行渲染,根布局为 stack,子组件通过 offset 确定位置、size 确定大小、type 确定组件类型。会有重叠、内嵌的情况,则递归循环渲染即可 - -参考鸿蒙 RN 团队在1月份的方案,使用 stack 组件内部,forEach 循环渲染子组件。适配初期,在嵌套不深的页面没有发现问题,整体上打通了从 JS 代码到引擎渲染的核心流程。 - - - -左侧的 UI 树和右侧的 Model 树,通过 `@ObjectLink` 、`@Observed` 来进行数据渲染和刷新。 - - - - - -## 鸿蒙APO 探索之路 - -AOP Aspect Oriented Programming 是一种编程范式,被允许开发者将关注点与业务逻辑中分离出来。 - -AOP 优势:解耦、复用、模块化 - -AOP 应用场景:日志、埋点、监控 - -### AOP 的实现方案 - -- 编译时:AspectJ -- 类加载时:AspectJ -- 链接时:fishhook -- 运行时:Epic - -### 为什么使用 AOP? - -日志、网络、性能监控、埋点等多个 AOP 使用场景。 - -### 鸿蒙 AOP 方案探索 - -#### 痛点 - -- 匿名函数、箭头函数:`Button.onClick(ClickEvent)` -- 函数局部类: `HttpClient#Builder()#build()` - -```ts -View(). -onClick(() => { - // handle business logic -}) -``` - -- 属性不可修改场景:`router.pushUrl` - - `Object.defineProperty()` 当 writeable 特性设置为 false 的时候,该属性是不可修改的。尝试对一个不可修改的属性进行写入时不会改变它。在严格模式下还会报错。 - - 鸿蒙也是基于 TS 的,所以也可以调用 `Object.freeze()` 冻结属性。比如鸿蒙早期路由实现,是基于 Router 的,很多 API 的参数,writeable 属性都是 false。 - -解决方案 - -无统一修改点场景一:箭头参数函数 `Button.onClick(ClickEvent)` - -构造一个第一个参数为函数的 wrappFn 函数,持有目标参数函数。就跟 Native 侧的 hook 一样。构造一个一样的 hook 函数。 - -```ts -function hookMethod(traget, action, beforeFn?, afterFn?) { - wrapMethod(target, action, (originalMethod) => function(callback) { - const wrappedCallback = (...args) => { - beforeFn?.apply(this, args) - callback.apply(this, args) - afterFn?.apply(this, args) - } - originalMethod.call(this, wrappedCallback) - }); -} -``` - -使用 - -```ts -Aspect.addBefore(Button, 'onClick', () => { - router.pushUrl({url: 'pages/Index' }) - logger.w(TAG, '1.Aspect add before --- Button#onClick()#action, do your business...'); -}, true) -``` - -无统一修改点场景二:局部类 `HttpClient#Builder()#build()` - -AOP 的本质是关注点分离,面对这种情况,每次通过 HttpClient 获取 Budiler() 就会产生不同的对象,所以传统的通过 hook 某个类的方法形式,已经不再适用了。所以需要通过更高层的 hook,即 `Object.defineProperty` - -通过属性定义拦截 HttpClient 的 Builder() 属性的获取,builder 的获取,都会被收口拦截。 - -```ts -function hookMethod(target: any, propertyName: string, methodName: string, beforeFn?: (context: any, args: any[]) => void, afterFn?: (context: any, args: any[], result: any) => void) { - - const propertyDescriptor = Object.getOwnPropertyDescriptor(target, propertyName) - if (propertyDescriptor && propertyDescriptor.get) { - Object.defineProperty(target, propertyName, { - get() { - const originalTarget = propertyDescriptor.get!.call(this) - const originalMethod = originalTarget.prototype[methodName] - originalTarget.prototype[methodName] = function (...args: any[]) { - beforeFn?.call(this, this, args) - let result = originalMethod.apply(this, args) - afterFn?.call(this, this, args, result) - return result - } - return originalTarget - } - }) - } -} -``` - -使用 - -```ts -Apsect.hookMethod({ - target: HttpClient, - methodNameOrProperty: 'Builder', - beforeFn: (context: args) => { - const builderContext = context as InstanceType; - builderContext._eventListener = new MyEnevtListener() - builderContext.addInterceptor(new MyEnevtListener()); - }, - propertyMethodNameOrType: 'build' -}) -``` - -无统一修改点场景三:`router.pushUrl`,编译时 + 运行时组合实现偷梁换柱。 - - - -如何实现编译时替换? - -鸿蒙提供了 Hvigor Plugin 编译时自定义插件,用于实现定制化构建。思路:直接利用 Hvigor Plugin hook 某个编译 task - - - -那到底 hook 哪个编译 task? - - - -问题: - -- output 修改无效 - - - - - - ArkTS 编译之后会产生临时目录,将 ets 编译为 ts。那是不是可以直接修改产物,看看最后能不能影响方舟字节码。 - - 发现修改了 index.protoBin 、ts 文件,发现最终无法影响编译产物 `*.abc` 文件 - - 联系了鸿蒙团队的工程师,验证了说是2条并行链路。并不是先编译产生临时文件,再通过临时文件产生 `*.abc` 文件。事实上是2个并行过程。所以此路不通 - -- input 无法 hook,Hvigor plugin 暂未开放相关能力 - - - - Hvigor 目前开放的能力有限,仅支持在默认的 task 前后加一些钩子函数。但无法修改 input。此路不通 - -- 既然没法直接修改默认 task,怎么实现? - - 思路:copy -> modify -> revert - - - - - - 插桩只能影响产物,不能影响源码。所以先对源码进行备份。 - - ```ts - export function HllEntreyPlugin(): HvigorPlugin { - return { - pluginId: 'HllEntryPlugin', - apply: (node: HvigorNode) => { - node.registerTask({ - name: 'HllEntreyPluginInjectTask', - run: () => { - dispatcherToPlugins(node) // copy & modify - } - }); - node.registerTask({ - name: 'HllEntryPluginResetTask', - run: () => { - resetPluginCodes(originalBackUpFiles) // revert - }, - dependcies: ['default@CompileArkTS'], - postDependencies: ['assembleHap'] - }) - } - } - } - ``` - - 如何修改 AST? - - 鸿蒙目前没有提供修改 AST 的能力。如何做? - - ArkTS 虽然没有提供 AST 相关 API。但从 CompileArkTS Task 产物来看,ArkTS 最终会编译成 TS。 - - 且 TS 提供 AST 相关 API。TS 是开源的,TS 开源代码中有关于 AST 的模块。[TS Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) - - 可以基于源码中 AST 相关的 API 可以抽取封装一下。 - - 于是,整个流程就变成了 - - - - 可以利用 [Typescript AST Viewer](https://github.com/dsherret/ts-ast-viewer) 来查看 AST 抽象语法树信息。可以在线查看 AST,官网地址为:[Typescript AST Viewer](https://ts-ast-viewer.com) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -#### Aspect(运行时) - -官方在 API 11 开始提供的方案,可快速实现对类方法前后进行插桩或替换。 - -关键点一:属于可修改-即 addBefore、addAfter、replace 接口的原理,基于 class - - - -#### AspectPro V1(编译时<正则> + 运行时) - -#### AspectPro V2(编译时 + 运行时) - diff --git a/Chapter1 - iOS/1.112.md b/Chapter1 - iOS/1.112.md deleted file mode 100644 index 7fd81a1..0000000 --- a/Chapter1 - iOS/1.112.md +++ /dev/null @@ -1,442 +0,0 @@ -# Swift 枚举值内存布局 - -> enum 使用很简单,那大家有没有思考过系统针对枚举的实现是怎么样的? -> -> 不同类型的枚举占用多大内存空间?下面结合汇编来窥探下系统实现原理 - - - -## 基础枚举(不带关联值、不带原始值) - -```swift -enum Season { - case spring - case summer - case antumn - case winter -} - -var season: Season = Season.spring -print(Mems.ptr(ofVal: &season)) -season = Season.summer -season = Season.antumn -print("over") -``` - -- `var season: Season = Season.spring` 基础枚举,默认值是0。 - - - -- `season = Season.summer`,此时可以看到第一个字节的位置是1. - - - -- `season = Season.antumn` ,此时可以看到第一个字节的位置是2 - - - -结论:查看内存信息,可以看到**不带关联值、不带原始值的基础枚举,只占1个字节大小空间,且值为默认值** - -延伸:对于无关联值、无原始值的简单枚举,Swift 编译器会进行内存优化: - -- 当枚举 case ≤ 256 个时,使用 1 字节(UInt8) -- 当 case ≤ 65536 时,使用 2 字节(UInt16) - - - -## 只有原始值的枚举 - -不带关联值、只有原始值的枚举 - -```swift -enum Season : Int { - case spring = 1 - case summer = 2 - case antumn = 3 - case winter = 4 -} - -//print(MemoryLayout.size) -//print(MemoryLayout.stride) -//print(MemoryLayout.alignment) - -var season: Season = Season.spring -print(Mems.ptr(ofVal: &season)) -season = .summer -season = .winter -print("over") -``` - -- `var season: Season = Season.spring` 基础枚举,变量默认值,可以看到第一个字节的位置是0 - - - -- `season = .winter` 基础枚举,当赋值为 winter 的时候,可以看到第一个字节的位置是3 - - - -结论:**只带有原始值的枚举,同样只占用1个字节,该字节的值为枚举的位置索引(比如1、2),而非原始值。原始值不占用枚举的内存** - - - -## 只带有关联值的枚举 - -```swift -enum Season { - case spring(Int, Int, Int) - case summer(Int, Int) - case antumn(Int) - case winter(Bool) - case unknown -} - -print(MemoryLayout.size) -print(MemoryLayout.stride) -print(MemoryLayout.alignment) - -var season: Season = Season.spring(1, 2, 3) -print(Mems.ptr(ofVal: &season)) -season = Season.summer(4, 5) -season = Season.antumn(6) -season = Season.winter(true) -season = Season.unknown -print("over") -``` - -- `var season: Season = Season.spring(1, 2, 3)` 带有关联值的枚举: - - `.spring` 有3个 Int,单个 Int 占8个字节空间,所以红色框代表 spring 的1,蓝色框代表 spring 的2,绿色框代表 spring 的3,黄色框代表枚举的第1个 case,剩余7个字节,为空。后续的7个字节是为了内存对齐而补齐的内存。 - - - - 其内存信息如下(8字节为1组,对应上图) - - ```shell - 01 00 00 00 00 00 00 00 - 02 00 00 00 00 00 00 00 - 03 00 00 00 00 00 00 00 - 00 - 00 00 00 00 00 00 00 - ``` - - 这段内存信息怎么看?我划分了下 - - ```shell - case spring 的关联值的第1个 Int: 01 00 00 00 00 00 00 00 - case spring 的关联值的第2个 Int: 02 00 00 00 00 00 00 00 - case spring 的关联值的第3个 Int: 03 00 00 00 00 00 00 00 - 表明哪个 case 的索引值: 00 - 内存对齐占用: 00 00 00 00 00 00 00 - ``` - - 下面的几组一样 - -- `season = Season.summer(4, 5)` 带有关联值的枚举,`.summer` 这个枚举关联值有2个 Int,单个 Int 占8个字节空间,所以红色框代表 summer 第一个关联值 4,蓝色框代表 summer 第二个关联值 5,绿色框为空,黄色框代表枚举的第2个 case,剩余7个字节,为空。 - - - - 其内存信息如下(8字节为1组,对应上图) - - ```shell - case summer 的关联值的第1个 Int: 04 00 00 00 00 00 00 00 - case summer 的关联值的第2个 Int: 05 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 表明哪个 case 的索引值: 01 - 内存对齐占用: 00 00 00 00 00 00 00 - ``` - -- `season = Season.antumn(6) 带有关联值的枚举,`. `antumn` 存在关联值 1个 Int,单个 Int 占8个字节空间,所以红色框代表 antumn 的6,蓝色框为空,绿色框为空,黄色框代表枚举的第3个 case,剩余7个字节,为空。 - - - - 其内存信息如下(8字节为1组,对应上图) - - ```shell - case autumn 的关联值的第1个 Int: 06 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 表明哪个 case 的索引值: 02 - 内存对齐占用: 00 00 00 00 00 00 00 - ``` - -- `season = Season.winter(true)` 带有关联值的枚举,`. `winter` 关联值是 1个 Bool,单个 Bool 占1个字节空间,所以红色框代表 winter 的 true,蓝色框为空,绿色框为空,黄色框代表枚举的第4个 case,剩余7个字节,为空。 - - - - 其内存信息如下(8字节为1组,对应上图) - - ```shell - case winter 的关联值的第1个 Int: 01 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 表明哪个 case 的索引值: 03 - 内存对齐占用: 00 00 00 00 00 00 00 - ``` - -- `season = Season.unknown` 带有关联值的枚举,`unknown` 没有关联值,所以红色框为空,蓝色框为空,绿色框为空,黄色框代表枚举的第5个 case,剩余7个字节,为空。 - - - - 其内存信息如下(8字节为1组,对应上图) - - ```shell - case unknown 关联值的第1个Int: 00 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 空(按照最大内存预留的位置): 00 00 00 00 00 00 00 00 - 表明哪个 case 的索引值: 04 - 内存对齐占用: 00 00 00 00 00 00 00 - ``` - -- `MemoryLayout.size` :3个 Int 最大为3*8,1个字节用来表达位置信息,`3*8 + 1 = 25` - -- `MemoryLayout.stride` :获取系统分配给数据类型的内存大小,也就是实际内存大小(对齐后的) - -- `MemoryLayout.alignment` 内存对齐系数,以8 Byte 为单位,对象分配的内存必须是该值的整数倍 - -结论: - -- **对于有不同枚举值有不同关联结构的枚举,其内存由关联值最大的 case 的内存决定(其余的 case 沿用该结构。所有 case 共享同一块内存区域。该区域大小为最大关联值所需内存)** -- **除了最大内存的 case 外,还需要1个字节存储所属哪个 case**(通常 1 字节,具体取决于 case 数量,1字节能表示的 case 数量为256个) -- **枚举总内存实际大小 = 1个字节存储所属哪个 case + 关联值所占内存最大的 case 的内存大小** -- **另外,枚举所占内存还需要考虑内存对齐的情况。比如本例中实际内存为25,内存对齐为8字节,所以最终分配了32字节的内存** - - - -## 只有一个 case 的枚举 - -```swift -enum SimpleEnum { - case one -} -var caseOne = SimpleEnum.one -print(MemoryLayout.size) // 0 -print(MemoryLayout.stride) // 1 -print(MemoryLayout.alignment) // 1 -``` - -为什么 size 为0?看上去是一个变量,但根本不占内存。因为枚举里面就一个 case,所以里面根本不需要存储值来区分是哪个 case。 - -```swift -enum SimpleEnum { - case one - case two -} -var caseOne = SimpleEnum.one -print(MemoryLayout.size) // 1 -print(MemoryLayout.stride) // 1 -print(MemoryLayout.alignment) // 1 -``` - -现在好理解,2个 case 需要存储1个 Byte 的值来区分是哪个 case,1 Byte 可以代表最多256个 case - - - -## 只有1个 case 且带关联值的枚举 - -```swift -enum SimpleEnum { - case one(Int) -} -var caseOne = SimpleEnum.one(4) -print(MemoryLayout.size) // 8 -print(MemoryLayout.stride) // 8 -print(MemoryLayout.alignment) // 8 -``` - -- 带有关联值且只有1个 case 的枚举,因为有1个 Int 的关联值,需要8 Byte 存储关联值 -- 但只有1个 case,不需要额外空间来判断所属哪个枚举值,所以不需要额外空间 - - - -请看下面的对照实验 - -```swift -enum SimpleEnum { - case one(Int) - case two -} -var caseOne = SimpleEnum.one(4) -print(MemoryLayout.size) // 9 -print(MemoryLayout.stride) // 16 -print(MemoryLayout.alignment) // 8 -``` - -2个 case,其中一个 case 有关联值 Int,所以需要8 Byte 存 Int 值,1 Byte 区分是哪个 case,实际需要占用 8 + 1 = 9 Byte,内存对齐单位是8,9向上为16。 - - - -### 为什么不能复用关联值的空间 - -case1 占用8个字节,case2 占用1个字节,能用 case1 的8个字节的最开始的位置存储 case2 的信息吗?这样的话节省内存 - -- **内存布局的确定性**:Swift 要求枚举实例的内存布局在编译时确定。若允许不同 case 复用同一块内存,会导致运行时动态解析内存布局,降低性能和安全性。 - -- **所有 case 共享同一块内存**:枚举实例的内存大小由**最大关联值的大小 + 标签所需空间**决定。 - -- **标签(Discriminant)**:用于区分不同 case,通常占用 1 字节(但具体由 case 数量决定)。 - - **标签的占用大小**由枚举的 `case` 数量决定: - - - **1 个 `case`**:不需要标签(因为没有其他可能性)。 - - **2~256 个 `case`**:标签通常占用 **1 字节**(`UInt8`,可以表示 256 种可能)。 - - **257~65536 个 `case`**:标签占用 **2 字节**(`UInt16`)。 - - 更大数量依此类推(但实际中几乎不会用到如此多的 `case`)。 - - - **内存对齐约束**:即使标签的逻辑占用小于 1 字节(例如只有 2 个 `case`,理论上只需 1 位),实际仍会占用 **至少 1 字节**(因为内存按字节寻址)。 - -- **内存对齐**:总大小会按对齐要求(如 8 字节)向上取整到最近的倍数(即 `stride`)。 - - - -以示例中的 `SimpleEnum` 为例: - -- `case one(Int)`:需要 8 字节存储 `Int` + 1 字节标签 → 共 9 字节。 -- `case two`:仅需 1 字节标签 → 但内存仍需按最大 case 分配(即 8 字节关联值空间 + 1 字节标签 → 总 9 字节)。 - -因此,无论当前是哪个 case,枚举实例始终占用 **9 字节**(对齐后 `stride` 为 16 字节)。 - - - -更改验证标签对于枚举占用内存大小的影响 - - - -可以看到 enum 只有1个 case 的时候,内存大小只和最大关联值大小有关,1个 case 的情况下不需要额外的空间来判断所属哪个 case。 - -因此此时枚举的内存大小 = 最大关联值的内存大小 = 8 - - - -## 用汇编验证下内存 - -```swift -enum Season { - case spring(Int, Int, Int) - case summer(Int, Int) - case antumn(Int) - case winter(Bool) - case unknown -} - -var season: Season = Season.spring(1, 2, 3) -print(Mems.ptr(ofVal: &season)) -season = Season.summer(4, 5) -season = Season.antumn(6) -season = Season.winter(true) -season = Season.unknown -print("over") -``` - -断点停到 `var season: Season = Season.spring(1, 2, 3)` 位置 - - - -将断点处的汇编单独摘出来研究 - -```assembly -0x10000334b <+11>: movq $0x1, 0x8eaa(%rip) ; demangling cache variable for type metadata for Swift.Array + 4 -0x100003356 <+22>: movq $0x2, 0x8ea7(%rip) ; SwiftDemo.season : SwiftDemo.Season + 4 -0x100003361 <+33>: movq $0x3, 0x8ea4(%rip) ; SwiftDemo.season : SwiftDemo.Season + 12 -0x10000336c <+44>: movb $0x0, 0x8ea5(%rip) ; SwiftDemo.season : SwiftDemo.Season + 23 -0x100003373 <+51>: movl $0x1, %edi -``` - -`rip` 存储的说指令的地址。CPU 要执行的下一条指令地址就存储在 rip 中。所以在执行第一行的时候,rip 寄存器的值。 - -所以第一句汇编代码的意思是:rip 为 `0x100003356`,再加上 `0x8eaa`,得到一个地址值(用 Mac 自带的计算器可以算出)`0X10000C200`,然后 movq 是将十六进制的1赋值给 `0X10000C200` 这个地址。 - -第二句汇编代码类似,此时 rip 为 `0x100003361`,再加上 `0x8ea7`,得到一个地址值 `0X10000C208`,然后 movq 将十六进制的2赋值给 `0X10000C208` 这个地址。 - -第三句汇编代码类似,此时 rip 为 `0x10000336c`,再加上 `0x8ea4`,得到一个地址值 `0X10000C210`,然后 movq 将十六进制的3赋值给 `0X10000C210` 这个地址。 - -第四句汇编代码类似,此时 rip 为 `0x100003373`,再加上 `0x8ea5`,得到一个地址值 `0X10000C218`,然后 movq 将十六进制的0赋值给 `0X10000C218` 这个地址。 - -此时断点走到下一行,拿到 season 的内存地址 `0X10000C200` ,查看内存发现和上面理论分析一直 - -```shell -01 00 00 00 00 00 00 00 -02 00 00 00 00 00 00 00 -03 00 00 00 00 00 00 00 -00 00 00 00 00 00 00 00 -``` - -结论:如果枚举存在关联值,内存大小为: -- 1个字节用来存储成员值 -- n个字节用来存储关联值(n取占用内存最大的关联值),任何一个 case 的关联值都共用这 n 个字节 -- 且存在内存对齐,所以占用大小为 n 和 1 的最大值,再结合内存对齐。 -- 如果枚举的定义非常简单,系统会用1个字节来存放值,最大范围是256个 case。 -- 枚举定义如果有原始值,也不会影响内存布局。 - - - -## switch 的工作原理 - -```swift -enum Season { - case spring(Int, Int, Int) - case summer(Int, Int) - case antumn(Int) - case winter(Bool) - case unknown -} - -var season: Season = Season.spring(1, 2, 3) -switch season { - case let .spring(v1, v2, v3): - print(".spring", v1, v2, v3) - case let .summer(v1, v2): - print(".summer", v1, v2) - case let .antumn(v1): - print(".antumn", v1) - case let .winter(v1): - print(".winter", v1) - case .unknown: - print(".unkown") -} -``` - -- Swift 先判断 season 的成员值,判断属于哪个 case。 - - 如果发现成员值为0,则走第1个 case,将 season 的前24个字节,分别赋值给第1个 case 的 v1、v2、v3 - - 如果发现成员值为1,则走第2个 case,将 season 的前16个字节,分别赋值给第2个 case 的 v1、v2 - - 如果发现成员值为2,则走第3个 case,将 season 的前8个字节,赋值给第3个 case 的 v1 - - 如果发现成员值为3,则走第4个 case,将 season 的第1个字节,赋值给第4个 case 的 v1 - - 如果发现成员值为4,则走第5个 case,则执行第5个 case 的打印逻辑 - - - -## Swift 枚举的本质 - -从表象来看,枚举存在以下情况: - -- **不带关联值、不带原始值的基础枚举,只占1个字节大小空间,且值为默认值** -- **只带有原始值的枚举,同样只占用1个字节,该字节的值为枚举的位置索引(比如1、2),而非原始值** -- **带有关联值的枚举内存大小:** - - **对于有不同枚举值有不同关联结构的枚举,其内存由关联值最大的 case 的内存决定(其余的 case 沿用该结构。所有 case 共享同一块内存区域。该区域大小为最大关联值所需内存)** - - **除了最大内存的 case 外,还需要1个字节存储所属哪个 case**(通常 1 字节,具体取决于 case 数量,1字节能表示的 case 数量为256个) - - **枚举总内存实际大小 = 1个字节存储所属哪个 case + 关联值所占内存最大的 case 的内存大小** - - **另外,枚举所占内存还需要考虑内存对齐的情况。比如本例中实际内存为25,内存对齐为8字节,所以最终分配了32字节的内存** - -但从本质来讲: - -- 对于无关联值、无原始值的简单枚举,Swift 编译器会进行内存优化: - - - 当枚举 case ≤ 256 个时,使用 1 字节(UInt8) - - - 当 case ≤ 65536 时,使用 2 字节(UInt16) - -- **内存布局的确定性**:Swift 要求枚举实例的内存布局在编译时确定。若允许不同 case 复用同一块内存,会导致运行时动态解析内存布局,降低性能和安全性。 - -- **所有 case 共享同一块内存**:枚举实例的内存大小由**最大关联值的大小 + 标签所需空间**决定。 - -- **标签(Discriminant)**:用于区分不同 case,通常占用 1 字节(但具体由 case 数量决定)。 - - **标签的占用大小**由枚举的 `case` 数量决定: - - - **1 个 `case`**:不需要标签(因为没有其他可能性)。 - - **2~256 个 `case`**:标签通常占用 **1 字节**(`UInt8`,可以表示 256 种可能)。 - - **257~65536 个 `case`**:标签占用 **2 字节**(`UInt16`)。 - - 更大数量依此类推(但实际中几乎不会用到如此多的 `case`)。 - - - **内存对齐约束**:即使标签的逻辑占用小于 1 字节(例如只有 2 个 `case`,理论上只需 1 位),实际仍会占用 **至少 1 字节**(因为内存按字节寻址)。 - -- **内存对齐**:总大小会按对齐要求(如 8 字节)向上取整到最近的倍数(即 `stride`)。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.113.md b/Chapter1 - iOS/1.113.md deleted file mode 100644 index c9f26bd..0000000 --- a/Chapter1 - iOS/1.113.md +++ /dev/null @@ -1,288 +0,0 @@ -# Swift 结构体和类的内存布局 - -## 结构体初始化器 - -实验1:在 struct 内部自己实现 init - -```swift -struct Point { - var x: Int - var y: Int - init () { - x = 0 - y = 0 - } -} -var point = Point() -``` - -在`init` 方法内第一行处加 断点,如下所示 - - - -实验2:struct 内不自己加 init - -```swift -struct Point { - var x: Int = 0 - var y: Int = 0 -} -var point = Point() -``` - -在`var point = Point()`处加 断点,如下所示 - - - -现象:可以看到加不加自定义初始化器的汇编代码基本相同。 - -结论:**如果没有为结构体声明初始化器,编译器会自动生成1个初始化器。目的是保证所有成员都有初始值。** - - - -## 结构体内存布局 - -```swift -struct CustomDate { - var year: Int - var month: Int - var isLeapYear: Bool -} -var date = CustomDate(year: 2024, month: 3, isLeapYear: false) -print(MemoryLayout.size) // 17 -print(MemoryLayout.stride) // 24 -print(MemoryLayout.alignment) // 8 -print(Mems.memStr(ofVal: &date)) // 0x00000000000007e8 0x0000000000000003 0x0000000000000000 -``` - -Int 占8 Byte,Bool 占1 Byte,共 2*8 + 1 = 17 Byte,由于存在内存对齐,所以17向上到24 Byte。 - - -- 值语义:`struct` 是值类型,这意味着当你将一个 `struct` 赋值给另一个变量或传递给函数时,会创建一个新的副本。每个副本都有其自己的内存空间,对其中一个副本的修改不会影响其他副本。 -- 内存连续性:`struct` 的成员变量在内存中是连续存储的,没有额外的内存开销(如对象指针或元数据)。这使得访问 `struct` 的成员变量非常高效。 -- 内存对齐:为了确保访问效率,编译器可能会对 `struct` 的成员变量进行内存对齐。这意味着某些成员变量之间可能会有未使用的内存空间(填充字节)。这种对齐通常是基于目标平台的硬件架构和访问性能考虑。 -- 嵌套结构体:如果 `struct` 包含其他 `struct` 或枚举作为成员,那么这些嵌套的类型也会按照它们自己的内存布局规则进行排列。 -- 可变大小结构体:在某些情况下,`struct` 的大小可能不是固定的。例如,如果 `struct` 包含可变长度的数组或字符串,那么它的实际大小将取决于这些成员的大小。然而,即使在这种情况下,`struct` 的内存布局仍然是紧凑的,并且遵循相同的访问规则。 -- 与类的比较:与 `class`(类)不同,`struct` 不需要额外的内存来存储类型信息、引用计数或其他元数据。这使得 `struct`通常比 `class` 更轻量级,并且在某些情况下具有更好的性能。 - - - -## 类的内存布局 - -类和结构体类似,但是编译器不会为类自动生成可以传入成员值的初始化器。 - -```swift -// 写法1 -class CustomDate { - var year: Int = 2024 - var month: Int = 3 -} -// 写法2 -class CustomDate { - var year: Int - var month: Int - init () { - year = 2024 - month = 3 - } -} -``` - -上面2个写法是等价的。 - - 结构体和类的区别: - -- 结构体是值类型(枚举也是值类型),类是引用类型(指针类型) - -值类型赋值给 var、let 或者给函数传参,是直接将所有内容拷贝一份。产生了全新副本,属于深拷贝。 - - - -### 值类型 - -```swift -func test() { - struct Point { - var x: Int - var y: Int - } - var point1 = Point(x: 10, y: 20) - var point2 = point1 - - point2.x = 11 - point2.y = 22 - print(point1.x) // 10 - print(point1.x) // 20 - print("over") -} -test() -``` - -因为是在函数内部变量,所以是在栈上分布 - -| 内存地址 | 内存数据 | 说明 | -| -------- | ---------------------------------- | -------- | -| 0x10000 | 10 ----赋值改变------> 11 | point2.x | -| 0x10008 | 20 ----赋值改变------> 22 | point2.y | -| 0x10010 | 10 | point1.x | -| 0x10018 | 20 | point1.y | - -断点打在 `var point1 = Point(x: 10, y: 20)` 处,查看汇编 - - - -乍一看如果不认识的话,先找字面量(立即数),比如红色框内的 `0xa`,就是 10,`0x14` 就是20。[之前](./109.md)学过寄存器的设计,64位寄存器是兼容32位寄存器的。红色框内将 `0xa`,也就是 10 保存到 `%edi ` 寄存器内部,也就是保存到 `%rdi` 中,将 `0x14` 也就是20,保存到 `%esi` 也就是保存到 `%rsi` 寄存器中。 - -LLDB 模式下输入 `si` 进入 init 方法内部。 - - - -可以查看到将 `%rsi` 里的10 保存到 `%rdx` 中了;将 `%rdi` 里的20 保存到 `%rax` 中了。这也就是 struct `init` 方法做的事情。 - -LLDB 模式下输入 `finish` 结束 init 方法。 - -可以看到下面几句汇编 - -```assembly -0x1000035ac <+44>: movq %rax, -0x10(%rbp) // rbp - 0x10 -0x1000035b0 <+48>: movq %rdx, -0x8(%rbp) // rbp - 0x8 -0x1000035b4 <+52>: movq %rax, -0x20(%rbp) // rbp - 0x20 -0x1000035b8 <+56>: movq %rdx, -0x18(%rbp) // rbp - 0x18 -0x1000035bc <+60>: movq $0xb, -0x20(%rbp) -0x1000035c4 <+68>: movq $0x16, -0x18(%rbp) -``` - -可以看到分别将 `%rax` 里的10赋值给内存地址为 `%rbp - 0x10` ,`%rdx` 里的20赋值给内存地址为 `%rbp - 0x8` 了。 - -可与看到 `0x10` 和 `0x8` 地址相差8,且地址连续,也就是 point1 的内存地址。同样下面的 `0x20` 和 `0x18` 地址相差8,且地址连续,也就是 point2 的内存地址。 - -第五行将 `0xb` 也就是11 赋值给 `%rbp - 0x20`的地址,`0x16` 也就是22赋值给 `%rbp-0x18`的地址,也就是 point2 的 x、y - - - -### COW 机制 - -**值类型的赋值操作:Swift 标准库中的 String、Array、Dictionary 和 Set 确实采用了 Copy-On-Write(COW,写时复制) 技术,这是一种内存优化策略,旨在提升性能并减少不必要的内存复制** - -核心思想: - -- **延迟复制**:当多个变量引用同一份数据时,它们共享底层存储,直到某个变量尝试修改数据时,才会真正复制一份独立的副本。 -- **节省资源**:避免对不可变数据进行冗余复制,减少内存占用和计算开销 - -仅当有“写”操作时,才会真正执行拷贝操作: - -- 对于标准库值类型的赋值操作,Swift 能确保最佳性能,所以没必要为了保证最佳性能来避免赋值 -- 自定义的值类型,比如结构体,在赋值的时候,就会立马发生深拷贝 - -举个例子 - -```swift -var array1 = [1, 2, 3] -var array2 = array1 // 此时共享底层存储 - -array2.append(4) // 触发 COW:array1 和 array2 的存储分离 -``` - -工作过程: - -- 赋值时:`array2` 与 `array1` 共享同一块内存 -- 修改时:当 `array2` 被修改时,检查引用计数。如果引用计数 > 1(即存在多个所有者),则复制底层存储,确保修改不影响其他变量 - -写操作触发检查机制: - -- **修改前检查**:执行写操作(删除、添加、修改)时,检查缓冲区的引用计数 - -- **唯一性检查**:若引用计数为1,则直接修改缓冲区;否则,复制缓冲区并修改新副本 - - 伪代码 - - ```swift - // 伪代码逻辑 - mutating func append(_ element: Element) { - if !isUniquelyReferenced(&buffer) { - buffer = buffer.copy() // 复制缓冲区 - } - buffer.append(element) // 修改新副本 - } - ``` - -#### 什么是缓冲区 - - Array 结构体(值类型) - +-------------------+ - | 指向缓冲区的指针 |-----→ Buffer 类(引用类型) - | | +----------------+ - | 其他元数据(长度、容量) | | 存储元素的内存块 | - +-------------------+ | [1, 2, 3, ...] | - +----------------+ - -1. **结构体轻量级**: - `Array` 结构体本身只包含一个指针和少量元数据(如长度、容量),占用固定大小(如 8 字节指针 + 8 字节长度 + 8 字节容量 = 24 字节)。 -2. **缓冲区动态分配**: - 实际存储元素的连续内存块由缓冲区动态分配在堆上,容量可扩展。 -3. **共享与复制**: - - **赋值时**:仅复制结构体的指针(浅拷贝),多个数组共享同一缓冲区。 - - **修改时**:通过 COW(写时复制)机制,仅在需要时复制缓冲区。 - - - -### 引用类型 - -引用赋值给 var、let 或者给函数传参,是将内存地址拷贝一份。属于浅拷贝 - -```swift -func testReferenceType() { - class Size { - var width: Int - var height: Int - init(width: Int, height: Int) { - self.width = width - self.height = height - } - } - - var size1 = Size(width: 10, height: 20) - var size2 = size1 - size2.width = 11 - size2.height = 22 -} -testReferenceType() -``` - -下断点,可以看到下面的汇编: - - - -在调用(汇编的 call)完 `allocating_init` 方法后,方法返回值用 `%rax` 保存的。然后打印出 `%rax` 寄存器的值,查看内存信息如下 - - - - 红色框代表类信息的地址,蓝色框代表引用计数,绿色框代表10,黄色框代表20. - -汇编的第17行,`movq %rdi, -0x50(%rbp)` 应该就是将 `%rdi` 里面的 对象内存地址赋值到 `%rbp - 0x50` 中去,也就是 `size1` 指针地址。 - -汇编的第20行,`movq %rdi, -0x10(%rbp)` 应该就是将 `%rdi` 里面的 对象内存地址赋值到 `%rbp - 0x10` 中去,也就是 `size12 指针地址。 - -再接下去的汇编 - -```swift -0x100003525 <+133>: movq -0x50(%rbp), %rax -0x100003529 <+137>: movq $0xb, 0x10(%rax) -0x100003531 <+145>: callq 0x100007434 ; symbol stub for: swift_endAccess -0x100003536 <+150>: movq -0x50(%rbp), %rdi -0x10000353a <+154>: callq 0x100007476 ; symbol stub for: swift_release -0x10000353f <+159>: movq -0x68(%rbp), %rdx -0x100003543 <+163>: movq -0x60(%rbp), %rcx -0x100003547 <+167>: movq -0x50(%rbp), %rdi -0x10000354b <+171>: addq $0x18, %rdi -0x10000354f <+175>: leaq -0x48(%rbp), %rsi -0x100003553 <+179>: movq %rsi, -0x58(%rbp) -0x100003557 <+183>: callq 0x100007410 ; symbol stub for: swift_beginAccess -0x10000355c <+188>: movq -0x58(%rbp), %rdi -0x100003560 <+192>: movq -0x50(%rbp), %rax -0x100003564 <+196>: movq $0x16, 0x18(%rax) -``` - -可以看到将 `%rbp - 0x50 ` 的值赋值给 `%rax` ,然后将 `oxb` 也就是 11 保存到 `%rax` 也就是 size1 指针的所指向的内存的 `0x10`处,为什么是前面空了16位?因为前8位保存类信息、后8位保存引用计数信息,所以从16位开始。 - -`movq $0x16, 0x18(%rax)` 将 `0x16` 也就是 22 保存到 `%rax` 也就是 size1 指针的所指向的内存的 `0x18`处,为什么是前面空了24位?因为前8位保存类信息、中间8位保存引用计数信息,后8位保存 Int 的 width,所以从24位开始。 diff --git a/Chapter1 - iOS/1.114.md b/Chapter1 - iOS/1.114.md deleted file mode 100644 index 5fc2a74..0000000 --- a/Chapter1 - iOS/1.114.md +++ /dev/null @@ -1,590 +0,0 @@ -# Swift 闭包研究 - -## 方法占用对象内存吗? - -实验一: - - ```swift -class Point { - var test = true - var age = 29 - var height = 175 -} -var p = Point() -print(Mems.size(ofRef: p)) // 48 - ``` - -为什么是48,而不是40? - -Point 类前16位的前8位表示类信息,后8位表示引用计算信息,2个 Int 占2*8 = 16 Byte,Bool 占用1 Byte。所以实际占用 8 + 8 + 2 * 8 + 1 = 33 Byte。但由于存在内存对齐(内存对齐以8为 base,都是8的整数倍),但 malloc 函数分配的内存都是 16的倍数,所以占用48 Byte。 - - - -Demo: - -```swift -class Person { - var age = 29 - func sayHi () { - var height = 175 - print("局部变量", Mems.ptr(ofVal: &height)) - print("I am \(age) old") - } -} -func sayOuterHi () { - print("Hello world") -} - -var p = Person() -p.sayHi() -sayOuterHi() - -print("全局变量", Mems.ptr(ofVal: &p)) -print("堆空间", Mems.ptr(ofRef: p)) -``` - - - - - -代码段:Person.sayHi 0x1000034d0 - -代码段:sayOuterHi 0x1000038e0 - -全局变量: 0x000000010000c388 - -堆空间: 0x20c820 - -局部变量(栈): 0x00007ff7bfeff2f8 - -可以看到写在类里面的方法地址和写在 Class 外部的方法地址差不多,都在代码段。 - -结论:方法不占用对象的内存。方法、函数存放于代码段。 - - - -## 闭包 - -### 定义 - -什么是闭包?一个函数和它所捕获的变量/常量环境组合起来,称为闭包。 - -- 一般指定义在函数内部的函数 -- 一般捕获的是外层函数的局部变量、常量 - - - -### 原理窥探 - -Demo - -```swift -func exec(a: Int, b: Int, fn: (Int, Int) -> Int) { - print(fn(a, b)) -} -// 写法1 -func sum(a: Int, b: Int) -> Int { return a + b } -exec(a: 1, b: 2, fn: sum) - -// 写法2:闭包 -exec(a: 1, b: 2, fn: { - (a: Int, b: Int) -> Int in - return a + b -}) -// 写法3:闭包简写 -exec(a: 1, b: 2, fn: { - a,b in return a + b -}) -// 写法4:闭包简写 -exec(a: 1, b: 2, fn: { - a,b in a + b -}) -// 写法5:闭包简写。用$0、$1来获取参数。 -exec(a: 1, b: 2, fn: { $0 + $1 }) -// 写法5:闭包简写。用 + 来代表操作,让编译器进行推断 -exec(a: 1, b: 2, fn: + ) -``` - -如果将一个很长的闭包表达式作为函数的最后一个实参,使用尾随闭包可以增强函数的可读性。尾随闭包是一个被书写在函数调用括号外面(后面)的闭包表达式 - -上面的写法等价于 - -```swift -// 写法6:尾随闭包 -exec(a: 1, b: 2) { - $0 + $1 -} -``` - -如果闭包表达式是函数的唯一实参,而且使用饿尾随闭包的语法,那就不需要在函数名后面写圆括号 - -```swift -func exec(fn: (Int, Int) -> Int) { - print(fn(1, 2)) -} - -exec(fn: { $0 + $1 }) // 3 -exec() { $0 + $1 } // 3 -exec{ $0 + $1 } // 3 -``` - -来个 Demo 看看系统数组的排序 - -```swift -var array = [1, 8, 9, 12, 32, 2] -//array.sort() -func compare(a: Int, b: Int) -> Bool { - return a < b -} -// 写法1 -//array.sort(by: compare) - -// 写法2 -// array.sort { $0 < $1 } - -// 写法3 -//array.sort { a, b in -// return a < b -//} - -// 写法4 -//array.sort(by: { -// (a: Int, b: Int) -> Bool in -// return a < b -//}) - -// 写法5 -//array.sort(by: <) - -// 写法6 -array.sort() { $0 < $1 } - -print(array) // [1, 2, 8, 9, 12, 32] -``` - - - -Demo2 - -闭包的变量捕获 - -```swift -typealias Fn = (Int) -> Int -func getFn() -> Fn { - var num = 0 - func plus(_ i: Int) -> Int { - return i - } - return plus -} -var fn = getFn() -print(fn(1)) // 1 -print(fn(2)) // 2 -print(fn(3)) // 3 -``` - - - - - -简单修改下代码 - -```swift -typealias Fn = (Int) -> Int -func getFn() -> Fn { - var num = 0 - func plus(_ i: Int) -> Int { - num += i - return num - } - return plus -} -var fn = getFn() -print(fn(1)) // 1 -print(fn(2)) // 3 -print(fn(3)) // 6 -``` - - - - - -可以看到上面有 `allocObject` 方法调用,说明产生了堆空间对象,用于存放 `num` 。是由 `var fn = getFn()` 造成的,调用1次 `getFn` 则产生1次堆空间分配,用于保存 num。 - -也就是说如果一个内层函数访问了外层函数的局部变量,也就会发生变量捕获,做法就是延长局部变量的生命周期,在堆空间申请一段内存,用户保存局部变量。 - -对代码进行修改 - -```swift -typealias Fn = (Int) -> Int -func getFn() -> Fn { - var num = 1 - func plus(_ i: Int) -> Int { - num += i - return num - } - return plus -} -var fn1 = getFn() -var fn2 = getFn() -var fn3 = getFn() -print(fn1(1)) // 2 -print(fn2(2)) // 3 -print(fn3(3)) // 4 -``` - -我们在汇编 `swift_allocObject` 下面下个断点 - - - - - -第一次:可以看到 `alloc` 在堆空间申请后的内存被存放到寄存器 `rax` 中,此时只是申请内存,没有赋值的。利用 `Debug -> Debug Workflow -> View Memory` 查看内存信息`0x0000600000210000`。此时还没值。 - -敏感点,查看到字面量1,给汇编代码15行加断点,执行完15行,继续查看内存信息 - - - -可以看到内存数据发生了改变。绿色框内有了值1。 - - - -第二次:可以看到 `alloc` 在堆空间申请后的内存被存放到寄存器 `rax` 中,此时只是申请内存,没有赋值的。利用 `Debug -> Debug Workflow -> View Memory` 查看内存信息`0x00006000002042c0`。此时还没值。 - -敏感点,查看到字面量1,给汇编代码15行加断点,执行完15行,继续查看内存信息 - - - -第三次:可以看到 `alloc` 在堆空间申请后的内存被存放到寄存器 `rax` 中,此时只是申请内存,没有赋值的。利用 `Debug -> Debug Workflow -> View Memory` 查看内存信息`0x000060000020d460`。此时还没值。 - -敏感点,查看到字面量1,给汇编代码15行加断点,执行完15行,继续查看内存信息 - - - -打印结果也说明了问题,因为调用3次 `getFn()` 所以会在堆上 alloc 3块内存,用于保存捕获的变量。所以调用 fn1 得到 2,调用 fn2 得到 3,调用 fn3 得到 4。 - -BTW,堆空间分配的内存,如果没有 `init` 或者赋特定的值,数据会是随机的。因为有可能是之前别的地方使用过的内存。 - - - -### 闭包内存结构 - -先来个简单的函数,看看指针内存结构 - -```swift -func sum(_ a: Int, _ b: Int) -> Int { return a + b } -var fn = sum -print(fn(1, 2)) // 3 -``` - -在 `var fn = sum` 处下断点,可以看到下面汇编 - - - -我们知道 `leaq` 是从 `%rip + 0x10f` 算出来的地址,赋值给 `%rax`。所以大概可以推测 `%rip + 0x10f` 就是 `sum` 函数地址。LLDM p 打印 `sum` 地址为 `0x0000000100003a30`。 - -`%rip` 是下一条指令的地址 `0x100003921`,`%rip + 0x10f` 也就是 `0x0000000100003a30`。和猜想一致。 - -第六行汇编的意思就是将 `sum` 函数的地址赋值给 `%rax`。 - -第七行汇编的意思是将保存在 `%rax` 内的地址,从 `%rip + 0x88d8` 处,取8个字节用来保存 `%rax` 的地址。`0x100003928 + 0x88d8 = 0x10000C200` - -第八行汇编的意思是将保存在 `%rax` 内的地址,从 `%rip + 0x88d85` 处,取8个字节用来保存 `$0x0` 。`0x100003933 + 0x88d5 = 0x10000C208` - -`0x10000C200` 到 `0x10000C208` 差8位,也是连续的。说明分配了一个函数指针,长度为16位。通过`MemoryLayout.stride(ofValue: sum)` 看到也是16位。符合猜想。 - - - -直奔主题,研究闭包内存 - - - -可以看到在调用完第六行的函数后将寄存器 `rax`、`rdx` 里的值取出来使用了。进入函数内部看看发生了什么,LLDB 输入 `si` - - - -可以看到 `rax` 里面存放了 `plus` 函数地址。第7行汇编是异或运算,2个 `ecx` 异或,结果为0,写入到 `ecx` 里。然后第8行汇编将 `ecx` 里的0写入到 `edx`,`edx` 也就是 `rdx`。走完第6行的汇编,继续看第7、8行 - - - -将第6行函数的返回结果 `rax` 里的函数地址赋值给 `rip +0x89e7 = 0x100003819 + 0x89e7 = 0x10000C200 `, 第7行的`rdx` 的值赋值个给 `rip +0x89e8 = 0x100003820 + 0x89e8 = 0x10000C208`。 - -也就是 fn1 前8个字节存放 plus 的函数地址,后8个字节存放0. - - - -继续对比实验,查看闭包放了什么东西(注意和上面的实验不同,下面存在闭包) - - - -基本可以断定:函数会返回一个长度为16 Byte 的内存。分别保存在 `rax` 和 `rdx`上。所以针对性的研究 `rdx` 和 `rax` - -汇编第10行,经过在堆上为捕获的变量 alloc 内存后,将内存保存到 `rax` 中,然后赋值给 `rdi`,第11行,将 `rdi` 再赋值给 `rbp - 0x10` 的地址。所以汇编19行的 `rbp - 0x10 ` 保存的也就是堆内存,赋值给 `rdx ` 了。 - -20行的 `rax` 保存了一个看似是 `plus` 的函数地址。具体是:`rip + 0x167 = 0x100003969 + 0x167= 0x100003C37 ` - - - -继续走,走到真正的 `plus` 方法内,可以看到函数地址是 `0x100003970` 。所以返回的 `rax` 里面可能是间接调用真正的 `plus` 函数的。 - - - -问题变得微妙起来了,`getFn` 方法返回一个地址,占用16个字节,但是前8个字节存储 `plus` 方法地址,后8个字节存储闭包捕获后在堆上申请内存存放的数据地址。那如何根据一个地址的前8位去真正调用方法? - - - -Tips:由于地址是动态生成的,所以真正去调用 plus 的时候一定不是写死的地址,一定是动态生成的。这种属于间接调用。 - -- call 后面直接加一个函数地址,属于直接调用。类似 `callq 0x100003970` -- call 后面的函数地址不是固定的,而是动态生成的叫间接调用。在 `at&t` 汇编里面,间接调用前面要加 `*` 。类似 `callq *%rax`,意思是从 `%rax` 里面取出一个地址值出来,当成函数地址,去调用 - -顺着思路,分析下汇编: - - - -我们看到25行 `callq *%rax` 存在动态调用,所以需要找到 `%rax` 里的值是哪里来的。然后顺着向上找,找到23行 `movq -0x30(%rbp), %rax`。然后继续向上看到16行 `movq %rax, -0x30(%rbp)`。继续向上看到15行 `movq 0x88db(%rip), %rax`。相当于就是在 `rip + 0x88db ` 处取出8个字节出来,当作函数地址调用(汇编代码的右边写了,`fn1` ),地址为:`0x88db(%rip) = rip + 0x88db = 0x10000392d + 0x88db = 0x10000C208` 。 - -断点继续放开,在汇编25行处加断点 `callq *%rax` - - - -可以看到在方法内部,第6行汇编处,直接调用一个代码段的函数地址 `jmp 0x1000039f0 ` - -指令 `jmp`、`call` 的区别在于: - -- `jmp` 指令控制程序直接跳转到目标地址执行程序,程序总是顺序执行,指令本身无堆栈操作过程。 -- `call` 指令跳转到指定目标地址执行子程序,执行完子程序后,会返回 `call` 指令的下一条指令处执行程序,执行 `call` 指令有堆栈操作过程 - -LLDB 输入 `si`,可以看到是 `plus` 函数真正的地址 - - - - - -`fn1` 函数调用的时候,参数如何传递? - - - -汇编17行 `movq 0x88d8(%rip), %r13 ; SwiftDemo.fn1 : (Swift.Int) -> Swift.Int + 8`可以看到将返回的 fn1 本身16字节的后8个字节,也就是堆地址空间值,保存到寄存器 `edi` 也就是寄存器 `rdi` 上了。 - -汇编24行 `movl $0x1, %edi` 将参数1传给寄存器 `rdi` 了。 - -然后 LLDB 输入 `si` 去分析 callq 内部 - - - - - -可以看到 `movq %r13, %rsi` 将寄存器 r13 的值写入到 `rsi` 了。目前为止:`rdi` 保存参数1,`rsi` 保存堆地址值。 - -继续输入 `si` - - - -可以看到汇编的第5行 `movq %rsi, -0x50(%rbp)` 将 `rsi` 里的1,保存到 `rbp - 0x50` 处。第6行汇编 `movq %rdi, -0x58(%rbp)` 将 `rdi` 堆地址值保存到 `rbp - 0x58` 处。 - -汇编26行 `movq -0x58(%rbp), %rdi` 将 `rbp - 0x58` 的值写入到 `rdi`,也就是堆地址值。第27行 `movq -0x50(%rbp), %rsi` 将 `rbp - 0x50` 的值写入到 `rsi`,也就是参数值1。 - -然后真正做 `plus` 加法运算的就是28行的 `addq 0x10(%rsi), %rdi`, 从 `rsi` 堆地址值的第16个字节的地方取出8个字节的值,也就是捕获的外部变量 `num` 再和参数1相加。 - - - -可以看到第6行堆地址空间的值写入到 `rbp -0x58` ,第26行又将 `rbp -0x58` 写入到 `rdi`,29行将 `rdi` 的值,写入到 `rbp - 0x48`,34行将 `rbp - 0x48` 写入到 `rcx`,35行将 `rcx` 的地址值,写入到 `rax` 对应的存储空间。也就是堆上的 `num` 值已经修改,被覆盖了。 - - - -总结:当 `getFn` 内部没有发生闭包的时候,fn1 的地址就是16 Byte,前8 Byte就是 `plus` 的函数地址。当发生函数闭包的时候,`fn1` 的16 Byte,前8 Byte 存储间接调用 `plus` 函数的中转函数,后8 Byte 存储着捕获的且在堆上分配内存的地址值。真正调用 `plus` 的时候会通过寄存器传递2个参数:1个是 fn1 函数的参数,1个是堆空间的地址值。 - -```swift -var fn1 = getFn() -fn1(1) // 2 -fn1(3) //4 -``` - -因为只调用1次 `getFn` 所以堆内存分配了1个被捕获的变量地址,调用过1次 fn1 后,堆地址所指向的内存上的数被修改了。当第二次调用的时候操作的还是同一块堆内存地址,也就是同一个被捕获的堆地址所指向的变量。 - -```swift -var fn1 = getFn() -fn1(1) // 2 -fn1(3) //4 -var fn2 = getFn() -fn2(2) // 3 -fn2(4) // 5 -``` - -因为调用了2次 `getFn` 所以堆内存分配了2个被捕获的变量地址,调用过1次 fn1 后,堆地址所指向的内存上的数被修改了。当第二次调用的时候操作的还是同一块堆内存地址,也就是同一个被捕获的堆地址所指向的变量。当调用 fn2 的时候操作的是被捕获的新的一个堆地址空间所指向的变量。 - -且 fn1 所占用的16个字节,fn2 所占用16个字节。2者的前8字节内容相同,都是包装了 `plus` 函数的一个地址。 - - - -### “闭包就是对象” - -捕获了外部变量的闭包类似于一个类,里面存在存储属性和方法 - -```swift -class { - var num: Int - func fn(_ i: Int) -> Int { - return i + num - } -} -``` - - - - - - - -## 自动闭包 - -**自动闭包是一种自动创建的,用来把作为实际参数传递给函数的表达式打包的闭包**。它不接受任何实际参数,并且当它被调用时,它会返回内部打包的表达式的值 - -这个语法的好处在于通过写普通表达式代替显示闭包,而使你省略包围函数的形式参数的括号. - -比如系统的断言 `assert` - -```swift -public func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line) - -``` - - - -```swift -// 语法糖。自动闭包 -func getPositiveValue(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int { - return v1 > 0 ? v1 : v2() -} - -//print(getPositiveValue(10, {20})) -//print(getPositiveValue(-10) {20}) -//print(getPositiveValue(-20) { -// let a = 10 -// return a + 1 -//} -//) - -print(getPositiveValue(-10, 22)) -``` - -`@autoclosure` 会自动将 22 封装成闭包 `{ 22 }`。 - -`@autoclosure` 只支持 `() -> T` 无参数,并且有一个返回值的闭包。 - -`@autoclosure` 并非只支持最后1个参数。 - -`??` 函数的本质就是自动闭包 - - - -自动闭包允许延迟处理,因此任何闭包内部的代码直到你调用的时候才会运行。对于有副作用或者占用资源的代码来说很有用。因为它可以允许你控制代码何时进行求值 - -```swift -var group = ["zhangsan", "lisi", "wangwu"] -//print(group.count) -//let groupRemover = { group.remove(at: 0) } -//print(group.count) -// -////print("execute remove function \(groupRemover())") -//print(group.count) - - -func serve(customer customerProvider: @autoclosure () -> String) { - print("Now serving \(customerProvider())!") -} -serve(customer: group.remove(at: 0)) -// Now serving zhangsan! -``` - -如果一个闭包作为参数,是可以去掉 `{}` 的,参数加了 `@autoclosure` 后,是会自动转换为闭包的。 - -但如果函数参数没有加 `@autoclosure`,在调用函数的时候,传参没有加 `{}` 编译器是会报错的 - - - -正确的做法是:要么加 `@autoclosure` 要么在函数调用的时候加 `{}` - -```swift -// 改法1 -func collectCustomerProviders(_ customerProvider: @escaping () -> String) { - customerProoviders.append(customerProvider) -} -collectCustomerProviders( { group.remove(at: 0) }) -// 改法2 -func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) { - customerProoviders.append(customerProvider) -} -collectCustomerProviders(group.remove(at: 0)) -``` - -如果你的自动闭包允许逃逸,就可以同时使用 `@autoclosure` 和 `@escaping ` - -```swift -var customerProoviders: [() -> String] = [] -func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) { - customerProoviders.append(customerProvider) -} -collectCustomerProviders(group.remove(at: 0)) -``` - - - -## 闭包和闭包表达式的区别 - -### 闭包 - -定义:闭包是自包含的功能代码块,可以在代码中被传递和使用。它能捕获并存储其所在上下文的常量和变量(即“闭合”环境) - -种类: - -- 全局函数(有名称,不捕获任何值) -- 嵌套函数(有名称,可捕获外曾函数的变量) -- 闭包表达式(匿名,轻量语法,可以捕获上下文变量) - -闭包强调的是:捕获上下文的能力。不管是函数还是闭包表达式 - -### 闭包表达式 - -定义:是 Swift 中一种简洁的语法格式,用于编写匿名闭包。是闭包的一种实现方式之一(函数和闭包表达式都可以实现闭包) - -特点: - -- 没有函数名 -- 语法清凉,支持参数类型推断、隐式返回、简写参数名(如 $0、$1)等特性 -- 通常用于作为函数的参数传递 - - - -### 总结 - -- **闭包**是广义概念,包含所有能捕获上下文的代码块(如函数、闭包表达式)。 -- **闭包表达式**是闭包的一种具体实现方式,专指用轻量语法编写的匿名闭包。 - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.115.md b/Chapter1 - iOS/1.115.md deleted file mode 100644 index e59869c..0000000 --- a/Chapter1 - iOS/1.115.md +++ /dev/null @@ -1,673 +0,0 @@ -# 属性 - -## 实例相关属性分类 - -### 存储属性 - -英文叫 Stored Property - -- 类似于成员变量的概念 -- 为什么叫存储属性?属性的内存直接存储在实例的内存中 -- 结构体、类,都有存储属性 -- **枚举不可以定义存储属性** - - - -### 为什么 enum 不可以定义存储属性? - -最基础的枚举,内存占用1个字节,只用来存储哪个 case 的索引值 - -```swift -enum Season { - case spring - case summer - case antumn - case winter -} -``` - -带有关联值的枚举 - -```swift -enum Season { - case spring(Int, Int, Int) - case summer(Int, Int) - case antumn(Int) - case winter(Bool) - case unknown -} -var season: Season = Season.spring(1, 2, 3) -print(MemoryLayout.size) // 3*8 + 1 -print(MemoryLayout.stride(ofValue: season)) // 32 -``` - -`.spring` 有3个 Int,单个 Int 占8个字节空间,所以红色框代表 spring 的1,蓝色框代表 spring 的2,绿色框代表 spring 的3,黄色框代表枚举的第1个 case,剩余7个字节,为空。后续的7个字节是为了内存对齐而补齐的内存。 - -1个字节用来表达位置信息。共 `3*8 + 1 = 25` - -内存对齐系数,以8 Byte 为单位,对象分配的内存必须是该值的整数倍,所以实际分配后的内存为32字节 - - - -带有原始值的枚举 - -```swift -enum Season : Int { - case spring = 1 - case summer = 2 - case antumn = 3 - case winter = 4 -} - -print(MemoryLayout.size) // 1 -print(MemoryLayout.stride) // 1 -print(MemoryLayout.alignment) // 1 -``` - -只带有原始值的枚举,同样只占用1个字节,该字节的值为枚举的位置索引(比如1、2),而非原始值。原始值不占用枚举的内存 - - - -总结: - -- Swift 的枚举是一种**值类型**,核心目的是表示一组**互斥的、有限的可能性** -- 允许存储属性,当枚举实例处于不同 case 时,这些属性的存在性和内存占用会变得不可预测 - - - -### 计算属性 - -英文名为:Computed Property - -- 计算属性的本质是方法 -- 不占用实例的内存 -- 计算属性只能用 var,不能用 let -- 有了 setter,必须有 getter -- 可以只有 getter,没有 setter -- **枚举、结构体、 类都可以定义计算属性** - -```swift -struct Circle { - var radius: Int // 存储属性 - var diameter: Int { // 计算属性 - set { - radius = newValue/2 - } - get { - 2 * radius - } - } -} -var circle = Circle(radius: 10) -// print(circle.diameter) // 20 -circle.diameter = 24 -// print(circle.radius) // 12 -let diameter = circle.diameter - -print(MemoryLayout.size) // 8 -print(MemoryLayout.stride) // 8 -print(MemoryLayout.alignment) // 8 -``` - -计算属性 `y` 等价于下面的代码: - -```swift -setDiameter (newValue: Int) { - radius = newValue/2 -} -getDiameter () { - return 2*radius -} -``` - -然后通过汇编来窥探下,在 `circle.diameter = 22` 处加断点,可以看到本质上调用的就是 `setter` 方法,`setter` 内部的实现就不一一窥探了 - - - -然后断点继续,在 `let diameter = circle.diameter` 处加断点,可以看到调用了 getter 方法 - - - -计算属性的本质就是方法,看上去是属性,但是不占用结构体的内存。而是独立在代码段中,所以只占用1个 Int 即8个字节的大小。 - - - -- **计算属性可以有只读计算属性** - - 也就是只有 getter,没有 setter 方法 - - ```swift - struct Circle { - var radius: Int // 存储属性 - var diameter: Int { radius*2 } - } - ``` - - - -### 延迟存储属性 - -常规写法 - -```swift -class Car { - init () { - print("Car init") - } - - func run () { - print("Car is running") - } -} - -class Person { - let car:Car = Car() - init () { - print("Person init") - } - - func goOut() { - car.run() - } -} - - -let p = Person() -print("---") -p.goOut() - -// console -Car init -Person init ---- -Car is running -``` - -但是想实现一个需求,就是在 Person 初始化的时候先不初始化 Car,当调用 Person 对象的 goOut 方法的时候再初始化,该怎么办? - -**使用 lazy 可以定义一个延迟存储属性,在第一次用到属性的时候才会进行初始化** - -对 Person 改造如下 - -```swift -class Person { - lazy var car:Car = Car() - init () { - print("Person init") - } - - func goOut() { - car.run() - } -} -// console -Person init ---- -Car init -Car is running -``` - -注意:延迟属性 lazy 必须和 var 搭配使用,不能是 let - - - -## 异同点 - -存储属性: - -- 类似于成员变量 - -- 存储在实例的内存中 - -- 结构体、类可以定义存储属性 - -- 枚举不可以定义存储属性 - -- 在创建类、结构体的实例时,必须为所有的存储属性设置一个合适的初始值 - -- 延迟存储属性必须是 `var`,不能是 `let`。 因为 Swift 规定 let 必须在实例的初始化方法完成之前就拥有值 - -- `lazy` 在多线程情况下,无法保证属性只被初始化1次。 - - ```swift - struct Point { - var x:Int - lazy var y = 0 - init(_ x: Int = 0) { - self.x = x - } - } - var p = Point(2) - print(p.y) - ``` - -- 当结构体包含一个延迟存储属性时,只有 var 才能访问延迟存储属性(因为延迟属性初始化的时候需要改变结构体内存)。Class 的话,实例可以用 let 修饰,访问延迟存储属性是可以的。 - - QA:为什么结构体包含一个延迟存储属性时,只有 var 才能访问延迟存储属性? - - 之前在 [Swift 结构体和类的内存布局](./1.113.md) 探究过 `struct` 的内存布局,`struct` 的成员变量在内存中是连续存储的。由于是延迟存储属性,等真正使用的时候才会执行延迟属性的初始化逻辑,才会更改 `struct `的内存,所以 `let` 无法满足更改内存的需求。 - - ```swift - struct Point { - var x = 0 - lazy var y = 0 - } - - let p = Point() - print(p.y) // Cannot use mutating getter on immutable value: 'p' is a 'let' constant - - var p2 = Point() - print(p2.y) // 0 - - - class Point { - var x:Int - lazy var y = 0 - init(_ x: Int = 0) { - self.x = x - } - } - - let p = Point(2) - print(p.y) // 0 - ``` - -计算属性: - -- 本质就是方法 -- 不占用实例内存 -- 枚举、结构体、类都可以定义计算属性 -- 计算属性只能用 var,不能用 let - - - - - -## 枚举 rawValue 的原理 - -枚举原始值 rawValue 的本质:只读的计算属性,不占用实例内存。 - -```swift -enum Season: Int { - case spring = 10 - case summer = 20 - case autumn = 30 - case winter = 40 -} - -let season = Season.summer -// season.rawValue = 22 // Cannot assign to property: 'rawValue' is immutable -print(season.rawValue) -``` - - - -通过汇编 `SwiftDemo.Season.rawValue.getter` 可以看到,在调用 **`enum` 的 `rawvalue` 的时候本质是通过计算属性调用 `getter` 来实现的**。 - -类似于: - -```swift -enum Season: Int { - case spring = 10 - case summer = 20 - case autumn = 30 - case winter = 40 - - var rawValue: Int { - get { - switch self { - case .spring: - return 10 - case .summer: - return 20 - case .autumn: - return 30 - case .winter: - return 40 - } - } - } -} - -let season = Season.summer -print(season.rawValue) -``` - -也侧面证明了 rawValue 不占用枚举的内存空间(是方法,存储在代码段) - - - -## 属性观察器 - -- 可以为非 `lazy` 的 `var` 存储属性设置属性观察期 - -- 计算属性由于有 set 和 get,因此不能有属性观察器 willSet 和 didSet - -- 在初始化器中设置属性值不会触发 `willSet`、`didSet` - -- 属性观察器、计算属性的功能,同样可以应用在全局变量、局部变量上 - - ```swift - var num: Int { - get { - return 10 - } - set { - print("newValue", newValue) - } - } - num = 11 - print(num) - // console - newValue 11 - 10 - - func test () { - var age: Int { - set { - print("new age is ", newValue) - } - get { - 28 - } - } - age = 29 - print(age) - } - test() - // console - new age is 29 - 28 - ``` - - - -## Inout 核心原理 - -### 普通的存储属性 - -```swift -struct Shape { - var width: Int - var side: Int { - willSet { - print("willset side", newValue) - } - didSet { - print("didset side", oldValue, side) - } - } - var girth: Int { - set { - width = newValue/side - print("set girth ", newValue) - } - get { - print("get girth") - return width * side - } - } - func show() { - print("width is \(width), side is \(side), girth is \(girth)") - } -} - -func changeValue(_ value: inout Int) { - value = 20 -} - -var shape = Shape(width: 10, side: 4) -changeValue(&shape.width) -shape.show() - -// console -get girth -width is 20, side is 4, girth is 80 -``` - -在 `changeValue(&shape.width)` 处加汇编可以看到断点停在第10行 `leaq 0x953c(%rip), %rdi ` 即将 `rip + 0x953c = 0x100002cbc + 0x953c = 0x10000C1F8 ` 赋值给 `rdi`。 - -第16行也是一样,`leaq 0x9523(%rip), %rdi ` 即将 `rip + 0x9523 = 0x100002cd5 + 0x953c = 0x10000C1F8 ` 赋值给 `rdi`。 - - - - - -然后看到17行的关键代码,LLDB 输入 `si`,可以看到在第6行 `movq $0x14, (%rdi)`,将16进制的 `0x14` 也就是20,移动到指定的内存地址 `rdi` 上 - - - -因为 `struct` 结构体内存布局中,成员变量在内存中是连续存储的。所以 `struct `的地址也就是 `struct` 中第一个成员变量 `width` 的地址。 - - - -总结:普通的存储属性,在调用方法的时候,如果参数是 `inout` 传递引用,则直接传递存储属性的地址值即可。在方法内部,对该地址对应的值进行修改即可。 - - - -### 计算属性 - -对调用的代码进行调整 - -```swift -var shape = Shape(width: 10, side: 4) -changeValue(&shape.girth) -shape.show() -// console -get girth -set girth 20 -get girth -width is 5, side is 4, girth is 20 -``` - -在 `changeValue(&shape.girth)` 处下断点,查看汇编 - - - -核心思路:方法参数用 `inout`修饰,则传递的是引用(内存地址)。 - -- 汇编19行 `callq 0x1000030e0 ; SwiftDemo.Shape.girth.getter : Swift.Int at main.swift:16` 调用了 `girth` 计算属性的 `getter`,`getter` 的返回值存放在寄存器 `rax` 上 - -- 20行将 `movq %rax, -0x28(%rbp)` 函数返回值 `rax` 存放在 `main` 函数的栈空间上(虽然没有写调用函数,但是代码主入口就是 `main` 函数),且 `rbp` 到 `rsp` 之间都是函数的栈空间。也就是一个局部变量 - -- 21行 `leaq -0x28(%rbp), %rdi` 将栈空间上 `-0x28(%rbp)` 的地址值赋值给 `rdi` 寄存器 - -- 22行 `callq 0x1000037b0 ; SwiftDemo.changeValue(inout Swift.Int) -> () at main.swift:26` 调用 `changeValue` 方法,参数通过寄存器 `rdi` 传递,里面是栈空间 getter 值的地址。 - -- LLDB 输入 `si` 查看 changeValue 内部。可以看到第6行 `movq $0x14, (%rdi)` 直接将20赋值给 `rdi` - - - - - -- LLDB 输入 `finish` 结束 `changeValue` 细节,查看外部23行汇编 `movq -0x28(%rbp), %rdi` ,将 `main` 函数栈空间上 `getter` 返回值的内存对应的值,保存到寄存器 `rdi` 上。 - -- 25行 `callq 0x100003250 ; SwiftDemo.Shape.girth.setter : Swift.Int at main.swift:12` 调用计算属性的 `setter`,函数参数为 `rid` 寄存器里的值(也就是20) - -总结:带有计算属性的存储属性,如果调用的方法参数是 `inout` 类型,系统真正实现,不能直接将对应的地址传进去,因为传进去很多简单,满足了修改值的需求。但是属性观察器的 `set`、`get` 就没办法触发了。所以为了触发属性观察器系统的设计是: - -- 第一步:先将传递进去的属性调用 `getter` ,保存在函数的栈地址空间内的某个内存上 -- 第二步:然后调用带有 `inout` 属性的方法,将步骤一得到的内存传递当作参数传进去。修改该内存对应的值 -- 第三步:将步骤二得到的值后,调用 `setter` 方法。 - -这个流程下来,满足了修改值,且触发了原始属性观察器的需求。 - - - -### 带有属性观察器的存储属性 - -对调用的代码进行调整 - -```swift -var shape = Shape(width: 10, side: 4) -changeValue(&shape.side) -shape.show() -// console -willset side 20 -didset side 4 20 -get girth -width is 10, side is 20, girth is 200 -``` - -在 `changeValue(&shape.side)` 处添加断点,查看汇编 - - - -分析: - -- 17行 `movq 0x9549(%rip), %rax ; SwiftDemo.shape : SwiftDemo.Shape + 8` 将地址格式为 `0x9549(%rip)` 一个全局变量,也就是 `shape` 的地址 + 8 的值,赋值给 `rax` 寄存器 - -- 18行 `movq %rax, -0x28(%rbp)` 将寄存器 `rax` 里的值,赋值给 `main` 函数的栈空间上的局部变量 `-0x28(%rbp)` - -- 19行 将 `main` 函数的栈空间上的局部变量 `-0x28(%rbp)` 的地址值,赋值给寄存器 `rdi` - -- 20行 `callq 0x1000037b0 ; SwiftDemo.changeValue(inout Swift.Int) -> () at main.swift:26` 调用 `changeValue `方法。函数参数通过寄存器 `rdi` 传递,函数内部修改了该内存上的值 - -- 21行 `movq -0x28(%rbp), %rdi` 将 `main` 函数的栈空间上的局部变量 `-0x28(%rbp)` 的值,赋值给寄存器 `rdi`。也就是修改后的20 - -- 23行 `callq 0x100002db0 ; SwiftDemo.Shape.side.setter : Swift.Int at main.swift:3` 调用 setter - -- LLDB 输入 `si`,可以看到 `setter` 方法内部,调用了 `willSet`、`didSet` - - - - - -总结:带有属性观察器的存储属性,如果调用的方法参数是 `inout` 类型,系统真正的实现,并不是直接将 inout 参数的地址传进去,因为传进去很简单,满足了修改值的需求,但属性观察器的 `willSet`、`didSet` 就没办法触发了。 - -问题症结是:**直接传递 inout 参数的地址,可以满足直接修改值的需求,但直接修改没办法触发属性观察器的 willSet 和 didSet** - -所以为了触发属性观察器系统的设计是: - -- 第一步:先将传递进去的地址,保存在函数的栈地址空间内的某个内存上 -- 第二步:然后调用带有 `inout` 属性的方法,将步骤一得到的内存传递当作参数传进去。修改该内存对应的值 -- 第三步:将步骤二得到的值后,调用一个 `setter` 方法,`setter` 方法内,先调用 `willSet`,再修改真正的带有观察属性的存储属性的值,最后调用 `didSet` 方法。 - -这个流程下来,满足了修改值,且触发了原始属性观察器的需求。 - - - - - -### 总结 - -1. 如果实参有内存地址,且没有设置属性观察器和计算属性,实现是直接将实参的内存地址传入带 `inout` 函数 -2. 如果实参是计算属性或者设置了属性观察器:系统采用了 **Copy In Copy Out** 的策略 - - 调用带 `inout` 函数时,先复制实参的值,产生副本 (get。栈空间上的局部变量 rbx + offset) - - 将副本的内存地址传入带 `inout` 函数(副本进行引用传递),在函数内部修改副本的值 - - 函数返回后,再将副本的值覆盖实参的值(set。willSet、didSet) - - - -## 类型属性 - -- 不同于存储属性,类型属性必须设置初始值,永伟类型属性不像存储属性那样有 `init` 初始化器来初始化存储属性 - -- 类型属性就不存储在每个实例的内存里 - -- 存储属性默认就是 `lazy`,会在第一次使用的时候才初始化 - -- 存储属性就算被多个线程同时访问,但系统会保证只初始化1次 - -- 存储类型属性可以是 `let` - -- 存储属性可以用 `static` 修饰 - -- **枚举 enum 里面不可以实例存储属性,但是可以定义类型存储属性** - - ```swift - enum Season { - static let age: Int = 0 - case spring, summer, antumn, winter - } - var season = Season.summer - ``` - -- 类型属性的经典场景就是单例模式 - - ```swift - class FileManager { - private init() {} - public static let sharedInstance: FileManager = FileManager() - } - - var manager1 = FileManager.sharedInstance - var manager2 = FileManager.sharedInstance - var manager3 = FileManager.sharedInstance - print(Mems.ptr(ofRef: manager1)) // 0x0000600000008030 - print(Mems.ptr(ofRef: manager2)) // 0x0000600000008030 - print(Mems.ptr(ofRef: manager3)) // 0x0000600000008030 - ``` - - - -### 内存角度分析:类型属性存储在哪 - -Demo1: - - - - `movq $0xa, 0x86d1(%rip) ` num1 的地址为: `rip + 0x86d1 = 0x100003b0f + 0x86d1 = 0x10000C1E0 ` - -`movq $0xb, 0x86ce(%rip)` num2 的地址为: `rip + 0x86ce = 0x100003b1a + 0x86ce = 0x10000C1E8 ` - - `movq $0xc, 0x86cb(%rip) ` num3 的地址为: `rip + 0x86cb = 0x100003b25 + 0x86cb = 0x10000C1F0 ` - -可以看到 `0x10000C1E0` `0x10000C1E8` `0x10000C1F0` 在内存上是连续的,间隔8Byte。可见分配的3个全局变量内存是连续的 - - - -Demo2 - - - -`movq $0xa, 0x8b65(%rip)` num1 的内存为 `rip + 0x8b65 = 0x1000037c3 + 0x8b65 = 0x10000C328 ` - -可以看到15行将11赋值给 rax,所以直接读取 rax 的地址:`0x000000010000c330` - -`movq $0xc, 0x8b38(%rip) ` num1 的内存为 `rip + 0x8b38 = 0x100003800 + 0x8b38 = 0x10000C338 ` - -可以看到 `0x10000C328` `0x10000c330` `0x10000C338` 也是内存连续的。所以**类型属性就是带有访问控制(必须通过类来访问)的全局变量** - - - -### 类型属性是线程安全的 - -看个 Demo - -```swift -class Manager { - static var count = Int.random(in: 1...100) -} - -Manager.count = 10 -Manager.count = 11 -``` - -下断点可以看到,调用了**地址访问器函数**。为什么需要地址访问器函数: - -- 线程安全初始化首次访问时触发初始化(可能包含 `dispatch_once` 逻辑) -- 抽象内存访问隐藏实际存储位置(可能位于不同段或延迟分配) -- 支持属性观察(didSet)通过封装访问点插入回调逻辑 - - - -lldb 输入 si 查看具体实现 - - - -可以看到底层调用了 `swift_once` 函数,函数传递了2个参数, rsi 存储 dispatch_once 的 block 参数,rdi 存储了 onceToken - -继续敲 si 可以看到底层调用的就是 GCD 的 `dispatch_once` 函数。 - - - - - -类型属性如何保证线程安全的?如何保证只会初始化一次 - -底层会调用 `swift_once` 进而调用 `dispatch_once_f`,`dispatch_once_t` 会传递一个函数地址进去执行,类型属性的初始化代码将会被包装成一个函数。 由 `dispatch_once_t` 保证线程安全和只初始化1次。 - -所以类型存储属性底层通过 dispatch_once 保证了只会初始化1次,线程安全。 - - diff --git a/Chapter1 - iOS/1.116.md b/Chapter1 - iOS/1.116.md deleted file mode 100644 index 67d00bd..0000000 --- a/Chapter1 - iOS/1.116.md +++ /dev/null @@ -1,1012 +0,0 @@ -# Swift 类底层剖析 - -## 类的内存结构 - -```swift -class Person { - var age: Int = 0 -} - -class Student: Person { - var score: Int = 0 -} - -class Worker: Student { - var salary: Int = 0 -} - -let person = Person() -person.age = 28 -print(Mems.size(ofRef: person)) -print(Mems.memStr(ofRef: person)) - -32 -0x000000010000c400 0x0000000000000003 -0x000000000000001c 0x0000000000000000 - -let student = Student() -student.score = 100 -print(Mems.size(ofRef: student)) -print(Mems.memStr(ofRef: student)) -32 -0x000000010000c4b0 0x0000000000000003 -0x000000000000001c 0x0000000000000064 - -let worker = Worker() -worker.salary = 1000 -print(Mems.size(ofRef: worker)) -print(Mems.memStr(ofRef: worker)) -48 -0x000000010000c580 0x0000000000000003 -0x000000000000001c 0x0000000000000064 0x00000000000003e8 0x00007ff8501c0938 -``` - -- 内存对齐都是16 Byte 的整数倍 -- 一个类内存中,至少占16字节的内存。前8位是类信息、其次的8位是引用计数信息,接着是属性内存区域 -- 由于类存在继承,所以子类中,前16字节存储类信息和引用计数信息,其次是属性内存,存在继承的话,前面的属性是父类的属性,后面才是自己的属性。 - -所以: - -- Person 类的内存: 8 Byte 的类信息 + 8 Byte 引用计数信息 + 8 Byte Int Age 属性 = 24 Byte,由于需要16的倍数,所以是32 Byte -- Student 类的内存: 8 Byte 的类信息 + 8 Byte 引用计数信息 + 8 Byte Int Age 属性 + 8 Byte 的 Int Score 属性 = 32 Byte,由于需要16的倍数,所以是32 Byte -- Worker 类的内存: 8 Byte 的类信息 + 8 Byte 引用计数信息 + 8 Byte Int Age 属性 + 8 Byte 的 Int Score 属性 + 8 Byte 的 Int Salary 属性 = 40 Byte,由于需要16的倍数,所以是 48 Byte - - - -## 继承 - -值类型(枚举、结构体)不支持继承,只有类支持继承 - -没有父类的类,称为基类。Swift 并不像 OC、Java 那样规定:任何类最终都要继承自某个基类(OC 的 NSObject)。 - -```swift -import Foundation -class Person {} -class Student: Person {} -print(class_getSuperclass(Student.self)!) // Person -print(class_getSuperclass(Person.self)!) // _TtCs12_SwiftObject -``` - -丛输出可以看出 Swift 还存在一个隐藏基类:`Swift._SwiftObject`,可查看 [Swift 源码](https://github.com/apple/swift/blob/main/stdlib/public/runtime/SwiftObject.h) - - - -## 方法 - -结构体和枚举是值类型,默认情况下,值类型的属性是不能被自身的实例方法修改。 - -如果想在方法内修改,需要在 `func` 前加 `mutating` 才可以 - -```swift -struct Point { - var x: Double = 0.0 - var y: Double = 0.0 - func moveBy(_ delatX: Double, _ delatY: Double) { - self.x += delatX - self.y += delatY - } -} -var point = Point() -point.moveBy(0.2, 0.2) -// compiler error -Left side of mutating operator isn't mutable: 'self' is immutable -``` - -改进 - -```swift -struct Point { - var x: Double = 0.0 - var y: Double = 0.0 - mutating func moveBy(_ delatX: Double, _ delatY: Double) { - self.x += delatX - self.y += delatY - } -} -var point = Point() -point.moveBy(0.2, 0.4) -print(point.x, point.y) -// 0.2 0.4 -``` - - - -## 重写方法 - -`override` - -被 class 修饰的类型方法、下标,允许被子类重写 - -被 static 修饰的类型方法、下标,不允许被子类重写 - -```swift -class Animal { - static var innerValue:Int = 0 - class func speak() { - print("Animal speak") - } - - class subscript(index: Int) -> Int { - set { - innerValue = newValue - } - get { - innerValue - } - } -} - -class Dog: Animal { - override class func speak() { - super.speak() - print("dog is bark") - } - override class subscript(index: Int) -> Int { - set { - innerValue = newValue - } - get { - innerValue - } - } -} - -Animal.speak() // Animal speak -Animal[5] = 3 -print(Animal[5]) // 3 -Dog.speak() // Animal speak dog is bark -``` - -但如果将 `Animal` 方法的 `class` 改为 `static`,就无法 `override` 了 - - - - - - -- 如果父类的方法是被 class 修饰的,子类继承后重写时,可以将 class 改为 static。 -- 虽然子类可以将父类方法的 class 改为 static。但影响的是当前子类的子类,无法再重写方法了。 - - - -## 重写属性 - -- 子类不可以将父类的属性改写为存储属性 -- 子类可以将父类的属性(存储属性、计算属性)重写为计算属性 -- 只能重写 var 属性,不能重写 let 属性 -- 重写时,属性名、类型要一致 -- 子类重写后的属性权限(读写),不能小于父类属性的权限 - - 如果父类属性是只读的,子类重写后的属性要么是只读的,要么是可读可写的 - - 如果父类的属性是可读可写的,子类重写后的属性也必须是可读可写的 - - - -## 重写类型属性 - -- 被 class 修饰的计算类型属性,可以被子类重写 -- 被 static 修饰的类型属性(存储、计算),不可以被子类重写 -- 可以在子类中为父类属性(除了只读的计算属性、let 属性)增加属性观察器 - -```swift -class Shape { - var radius: Int = 1 { - willSet { - print("Shape will set radius", newValue) - } - didSet { - print("Shape did set radius", oldValue, radius) - } - } -} -class Circle: Shape { - override var radius: Int { - willSet { - print("Cirle will set radius", newValue) - } - didSet { - print("Circle did set radius", oldValue, radius) - } - } -} -var circle = Circle() -circle.radius = 2 -// console -Cirle will set radius 2 -Shape will set radius 2 -Shape did set radius 1 2 -Circle did set radius 1 2 -``` - -可以看到输出类似 Node 的洋葱模型,willset 从外到里,didset 从里到外。 - - - -## final - -- 被 final 修饰的方法、属性、下标是禁止被重写的 - -- 被 final 修饰的类,禁止被继承 - - -## Swift 协议(Protocol)中声明的属性必须使用 var 关键字 - -协议的核心目标:定义“能力”而非“实现” -协议是描述类型应该具备什么能力的抽象蓝图,而不是具体实现。 -属性在协议中本质上定义的是对外的访问接口(读、写),而不是存储方式(常量或变量)。 -因此,**协议中的属性声明必须明确其访问权限({ get } 或 { get set }),而 var 是唯一能表达这种动态性的关键字**。 - -- 协议中的属性用 var:统一表示“访问接口”,支持动态约束({ get } 或 { get set })。 -- 遵循类型可用 let 或 var:只要满足协议的访问权限要求即可。 -- let 无法用于协议:因其无法表达可写性,违背协议动态描述能力的初衷。 - - - - -## 多态的实现原理 - -- OC: Runtime -- C++:虚函数表 -- Swift:没有 Runtime,所以多态的实现类似 C++ - -来个 Demo -```swift -class Animal { - func speak () { - print("Animal speak") - } - func eat () { - print("Animal eat") - } - func sleep () { - print("Animal sleep") - } -} - -class Dog: Animal { - override func speak() { - print("Dog speak") - } - override func eat() { - print("Dog eat") - } - func run () { - print("Dog run") - } -} - -var animal = Animal() -animal.speak() -animal.eat() -animal.sleep() - -animal = Dog() -animal.speak() -animal.eat() -animal.sleep() -// console -Animal speak -Animal eat -Animal sleep -Dog speak -Dog eat -Animal sleep -``` - -在 `animal.speak()` 处加断点,可以看到 - - - - - -解释: - -- 汇编84行 `movq 0x9356(%rip), %r13 ` 是将全局变量 `animal` 的地址赋值给 `r13` -- 汇编90行 `movq (%r13), %rax` 将 `r13` 处取出内存的前8个字节,赋值给 `rax` -- 汇编91行 `callq *0x50(%rax)` ,也就是计算出 `rax + 0x50` 的地址,然后取出8 Byte 出来,也就是 `Dog.speak` 然后调用 -- 汇编107行 `callq *0x58(%rax)` ,也就是计算出 `rax + 0x508` 的地址,然后取出8 Byte 出来,也就是 `Dog.eat` 然后调用 -- 汇编123行 `callq *0x60(%rax)` ,也就是计算出 `rax + 0x60` 的地址,然后取出8 Byte 出来,也就是 `Animal.sleep` 然后调用 - - - -画了张图,也就是说 `rax` 中存放了 Dog 对象内存中的前8个字节,也就是下图的最右侧 - - - - - -核心是上面的内存布局图。结合汇编就知道多态是如何实现的。 - -1. Swift 多态的实现原理 - -Swift 的多态通过 虚函数表(vtable) 实现,这是一种 编译时确定的动态派发机制。其核心逻辑是: - -- 每个类类型在编译时会生成一个 虚函数表,表中存储了类的方法实现指针 -- 子类继承父类时,会复制父类的虚函数表,并替换重写方法的指针为自己的实现 -- 在运行时,通过对象的 类型元数据指针 找到对应的虚函数表,从而调用正确的方法 - -动态派发与静态派发的区别: -- 动态派发:通过虚函数表实现(例如普通类方法),允许子类重写。 -- 静态派发:编译时直接绑定方法地址(例如 final 方法、static 方法、结构体和枚举的方法),性能更高 - -2. 虚函数表(vtable)的作用 - -虚函数表的核心作用是为 动态派发 提供支持: -- 方法重写:子类通过覆盖虚函数表中的方法指针,实现多态。 -- 运行时方法查找:对象调用方法时,通过虚函数表找到实际的方法实现。 -- 类型安全性:保证方法调用的正确性,即使对象被向上转型(例如 父类引用 = 子类对象)。 - - -总结: **虚函数表**(vtable)是一种用于实现动态多态性的机制,通常用于面向对象的编程语言中(C++ 也是一样)。在 Swift 中,虚函数表用于存储类或协议中方法的地址,以便在运行时进行动态分派。 - -在 Swift 中,虚函数表的作用是为每个类或协议创建一个表,其中包含了对应方法的地址。当调用对象的方法时,运行时系统会根据对象的实际类型查找对应的虚函数表,然后调用表中存储的方法地址,从而触发特定的实现。 - -虚函数表在 Swift 中的作用是实现动态分派,使得在运行时根据对象的实际类型确定调用的具体实现。这为 Swift 中的多态性提供了基础,允许相同的方法名称根据对象的类型触发不同的实现,从而实现灵活的对象行为。 - -最小内存占用:一个没有属性的类对象至少占用 16 字节(类型元数据指针 8 字节 + 引用计数 8 字节)。 -属性存储:属性从第 17 字节开始存储 -引用计数细节: -- 默认情况下,引用计数直接存储在对象头部。 -- 当引用计数溢出时,Swift 会使用 Side Table 扩展存储,此时对象头部的引用计数字段会指向 Side Table。 - - -## 类的类型信息存储在哪 - -说明:同一个类的不同对象,它的类信息是一样的。也就是说不通的对象指针,所指向的类信息内存是同一块。 - -```swift -var dog1 = Dog() -var dog2 = Dog() -``` - -存储在全局区。可以利用 MachOView 去查看。 - - - -## 初始化器 - -### 初始化器可以继承 -- convenience 便捷初始化器只可以横向调用,不可以纵向调用(比如子类继承父类后,子类重写指定初始化器的时候,必须加 override 且子类中只能调用父类的指定初始化器,不能调用便捷初始化器) -- 便捷初始化器是不能被子类调用的 - - -### 自动继承 -- 如果子类没有自定义任何指定初始化器,则会自动继承父类所有的指定初始化器 - - -### require - -- 用 required 修饰的指定初始化器,表明其所有的子类都必须实现该初始化器(通过继承或者重写来实现) -- 如果子类重写了 required 初始化器,也必须加上 required,不用加 override - - - -### 可失败初始化器 - -类、结构体、枚举都可以使用 `init?` 定义可失败初始化器,也可以用 `init!` 来定义可失败初始化器。区别下面会讲 - -```swift -class Person { - var name: String - init?(_ name: String) { - if name.isEmpty { - return nil - } - self.name = name - } -} - -var person1 = Person("") -print(person1) // nil -var person2 = Person("FantasticLBP") -print(person2) // Optional(SwiftDemo.Person) -print(person2!) // SwiftDemo.Person -``` - -这种设计系统中也存在,比如 Int 的可失败初始化器:`@inlinable public init?(_ description: String)` - -```swift -var num = Int("12e2") -print(num) // nil -num = Int("12") -print(num) // Optional(12) -``` - -注意点: - -1. 不允许同时定义参数标签、参数个数、参数类型相同的可失败初始化器和非可失败初始化器。因为在外部调用的时候,不知道到底是使用哪个初始化方法。编译器会报错 `Invalid redeclaration of 'init(_:)'` - - - -2. 可以用 `init!` 来定义隐式解包的可失败初始化器 - -3. 可失败初始化器可以调用非可失败初始化器,非可失败初始化器调用可失败初始化器需要进行解包。如果直接调用会报错 `A non-failable initializer cannot delegate to failable initializer 'init(_:)' written with 'init?'` - - ```swift - class Person { - var name: String - init?(_ name: String) { - if name.isEmpty { - return nil - } - self.name = name - } - convenience init() { - self.init("")! // 极端 case,设计不合理 - } - } - ``` - - 非可失败初始化器也可以调用可失败初始化器的隐式解包。 - - ```swift - class Person2 { - var name: String - init!(_ name: String) { - if name.isEmpty { - return nil - } - self.name = name - } - convenience init() { - self.init("") - } - } - ``` - - - - 且前面的写法比较危险,假设第一个 `init?` 返回 `nil`,第二个 `convenience init()` 去对 nil 强制解包,则会 crash - -4. 可以用一个非可失败初始化器重写一个可失败初始化器,但反过来不行 - -5. 如果初始化器调用一个可失败初始化器导致初始化失败,那么整个初始化过程都失败,并且之后的代码都停止执行 - - ```swift - class Person { - var name: String - init?(_ name: String) { - if name.isEmpty { - return nil - } - self.name = name - } - convenience init?() { - self.init("") - print("我是后面的代码1") - print("我是后面的代码2") - } - } - - var person1 = Person() - print(person1) - ``` - - `init` 初始化失败,后面的 `我是后面的代码1` 均不会执行 - -### 可失败初始化器设计哲学 - -- 安全性优先:Swift 注重安全性,可失败初始化器的设计使得对象的初始化过程更加可靠和安全。通过返回一个可选值来表示初始化成功或失败,可以避免在初始化失败时产生不确定的对象状态 -- 错误处理:可失败初始化器与 Swift 的错误处理机制结合使用,使得在初始化失败时能够更好地捕获和处理错误。这种设计哲学强调了对异常情况的处理和错误信息的传递。 -- **灵活性**:可失败初始化器提供了一种灵活的初始化机制,允许开发者更加精确地控制对象的初始化过程。这种设计哲学使得对象初始化更加灵活和可定制。 - - - -### OC alloc init,为什么 Swift 只需要 init? - -1. 语言设计哲学的分歧 - - OC 显示控制与动态性。OC 是 C 的超集,继承了对底层内存管理的直接控制。`alloc` 和 `init` 的分离体现了**职责分离**原则: - - - **`alloc`**:类方法(`+alloc`),负责**内存分配**(计算对象大小、向系统申请内存空间,返回一个“空白”实例)。 - - **`init`**:实例方法(`-init`),负责**状态初始化**(设置属性默认值、建立对象依赖关系等)。 - - 这种分离允许开发者灵活干预内存分配(例如自定义 `+allocWithZone:`)或初始化过程(例如工厂方法 `+new`)。 - - Swift 简洁性与安全性 - - - Swift 作为现代语言,追求代码简洁和安全性。`Person()` 的语法**隐藏了内存分配细节**,开发者只需关注初始化逻辑。编译器会自动插入内存分配代码(类似 `__allocating_init`)并调用初始化方法。类似 `let person = Person.__allocating_init()` - - Swift 强制在初始化完成前为所有存储属性赋值,并通过两段式初始化(Phase 1: 分配内存并设置默认值;Phase 2: 自定义初始化)避免未定义状态 - -2. 编译器与运行时的工作 - - OC:运行时开放性 - - Objective-C 的 `+alloc` 方法由运行时动态处理。开发者可以重写 `+alloc` 或 `+allocWithZone:` 实现自定义内存分配策略(例如对象池、单例)。为了实现这种灵活性,更需要显式调用 alloc - - ```objective-c - // 自定义 alloc 方法 - + (instancetype)alloc { - if (单例条件) { - return sharedInstance; - } - return [super alloc]; - } - ``` - - Swift: 编译时的静态优化 - - - 内存分配的编译时确定:Swift 的对象大小和内存布局在编译时即可确定(值类型更是完全静态)。编译器直接生成内存分配指令,无需运行时动态计算。 - - 初始化器的静态派发:Swift 的初始化方法通过静态派发(或虚表派发)调用,无需 Objective-C 的消息转发开销。编译器能安全地合并内存分配和初始化步骤。 - -为什么 Swift 可以省略 `alloc`? - -1. **编译器自动化**:内存分配由编译器隐式插入代码处理,无需开发者参与。 -2. **类型安全性**:严格的初始化规则确保对象在初始化完成后处于合法状态。 -3. **现代语法设计**:隐藏底层细节,提升代码可读性和编写效率。 -4. **静态优化**:编译时确定对象内存布局,无需运行时动态分配逻辑。 - -而 Objective-C 保留 `alloc` 和 `init` 的分离,既是对历史的兼容,也为需要精细控制内存或动态行为的场景保留了灵活性。 - - - -### deinit - -deinit 也叫反初始化器,类似于 C++ 的析构函数、OC 中的 dealloc 方法 - -当类的实例对象被释放内存时,就会调用实例对象的 deinit 方法 - -```swift -class Person { - deinit { - print("Person deinit") - } -} - -class Student: Person { - deinit { - super.deinit() // Deinitializers cannot be accessed - print("Student deinit") - } -} - -func test() { - let st = Student() -} -test() -``` - -上述代码编译报错:Deinitializers cannot be accessed - -deinit 的基本规则: - -- **不可继承性**:`deinit` 本身不会被继承。每个类必须定义自己的 `deinit` 方法(显式或隐式)。 -- **自动链式调用**:无论子类是否重写 `deinit`,父类的 `deinit` 方法总会在子类析构完成后被自动调用,无需手动调用 `super.deinit()`。 - -## 可选链 - -```swift -var dict:[String: (Int, Int) -> Int] = [ - "sum": (+), - "minus": (-), - "multiple": (*), - "divide": (/) -] -print(dict["sum"]) // Optional((Function)) -var result = dict["divide"]?(40, 20) // 2 -print(result!) -``` - -- 如果可选项为 nil,调用方法、下标、属性失败,结果为 nil -- 如果可选项不为 nil,调用方法、下标、属性成功,结果会被包装为可选项 -- 如果结果本来是可选项,则不会进行再次包装 -- 如果链中任何一个节点为 nil,那么整个链就会调用失败。`var weight = person?.dog?.weight // Int?` -- 多个 `?` 可以链接在一起 `var weight = person?.dog?.weight` - - - -## 可选项 Optional 的本质 - -可选项的本质是 **enum 类型 + 泛型** - -```swift -@frozen public enum Optional : ExpressibleByNilLiteral { - - /// The absence of a value. - /// - /// In code, the absence of a value is typically written using the `nil` - /// literal rather than the explicit `.none` enumeration case. - case none - - /// The presence of a value, stored as `Wrapped`. - case some(Wrapped) - - /// Creates an instance that stores the given value. - public init(_ some: Wrapped) -} -``` - -`var age:Intt? = 20` 是语法糖,本质是 `var age:Optional = .some(20)` 所以下面写法是一样的 - -```swift -// 写法1 -var age1: Int? = 30 -age1 = 20 -age1 = nil - -// 写法2 -let age2: Optional = .some(30) -age2 = 20 -age2 = .none -``` - -一些不合格的写法: - -Optional 是 enum + 泛型,所以必须要设置泛型类型 - -```swift -var age = Optional.none // Generic parameter 'Wrapped' could not be inferred -``` - -`if let` 是专门用于 Optional 解包的语法糖。 -```swift -var age: Int? = .none - -if let a = age { - print(a) -} else { - print("nil") -} -``` -等价于 -```swift -if age != nil { - let a = age! - print(a) -} else { - print("nil") -} -``` - -- 只有非 nil 时,才会进入 if 分支,并将解包后的值绑定到 a -- nil 时,直接进入 else 分支 - -`switch case` 是通用模式匹配,不针对 Optional 做特殊处理。 -```swift -switch age { - case let a: - print("age is ", a) - case nil: - print("nil") -} -``` -- 第一个 case let a 会匹配所有可能的值(包括 .some(30) 和 .none,即 nil),因为 a 的类型是 Int?。 -- 一旦匹配到第一个 case,后续的 case nil 会被跳过。除了第一个之外的 case 都无法执行 -**要在 switch 中正确处理 Optional,需明确匹配 .some 和 .none,需要用 `case let a?`** -```swift -switch age { - case let a?: - print("age is ", a) - case nil: - print("nil") -} -``` -下面写法效果等价于 -```swift -var age: Int? = .none -age = nil - -if let a = age { - print(a) -} else { - print("nil") -} - -switch age { - case let a?: - print("age is ", a) - case nil: - print("nil") -} - -switch age { - case let .some(a): - print("age is ", a) - case nil: - print("nil") -} -``` -双层嵌套可选型: -```swift -var age1 = Optional.some(Optional.some(30)) -var age2: Int?? = 30 -var age3: Optional = .some(.some(30)) -var age4: Optional = .some(30) -print(age1!!) -print(age2!!) -print(age3!!) -print(age4!!) -``` - - -## X.self , X.Type, AnyClass - -- `X.self` 是一个元类型(metadata)的指针,metadata 存放着类型相关信息 -- `X.self` 属于 `X.type` 类型 - -通过汇编探究下背后细节 - -```swift -class Person { } -var person: Person = Person() -var personType: Person.Type = Person.self -``` - - - - - -在第二行代码下断点,可以看到关键的汇编是第8行和第12行: - -- 第14行可以看到 `rip + 0x89ae = 0x10000396a + 0x89ae = 0x10000C318 `,明显是一个堆地址空间,也就是全局变量 `person` - -- 第15行可以看到 `rip + 0x89af = 0x100003971 + 0x89af = 0x10000C320 `,明显是一个堆地址空间,也就是全局变量 `personType` - -- 顺着关键代码找上去,看看 `rax`、`rcx` 的值是哪来的 - -- 第8行调用函数后可以看到 Xcode 的说明,获取 `metadata`,函数返回值保存到 `rax`,LLDB 打印出为 `0x000000010000c248` - -- 第11行初始化堆内存后,将地址保存到寄存器 `rax`,LLDB 打印出地址为 `0x0000600000004010`,然后查看 `0x0000600000004010` 对应的对象信息,可以看到内存的前8个字节的值,就是上面得到的 `metadata` 对象的地址值 - -- person 对象的内存布局中,前8个字节就是 personType 的地址。 - -- `metadata` 结构类似下图右侧 - - - - - -`X.self` 和 `type(of:x)` 效果等价 - -```swift -class Person { } -var person: Person = Person() -print(Person.self == type(of: person)) // true -``` - -`AnyObject.Type` 的用法 - -```swift -class Person { - -} -class Student: Person { - -} - -var anyType: AnyObject.Type = Person.self -anyType = Student.self - -public typealias AnyClass = AnyObject.Type - -var anyType2: AnyClass = Person.self -anyType2 = Student.self - -``` - - - -### 元类型的应用 - -```swift -class Person { - required init() {} -} -class Worker: Person {} -class Student: Person {} -func createInstance(_ items: [Person.Type]) -> [Person] { - var people:[Person] = Array() - for item in items { - people.append(item.init()) - } - return people -} - -let student = Student() -let studentType = type(of: student) -let workerType = Worker.self -var people: Array = createInstance([studentType, workerType]) -print(people) // [SwiftDemo.Student, SwiftDemo.Worker] -``` - -注意:为了保证子类一定有 `init(){ }` 方法,在基类中需要声明为 `required init() {}` - - - -## Swift 继承和基类 - -```swift -import Foundation -class Person { - var age:Int = 0 -} - -class Student: Person { - var no:Int = 0 -} - -print(class_getInstanceSize(Student.self)) // 32 -print(class_getSuperclass(Student.self)!) // Person -print(class_getSuperclass(Person.self)!) //_TtCs12_SwiftObject - -``` - -分析: - -- Student 类继承自 Person 类,类的内存布局中: - - - isa:前8个字节是 isa 指针,指向类的元数据(AnyObject.Type),包含类型信息、方法表。 虚函数表(vtable)存储在类的元数据中,虚函数表并不直接存储在实例内存中,而是通过 isa 指向的类元数据(ClassMetadata)中。 - - 调用方法时候,运行时通过 isa 找到类元数据,再从元数据中读取 vtable 地址,最终定位到具体方法实现地址 - - - 引用计数:紧接着的8个字节存储引用计数信息 - - - 紧接着是从 Person 继承来的 age 属性,占8个字节。然后是自己的 no 属性,也占8个字节。 - -- Student 类的父类是 Person 类,打印没问题 - -- Swift 类的隐式根类 - - - Swift 有个隐藏基类:`Swift._SwiftObject` - - Person 类没有显式继承其他类,它默认会隐式继承自 Swift 的内部根类 `SwiftObject`。这个类是 Swift 运行时的基础,类似于 Objective-C 的 `NSObject`,但独立存在。蕾丝 - - `_TtCs12_SwiftObject` 是 `SwiftObject` 类在 Objective-C 运行时中的**符号化名称**(mangled name) - - `_TtC`:Swift 类的固定前缀。 - - `s12`:模块名或类名的编码长度。 - - `SwiftObject`:实际类名 - -- 与 Objective-C 运行时的交互 - - - **`class_getSuperclass` 的局限性** - `class_getSuperclass` 是 Objective-C 运行时函数,返回的是 Objective-C 运行时能识别的父类。由于 `SwiftObject` 是 Swift 内部类,Objective-C 运行时无法直接理解它,因此返回其符号化名称。 - - **Foundation 的影响** - 导入 `Foundation` 会引入 Objective-C 运行时,但不会改变 Swift 类的默认根类。只有显式继承 `NSObject` 的 Swift 类才会在 Objective-C 运行时中以 `NSObject` 为根类。 - - - -`Swift._SwiftObject` 的作用: - -- **纯 Swift 类的默认父类** - 当 Swift 类不显式继承 `NSObject` 或其他类时,默认隐式继承自 `Swift._SwiftObject`。 -- **提供基础能力** - 类似于 Objective-C 的 `NSObject`,`Swift._SwiftObject` 提供了: - - 内存管理:引用计数(通过 `swift_retain`/`swift_release`)。 - - 类型元数据:存储类的方法表、属性信息等。 - - 动态派发:支持方法重写和协议扩展。 - -通过源码查看 Swift 类的内存布局 - -```swift -struct HeapObject { - HeapMetadata const *metadata; // 包含 isa 和引用计数 - - SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS; - ... -} - -#define SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS \ - InlineRefCounts refCounts -``` - -`HeapObject` 是 Swift 对象的基础结构,包含 `isa` 和引用计数字段 - - - -## Self - -**`Self` 是动态类型,会随着子类调用而改变**。Self 一般用作返回值类型,限定返回值跟方法调用者必须是同一类型(也可以当作参数类型) - -```swift -protocol Runable { - init() - func copy() -> Self -} - -class Person: Runable { - required init() {} - func copy() -> Self { - type(of: self).init() - } -} - -class Student: Person { } - -var person = Person() -print(person.copy()) // Person -var student = Student() -print(student.copy()) // Student -``` - -QA:上面的 Person 类在遵循 Runable 协议,实现 copy 方法,方法里能返回 `Person()` 吗? - -```swift -class Person: Runable { - required init() {} - func copy() -> Self { - Person() - } -} -``` - -答:不行。因为 Person 类可以被继承,如果 copy 方法里写死返回 Person 实例。Student 继承 Person 后,copy 方法会也会返回 Person 对象。但协议要求的是返回当前类的对象,这明显违法了协议“契约” - - - - - -## OC/Swift 运行时 - -### 消息派发方式 - -消息派发方式有3种 - -#### 直接派发(Direct Dispatch) - -会将整个方法的地址,直接硬编码到函数调用的地方。直接派发是最快的,不止是因为需要调用的指令集会更少,并且编译器还能够有很大的优化空间,例如函数内联等,直接派发也被称为静态调用 - -然而,对于编程来水,直接调用也是最大的局限,而且因为缺乏动态性,所以没有办法支持继承和多态等特性。 - - - -#### 函数表派发(Table Dispatch) - -函数表派发是编译型语言实现动态行为最常见的方式。寒暑表使用了一个数组来存储类生命的每一个函数的指针。大部分语言把整个称为“Virtual table”(虚函数表、虚表,c++),Swift 里称为 “witness table”。每一个类都会维护一个函数表,里面记录着类所有的函数,如果父类函数被 override 的话,表里面只会保存被 overrride 后的函数。一个子类新添加的函数都会被插入到这个数组的最后,运行时会根据这一个表去决定实际需要被调用的函数。 - -就像上面[多态实现的原理](#target-anchor)这里讲到的一样 - - - -查表是一种简单、易实现、性能可预知的方式。然而,这种派发方式比起直接派发来说,还是慢了一点(从字节码的角度来看,多了两次读和一次跳转。由此带来了性能损耗)。另一个慢的原因在于编译器可能会由于函数内执行的任务,导致无法优化(如果函数带有副作用的话) - -这种基于数组的实现,缺陷在于函数表无法拓展。子类会在虚函数表的最后插入新函数,没有位置可以让 extension 安全地插入函数。 - - - -#### 消息机制派发(Message Dispatch) - -消息机制是调用函数最动态的方式,也是 Cocoa 的基石,催生了 KVO、UIAppearance、CoreData 等,这种运作方式的关键在于开发者可以在运行时改变函数的行为。不止可以通过 swizzling 来改变,甚至可以用 isa-swizzling 修改对象的继承关系,可以在面向对象的基础上实现自定义派发。 - - - -### OC 运行时 - -主要体现在 -- 动态类型(dynamic typing) -- 动态绑定(dynamic binding) -- 动态装载(dynamic loading) - - - -### Swift 运行时 -- 纯 Swift 类的函数调用已经不再是 Objective-C 的运行时发消息,而是类似 c++ 的虚表 vtable,在编译时就确定了调用哪个函数,所以没办法通过 runtime 获取方法、属性 -- 而 Swift 为了兼容 Objective-C,凡是继承自 NSObject 的类都会保留其动态性,所以能够通过 runtime 拿到方法。老版本的 swift(如2.2)是编译期隐式的自动帮你加上了 `@objc`,而4.0以后版本的 swift 编译期去掉了隐式特性,必须显示声明 -- 不管是 Swift 类,还是继承自 NSObject 的类,只要在属性和方法前面加 `@objc` 关键字,就可以使用 runtime - - - -| | 原始定义 | 拓展 | -| -------------------- | ---------- | ---------- | -| 值类型 | 直接派发 | 直接派发 | -| 协议 | 函数表派发 | 直接派发 | -| 类 | 函数表派发 | 直接派发 | -| 继承自 NSObject 的类 | 函数表派发 | 函数表派发 | - - - -- 值类型总是会使用直接派发,简单易懂 -- 协议和类的 extension 都会使用直接派发 -- NSObject 的 extention 会使用消息机制进行派发 -- NSObject 声明作用域的函数都会使函数表进行派发 -- 协议里声明的,并且带有默认实现的函数会使用函数表进行派发 - - - -修饰符 - -| final | 直接派发 | -| ---------------- | ---------------------- | -| dynaminc | 消息机制派发 | -| @objc & @nonobjc | 改变在 oc 里的可见性 | -| @inline | 告诉编译器可以直接派发 | - - - -有个特殊的组合 final 和 @objc。在标记为 final 的同时,也可以使用 @objc 来让函数可以使用消息机制派发。这么做的结果就是,调用函数的时候会使用直接派发,但也会在 Objective-C 的运行时里注册对应的 selector,函数可以响应 `perform(selector:)` 以及别的 Objective-C 特性,但在直接调用时,又可以有直接派发的性能。 - diff --git a/Chapter1 - iOS/1.117.md b/Chapter1 - iOS/1.117.md deleted file mode 100644 index a02889d..0000000 --- a/Chapter1 - iOS/1.117.md +++ /dev/null @@ -1,266 +0,0 @@ -# Swift 协议探究 - -- 协议可以用来定义属性、方法、下标的声明,协议可以被类、枚举、结构体遵守(多个协议用逗号隔开) - -- 协议中定义的方法不能有默认参数值 - -- 协议中定义属性必须是 `var` - -- 实现协议时定义的属性权限,要不小于协议中定义的属性权限 - -- 协议定义属性是 `get`、`set` 时,用 `var` 存储属性或者 `get`、`set` 计算属性实现 - -- 为了保证通用,协议中必须用 `static` 定义类型方法、类型属性、类型下标 - -- 只有将协议中的实例方法标记为 `mutating` - - - 才可以允许结构体、枚举对象在方法里修改自身内存。否则编译器会报错:`Cannot assign to property: 'self' is immutable` - - 类遵循协议,实现方法的时候不用加 `mutating`,枚举、结构体的实现需要加 `mutating` - - - - ```swift - protocol Drawable { - func draw() - } - - class Size: Drawable { - var width: Int = 0 - func draw() { - width = 10 - } - } - var size = Size() - print(size.width) // 0 - size.draw() - print(size.width) // 10 - - struct Point: Drawable { - var x : Int = 0 - var y: Int = 0 - func draw() { - x = 10 // Cannot assign to property: 'self' is immutable - y = 10 // Cannot assign to property: 'self' is immutable - } - } - ``` - - 要想修改需要加 `mumating` - - ```swift - struct Point: Drawable { - var x : Int = 0 - var y: Int = 0 - mutating func draw() { - x = 10 - y = 10 - } - } - var point = Point() - print(point.x, point.y) - point.draw() - print(point.x, point.y) - ``` - -- 协议中还可以定义初始化器 `init`,非 `final` 类实现协议时, `init` 方法必须加 `required` - - ```swift - protocol Drawable { - init(x: Int, y: Int) - } - class Point: Drawable { - var x: Int = 0 - var y: Int = 0 - required init(x: Int, y: Int) { - self.x = x - self.y = y - } - } - final class Size: Drawable { - var x: Int = 0 - var y: Int = 0 - init(x: Int, y: Int) { - self.x = x - self.y = y - } - } - - var point = Point(x: 10, y: 20) - print(point.x , point.y) // 10 20 - var size = Size(x: 30, y: 40) - print(size.x , size.y) // 30 40 - ``` - -- 如果协议声明了初始化器,某个类遵循协议并实现了初始化器。且该初始化器也恰好是父类指定初始化器,那么这个初始化必须同事加 `required` 和 `override` - - ```swift - protocol Drawable { - init(x: Int, y: Int) - } - - class Shape { - init(x: Int, y: Int) {} - } - - class Circle: Shape, Drawable { - var x: Int = 0 - var y: Int = 0 - required override init(x: Int, y: Int) { - super.init(x: x, y: y) - self.x = x - self.y = y - } - } - var circle = Circle(x: 10, y: 20) - print(circle.x , circle.y) // 10 20 - ``` - -- 协议也可以继承 - -- 协议也可以组合 - - ```swift - protocol Drawable {} - protocol Colorable {} - func test1(obj: Shape) {} // 参数接收 Shape 类或者 Shape 类的子类 - func test2(obj: Drawable) {} // 参数接收遵循 Drawable 的实例 - func test3(obj: Drawable & Colorable) {} // 参数接收同时遵循 Colorable 和 Drawable 2个协议的实例 - func test4(obj: Shape & Drawable & Colorable) {} // 参数接收同时遵循 Colorable 和 Drawable 2个协议,且是 Shape 的子类的实例 - ``` - -- 遵循 `CustomStringConvertible` 可以自定义打印的字符串内容 - - ```swift - class Person: CustomStringConvertible { - var name: String - var age: Int - init(name: String, age: Int) { - self.name = name - self.age = age - } - var description: String { - "My name is \(name), age is \(age)" - } - } - var p = Person(name: "杭城小刘", age: 28) - print(p) // My name is 杭城小刘, age is 28 - ``` - - - -## Any、AnyObject - -Swift 提供了2种特殊的类型:Any、AnyObject - -- Any 可以代表任意类型(枚举、结构体、类、函数类型) -- AnyObject:代表任意类类型。比如可以在协议后面加上 AnyObject 则代表只有类能遵循这个协议。编译器会做检查 `Non-class type 'point' cannot conform to class protocol 'Eatable'` - - ```swift - protocol Eatable: AnyObject {} - class Person: Eatable { } - struct point: Eatable {} // Non-class type 'point' cannot conform to class protocol 'Eatable' - ``` - - - -## 关联类型 - -关联类型的作用:给协议中用到的类型,定义一个占位名称 - -协议中可以拥有多个关联类型 - -```swift -protocol Stackable { - associatedtype Element - mutating func push(_ element: Element) - mutating func pop() -> Element - func top() -> Element - func size() -> Int -} - -class Stack: Stackable { - var elements = Array() - func push(_ element: Element) { - elements.append(element) - } - func pop() -> Element { - elements.removeLast() - } - func top() -> Element { - elements.last! - } - func size() -> Int { - elements.count - } -} -``` - - - -## Swift 泛型本质 - -```swift -func swapValue(_ value1: inout T, _ value2: inout T) { - (value1, value2) = (value2, value1) -} -var i1 = 11 -var i2 = 22 -swapValue(&i1, &i2) - -var s1 = "Hello" -var s2 = "world" -swapValue(&s1, &s2) -``` - -在 `swapValue(&i1, &i2)` 处下断点可以看到下面的汇编代码: - - - -可以看到: - -- 在第一处调用 `swapValue ` 方法的时候,将8字节的 metadata 信息保存到 `rdx` 寄存器了。也就是在调用 `swapValue` 方法的时候,分别将 `i1`(0x000000000000000b,也就是11)的地址值赋值给 rdi 寄存器,将 `i2`(0x0000000000000016,也就是22)的地址值赋值给 rsi 寄存器 -- 将 `Int` 的 `metadata` 赋值给 `rdx` 寄存器 -- 然后调用 `swapValue` 方法 -- 后续的 `String` 的 `SwapValue` 过程类似 - -所以编译器最后在执行的时候,会将泛型真正的类型对应的 metadata 信息当作函数参数,传递进去,再去执行函数 - - - -## 泛型类型约束 - -泛型必须遵循协议,可以在方法后加 `<>`,在 `<>` 内写泛型 `T: 需要继承的类 & 协议` (也可以是其他名字,大多数语言都写 T), - -```swift -protocol Runable {} -class Person {} -func swapValue(_ a: inout T, _ b: inout T) { - (a, b) = (b, a) -} -``` - -另一种场景是在方法参数是某个泛型且遵循协议后,对其泛型有更多限制,则用 `where` 去处理。如下例子 - -```swift -protocol Stackable { - associatedtype Element: Equatable -} - -class Stack: Stackable { - typealias Element = E -} - -func equal(_ s1: S1, _ s2: S2)-> Bool -where S1.Element == S2.Element, S1.Element: Hashable { - return true -} - -let s1 = Stack() -let s2 = Stack() -let s3 = Stack() -var result:Bool = equal(s1, s2) -print(result) // true -result = equal(s1, s3) // Global function 'equal' requires the types 'Stack.Element' (aka 'Int') and 'Stack.Element' (aka 'String') be equivalent -print(result) -``` - diff --git a/Chapter1 - iOS/1.118.md b/Chapter1 - iOS/1.118.md deleted file mode 100644 index 4cab1d8..0000000 --- a/Chapter1 - iOS/1.118.md +++ /dev/null @@ -1,136 +0,0 @@ -# Swift 错误处理 - -```swift -enum SomeError: Error { - case illegalArg(String) - case outOfBounds(Int, Int) - case outOfMemory -} - -func divide(_ num1: Int, _ num2: Int) throws -> Int { - if num2 == 0 { - throw SomeError.illegalArg("分母不能为0") - } - return num1/num2 -} - -func testErrorCapture() { - print("1") - do { - print("2") - print(try divide(20, 0)) - print("3") - } catch let SomeError.illegalArg(msg) { - print("参数异常:", msg) - } catch let SomeError.outOfBounds(size, index) { - print("下标越界: size = \(size), index = \(index)") - } catch SomeError.outOfMemory { - print("内存溢出") - } catch { - print("其他异常") - } - print("4") -} -testErrorCapture() -// console -1 -2 -参数异常: 分母不能为0 -4 -``` - -如果 `try divide(20, 0)` 则输出: 1 、2、参数异常: 分母不能为0、4 - -如果 `try divide(20, 2)` 则输出: 1 、2、10、3、4 - -说明:如果 do 抛出异常,则作用域内的后续的其他代码都不会被执行 - - - -## Error 处理方式 - -- 通过 do-catch 捕捉 Error - -- 不捕捉 Error,在当前函数增加 throws 声明,Error 继续向上层调用函数抛 - - ```swift - func divide(_ num1: Int, _ num2: Int) throws -> Int { - if num2 == 0 { - throw SomeError.illegalArg("分母不能为0") - } - return num1/num2 - } - - func safeDivide(_ num1: Int, _ num2: Int) -> Int { - var result:Int = 0 - do { - result = try divide(num1, num2) - } catch let SomeError.illegalArg(msg) { - print("参数异常:", msg) - } catch let SomeError.outOfBounds(size, index) { - print("下标越界: size = \(size), index = \(index)") - } catch SomeError.outOfMemory { - print("内存溢出") - } catch { - print("其他异常") - } - return result - } - - print("1") - print(safeDivide(8, 2)) - print("3") - // console - 1 - 4 - 3 - ``` - -- 如果有些错误不需要向上抛,可以用 `try?` ` try!` 调用可能会抛出 Error 的函数,这样就不需要去处理 Error - - ```swift - func testIgnoreError () { - print("1") - print(try? divide(10, 0)) - print("2") - } - testIgnoreError() - // console - 1 - nil - 2 - ``` - - - -## rethrows - -如果一个函数本身不会抛出错误,但某个参数是闭包,且闭包抛出错误,那么函数需要用 `rethrows` 向上抛出错误 - -```swift -func exec(_ fn: (Int, Int) throws -> Int, _ num1: Int, _ num2: Int) rethrows { - print(try fn(num1, num2)) -} - -func testCaptureClouserError () { - do { - try exec(divide, 20, 0) - } catch let error { - switch error { - case let SomeError.illegalArgs(msg): - print("参数异常:", msg) - break - case let SomeError.outOfBounds(size, index): - print("下标越界: size = \(size), index = \(index)") - break - case SomeError.outOfMemory : - print("内存溢出") - break - default: - print("其他异常") - } - } -} -testCaptureClouserError() // 参数异常: 分母不能为0 -``` - diff --git a/Chapter1 - iOS/1.119.md b/Chapter1 - iOS/1.119.md deleted file mode 100644 index d98e747..0000000 --- a/Chapter1 - iOS/1.119.md +++ /dev/null @@ -1,381 +0,0 @@ -# 剖析 Swift String - - 带着问题研究下 Swift 中的 String - -- 1个 String 变量占用多少内存? -- String 存放在什么位置? - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//MemoryLayout.png) - - - -## 字符串的创建过程 - -```swift -var str1: String = "0123456789" -``` - -实验很简单,就一行代码。来窥探下 String 的初始化和内存结构。出发断点看到下面汇编: - - - -简单分析下: - -- 第4行 `leaq 0x3d45(%rip), %rdi` 将 `rip + 0x3d45` 计算出的地址值赋值给寄存器 `rdi` -- 第5行 `movl $0xa, %esi` 将 10 赋值给寄存器 `esi`,也就是 `rsi` -- 第6行 `movl $0x1, %edx` 将 1 赋值给寄存器 `edx`,也就是 `rdx ` -- 第7行 `callq 0x100007578 ; symbol stub for: Swift.String.init(_builtinStringLiteral: Builtin.RawPointer, utf8CodeUnitCount: Builtin.Word, isASCII: Builtin.Int1) -> Swift.String ` -- 调用完第7行的方法有2个返回值,保存到寄存器 `rax` 、`rdx` 中 -- 第8行 `movq %rax, 0x86cf(%rip)` 将 `rax` 的值赋值给 `rip + 0x86cf ` -- 第8行 `movq %rdx, 0x86d0(%rip) ` 将 `rdx` 的值赋值给 `rip + 0x86d0 ` - -可以看到 String 指针占用8 + 8 = 16个字节. - - - -QA:这个10是什么东西?1是什么东西? - -结合调用 `Swift.String.init(_builtinStringLiteral: Builtin.RawPointer, utf8CodeUnitCount: Builtin.Word, isASCII: Builtin.Int1)` 方法猜测,10 应该是 `utf8CodeUnitCount` 即 utf8格式的字符个数,1 应该是 `isASCII` 即是 ASCII - -做个实验验证下 - -```swift -var str1: String = "01234" -``` - - - -可以看到其他和上面的没变化,唯一不同的是 `movl $0x5, %esi` 将5赋值给寄存器 `esi`,即寄存器 `rsi`。实验的字符串 "01234" UTF8 字符个数为5 - -继续改变 - -```swift -var str1: String = "01234😄" -``` - - - -可以看到将9赋值给寄存器 `esi` 即 `rsi`,字符串赋值给寄存器 `rdi`,`xorl %edx, %edx` 异或运算结果 0 赋值给寄存器 `edx` 即 `rdx`,也就是不纯粹为 - -所以猜想正确。具体字符串创建过程可以查看 [Swift String 源码](https://github.com/apple/swift/blob/81812eaf2a6589610054a5db655bf7de3f3c8de6/stdlib/public/core/String.swift#L774) - -```swift -// stdlib/public/core/String.swift -extension String: _ExpressibleByBuiltinStringLiteral { - @inlinable @inline(__always) - @_effects(readonly) @_semantics("string.makeUTF8") - public init( - _builtinStringLiteral start: Builtin.RawPointer, - utf8CodeUnitCount: Builtin.Word, - isASCII: Builtin.Int1 - ) { - let bufPtr = UnsafeBufferPointer( - start: UnsafeRawPointer(start).assumingMemoryBound(to: UInt8.self), - count: Int(utf8CodeUnitCount)) - if let smol = _SmallString(bufPtr) { - self = String(_StringGuts(smol)) - return - } - self.init(_StringGuts(bufPtr, isASCII: Bool(isASCII))) - } -} -``` - - - - 也就是说 String 本身会占用16个字节长度,会调用 `Swift.String.init(_builtinStringLiteral: Builtin.RawPointer, utf8CodeUnitCount: Builtin.Word, isASCII: Builtin.Int1) -> Swift.String` 方法,该方法传递3个参数:字符串真实地址、字符串 UTF8 格式的个数、是否是 ASCII 。 - - - -### 字符串长度小于15位的创建 - -继续探索: - - - -可以看到 `str1` 地址为 `rip + 0x8853 = 0x1000039a5 + 0x8853 = 0x10000C1F8 `,然后读取对应堆上的内容为: - -```shell -0x10000c1f8: 30 31 32 33 34 35 36 37 38 39 00 00 00 00 00 ea 0123456789...... -0x10000c208: 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ................ -``` - -打印出 `0x3736353433323130 0xea00000000003938`。怎么理解呢? - -从 [ASCII 码表](https://www.ascii-code.com) 可以看出 0 对应 `0x30`,1 对应 `0x31`,所以字符串`0123456789` 从 `0x30` 到 `0x39` - -`ea` 代表什么? - -a 即10,代表10个字符。最大为 f,只能存储15个字符。e 代表字符串类型。 - -```swift -var str1: String = "0123456789ABCDE" -print(Mems.memStr(ofVal: &str1)) // "0x3736353433323130 0xef45444342413938" -``` - -当把字符改为15个时,输出的内存上的值为 `0x3736353433323130 0xef45444342413938`。 - -也就是说:当字符串长度小于16位的时候,通常会使用内联存储来存储字符串的内容。内联存储意味着字符串的实际内容会直接存储在字符串对象本身的内存空间中,而不需要额外的内存分配。类似 OC `NSString` 的 `NSTaggedPointerString` - - - -### 字符串长度大于15位 - -```swift -var str1: String = "0123456789ABCDEF" -print(Mems.memStr(ofVal: &str1)) -``` - - - -分析下: - -- 第12行 `cmpq $0xf, %rsi` 拿 `0xf` 15 和寄存器 `rsi` 的值进行比较。上面已经分析过了,`rsi` 里面存放的是字符串的长度 - -- 第13行 `jle 0x7ff81a7b9017 ` 如果12行比较结果为真,则跳转到 `0x7ff81a7b9017` - -- 实际发现,字符串长度大于15,则继续向下执行 - -- 第20行 `movabsq $0x7fffffffffffffe0, %rdx` 则会把立即数 `0x7fffffffffffffe0` 移动到寄存器 `rdx` - -- 第21行 `addq %rdx, %rdi` 将 `rdx` + ` rdi` 的值赋值给 `rdi`,也就是 `rdi` 存放了: 字符串的真实地址 + `0x7fffffffffffffe0 相加后的值。` - - 所以字符串真实地址 = `rdx 的地址` - `0x7fffffffffffffe0`。 - - 寄存器 `rdi` 读取出地址为 `0x8000000100007800` 。所以字符串真实地址为:`0x8000000100007800` - `0x7fffffffffffffe0` = `0x100007820`。 - - LLDB 读取下 `x 0x100007820 ` 看到 30、31...46刚好是字符串 `0123456789ABCDEF` 的 ASCII 值。 - - 所以 **`字符串真实地址 = 指针内存8个字节地址 - 0x7fffffffffffffe0`**,又等价于 `字符串真实地址 = 指针内存8个字节地址 + 0x20` - -- 经过23行后 `orq %rdi, %rdx` 可以看到 `rdx` 、`rdi` 里面存储的都是:`字符串真实地址` + `0x7fffffffffffffe0` - -- LLDB 输入 finish 结束函数细节,外部可以看到第10行 `movq %rdx, 0x8864(%rip) ` 将 `rdx` 寄存器里的值(也就是:`字符串真实地址` + `0x7fffffffffffffe0` )赋值给 `str1` 指针的后8个字节 - - - - - - - - `字符串真实地址 = 指针内存8个字节地址 - 0x7fffffffffffffe0`,等价于 `字符串真实地址 = 指针内存8个字节地址 + 0x20` - - - -### 字符串存储在内存中什么地方 - -```swift -var str1: String = "0123456789ABCDEF" -``` - -字符串地址为 ` 0x10000397f + 0x3ea1 = 0x100007820`,看着像全局变量、也有可能是常量区?字符串内容写死的情况下,编译器编译后内存地址应该可以确定,那到底在什么地方呢?利用 `MachOView` 窥探下 `MachO` 文件吧 - - - - - -利用 MachOView 打开如下 - - - - - -X86_64 架构下,我们在 MachOView 看到的地址,真实对应地址为: `0x10000000 + 00007820 = 0x10007820` 好巧啊,发现计算出的值刚好就是字符串 `str1` 的真实地址。MachOView 右侧也显示了字符串的内容,刚好就是 str1 - -解释下: - -在 macOS 和 iOS 的二进制文件(如 Mach-O 格式的可执行文件或动态库)中,`Section64` 是一个结构体,用于描述二进制文件中的一个段(section)。每个段包含特定类型的数据或代码,并且具有特定的属性,比如是否可写、是否可执行等。 - - `Section64(__TEXT,__cstring)` 中: - -- `__TEXT` 是段的段名(segment name),存储**只读且可执行**的内容,包括代码和只读数据。**典型节(Sections)**: - - `__text`:存放机器指令(代码段)。 - - `__cstring`:存放字符串常量。 - - `__const`:存放其他常量数据。 - -- **`__DATA` 段**:存储**可读写**的数据(如全局变量、静态变量)。 -- `__cstring` 节: - - **功能**:`_cstring` 专门存储硬编码的字符串常量(如 `"Hello, World!"`)。 - - **内存权限**:映射到内存时,`__TEXT` 段整体为**只读**(`r--` 或 `r-x`),但 `_cstring` 本身**不可执行**,仅用于数据存储 - - **所属区域**: - - 逻辑上属于**常量区**(类似 ELF 格式的 `.rodata`)。 - - 物理上可能与代码段(`__text`)同属 `__TEXT` 段,但用途和权限不同。 - - -因此,`Section64(__TEXT,__cstring)` 描述的是二进制文件中存储 C 字符串字面量的一个节。这个节位于 `__TEXT__` 段中,并且包含的是只读数据。 - - - -再做下调整 - -```swift -var str1: String = "0123456789ABCDEF" -var str2: String = "012345" -print(Mems.memStr(ofVal: &str1)) // 0xd000000000000010 0x8000000100007800 -print(Mems.memStr(ofVal: &str2)) // 0x0000353433323130 0xe600000000000000 -``` - - - -可以看到: - -- str1、str2 指针长度为均为16个字节,且内存连续 `00007820`、`00007830` -- 字符串长度小于15的时候,打印出 str2 的内存值的前8个字节存储的就是字符串本身 `0x0000353433323130`,后8个字节 `0xe600000000000000` e 代表字符串类型,6代表字符串长度 -- 字符串长度大于15的时候,内存值的前8位 `0xd000000000000010` 最后的10也就是16,代表字符串长度。内存的后8位代表字符串计算后的地址(`字符串真实地址` + `0x7fffffffffffffe0` ) -- 字符串是存储在 `__TEXT__` 段的 `__cstring` 节中,属于常量区。 - -做了调整,可以看到 str3 内存值的前8个字节 `0xd000000000000015` 中的15也就是21位字符串,符合预期。 - -```swift -var str1: String = "0123456789ABCDEF" -var str2: String = "012345" -var str3: String = "0123456789ABCDEFGHIJK" -print(Mems.memStr(ofVal: &str1)) // 0xd000000000000010 0x80000001000077e0 -print(Mems.memStr(ofVal: &str2)) // 0x0000353433323130 0xe600000000000000 -print(Mems.memStr(ofVal: &str3)) // 0xd000000000000015 0x8000000100007800 -``` - - - -### Swift 字符串存储本质 - -Swift 字符串存储的两种模式: - -- **内联存储(Small String Optimization,SSO)**: - - **条件**:字符串长度 ≤15 个 **ASCII 字符**(或 ≤7 个 **UTF-16 字符**)。 - - **特点**:字符串内容直接存储在 `StringObject` 的 16 字节内存中,无需堆分配。有点类似 Objective-C 的 **Tagged Pointer** -- **堆存储(Heap-Allocated)**: - - **条件**:字符串长度超过上述限制(字符串长度 > 15 个 **ASCII 字符** 或 > 7 个 **UTF-16 字符**) - - **特点**:字符串内容存储在堆内存。其指针结构是一个 16 字节的 `StringObject`,`StringObject` 存储堆地址和元数据 - - - -#### 内联存储(SSO)的具体实现 - -内存布局:前8个字节(元数据 + 部分字符) + 后8个字节(剩余字符 + 填充) - -元数据编码: - -最低有效位(LSB)用于标识存储模式: - -- **0**:内联存储 -- **1**:堆存储 - -其余位存储字符串长度和编码信息(ASCII 或 UTF-16) - -Demo - -````Swift -let str = "Hello" // 5 个 ASCII 字符 -内存布局如下: -0x0000000000000a05 // 元数据(长度=5, ASCII, 内联标志位=0) -0x48656c6c6f000000 // ASCII 字符 "Hello" 的十六进制表示 + 填充 -```` - -与 Objective-C Tagged Pointer 的区别 - -| **特性** | **Swift 内联存储 (SSO)** | **Objective-C Tagged Pointer** | -| :----------- | :------------------------- | :------------------------------ | -| **存储位置** | 字符串对象的 16 字节内存中 | 指针值本身(64 位) | -| **标识方式** | 元数据的最低有效位 (LSB) | 指针的最高有效位 (MSB) | -| **兼容性** | 需考虑 Unicode 编码复杂性 | 仅支持有限类型(如短 NSString) | -| **内存安全** | 完全由编译器管理 | 需运行时特殊处理 | - - - - - - - -## 字符串拼接 - -### 长度小于15的字符串拼接 - -```swift -var str1: String = "012345" -print(Mems.memStr(ofVal: &str1)) // 0x0000353433323130 0xe600000000000000 -str1.append("ABC") -print(Mems.memStr(ofVal: &str1)) // 0x4241353433323130 0xe900000000000043 -str1.append("DEFGHI") -print(Mems.memStr(ofVal: &str1)) // 0x4241353433323130 0xef49484746454443 -``` - -可以看到不管字符串怎么拼接,只要拼接后的内容小于小于等于15,则依旧是在字符串的内容存放在自身的16个字节中。 - - - -### 长度大于15的字符串拼接 - -```swift -var str1: String = "0123456789ABCDEF" -print(Mems.memStr(ofVal: &str1)) // 0xd000000000000010 0x8000000100007800 -str1.append("G") -print(Mems.memStr(ofVal: &str1)) // 0xf000000000000011 0x0000600001700440 -print("explore") -``` - -可以看到:长度为16的字符串拼接后 - -- 内存的前8个字节,从 `0xd000000000000010` 变到了 `0xf000000000000011`,最后2位代表字符串长度,16进制的10就是16。从16位变成17位。 -- 内存的后8个字节,字符串的地址改变了 - -上面可知, `字符串真实地址 = 指针内存8个字节地址 - 0x7fffffffffffffe0`,又等价于 `字符串真实地址 = 指针内存8个字节地址 + 0x20` - - - -字符串真实地址:`0x0000600001700440 + 0x20 = 0x0000600001700460`,LLDB `x 0x0000600001700460 ` 可以看到字符串拼接后的结果。看上去是存储在堆上。如何验证? - - - -我们知道堆上的内存初始化在 Swift 侧,关键方法为 `swift_allocObject ` -> `swift_slowAlloc` -> `malloc `。给 malloc 下断点,然后在断点出查看调用堆栈,可以看到在 `string.append()` 后有堆分配内存 - - - -结束当前函数调用,在外层可以看到 str2 地址值的后8个字节为 `0x000060000170410`,再 + `0x20` 就是字符串真实地址,打印如下。 - - - -0x20 是什么?这32个字节存放了什么信息?存储字符串的描述信息,比如:引用计数、字符串长度等信息。 - - - - 总结: - -- 当字符串长度小于等于 0xF(也就是15),字符串内容直接存放在指针变量对应的内存中 -- 当字符拼接时候,拼接后字符串长度小于等于15,则字符串内容依旧存储在指针变量的内存中 -- 当字符串长度大于 0xF(也就是15),字符串的内容存放在 `__TEXT,__cstring` 中(常量区)。字符串的地址值信息存放在指针变量的后8个字节中,且真正的地址值为 (`后8个字节值` + `0x20` ),前32字节存储字符串的基础信息(长度、引用计数等) - - - -## dyld_stub_binder - -`__TEXT` 是 Mach-O 文件中通常包含代码和只读数据的段。这个段包含了程序的主要执行代码,以及常量字符串、符号表等。 - -`dyld_stub_binder` 是一个由动态链接器(dyld)在运行时生成和使用的函数。它的主要目的是在首次调用某个动态链接的符号(如函数或方法)时,将该符号的实际地址绑定到调用点 - -Swift 中 `String` 类型的初始化方法(`init`)的地址是否采用延迟绑定(Lazy Binding),取决于 **编译环境、优化级别和具体方法实现** - -### 延迟绑定的基本原理 - -在编译时,对于动态链接的符号,编译器会生成一个桩(stub),而不是直接调用该符号。桩是一个小段的代码,当被首次执行时,它会触发 `dyld_stub_binder` 的调用。`dyld_stub_binder` 的任务就是找到该符号的实际地址,并将其写入桩中,从而替换桩的原始代,这样,下一次调用该符号时,就可以直接跳转到实际的地址,而无需再次通过桩和 `dyld_stub_binder`。 - -延迟绑定(Lazy Binding)是动态链接的机制,用于推迟符号(如函数、方法)地址的解析到首次调用时。其核心步骤为: - -1. **编译阶段**:生成存根(Stub),指向符号占位地址。 -2. **启动阶段**:存根指向动态链接器(如 `dyld`)的解析函数(如 `dyld_stub_binder`)。 -3. **首次调用**:触发符号解析,动态链接器填充真实地址到存根。 -4. **后续调用**:直接跳转到已解析的地址。 - - - -替换桩,位于 `__DATA,__la_symbol_ptr` 数据段可读可写,所以可以修改。 - - - - - -`__stub_helper` 节是 `__TEXT` 段中的一个特定节,它包含了用于处理符号懒加载的辅助函数和代码。当动态链接的符号首次被调用时,这些辅助函数会被触发,以解析符号并将其地址绑定到调用点。 - -这个过程也叫 `Lazy_binding`。懒加载是一种优化技术,允许程序在启动时不必立即解析和绑定所有动态链接的符号。相反,这些符号的解析和绑定被推迟到它们实际被使用时进行。这种延迟可以减少应用程序启动时的内存和性能开销。 diff --git a/Chapter1 - iOS/1.12.md b/Chapter1 - iOS/1.12.md deleted file mode 100644 index 78acdff..0000000 --- a/Chapter1 - iOS/1.12.md +++ /dev/null @@ -1,224 +0,0 @@ - -# NSFileManager - -> 想操作文件,该去了解下NSFileManager - -注意://小窍门:打印数组或者字典,里面包含中文,直接用%@打印会看不到中文,可用for遍历访问 - -* 单例方法得到文件管理者对象 - -``` - NSFileManager *fileManager = [NSFileManager defaultManager]; -``` - -* 判断是否存在指定的文件 - -``` - #define LogBool(value) NSLog(@"%@",value==YES?@"YES":@"NO"); - - NSString *filepath = @"/Users/geek/Desktop/data.plist"; - BOOL res = [fileManager fileExistsAtPath:filepath]; - LogBool(res) -``` - -* 根据给出的文件路径判断是否存在文件,且判断路径是文件还是文件夹 - -``` -NSString *filepath1 = @"/Users/geek/Desktop/data.plist"; - BOOL isDirectory = NO; - BOOL isExist = [fileManager fileExistsAtPath:filepath1 isDirectory:&isDirectory]; - if (isExist) { - NSLog(@"文件存在"); - if (isDirectory) { - NSLog(@"文件夹路径"); - }else{ - NSLog(@"文件路径"); - } - }else{ - NSLog(@"给定的路径不存在"); - } -``` - -* 判断文件或者文件夹是否可以读取 - -``` - //这是一个系统文件(不可读) - NSString *filePath2 = @"/.DocumentRevisions-V100 "; - BOOL isReadable = [fileManager isReadableFileAtPath:filePath2]; - if (isReadable) { - NSLog(@"文件可读取"); - } else { - NSLog(@"文件不可读取"); - } -``` - -* 判断文件是否可以写入 - -``` - //系统文件不可写入 - BOOL isWriteAble = [fileManager isWritableFileAtPath:filePath2]; - if (isWriteAble) { - NSLog(@"文件可写入"); - } else { - NSLog(@"文件不可写入"); - } -``` - -* 判断文件是否可以删除 - -``` -//系统文件不可删除 - BOOL isDeleteAble = [fileManager isDeletableFileAtPath:filePath2]; - if (isDeleteAble) { - NSLog(@"文件可以删除"); - } else { - NSLog(@"文件不可删除"); - } -``` - -* 获取文件信息 -![文件信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-07-02%20下午5.58.38.png) - -``` - NSError *error = nil; - NSDictionary *fileInfo = [fileManager attributesOfItemAtPath:filepath1 error:&error]; -// NSLog(@"文件信息:%@,错误信息:%@",fileInfo,error); - NSLog(@"文件大小:%@",fileInfo[NSFileSize]); -``` - -* 获取指定目录下的所有目录(列出所有的文件和文件夹) - -``` -NSString *filePath3 = @"/Users/geek/desktop"; - NSArray *subs = [fileManager subpathsAtPath:filePath3]; - NSLog(@"Desktop目录下所有的所有文件和文件夹"); - //小窍门:打印数组或者字典,里面包含中文,直接用%@打印会看不到中文,可用for遍历访问 - for (NSString *item in subs) { - NSLog(@"%@",item); - } -``` - -* 获取指定目录下的子目录和文件(不包含子孙) - -``` -NSError *erroe = nil; - NSArray *children = [fileManager contentsOfDirectoryAtPath:filePath3 error:&erroe]; - NSLog(@"Desktop目录下的文件和文件夹"); - for (NSString *item in children) { - NSLog(@"%@",item); - } -``` - -* 在指定目录创建文件 - -``` - NSString *filePath1 = @"/Users/geek/Desktop/data.text"; - NSData *data = [@"我要学好OC" dataUsingEncoding:NSUTF8StringEncoding]; - BOOL createFile = [fileManager createFileAtPath:filePath1 contents:data attributes:nil]; - if (createFile) { - NSLog(@"文件创建成功"); - } else { - NSLog(@"文件创建失败"); - } -``` - -* 在指定目录创建文件夹(参数说明:withIntermediateDirectories后的参数为Bool代表。YES:一路创建;NO:不会做一路创建) - -![正常创建文件夹成功](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-07-02%20下午7.02.53.png) -![创建文件夹失败](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-07-02%20下午7.07.55.png) - - -设置一路创建为NO,如果文件夹不存在则停止创建文件 - -``` - NSString *filePath2 = @"/Users/geek/Desktop/海贼王"; - NSError *error = nil; - BOOL createDirectory = [fileManager createDirectoryAtPath:filePath2 withIntermediateDirectories:NO attributes:nil error:&error]; - if (createDirectory) { - NSLog(@"文件夹创建成功"); - } else { - NSLog(@"文件夹创建失败,原因:%@",error); - } - - - - //一路创建失败(文件夹不存在就不创建) - NSString *filePath3 = @"/Users/geek/Desktop/海贼王"; - BOOL createDirectory1 = [fileManager createDirectoryAtPath:filePath3 withIntermediateDirectories:NO attributes:nil error:&error]; - if (createDirectory1) { - NSLog(@"文件夹创建成功"); - } else { - NSLog(@"文件夹创建失败,原因:%@",error); - } -``` - -* 复制文件 - -``` - NSString *filePath4 = @"/Users/geek/Desktop/动漫"; - - BOOL copyRes = [fileManager copyItemAtPath:filePath3 toPath:filePath4 error:nil]; - if (copyRes) { - NSLog(@"文件复制成功"); - } else { - NSLog(@"文件复制失败"); - } -``` - -* 移动文件 - -``` - NSString *filePath5 = @"/Users/geek/Downloads/动漫"; - BOOL moveRes = [fileManager moveItemAtPath:filePath3 toPath:filePath5 error:nil]; - if (moveRes) { - NSLog(@"文件移动成功"); - } else { - NSLog(@"文件移动失败"); - } -``` - -* 可以给文件重命名 - -``` - //可以给文件重命名 - NSString *filePath6 = @"/Users/geek/Downloads/卡通"; - [fileManager moveItemAtPath:filePath5 toPath:filePath6 error:nil]; -``` - -* 删除文件 - -``` - BOOL deleteRes = [fileManager removeItemAtPath:filePath6 error:nil]; - if (deleteRes) { - NSLog(@"文件删除成功"); - } else { - NSLog(@"文件删除失败"); - } -``` - -# NSFileManager小病毒 -``` - //单例方法得到文件管理者对象 - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *filePath = @"/Users/geek/desktop/delete/"; - while (1) { - //判断该文件路径是否存在 - BOOL exist = [fileManager fileExistsAtPath:filePath]; - if (exist) { - //找出该路径下的所有文件 - NSArray *subs = [fileManager contentsOfDirectoryAtPath:filePath error:nil]; - if (subs.count > 0) { - for (int i=0; i= 变量 - - ```swift - fileprivate class Person { - func sayHi() {} - } - internal var p:Person = Person() // Variable cannot be declared internal because its type uses a fileprivate type - class Dog { - func sepak() { - p.sayHi() - } - } - ``` - - 编译器报错:`Variable cannot be declared internal because its type uses a fileprivate type`。 访问权限前后冲突了。变量 p 被声明为 internal,也就是其他类中也可以使用该对象。但 Person 类被 fileprivate 修饰了,也就是只可以在定义该类的文件中使用。所以会冲突 - -- 参数类型、返回值类型 >= 函数 - -- 父类 >= 子类 - 因为定义的子类如果是 internal,而父类是 fileprivate,则可以访问到子类的地方,访问不到父类,这是不合理的。 -- 父协议 >= 子协议 - 协议也可以继承,父协议里的一些属性如果访问级别较低,遵循子协议,实现子协议的类,是无法访问到父协议中的属性的。 -- 原类型 >= typealias - - ```swift - class Person {} - fileprivate typealias People = Person // 编译通过 - public typealias People = Person // Type alias cannot be declared public because its underlying type uses an internal type - ``` - - `Person` 默认是 internal,所以 typealias 的权限修饰符需要小于等于 internal。 - -- 原始值类型、关联值类型 >= 枚举类型 - - ```swift - // 编译通过 - typealias OwnInt = Int - typealias OwnString = String - internal enum DataKind { - case int(OwnInt) - case string(OwnString) - } - // 编译失败 - fileprivate typealias OwnInt = Int - fileprivate typealias OwnString = String - - public enum DataKind { - case int(OwnInt) // Enum case in a public enum uses a fileprivate type - case string(OwnString) // Enum case in a public enum uses a fileprivate type - } - - ``` - -- 定义类型 A 时用到的其他类型 >= 类型A - -- 元祖类型:元祖类型的访问级别是所有成员类型最低的那个(木桶原理) - - ```swift - internal struct Dog {} - fileprivate class Person {} - - internal var data1:(Dog, Person) // Error:Variable cannot be declared internal because its type uses a fileprivate type - fileprivate var data1:(Dog, Person) - private var data2:(Dog, Person) - ``` -- 泛型类型:泛型类型的访问级别由:类型的访问级别以及所有泛型类型参数的访问级别最低的那个决定 - Demo1: 编译器报错:`Variable must be declared private or fileprivate because its type uses a fileprivate type` - 分析: - - 泛型的访问级别由最低的类别的访问级别决定。Car 为 internal,Dog 为 fileprivate,所以最低的是 fileprivate,所以 Person 类型的访问级别为 fileprivate。因此 Person 类型变量的访问级别为 fileprivate 或者比 fileprivate 更低的访问级别。 - - Swift 默认所有的变量、方法、类的默认访问级别为 internal。internal 访问级别高于 fileprivate,所以报错。 - - ```swift - internal class Car { } - fileprivate class Dog { } - public class Person { } - - var person: Person = Person() // compile error: Variable must be declared private or fileprivate because its type uses a fileprivate type - ``` - - 改进下: `fileprivate var person: Person = Person()` 和 `private var person: Person = Person()` 都不会报错。 - - - -- 类型的访问级别会影响成员(属性、方法、初始化器、下标)、嵌套类型的默认访问级别: - - 一般情况下,类型为 fileprivate、private,那么成员、嵌套类型默认也是 private、fileprivate - - 一般情况下,类型为 internal、public,那么成员、嵌套类型默认也是 internal - - 测试: - - ```swift - // 编译不通过 - class TestClass { - private class Person {} - fileprivate class Student : Person {} // Class cannot be declared fileprivate because its superclass is private - } - - // 编译通过 - private class Person {} - fileprivate class Student : Person {} - ``` - -- 当 fileprivate、private 都写在文件的全局作用域时,访问权限是一样的。 - -总结:看似规则比较多,实际上就是对「一个实体不可以被更低访问级别的实体定义」的解释。 - - -## getter、setter -- getter、setter 默认自动接收他们所属换的访问级别 -- **可以给 setter 单独设置一个比 getter 权限更低的访问级别,用以限制写权限** - -```swift -private(set) var num = 10 -class Person { - private(set) var age = 0 - init(age: Int = 0) { - self.age = age - } - public var weight: Int { - set {} - get { 10 } - } - -} - -var p = Person(age: 10) -//print(p.age) -//p.age = 20 -print(num) -num = 20 -print(num) -``` - - - -## 初始化器 -- 如果一个 public 类,想在另一个模块调用编译生成的默认无参初始化器,必须显示提供 public 的无参初始化器(因为 public class,编译器生成的 init 初始化器是 internal。另一个模块是无法访问的) - - ```swift - // APM.dylib - public class APMManager { - public init () { - - } - } - - // 另一个模块(动态库/静态库/主工程) - var manager = APMManager() - ``` - - - -## 枚举类型 -不能给 enum 的 case 单独设置访问级别,每个 case 自动对齐 enum 的访问级别(比如:public 的 enum,各个 case 也是 public) - - - -## 协议 -协议中定义的要求自动接收协议的访问级别,不能单独设置访问级别(比如:public 定义的协议,各个属性、方法也是 public 级别) -**协议实现的访问级别 >= 类型的访问级别(协议的访问级别)** - -```swift -// 编译通过 -public protocol Runnable { - func run() -} -internal class Person : Runnable { - internal func run() { } -} -// 编译失败 -public protocol Runnable { - func run() -} -public class Person : Runnable { - internal func run() { } // Method 'run()' must be declared public because it matches a requirement in public protocol 'Runnable' -} -``` - - - - - diff --git a/Chapter1 - iOS/1.121.md b/Chapter1 - iOS/1.121.md deleted file mode 100644 index b80f91e..0000000 --- a/Chapter1 - iOS/1.121.md +++ /dev/null @@ -1,644 +0,0 @@ -# Swift 内存管理 - - -## 弱引用 -Swift 和 OC 都是通过引用计数方式来管理内存的。 - -Swift 的 ARC 存在3种情况: - -- 强引用(strong reference)。默认情况下,都是强引用。 - 当一个强指针离开作用域后,会自动释放对象,调用 deinit 方法。 - ```swift - class Person { - deinit { - print("Person deinit") - } - } - - func test () { - let p: Person = Person() - } - - print("1") - test() - print("2") - // console - 1 - Person deinit - 2 - ``` - -- 弱引用(weak reference)。通过 weak 定义弱引用。**必须是可选类型,因为实例销毁后,ARC 会自动将弱引用设置为 nil**。 - ```swift - weak var p: Person? = Person() - ``` - - 弱引用如果被设置为 nil,是不会触发属性观察器的 willSet、didSet 方法的 - ```swift - class Dog { - deinit { - print("Dog deinit") - } - } - - class Person { - weak var dog: Dog? { - willSet { - print("willSet") - } - didSet { - print("didSet") - } - } - deinit { - print("Person deinit") - } - } - - func test () { - let p: Person = Person() - p.dog = Dog() - print(p) - } - - - print("1") - test() - print("2") - // console - 1 - willSet // 这里的触发是 test 方法里,给 person 对象设置了 dog 属性时触发的。但是 weak 指针设置为 nil 的时候没有触发属性观察器 - didSet - Dog deinit - SwiftDemo.Person - Person deinit - 2 - ``` - 换一种写法。可以发现在 init 方法里面,属性观察器 willSet、didSet 是不会触发的。 - ```swift - class Dog { - deinit { - print("Dog deinit") - } - } - - class Person { - - weak var dog: Dog? { - willSet { - print("willSet") - } - didSet { - print("didSet") - } - } - - init (dog: Dog?) { - self.dog = dog - } - deinit { - print("Person deinit") - } - } - - func test () { - let p: Person = Person(dog: Dog()) - print(p) - } - - - print("1") - test() - print("2") - // console - 1 - Dog deinit - SwiftDemo.Person - Person deinit - 2 - ``` -- 无主引用(unowned reference)。通过 unowned 定义无主引用 - - 不会产生强引用,非可选类型。实例销毁后仍然存储着实例的内存地址,类似 OC 的 `unsafe_retained` - - 如果在实例销毁后访问无主引用,会产生野指针错误 - - -weak、unowned 只能用在类实例上。比如: -```swift -protocol Liveavle: AnyObject { } -class Person { } - -weak var p1: Person? -weak var p2: AnyObject? -weak var p3: Liveavle? - -unowned var p4: Person? -unowned var p5: AnyObject? -unowned var p6: Liveavle? -``` - - -## 循环引用 -weak、unowned 都能解决循环引用问题。但是 weak 由于当对象释放后,会把指针设置为 nil。所以 unowned 会比 weak 的性能更好。 -- 在生命周期中对象可能会变为 nil,推荐使用 weak -- 初始化赋值后再也不会变为 nil 的对象,推荐使用 unowned - -## 闭包的循环引用 - -上面的代码会发生循环引用,会导致局部变量的 p 无法释放(看不到 Person 的 deinit 方法调用) - -解法: -- **在闭包表达式的捕获列表声明 weak 或者 unowned 引用,解决循环引用的问题** -因为在闭包里,声明的捕获列表中将 p 用 weak 修饰,所以可以为 nil。p 使用到的地方必须用 `p?.run()` -```swift -class Person { - var fn:(() -> ())? - func run () { print("run") } - deinit { print("deinit") } -} - -func test () { - let p = Person() - p.fn = { - [weak p] in - p?.run() - } -} - -test() -// deinit -``` -另一种写法 -```swift -p.fn = { - [unowned p] in - p.run() -} -``` -- -```swift -class Person { - lazy var fn:() -> () = { - self.run() - } - func run () { print("run") } - deinit { print("deinit") } -} -``` - -## @escaping - -- 非逃逸闭包、逃逸闭包,一般都是当作参数传递给函数 -- 非逃逸闭包:闭包调用发生在函数结束前,闭包调用在函数作用域内 -- 逃逸闭包:闭包油可能在函数结束后调用, 闭包调用逃离了函数的作用域,需要通过 `@eascaping` 声明 - -```swift -typealias Fn = () -> () -var globalFn:Fn? -func setFn(_ fn: @escaping Fn) { - globalFn = fn -} -setFn { - print("Hello world") -} -globalFn?() // Hello world -``` - - - -注意点:逃逸闭包不可以捕获 `inout` 参数 - -```swift -typealias Fn = () -> () -func other1(_ fn: Fn) { - fn() -} - -func other2(_ fn: @escaping Fn) { - fn() -} - -func test(value: inout Int) -> Fn { - other1 { - value += 1 - } - other2 { // compile error:Escaping closure captures 'inout' parameter 'value' - value += 1 - } - func add() { - value += 1 - } - return add // compile error:Escaping closure captures 'inout' parameter 'value' -} -``` - -原因:因为 `inout` 参数的本质是要求函数在调用期间直接操作变量的内存地址,而逃逸闭包可能会在函数返回后的任何时刻调用(不确定),这时 `inout` 参数所在的内存地址可能已经不再有效或者已经被其他值覆盖。因此,允许逃逸闭包捕获 `inout` 参数会导致潜在的数据不一致和安全问题。 - - - - - -## 内存访问冲突 - -Confilicting Access to Memory, 内存访问冲突发生在: - -- 至少一个是写入操作 -- 它们访问的是同一块内存 -- 它们的访问时间重叠(比如在同一个函数内) - - - -Demo1: - -```swift -var step = 1 -func increament(_ num: inout Int) { - num += step -} -increament(&step) -``` - - - - - -解决办法就是打破3个条件之一。显然不可以换函数,只有改变「同时访问一块内存地址」这个条件了 - -```swift -var step = 1 -func increament(_ num: inout Int) { - num += step -} -var stepCopy = step -increament(&stepCopy) -step = stepCopy -``` - - - -Demo2: - -```swift -func balance(_ x: inout Int, _ y: inout Int) { - let sum = x + y - x = sum/2 - y = sum - x -} - -var num1 = 1 -var num2 = 2 -balance(&num1, &num2) // -balance(&num1, &num1) // compile error: Inout arguments are not allowed to alias each other -``` - -Demo3: 下面代码虽然看着传入的是不同内存地址,但是 health 和 power 都属于元祖,还是同一个内存地址。 - - - - - -如何解决? - -Swift 规定以下 case,就说明重叠访问结构体的属性就是安全的 - -- 只访问实例存储属性,不是计算属性或者类属性 -- 结构体是局部变量而非全局变量 -- 结构体要么没有被闭包捕获,要么只被非逃逸闭包捕获 - -```swift -func balance(_ x: inout Int, _ y: inout Int) { - let sum = x + y - x = sum/2 - y = sum - x -} -func testConflictingAccessToMemory() { - var tumple = (health: 100, power: 100) - balance(&tumple.health, &tumple.power) -} -testConflictingAccessToMemory() -``` - - - - - -## 指针 - -Swift 也有专门的指针类型,都被定义为 Unsafe(不安全的),有: - -- `UnsafePointer` 类似于 `const Person *` -- `UnsafeMutablePointer` 类似于 `Person *` -- `UnsafeRawPonter` 类似于 `const void *` -- `UnsafeMutableRawPonter` 类似于 `void *` - - - -Demo1 - -因为 changeValue1 的参数是不可变的指针,所以方法内部去修改值,编译器会报错。 - -```swift -var age = 27 -func changeValue1(_ num: UnsafePointer) { - num.pointee = 28 // compile error: Cannot assign to property: 'pointee' is a get-only property -} -func changeValue2(_ num: UnsafeMutablePointer) { - num.pointee = 28 -} -changeValue1(&age) -print(age) -changeValue2(&age) -print(age) -``` - -- `changeValue1` 的参数是不可变的指针,所以方法内部去修改值,编译器会报错 -- `changeValue2` 的参数是可变的指针,所以方法内部去修改值,编译没问题 -- 指针加了泛型,访问真实的值可以通过 `指针.pointee` 去访问 - - - -Demo2 - -```swift -func changeValue3(_ num: UnsafeRawPointer) { - let value = num.load(as: Int.self) - print("value is \(value)") -} -func changeValue4(_ num: UnsafeMutableRawPointer) { - num.storeBytes(of: 30, as: Int.self) -} -changeValue3(&age) -print(age) -changeValue4(&age) -print(age) -// console -value is 27 -27 -30 -``` - -- `changeValue3` 传递了不可变的原始指针,所以访问内存上的值,需要程序员自己指定访问的数据类型。使用 `指针.load(as: 数据类型.self)` 这种格式 -- `changeValue4` 传递了可变的原始指针,所以修改内存上的值,需要程序员自己指定访问的数据类型。使用 `指针.storeBytes(of: 值, as: 数据类型.self)` 这种格式 - - - -系统使用场景 - -```swift -import Foundation -var objcArray = NSArray(objects: 10, 11, 12, 13) -objcArray.enumerateObjects { element, idx, stop in - print("element is \(element) at index \(idx)") -} -print("--------------------") -objcArray.enumerateObjects { element, idx, stop in - if idx == 1 { - stop.pointee = true - } - print("element is \(element) at index \(idx)") -} -element is 10 at index 0 -element is 11 at index 1 -element is 12 at index 2 -element is 13 at index 3 --------------------- -element is 10 at index 0 -element is 11 at index 1 -``` - -tips: 不可以在数组 `enumerateObjects` 方法中使用 break,否则编译器会提示 `Unlabeled 'break' is only allowed inside a loop or switch, a labeled break is required to exit an if or do` - - - -## 获取某个变量的指针 - -`withUnsafePointer` `withUnsafeMutablePointer` 可以获取到不可变、可变的指针 - -```swift -var age = 27 -let pointer = withUnsafePointer(to: &age) { pointer in - let address = UnsafeRawPointer(pointer).load(as: Int.self) - print("Memory address is \(pointer), the value is \(address)") - return pointer -} -var ptr = withUnsafePointer(to: &age) { $0 } -print(ptr) -// console -Memory address is 0x000000010000c208, the value is 27 -0x000000010000c208 -0x000000010000c208 -``` - -继续添加代码,利用指针修改值,编译器报错 `Cannot assign to property: 'pointee' is a get-only property` - -```swift -ptr.pointee = 28 // Cannot assign to property: 'pointee' is a get-only property -``` - -再修改代码 - -```swift -var mutablePtr = withUnsafeMutablePointer(to: &age) { $0 } -mutablePtr.pointee = 28 -print("mutable address is \(mutablePtr), value is \(age)") -// console -mutable address is 0x000000010000c218, value is 28 -``` - -说明: - -``` -let pointer = withUnsafePointer(to: &age) { pointer in - return pointer -} -``` - -等价于下面的写法($0 是第一个参数,return 可以省略) - -```swift -let pointer = withUnsafePointer(to: &age) { pointer in - return $0 -} -let pointer = withUnsafePointer(to: &age) { $0 } -``` - - - -那如何获取不可变和可变的 rawPointer - -```swift -let rawPointer: UnsafeRawPointer = withUnsafePointer(to: &age) { - UnsafeRawPointer($0) -} -print("Raw pointer address is \(rawPointer), the value is \(rawPointer.load(as: Int.self))") - -let mutableRawPointer: UnsafeMutableRawPointer = withUnsafeMutablePointer(to: &age) { - UnsafeMutableRawPointer($0) -} -print("Mutable raw pointer address is \(rawPointer), the value is \(rawPointer.load(as: Int.self))") - -mutableRawPointer.storeBytes(of: 28, as: Int.self) -print(age) - -// console -Raw pointer address is 0x000000010000c218, the value is 27 -Mutable raw pointer address is 0x000000010000c218, the value is 27 -28 -``` - - - -pointer、pointee,英语中 er、ee,er 表示主动,ee 表示被动,分别是:指针、被指向的对象。 - -上述方式获取的都是指针变量的地址值,而不是堆空间对象的地址值。 - - - - - -## 获取堆空间对象的指针 - -先获取 `UnsafeRawPointer`,然后利用 `UnsafeRawPointer(bitPattern:**)` 获取堆空间对象的地址值 - -```swift -class Person { - var age: Int - init(age: Int) { - self.age = age - } -} -var p: Person = Person(age: 27) -var ptr1 = withUnsafePointer(to: p) { UnsafeRawPointer($0) } -var personHeapAddress = ptr1.load(as: UInt.self) -var ptr2 = UnsafeRawPointer(bitPattern: personHeapAddress) -print(ptr2) -print(Mems.ptr(ofRef: p)) -``` - - - -## 创建指针 - -创建内存方法1: `malloc` - -```swift -import Foundation -var ptr = malloc(16) -print("malloc address is \(ptr)") -// 存 -ptr?.storeBytes(of: 10, as: Int.self) -ptr?.storeBytes(of: 20, toByteOffset: 8, as: Int.self) - -let firstValue = (ptr?.load(as: Int.self))! -let secondValue = (ptr?.load(fromByteOffset: 8, as: Int.self))! -print("The first part is \(firstValue), second part is \(secondValue)") - -free(ptr) -// console -malloc address is Optional(0x0000600000008040) -The first part is 10, second part is 20 -``` - -创建内存方法2: `UnsafeMutableRawPointer.allocate(byteCount: 字节数, alignment: 内存对齐)` - -```swift -// 创建 -let ptr: UnsafeMutableRawPointer = UnsafeMutableRawPointer.allocate(byteCount: 16, alignment: 1) -print("malloc address is \(ptr)") -ptr.storeBytes(of: 10, as: Int.self) -// ptr.storeBytes(of: 20, toByteOffset: 8, as: Int.self) -ptr.advanced(by: 8).storeBytes(of: 20, as: Int.self) - -let firstValue = ptr.load(as: Int.self) -let secondValue = ptr.load(fromByteOffset: 8, as: Int.self) -print("The first part is \(firstValue), second part is \(secondValue)") -// 释放 -ptr.deallocate() - -// console -malloc address is 0x0000000100604370 -The first part is 10, second part is 20 -``` - -上面的 `ptr.storeBytes(of: 20, toByteOffset: 8, as: Int.self)` 写法等价于 `ptr.advanced(by: 8).storeBytes(of: 20, as: Int.self)` - - - -创建内存方法3: `UnsafeMutablePointer.allocate(capacity: 2)` 创建2* 8 Byte 大小的内存 - -```swift -import Foundation -var ptr = UnsafeMutablePointer.allocate(capacity: 2) -print("malloc address is \(ptr)") -// 初始化赋值 -//ptr.pointee = 27 -ptr.initialize(to: 27) - -ptr.successor().initialize(to: 10) - -// 访问 -print(ptr.pointee) -print((ptr + 1).pointee) - -print(ptr[0]) -print(ptr[1]) - -print(ptr.pointee) -print(ptr.successor().pointee) - -ptr.deinitialize(count: 2) -ptr.deallocate() -// console -malloc address is 0x0000000100604190 -27 -10 -27 -10 -27 -10 -``` - - - -## 指针之间的转换 - -`unsafeBitCast` 是忽略数据类型的强制转换,不会因为数据类型的变化而改变原来的内存数据。类似 C++ 中的 `reterpret_cast` - - -## 内存泄漏 -weak 和 unowned 是两种用于处理引用循环(retain cycles)的关键字,它们主要用在类的属性中,以确保对象之间的引用不会导致内存泄漏。这两种引用类型都用于表示对另一个对象的非拥有(non-owning)引用,但它们在行为上有所不同。 - -weak 引用是一个不持有对象引用的引用。这意味着它不会增加对象的引用计数。当对象不再被拥有时(即其引用计数为 0),weak 引用会自动设置为 nil。 -weak 引用通常用于避免循环引用,特别是在闭包或代理模式中。例如,如果你有一个视图控制器(ViewController)和一个代理(Delegate),并且 ViewController 持有一个对 Delegate 的强引用,那么为了避免循环引用,Delegate 通常会对 ViewController 持有一个 weak 引用。 - - -unowned 引用也是一个不持有对象引用的引用,但它不会在对象被释放时自动设置为 nil。因此,使用 unowned 引用时需要格外小心,因为如果引用的对象被释放了,而你的代码仍然试图访问它,那么你的程序将会崩溃。 -通常,当你确信引用的对象在其生命周期内始终存在时,才会使用 unowned 引用。例如,在一个父对象和子对象的关系中,如果父对象始终在子对象之前存在,并且子对象需要引用父对象,那么子对象可以使用 unowned 引用指向父对象。 - -## inout 参数访问冲突 - -``` -var step = 1 -func increment(_ number: inout Int) { - number += step -} -let rs = increment(&step) -print(rs) -```swift - - - -问题本质是:`inout` 关键词代表要对函数的参数进行写操作,而函数内部的实现利用 step 进行反问操作。读写同时存在就有问题。 - -如何解决?产生一个备份,然后调用函数,最后将备份产生的结果覆盖老值 -``` -var step = 1 -func increment(_ number: inout Int) { - number += step -} -// make an explicit copy -var copyOfStep = step -// invoke -increment(©OfStep) -// update the original value -step = copyOfStep -print(step) // 2 -```swift \ No newline at end of file diff --git a/Chapter1 - iOS/1.122.md b/Chapter1 - iOS/1.122.md deleted file mode 100644 index b00f1c0..0000000 --- a/Chapter1 - iOS/1.122.md +++ /dev/null @@ -1,86 +0,0 @@ -# Swift 字面量本质 - -## 常见的字面量默认类型 - -- `public typealias IntegerLiteralType = Int` -- `public typealias FloatLiteralType = Float` -- `public typealias BooleanLiteralType = Bool` -- `public typealias StringLiteralType = String` - -可以通过 typealias 修改字面量的默认类型,Demo 如下: - -```swift -typealias IntegerLiteralType = UInt8 -var val = 8 -val -``` - - - - - -## 字面量协议 - -Swift 自带的数据类型基本都可通过字面量初始化,本质原因是遵循了对应的协议 - -| | | | -| ------------- | ------------------------------------------------------ | ------------------------------------------------------------ | -| Bool | ExpressibleByBooleanLiteral | var b:Bool = false | -| Int | ExpressibleByIntegerLiteral | var num:Int = 2 | -| Float、Double | ExpressibleByIntegerLiteral、ExpressibleByFloatLiteral | var height:Float = 175
var height1:Float = 175.2
var weight:Double = 130
var weight1:Double = 130.1 | -| Dictionary | ExpressibleByDictionaryLiteral | var dic:Dictionary = ["name": "FantasticLBP"] | -| String | ExpressibleByStringLiteral | var name:String = "FantasticLBP" | -| Array、Set | ExpressibleByArrayLiteral | var arr:Array = [1, 2, 3] | -| Optional | ExpressibleByNilLiteral | var o:Optional = nil | - - - -## 字面量协议的使用 - -Demo1 - -```swift -extension Int : ExpressibleByBooleanLiteral { - public init(booleanLiteral value: Bool) { - self = value ? 1 : 0 - } -} -var num: Int = true -print(num) // 1 -num = false -print(num) // 0 -``` - -Demo2 - -```swift -class Student: ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, ExpressibleByStringLiteral, CustomStringConvertible { - var name: String = "" - var score: Double = 0 - - required init(floatLiteral value: FloatLiteralType) { - self.score = value - } - required init(stringLiteral value: StringLiteralType) { - self.name = value - } - required init(integerLiteral value: IntegerLiteralType) { - self.score = Double(value) - } - required init(extendedGraphemeClusterLiteral value: String) { - self.name = value - } - required init(unicodeScalarLiteral value: String) { - self.name = value - } - var description: String { - "name is \(self.name), score is \(self.score)" - } -} - -var student: Student = "杭城小刘" -print(student) // name is 杭城小刘, score is 0.0 -student = 100 -print(student) // name is , score is 100.0 -``` - diff --git a/Chapter1 - iOS/1.123.md b/Chapter1 - iOS/1.123.md deleted file mode 100644 index e9893bc..0000000 --- a/Chapter1 - iOS/1.123.md +++ /dev/null @@ -1,142 +0,0 @@ -# Swift 模式匹配 - - - -## 模式匹配的底层实现 - -为了去除其他语句对汇编研究造成的干扰,所以 case 和 default 里面都很简单,聚焦于研究 case 的模式匹配实现。测试代码如下: - -```swift -var age = 28 -switch age { -case 0...30: - break -default: - break -} -``` - -在 `case 0...30:` 处加断点可以看到汇编,然后看到可疑方法 `Swift.RangeExpression.~= infix(τ_0_0, τ_0_0.Bound) -> Swift.Bool` - - - -LLDB 输入 `si` 进去窥探下 - - - -看样子,函数还没到底,看到地址 `0x00007ff81a86f530` 很大很大,猜测应该是一个系统动态库方法地址,继续跟进去研究 `si` - - - -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/MemoryLayout.png) - -可以看到内部还有函数调用 - - - -继续跟进 `si` - - - -看上去是在做继续 `clasedRange` 区间符合的判断,继续跟进 `si`,里面确实是在判断是否命中区间的判断。不一一研究了,这次目的是判断 switch...case pattern 的实现。 - - - - - -结论:switch case pattern 模式匹配,系统底层实现是依赖 - - - -## 模式匹配的应用 - -自定义模式匹配。根据上面通过汇编进行分析,我们知道 - -- `static func ~=(pattern: case 后面的值, value: switch 后面的值)` pattern 代表的是 case 后面的值,value 代表 switch 后面的值。 - -- 当 case 有不同类型的时候,如果编译报错,则需要重写 `static func ~=(pattern: ,value: )` 方法,调整 pattern 的数据类型,数据类型应该和 case 后的数据类型一致(可以是函数等) - -```swift -struct Student { - var score = 0 - var name = "" - - - /// Student 和 Int 模式匹配的方法 - /// - Parameters: - /// - pattern: case 后面的内容 - /// - value: switch 后面的内容 - /// - Returns: 是否命中 - static func ~= (pattern: Int, value: Student) -> Bool { - value.score >= pattern - } - static func ~= (pattern: Range, value: Student) -> Bool { - pattern.contains(value.score) - } - static func ~= (pattern: ClosedRange, value: Student) -> Bool { - pattern.contains(value.score) - } - static func ~= (pattern: String, value: Student) -> Bool { - Int(pattern) == value.score - } -} - -var student: Student = Student(score: 55, name: "杭城小刘") -switch student { -case 100: - print(">=100") -case 90: - print(">=90") -case 80..<90: - print("[80, 90)") -case 60...79: - print("[60, 79]") -case "55": - print("斯国一") -default: - print("just so so") -} -// console -斯国一 -``` - -修改 `var student: Student = Student(score: 78, name: "杭城小刘")` 则输出 `[60, 79]` - - - - - -```swift -extension String { - static func ~=(pattern: (String) -> Bool ,value: String) -> Bool { - pattern(value) - } -} - -func hasPrefix(_ prefix: String) -> (String) -> Bool { - {$0.hasPrefix(prefix)} -} - -func hasSuffix(_ prefix: String) -> (String) -> Bool { - {$0.hasSuffix(prefix)} -} - -var name = "城小刘" -switch name { -case hasPrefix("杭"): - print("有杭哦") -case hasSuffix("刘"): - print("有刘哦") -default: - print("just a name") -} -// console -有刘哦 -``` - - - -## - - - diff --git a/Chapter1 - iOS/1.124.md b/Chapter1 - iOS/1.124.md deleted file mode 100644 index 3537a4e..0000000 --- a/Chapter1 - iOS/1.124.md +++ /dev/null @@ -1,590 +0,0 @@ -# 从 OC 到 Swift - -## OC 与 Swift 混编模式下,方法调用原理探究 - -OC 与 Swift 混编 - -`Person.h` - -```objective-c -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface Person : NSObject - -- (instancetype)initWithCat:(id)cat; - -- (void)showPower; - -@end - -NS_ASSUME_NONNULL_END - -#import "Person.h" -#import "TestiOSWithSwift-Swift.h" - -@interface Person() - -@property (nonatomic, strong) Cat *cat; -@end -@implementation Person - -- (instancetype)initWithCat:(id)cat { - if (self = [super init]) { - _cat = cat; - } - return self; -} - -- (void)showPower { - NSLog(@"I have a cat"); - [self.cat sayHi]; - [self.cat run]; -} - -@end -``` - -`Cat.Swift` - -```swift -import Foundation - -@objcMembers class Cat: NSObject { - var name: String - init(_ name: String = "Tom") { - self.name = name - } - - func sayHi () { - print("My name is \(name)") - } - - func test1(v1: Int) { - print("test1") - } - - func test2(v1: Int, v2: Int) { - print("test2") - } - - func test2(_ v1: Double, _ v2: Double) { - print("test2 _") - } - - func run () { - perform(#selector(test1)) - perform(#selector(test1(v1:))) - perform(#selector(test2(v1:v2:))) - perform(#selector(test2(_:_:))) - } -} -``` - -点击屏幕触发事件,在 `ViewController.swift` - -```swift -override func touchesBegan(_ touches: Set, with event: UIEvent?) { - var cat: Cat = Cat("屁屁") - var person: Person = Person(cat: cat) - person.showPower() -} -``` - -问题: - -1. 为什么 Swift 暴露给 OC 的类最终要继承自 NSObject? - - 因为在 OC 中,方法消息走的是消息传递,也就是 Runtime 的机制,Runtime 的实现依赖于 isa 指针,所以类必须继承自 NSObject。 - -2. Swift 代码中调用 OC 对象的方法 `person.showPower() ` 底层是怎么调用的? - - 底层实现还是需要用汇编来验证。断点加在 `person.showPower() ` 处 - - - - 可以看到即使在 Swift 代码中,调用 OC 对象方法,本质上还是走 Objc Runtime 的一套流程。50行代码,将 showPower 的地址赋值给 `rsi` 寄存器,然后调用 `objc_msgSend` 方法。 - - LLDB 下 输入 `si` 窥探下实现。 - - - - 可以看到一个很大的地址 `0x00007ff80002d7c0` 就是动态库的符号方法地址。同时 Xcode 很智能,右侧给出了函数名称。 - -3. OC 调用 Swift 底层又是如何调用的?在 OC 类 Person 中,底层调用 Swift Cat 类的 sayHi 方法。 - - 断点加在 `[self.cat sayHi]` 处,可以看到本质上还是 Runtime objc_msgSend 那一套。 - - - -4. `cat.run()` 底层是怎么调用的? - - 如果一个 Swift 类,不继承自 NSObject,那么方法调用的本质就是走虚表那套逻辑,找到指针的前8个字节,根据前8个字节找到类信息,然后在类信息中,前面一些内存地址存储类型信息,后续根据偏移在方法列表中,找到需要调用的函数地址。类似下面的图。 - - - - 那 Swift 类继承自 NSObject 后,依然在 Swfit 中调用方法,背后的原理是什么? - - 在 ViewController.swift 中 `cat.sayHi()` 下断点 - - - - - -## Swift 方法如何走 Runtime 消息机制 - -可以看到,即使一个 Swift 类继承自 NSObject,但依旧在 Swift 中调用对象方法,本质上还是走虚表那套方法调用流程,不会走 Runtime 消息机制。 - -如果想让 Swift 方法调用走 Runtime 消息机制,可以在方法前加 `@objc dynamic` - -```swift -dynamic func sayHi () { - print("My name is \(name)") -} -``` - -断点查看,发现在 Swift 代码中调用同样的 Swift 对象方法,此时走了 Runtime 消息机制。 - - - - - -## Swift OC 混编,内存布局会改变吗 - -如果一个 Swift 类继承自 NSObject,内存布局会改变 - -```swift -class Person { - var age: Int = 28 - var height: Int = 175 -} -let p: Person = Person() -print(Mems.memStr(ofRef: p)) -// console -0x0000000100010540 0x0000000000000003 0x000000000000001c 0x00000000000000af -``` - -可以看到一个 Swift 类,前8个字节用来存放类信息的指针,其次8个字节用来存放引用计数信息,后16个字节用来存放28和175,就是存储属性信息 - -调整下: - -```swift -import Foundation -class Person: NSObject { - var age: Int = 28 - var height: Int = 175 -} -let p: Person = Person() -print(Mems.memStr(ofRef: p)) -// console -0x011d8001000104e9 0x000000000000001c 0x00000000000000af 0x0000000000000000 -``` - -可以看到当 Swift 类继承自 NSObject 后,前8个字节存放的是 isa 指针,其次的16个字节存放存储属性信息,最后的8个字节用来内存对齐。 - - -## 混编 -### OC 调用 Swift -OC 项目,使用 Swift 开发默认会创建 `项目名-Swift.swift` 文件。Swift 类都可以在该文件中找到 - -默认情况下生成的 Swift class 是不可以直接在 OC 中使用的,如果需要**访问需要在 class 前加 `@objc` 且继承自 NSObject**,编译器生成的代码如下: - - -class 不仅需要创建对象,还需要访问属性和方法,可以在属性或者方法前加 `@objc`,效果如下 - - -但这样很麻烦,需要给每个属性、方法添加 `@objc`。有简便方法,可以直接在 class 前加 `@objcMembers`,这样该 class 所有的属性、方法都可以在 OC 中访问 - - -**Swift 写的 extension,在`项目名-Swift.swift` 文件中可以看到,是被编译器编译为 OC 的分类 Category**。 -```swift -@objcMembers class Car : NSObject { - var price: Double - var band: String - init(price: Double, band: String) { - self.price = price - self.band = band - } -} - -extension Car { - func test() { - print("Car test") - } -} -``` - -```objective-c -@interface Car : NSObject -@property (nonatomic) double price; -@property (nonatomic, copy) NSString * _Nonnull band; -- (nonnull instancetype)initWithPrice:(double)price band:(NSString * _Nonnull)band OBJC_DESIGNATED_INITIALIZER; -@end - -@interface Car(SWIFT_EXTENSION(TestSwift)) -- (void)test -@end - -``` - -可以通过 `@objc(name)` 重命名 Swift 暴露给 OC 的符号名(类名、属性名、函数名等) - - -### Swift 中访问 OC 的对象、方法 -要在 Swift 中访问 OC 类,需要创建桥接文件,OC 工程首次创建 Swift 文件时,Xcode 默认创建桥接文件 `项目名-Bridging-Header.h`。如果是手动创建的,则需要配置(在项目的 Build Settings 中,找到 Objective-C Bridging Header 设置项,并指定桥接头文件的路径。确保桥接头文件的路径正确无误,并且文件名和扩展名都正确)。 - -在桥接文件中(`项目名-Bridging-Header.h`) 写好需要在 Swift 中使用的 objective-C 类。 - -Swift 中不允许访问 objective-c 的方法或者需要换个方法名去调用,该怎么实现? - - -`- (void)showPower NS_SWIFT_NAME(diaplayPower());` oc 对象方法名,在 Swift 中使用时,想换个名字,可以用 `NS_SWIFT_NAME(新的方法名())` -`- (void)displayPower NS_SWIFT_UNAVAILABLE("请使用 showPower");` oc 对象方法名,不想在 Swift 使用时,可以加 `NS_SWIFT_UNAVAILABLE(原因)` - -### 符号名映射 -`@_silgen_name` 是 Swift 中用于底层符号控制的工具,适合需要直接操作函数符号的场景 - -- 符号名称映射 - 将 Swift 函数直接映射到指定的 C 函数名(或其他语言符号),绕过 Swift 默认的名称修饰(name mangling) - - 声明 `@_silgen_name("my_c_function") func mySwiftFunction()` 后,意味着在 Swift 代码中调用 `mySwiftFunction` 会直接链接到 C 函数 `my_c_function` 中 -- 与系统 API 或 C 函数交互 - 直接调用系统库函数或 C 函数,无需通过 Swift 的桥接机制(如 @_cdecl 或 Objective-C 兼容层)。 - 适用于需要精确控制符号名称的场景(如调用 libc 函数、系统调用等 -- 导出 Swift 函数供外部使用 - 强制 Swift 函数在编译后使用特定名称导出,方便其他语言(如 C、Python)通过动态链接调用。 - - -## QA -### 为什么 Swift 暴露给 OC 的类,最终都要继承自 NSObject? -什么时候会用到一个类?肯定是抽象一个问题为类吧,那么也一定会访问该类的属性或者方法吧。但在 OC 的世界中,一切皆对象,也遵循 NSObject 的内部布局,也会走 Runtime 的标准流程。 - -1. OC 运行时依赖 -- 必须是 OC 对象:Objective-C 的 id 类型指向的对象,本质是 objc_object 结构体,其核心是通过 isa 指针关联到类(objc_class) -- 必须支持运行时、消息系统的:Objective-C 的方法调用依赖运行时动态查找方法实现(通过 `objc_msgSend`),而这一机制需要类继承自 NSObject - -如果 Swift 类不继承 NSObject,则: -- 它的实例在内存中缺少 isa 指针,无法被 Objective-C 运行时识别为有效对象。 -- Objective-C 代码无法通过 id 类型接收该对象,也无法调用其方法。 - -2. NSObject 基类提供的基础能力 -NSObject 是 Objective-C 的根类,定义了对象的基本行为: -- 内存管理:实现引用计数(retain/release)和 weak 指针、 dealloc 方法 -- 运行时元数据:提供 class、respondsToSelector: 等反射方法 -- 协议支持:实现 NSObjectProtocol(如 isEqual:、hash、description)。 -若 Swift 类不继承 NSObject,则无法直接使用这些基础功能,导致与 Objective-C 交互时出现兼容性问题。 - - -3. 互操作性的桥梁 -- 当 Swift 类继承 NSObject 时,编译器会生成一个 Objective-C 兼容的类结构(包括 isa 指针和元数据) -- 若使用 @objc 修饰非 NSObject 子类,编译器会报错 - - -### `p.run()` 底层是怎么调用的? -Demo1: Swift 调用 Swift 对象方法 - - - -纯 Swift 环境中,调用对象的方法,走的是虚表的逻辑。最终底层会调用 `callq *0x78(%rax)` - -可以看到:Swift 调用 Swift 对象和方法,断点处显示直接调用方法地址,lldb 模式下输入 `si` 可以看到汇编代码停在了 **SwiftDemo`Cat.sayHi():** 的地方。所以走的是虚函数表逻辑。 - -Demo2: OC 调用 Swift 对象方法 -1. Swift 类继承自 NSObject,在前面加 `@objcMembers` 暴露给 OC 环境 -2. Swift 环境调用 OC 对象和方法 -3. OC 方法中调用 Swift 对象和方法 -4. 给 OC 环境中,调用 Swift 对象方法的地方下个断点,查看走的是 OC 的 Runtime 还是 Swift 的虚函数表的逻辑 -断点截图如下: - - -可以看到在 OC 环境中,调用 Swift 对象的方法,本质上走的是 Runtime 的流程,汇编可以看到走的是 `objc_msgSend` 流程,效果类似 `objc_msgSend(p, @selector(run))` - -结论:OC 类暴露给 Swift 环境后,调用 OC 对象的方法,本质走的是 Runtime 流程。 - -### 被 @objcMembers 修饰的 Swift 对象,在 Swift 中调用 - -Demo3: 暴露给 OC 的 Swift 对象,被 Swift 环境调用 -1. 继承自 NSObject 的 Swift 类 -2. 被 `@objcMembers` 修饰 -3. 在 Swift 环境中调用暴露给 OC 的 Swift 对象方法 -4. 断点查看方法调用的本质 - - -可以看到,在 Swift 环境中,即使某个 Swift 类暴露给了 OC,调用其对象方法的本质,依旧是走虚函数表。因为此时用不到 Runtime 的能力 - -Demo4: 在 Demo3 的基础上,swift 方法内部调用 OC 对象方法 - -也就是说: -1. Cat 类继承自 NSObject,被 `@objcMembers` 修饰 -2. 在 Swift 中调用 Cat 对象的 sayHi 方法 -3. 在 sayHi 方法内部,调用 OC Person 对象的 run 方法 - -下断点可以看到: - - - -分为2个阶段: -1. 第一阶段:在 Swift 环境调用虽然暴露给 OC 的 Swift 对象方法,但因为没有和 OC 直接交互,所以走的是 Swift 虚函数表逻辑 -2. 第二阶段:在 Swift 环境调用 OC 对象方法,因为底层是 OC 方法调用,所以走的是 OC Runtime 逻辑 - -思考:想让 Swift 方法也走 OC 的 Runtime,可以利用 **`dymanic`** 关键词修饰方法。如下: - - - - -## dynamic 的作用 -在 Swift 中,dynamic 关键字用于强制方法或属性通过 Objective-C 运行时(Runtime)进行动态派发,即使该方法或属性在纯 Swift 代码中被调用。它的核心应用场景与 Objective-C 运行时的动态特性(如 KVO、方法交换、动态解析等)紧密相关 - -核心作用:绕过 Swift 的静态优化 -Swift 默认会尝试优化方法派发(如使用虚函数表或直接派发),而 dynamic 会强制方法或属性始终通过 Objective-C 的 objc_msgSend 机制调用,确保动态性。 -启用 Objective-C 运行时特性 - -若需要实现以下功能,必须使用 dynamic: -- 键值观察(KVO):标记为 dynamic 的属性会自动支持 KVO。 -- 方法交换(Method Swizzling):运行时替换方法实现。 -- 动态方法解析:通过 resolveInstanceMethod: 动态添加方法实现。 -- 消息转发:通过 forwardingTargetForSelector: 或 forwardInvocation: 处理未实现的方法。 - - -### 支持 KVO -Swift 中默认的存储属性不支持自动 KVO 通知,但通过 dynamic 标记属性后,属性访问会通过 Objective-C 运行时,从而触发 KVO 机制。 -```swift -@objcMembers class Cat: NSObject { - dynamic var name: String // 支持 KVO - init(name: String) { self.name = name } -} -``` -在 OC 中监听 name 变化 -```Objective-c -Cat *cat = [[Cat alloc] initWithName:@"PiPi"]; -[cat addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil]; -``` - -### 方法交换 -若要在运行时替换方法实现(如 AOP 编程、调试 Hook),必须确保目标方法是动态派发的。 - -```swift -@objcMembers class Cat: NSObject { - dynamic func sayHi() { print("Original") } -} -``` -在 Objective-C 中交换方法实现 -```Objective-c -Method originalMethod = class_getInstanceMethod([Cat class], @selector(sayHi)); -Method swizzledMethod = class_getInstanceMethod([Cat class], @selector(swizzled_sayHi)); -method_exchangeImplementations(originalMethod, swizzledMethod); -``` - -### 动态解析未实现的方法 -当调用一个未实现的方法时,可通过 resolveInstanceMethod: 动态添加实现。 -```swift -@objcMembers class Cat: NSObject { - dynamic func sayHi() { print("Hello") } // 假设此方法未实现,运行时动态添加 -} -``` -Objective-C 运行时动态解析 -```Objective-c -+ (BOOL)resolveInstanceMethod:(SEL)sel { - if (sel == @selector(sayHi)) { - class_addMethod([self class], sel, (IMP)dynamicSayHi, "v@:"); - return YES; - } - return [super resolveInstanceMethod:sel]; -} - -void dynamicSayHi(id self, SEL _cmd) { - NSLog(@"Dynamic Hello"); -} -``` - -### 动态调用 -当 Swift 类的方法需要被 Objective-C 或其他动态语言(如通过 performSelector:)调用时,若方法未被标记为 dynamic,可能因编译器优化导致动态调用失败。 -```swift -@objcMembers class Cat: NSObject { - dynamic func sayHi() { print("Hello") } -} -``` -Objective-C 中动态调用 -```Objective-c -Cat *cat = [[Cat alloc] init]; -[cat performSelector:@selector(sayHi)]; // 需 dynamic 支持 -``` - - -## 数据类型转换 -在 Swift 和 Objective-C 的类型桥接机制中,String 与 NSString 可以互相转换,而 String 不能直接与 NSMutableString 互相转换,但 NSMutableString 可以转为 String。类似的情况也出现在 Array/NSArray/NSMutableArray 和 Dictionary/NSDictionary/NSMutableDictionary 之间 - -### 核心原因 -可变性的语义差异: -- Swift 的值类型(String、Array、Dictionary) 被设计为不可变的值语义。每次修改会产生新实例(Copy on Write)。比如 `var str = "A"; str += "B"` -会创建新字符串 'AB',而非修改原内存 -- OC 的类型 NSString 是不可变的引用类型,NSMutableString 是可变的引用类型,允许直接修改内容 -这种差异导致桥接时需要严格处理可变性,确保类型安全和语义一致。 - - -### Swift string 与 OC NSString -双向隐式桥接。因为两者都是不可变的,语义一致,没有副作用,都可以直接互相转换。 - -```swift -// Swift -> OC -let swiftStr1: String = "Hello" -let nsStr1: NSString = swiftStr1 as NSString -// OC -> Swift -let nsStr12: NSString = "World" -let swiftStr2: String = nsStr12 as String -``` -### Swift string 与 OC NSMutableString - -Swift string 与 OC NSMutableString 是单向桥接的。OC NSMutableString 可以转为 Swift String。但 Swift String 不能转为 OC NSMutableString - -原因:OC NSMutableString 是可变类型,转为 Swift String 时会创建一份不可变的副本,避免被 Swift String 修改造成意外修改。 -若允许 Swift String 直接转换为 OC NSMutableString,则可能通过 OC 代码修改 String 的值,破坏 Swift 值语义。 -```swift -// Objective-C → Swift(允许) -let mutableStr: NSMutableString = "Hello".mutableCopy() as! NSMutableString -let swiftStr: String = mutableStr as String // 隐式桥接,生成不可变副本 - -// Swift → Objective-C(禁止隐式桥接) -let swiftStr = "Hello" -let mutableStr = swiftStr as NSMutableString // ❌ 编译错误 -``` - -### 类似情况 -- Swift Array 与 OC NSArray 可以互相转换。 -- OC 的 NSMutableArray 可以转换为 Swift Array。但是 Swift Array 不能转换为 OC NSMutableArray - -### 底层原理 -1. 类型桥接的实现方式:Swift 编译器通过 `_ObjectiveCBridgeable` 协议实现与 OC 类型的桥接。 -例如 String 实现了 `_ObjectiveCBridgeable`,使其能与 NSString 自动转换 - -2. 可变类型桥接限制 -Swift String: -- 值类型:Swift String 是结构体,遵循值语义。每次赋值或者修改都会生成新的独立副本,确保数据不可变性和线程安全 -- 不可变:即使用 var 声明,修改 String 也会通过创建新实例实现,而非直接修改内存 -Objective-C 的 NSMutableString -- 引用类型(Reference Type):NSMutableString 是类(class),遵循引用语义。变量持有的是指向内存地址的指针。 -- 可变(Mutable):允许直接修改内存中的内容(如追加、删除字符),所有持有该引用的代码都会感知到变化。 - -3. 为什么 String 不能直接桥接为 NSMutableString? - -- 原因 1:值语义与引用语义的冲突 -若允许将 Swift 的 String 直接桥接为 NSMutableString,则相当于将一个值类型强制转换为可变的引用类型。 -风险示例: -```swift -let swiftStr = "Hello" -let mutableStr = unsafeBridgeToNSMutableString(swiftStr) // 假设存在这种桥接 -mutableStr.append("!") // 修改 mutableStr -print(swiftStr) // 若桥接是共享内存,此处会输出 "Hello!",违背值语义! -``` - -这会导致 Swift 的 String 失去其不可变性保证,破坏类型安全。 - -- 原因 2:内存管理的不兼容 -Swift 的 String 可能存储在栈内存或静态区(尤其是短字符串),而 NSMutableString 必须分配在堆内存。 -直接桥接可能导致内存访问错误(如悬垂指针)。 - -- 原因 3:设计哲学的保护 -Swift 强调安全性,禁止隐式共享可变状态。若允许直接桥接,开发者可能无意中在多个地方修改同一块内存,导致难以调试的问题。 - -QA: 如何显式实现 String → NSMutableString? -若需要将 Swift String 转为 NSMutableString,必须显式创建新对象,而非直接桥接: -```swift -let swiftStr = "Hello" -let mutableStr = NSMutableString(string: swiftStr) // 显式拷贝 -mutableStr.append("!") // 安全修改 -``` - -在 Swift 和 Objective-C 的互操作中,`String` 和 `NSMutableString` 之间的转换规则是由两者的**类型语义**和**内存管理机制**共同决定的。以下是具体原因和底层逻辑: - ---- - -### **1. 类型语义的根本差异** -#### **Swift 的 `String`** -- **值类型(Value Type)**: - Swift 的 `String` 是结构体(`struct`),遵循值语义。每次赋值或修改都会生成新的独立副本,确保数据不可变性和线程安全。 -- **不可变(Immutable)**: - 即使使用 `var` 声明,修改 `String` 也会通过创建新实例实现,而非直接修改内存。 - -#### **Objective-C 的 `NSMutableString`** -- **引用类型(Reference Type)**: - `NSMutableString` 是类(`class`),遵循引用语义。变量持有的是指向内存地址的指针。 -- **可变(Mutable)**: - 允许直接修改内存中的内容(如追加、删除字符),所有持有该引用的代码都会感知到变化。 - ---- - -### **2. 为什么 `String` 不能直接桥接为 `NSMutableString`?** -#### **原因 1:值语义与引用语义的冲突** -- 若允许将 Swift 的 `String` 直接桥接为 `NSMutableString`,则相当于将一个值类型强制转换为可变的引用类型。 - **风险示例**: - ```swift - let swiftStr = "Hello" - let mutableStr = unsafeBridgeToNSMutableString(swiftStr) // 假设存在这种桥接 - mutableStr.append("!") // 修改 mutableStr - print(swiftStr) // 若桥接是共享内存,此处会输出 "Hello!",违背值语义! - ``` - 这会导致 Swift 的 `String` 失去其不可变性保证,破坏类型安全。 - -#### **原因 2:内存管理的不兼容** -- Swift 的 `String` 可能存储在栈内存或静态区(尤其是短字符串),而 `NSMutableString` 必须分配在堆内存。 - 直接桥接可能导致内存访问错误(如悬垂指针)。 - -#### **原因 3:设计哲学的保护** -- Swift 强调安全性,禁止隐式共享可变状态。若允许直接桥接,开发者可能无意中在多个地方修改同一块内存,导致难以调试的问题。 - ---- - -### **3. 为什么 `NSMutableString` 可以转为 `String`?** -当 `NSMutableString` 桥接到 Swift 时,**会生成一个不可变的副本**,切断与原对象的关联: -```swift -let mutableStr: NSMutableString = "Hello".mutableCopy() as! NSMutableString -mutableStr.append("!") // 修改原对象 - -let swiftStr = mutableStr as String // 桥接生成新副本 -print(swiftStr) // "Hello!" -mutableStr.append("?") // 继续修改原对象 -print(swiftStr) // 仍然是 "Hello!",不受影响 -``` -- **行为安全**:生成的 `String` 是独立的不可变副本,与原 `NSMutableString` 解耦。 -- **符合语义**:Swift 的 `String` 仍然是值类型,后续修改不会影响副本。 - ---- - -### **4. 如何显式实现 `String` → `NSMutableString`?** -若需要将 Swift `String` 转为 `NSMutableString`,必须**显式创建新对象**,而非直接桥接: -```swift -let swiftStr = "Hello" -let mutableStr = NSMutableString(string: swiftStr) // 显式拷贝 -mutableStr.append("!") // 安全修改 -``` -- **显式拷贝**:通过 `NSMutableString` 的构造器生成独立可变对象,避免共享内存。 - ---- - -### **5. 类似场景:`Array` ↔ `NSMutableArray`** -同样的规则适用于集合类型: -- **Swift `Array`**: - 值类型,桥接到 `NSArray`(不可变),但无法直接桥接为 `NSMutableArray`。 -- **`NSMutableArray` → `Array`**: - 生成不可变副本,与原对象解耦。 - -```swift -let swiftArray = [1, 2, 3] -let mutableArray = NSMutableArray(array: swiftArray) // 显式拷贝 -mutableArray.add(4) // 安全修改 -``` - -Swift 通过严格的类型桥接规则,确保值类型的不可变性和引用类型的可控性。这种设计虽然牺牲了部分灵活性,但从根本上避免了数据竞争、意外修改等风险,符合其安全至上的哲学。 - - - -| Swift 数据类型 | 单向转换 or 双向转换 | OC 数据类型 | -| -------------- | -------------------- | ------------------- | -| String | <--------> | NSString | -| String | <-------- | NSMutableString | -| Array | <--------> | NSArray | -| Array | <-------- | NSMutableArray | -| Dictionary | <--------> | NSDictionary | -| Dictionary | <-------- | NSMutableDictionary | - diff --git a/Chapter1 - iOS/1.125.md b/Chapter1 - iOS/1.125.md deleted file mode 100644 index b086ada..0000000 --- a/Chapter1 - iOS/1.125.md +++ /dev/null @@ -1,220 +0,0 @@ -# Swift 函数式编程 - -## 定义 - - 函数式编程(Funtional Programming,简称 FP)是一种编程范式,也就是如何编写程序的方法论 - -- 主要思想:把计算过程尽量分解成一系列可复用函数的调用 -- 主要特征:函数是“第一等公民”。函数与其他数据类型一样的地位,可以赋值给其他变量,也可以作为函数参数、函数返回值 - - - -函数式编程最早出现在 LISP 语言,绝大部分的现代编程语言也对函数式编程做了不同程度的支持,比如:Haskell、JavaScript、Python、Swift、Kotlin、Scala 等 - - - -函数式编程中几个常用的概念: - -- Higher-Order Function、Function Currying -- Functor、Applicative Functor、Monad - - - -## 高阶函数 - - 高阶函数是至少满足下列一个条件的函数: - -- 接受一个或多个函数作为输入(map、filter、reduce等) -- 返回一个函数 - -FP中到处都是高阶函数 - - - -## 柯里化(Currying) - -将一个接受多个参数的函数变换成为一系列只接受单个参数的函数 - -Demo: - -```swift -// 函数式编程,为了过程的复用 -let num = 10 -func add(_ v: Int) -> (Int) -> Int {{ $0 + v }} -func sub(_ v: Int) -> (Int) -> Int { { $0 - v } } -func multiple( _ v: Int) -> (Int) -> Int { { $0 * v }} -func divide(_ v: Int) -> (Int) -> Int { { $0 / v } } -func mod(_ v: Int) -> (Int) -> Int { { $0 % v }} - -// 函数合成,泛型 -infix operator >>> : AdditionPrecedence -func >>>(_ f1: @escaping (A) -> B, - _ f2: @escaping (B) -> C) - -> (A) -> C { - { f2(f1($0)) } -} - -// result = ((((x + 3)*5) - 1 )%10)/2 -let fn = add(3) >>> multiple(5) >>> sub(1) >>> mod(10) >>> divide(2) - -print(fn(num)) - - -func multipleAdd(_ v1: Int) -> ((Int) -> ((Int) -> Int)) { - return { v2 in - return { v3 in - return - v1 + v2 + v3 - } - } -} - -/* - - multipleAdd(1),调用函数,传递1给参数V3,此时继续返回一个函数。 - - multipleAdd(1)(2) 拿着上一步返回的函数,再去调用,传入的2给参数V2.此时继续返回一个函数 - - multipleAdd(1)(2)(3) 拿着上一步返回的函数,再去调用,此时传入的3给参数V1,此时则执行相加操作。V1 + V2 + V3 - */ -let rs = multipleAdd(1)(2)(3) -print(rs) -``` - -参数对应如下图圈选部分 - - - - - -Demo2:将一个三个参数的函数变成柯里化 - -```swift -func addThree(_ v1: Int, _ v2: Int, _ v3: Int) -> Int { v1 + v2 + v3 } -func addThree(_ v1: Int) -> ((Int) -> (Int) -> Int) { - return { v2 in - return { v3 in - return v1 + v2 + v3 - } - } -} - -func currying(_ fn: @escaping (A, B, C) -> D) -> ((A) -> ((B) -> ((C) -> D))) { - return { v1 in - return { v2 in - return { v3 in - fn(v1, v2, v3) - } - } - } -} - -print(addThree(1, 2, 3)) // 6 -print(addThree(1)(2)(3)) // 6 -print(currying(addThree)(1)(2)(3)) // 6 -``` - - - -## 函子 - -### 概念 - -在函数式编程中,**函子(Functor)** 是一个核心概念,它表示一种可以**被映射**的容器或结构。简单来说,函子能够接受一个函数,将该函数应用到其内部的值上,并返回一个**保持原有结构**的新函子。 - -函子存在3个特性: - -- **容器性**:函子是一个包装值的容器(`List` 等) -- **可映射性**:通过 `map` 方法将函数应用到容器内的值,例如 `var array2 = [1, 2, 3].map { $0 + 1 }` -- **结构不变性**:映射过程不会改变容器的结构,例如数组的 `map` 方法返回新数组而非其他类型 - -函子提供了一种**安全操作上下文中的值**的方式,是函数式编程中组合和抽象的基础工具。它通过 `map` 方法解耦了“值”和“上下文”,使得代码更模块化、可复用 - - - -### 函子定律 - -合法的函子必须满足以下规则: - -1. **恒等律**:`fmap id = id`(映射恒等函数后,容器不变)。 -2. **组合律**:`fmap (f . g) = fmap f . fmap g`(函数组合的映射等价于分别映射) - - - -### 总结 - -像 Array、Optional 这样支持 map 运算的类型,称为函子(Functor) - -```swift -// Array -@inlinable public func map(_ transform: (Element) throws -> T) rethrows -> [T] - -// Optional -@inlinable public func map(_ transform: (Wrapped) throws -> U) rethrows -> U? -``` - -跳出语言来看,符合函数式编程的语言都存在**函子** 这一概念,符合下面的形式,都可以叫函子 - -```swift -func map(_ fn: (Element) -> T) -> Type -``` - - - -## 适用函子(Applicative Functor) -对任意一个函子 F,如果能支持以下运算,该函子就是一个适用函子 -```swift -func pure(_ value: A) -> F -func <*>(fn: F<(A) -> B>, value: F) -> F -``` - - Array 可以成为适用函子 - -```swift -func pure(_ value: A) -> [A] { - [value] -} - -infix operator <*> : AdditionPrecedence -func <*>(fn:[(A) -> B], value: [A]) -> [B] { - var resultArray: [B] = [] - if fn.count == value.count { - for i in fn.startIndex.. [1, 2, 3] -print(array) -// console -[10] -[2, 12, -2] -``` - - - -## 单子 - - 对任意一个类型 F,如果能支持以下运算,那么就可以称为是一个单子(Monad) - -```swift -func pure(_ value: A) -> F -func flatMap(_ value: F, _ fn: (A) -> F) -> F -``` - - 很显然,Array、Optional 都是单子 - - - -“单子”(Monad)是一个抽象概念,它代表了一种设计模式,用于组合计算并管理可能包含副作用的值。单子是一种在函数式编程中用于封装和组合计算的通用结构,它允许程序员以一致的方式处理各种复杂的计算情况,包括错误处理、异步操作、状态管理等。 - -单子通常定义了几个操作,这些操作允许你以统一的方式对封装在单子中的值进行操作。这些操作包括: - -- `return` 或 `pure`:将一个值“提升”到单子中。 -- `bind` 或 `flatMap`:用于组合单子中的计算,允许你将一个单子的输出作为另一个单子的输入。 - -在 Swift 中,单子并没有像在其他一些语言(如 Haskell 或 Scala)中那样作为语言内建的概念,但你可以通过定义自己的类型和方法来实现单子模式。例如,Swift 中的 `Optional` 类型可以看作是一个简单的单子,它表示一个值可能存在或不存在。`Optional` 提供了 `map` 和 `flatMap` 方法,允许你对封装的值进行链式操作。 - -其他常见的单子实现包括处理异步操作的单子(如 Promise 或 Future),处理错误和异常的单子,以及管理状态的单子(如 State Monad)。 - -单子提供了一种强大的方式来管理复杂性和副作用,使代码更易于理解和测试。然而,它们也增加了抽象层次,可能需要一些时间来适应和理解。在 Swift 中,你可以根据自己的需要选择是否使用单子,以及使用哪种类型的单子。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.126.md b/Chapter1 - iOS/1.126.md deleted file mode 100644 index 90a5e8a..0000000 --- a/Chapter1 - iOS/1.126.md +++ /dev/null @@ -1,225 +0,0 @@ - # Swift 面向协议编程 - -## 概念 - -面向协议编程(Protocol Oriented Programming,简称 POP) - -- 是 Swift 的一种编程范式, Apple 于2015年 WWDC 提出 -- 在 Swift 的标准库中,能见到大量 POP 的影子 - -同时,Swift 也是一门面向对象的编程语言(Object Oriented Programming,简称OOP) - 在 Swift 开发中,OOP 和 POP 是相辅相成的,任何一方并不能取代另一方,POP 能弥补 OOP 一些设计上的不足 - - - -## 优势 - -OOP 三大特性:继承、封装、多态 - - 继承的经典使用场合:当多个类(比如 A、B、C 类)具有很多共性时,可以将这些共性抽取到一个父类中(比如 D 类),最后A、B、C类继承 D 类。 - -但一些情况下,继承并不能解决问题 - -比如 AVC 继承自 UIViewController 有 run 方法,BVC 继承自 UITableViewController,有 run 方法。AVC、BVC 有重复代码,如何消除?继承吗?可能会存在菱形继承问题。 - -菱形继承,也称为钻石继承或多头继承,发生在当一个类从两个或多个具有共同父类的类继承时。这种结构形成了一个菱形的继承图,因此得名。菱形继承可能导致一些问题,主要是二义性和数据冗余。 - -1. **二义性**:在菱形继承中,子类可能会从多个路径继承同一个基类的方法或属性。这会导致在子类中调用该方法或属性时存在不确定性,编译器或解释器不知道应该使用哪个版本的方法或属性。这种不确定性可能导致难以调试的错误和不可预测的行为。 -2. **数据冗余**:菱形继承也可能导致数据冗余。如果子类从多个父类继承了相同的数据成员,那么这些数据成员在内存中会有多个拷贝。这不仅浪费了存储空间,而且可能导致数据不一致的问题,因为对其中一个拷贝的修改不会影响到其他拷贝。 - -为了解决这些问题,一些编程语言提供了虚继承(virtual inheritance)的机制。虚继承允许子类只继承共享基类的一个拷贝,从而避免了二义性和数据冗余的问题。在虚继承中,共享基类被视为虚基类,子类只通过其中一个父类继承该基类的实现。 - -然而,虚继承并不是没有代价的。它可能会引入一些性能开销,因为编译器需要处理额外的间接引用和内存布局问题。此外,虚继承也可能使代码更加复杂和难以理解,特别是对于不熟悉该概念的开发者来说。 - -因此,在使用多继承时,需要谨慎考虑其潜在的问题和代价。在可能的情况下,尽量避免菱形继承结构,并通过其他方式(如接口、组合或委托)来实现所需的功能。如果必须使用菱形继承,那么应该仔细规划类的设计和继承关系,并充分利用虚继承等机制来减少潜在的问题。 - -采用 POP 实现。 - -```swift -protocol Runable { - func run() -} -extension Runable { - func run() { - print("running") - } -} - -class AVC: UIViewController, Runable { - // ... -} -class BVC: UITableViewController, Runable { - // ... -} -``` - - - -## 思想转换 - -- 优先考虑创建协议,而不是基类 -- 优先考虑值类型(struct,enum),而不是引用类型(class) -- 巧用协议的拓展功能 (extension Runable { ... }) -- 不要为了面向协议而使用协议 - - - -## 应用 - -统计字符串中数字个数 - -```swift -// 方法1 -var testString: String = "ab1783893cs" -extension String { - var countOfNumber: Int { - var count: Int = 0 - for c in self where ("0"..."9").contains(c) { - count += 1 - } - return count - } -} -print(testString.countOfNumber) // 7 - -// 方法2.优雅的 Swift 风格 -struct Counter { - var originalString: String - init(originalString: String) { - self.originalString = originalString - } - var countOfNumber: Int { - var count: Int = 0 - for c in originalString where ("0"..."9").contains(c) { - count += 1 - } - return count - } -} -extension String { - var counter: Counter { - Counter(originalString: self) - } -} -print(testString.counter.countOfNumber) // 7 -``` - -上述2个方法虽然实现了统计功能,但是不够优雅,没有那么的 Swift 化。改写如下 - -```swift -struct MY { - let base: Base - init(base: Base) { - self.base = base - } -} - -protocol MyCompitable {} -extension MyCompitable { - var my: MY { - set{} - get{ MY(base: self)} - } - static var my: MY.Type { - set{} - get{ MY.self } - } -} -``` - -具体使用的地方 -```swift -// 第一步:让 String 拥有 my 前缀属性 -extension String: MyCompitable { } -// 第二步:给 String.my、String().my 前缀拓展功能(因为协议的 extension 中有 my 计算属性,也有个 my 静态计算属性,也就是一个方法) -extension MY where Base == String { - func countOfNumber() -> Int { - var count: Int = 0 - for c in base where ("0"..."9").contains(c) { - count += 1 - } - return count - } - - static func test() { - print("I am a static method") - } - - mutating func modify() { - print("I am a mutating method") - } -} -print(testString.my.countOfNumber()) -String.my.test() -testString.my.modify() -// console -7 -I am a static method -I am a mutating method -``` - -要拓展系统提供的类型,可以按照上述模版进行修改。 - -- 加前缀 `my` 的目的是防止重复,系统实现是黑盒,如果自己直接提供类似 `testString.countOfNumber` 怕后续系统也提供 `countOfNumber` 方法。所以加前缀 `testString.my.countOfNumber` - -QA:如果是 NSString、NSMutableString 可以满足需求吗? -答案是不行的。但是可以按照上述方式进行修改调整。怎么修改?按照 `extension MY where Base == String` 和 `extension MY where Base == NSString` 的方式写2遍? - -当然不,找共性,**String、NSString、NSMutableString 都遵循 ExpressibleByStringLiteral 协议**。所以实现 `extension MY where Base: ExpressibleByStringLiteral` 即可。 - -```swift -// 第一步:让 String 拥有 my 前缀属性 -extension String: MyCompitable { } -extension NSString: MyCompitable { } - -// 第二步:给 String.my、String().my 前缀拓展功能(因为协议的 extension 中有 my 计算属性,也有个 my 静态计算属性,也就是一个方法) -extension MY where Base: ExpressibleByStringLiteral { - func countOfNumber() -> Int { - var count: Int = 0 - for c in (base as! String) where ("0"..."9").contains(c) { - count += 1 - } - return count - } - - static func test() { - print("I am a static method") - } - - mutating func modify() { - print("I am a mutating method") - } -} - - -var str:String = "123" -print(str.my.countOfNumber()) -String.my.test() -str.my.modify() -print("") - -var ocStr: NSString = "456" as NSString -print(ocStr.my.countOfNumber()) -String.my.test() -ocStr.my.modify() -print("") - -var ocMutableStr: NSString = "456" as NSMutableString -print(ocMutableStr.my.countOfNumber()) -String.my.test() -ocMutableStr.my.modify() -``` - - -这种设计在 SnapKit 中也存在: -```swift -// 实例属性用法 -view.my.makeConstraints { ... } -// 静态属性用法 -UIView.my.registerDefaultConfig() -``` -在 RxSwift 中也存在: -```swift -let observable = Observable.timer(.second(2), period: .second(1), scheduler: MainScheduler.instance) -observable.map{ "\($0)" }.bind(to: priceLabel.rx.text) -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.127.md b/Chapter1 - iOS/1.127.md deleted file mode 100644 index b0273a0..0000000 --- a/Chapter1 - iOS/1.127.md +++ /dev/null @@ -1,14 +0,0 @@ - # 响应式编程 - - - -## 概念 - -响应式编程(Reactive Programming,简称RP) - -也是一种编程范式,于1997年提出,可以简化异步编程,提供更优雅的数据绑定。一般与函数式融合在一起,所以也会叫做:函数响应式编程(Functional Reactive Programming,简称 FRP) -比较著名的、成熟的响应式框架: - -- ReactiveCocoa 简称 RAC,有 Objective-C、Swift版本。[官网](http://reactivecocoa.io/) 、[github](https://github.com/ReactiveCocoa) -- ReactiveX 简称 Rx,类似一个规范,有众多编程语言的版本,比如: RxJava、RxKotlin、RxJS、RxCpp、RxPHP、RxGo、RxSwift 等等。[官网](http://reactivex.io/)、[github]( https://github.com/ReactiveX) - diff --git a/Chapter1 - iOS/1.128.md b/Chapter1 - iOS/1.128.md deleted file mode 100644 index 5ddc88e..0000000 --- a/Chapter1 - iOS/1.128.md +++ /dev/null @@ -1,1876 +0,0 @@ -# SwiftUI 研究 - - - -## Quick Start - -Xcode 新建项目 Language 选择 Swift 语言、Interface 选择 SwiftUI。然后就可以生成默认的工程项目。 - -可以看到下面的文件: - - - -奇怪的事情发生了:AppDelegate 不见了,也没地方构建 keyWindow,怎么办?为什么文件叫 `SwiftUIDemoApp`? - -其实: - -- SwiftUIDemo 是项目名称,SwiftUI 规范约定,默认生成 `项目名 + App.swfit` -- Apple 设计 SwiftUI 的时候,打算让 UIKit、AppKit 退居二线,所以默认没有 AppDelegate、KeyWindow -- `@mian` 属性告诉编译器,这是应用程序主入口。编译器会自动生成一个入口函数,类似传统的 main 函数,内部初始化应用程序和启动 RunLoop - - - -如果需要使用 AppDelegate 来处理一些逻辑,可以按照下面的方式: - -- 声明一个类,需继承自 NSObject、遵循 `UIApplicationDelegate` 协议,实现协议方法 -- 在主入口处,添加 `@UIApplicationDelegateAdaptor` 标记 - -```swift -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - print("applicationDidFinishLaunching") - return true - } - func applicationDidReceiveMemoryWarning(_ application: UIApplication) { - print("applicationDidReceiveMemoryWarning") - } -} - -@main -struct SwiftUIDemoApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - var body: some Scene { - WindowGroup { - ContentView() - } - } -} -``` - -QA:需要注意的是,在 SwiftUI 中只有部分代理可以使用。为什么? - -iOS 13 以前,由 UIApplicationDelegate 来控制生命周期,iOS 13 以后,由 UISceneDelegate 来控制生命周期。在 iOS 13 之后,用UIScene 替代了之前 UIWindow 来管理视图,背后的设计考量主要是为了解决 iPadOS 展示多窗口的问题。 - -在 iOS 14 之后,Apple 又给 SwiftUI 提供了更优雅的 API 来显示和控制 Scene。所以控制应用展示可以这样: - -```swift -@main -struct SwiftUIDemoApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @Environment(\.scenePhase) var scenePhase - var body: some Scene { - WindowGroup { - ContentView() - }.onChange(of: scenePhase) { newScenePhase in - switch newScenePhase { - case .active: - print("应用启动了") - case .inactive: - print("应用休眠了") - case .background: - print("应用在后台展示") - @unknown default: - print("default") - } - } - } -} -``` - -SwiftUI 的文档写的还是不错。 - - - - - -ContentView.swift 及其效果如下: - - - -上面的代码 `some view` 中,view 是 SwiftUI 一个核心的协议,代表了闭包中元素描述。如下代码所示,其是通过一个 associatedtype 修饰的,带有这种修饰的协议不能作为类型来使用,只能作为类型约束来使用。 - -通过 Some View 的修饰,其向编译器保证:每次闭包中返回的一定是一个确定,而且遵守 view 协议的类型。这样的设计,为开发者提供了一个灵活的开发模式,抹掉了具体的类型,不需要修改公共 API 来确定每次闭包的返回类型,也降低了代码书写难度。 - -@State 内部是在 Get 的时候建立数据源与视图的关系,并且返回当前的数据引用,使视图能够获取,在 Set 方法中会监听数据发生变化、会通知 SwiftUI 重新获取视图 body,再通过 Function Builders 方法重构 UI,绘制界面,在绘制过程中会自动比较视图中各个属性是否有变化,如果发生变化,便会更新对应的视图,避免全局绘制,资源浪费。 - - - -## Xcode 对于 SwiftUI 的支持 - -- Xcode 支持预览 - - - -- 在预览界面选中某个空间,同时按住 `command + 单击`,可以调出一个操作面板。第一个是 UI 检查器,可以查看和修改 - - - - 在代码区域选中控件,同时按住 `command + 单击`,同样可以调出一个操作面板 - - - -- 预览模式下,支持代码和预览界面的实时刷新同步。 - - - -## FunctionBuilder - -Swift 源代码路径:`lib/Parse/ParseDecl.cpp` - -```swift -// Historical name for result builders. -checkInvalidAttrName("_functionBuilder", "resultBuilder", - DeclAttrKind::ResultBuilder, diag::attr_renamed_warning); -``` - -遵循 View 协议的,其实本质上都是调用 body 来绘制 UI 的。`@ViewBuilder` 其实就是 `@_functionBuilder`,编译器会对它所包含的方法有一定的要求,其隐藏在各个容器类型的最后一个闭包参数中。 - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public protocol View { - - /// The type of view representing the body of this view. - /// - /// When you create a custom view, Swift infers this type from your - /// implementation of the required ``View/body-swift.property`` property. - associatedtype Body : View - - /// The content and behavior of the view. - /// - /// When you implement a custom view, you must implement a computed - /// `body` property to provide the content for your view. Return a view - /// that's composed of built-in views that SwiftUI provides, plus other - /// composite views that you've already defined: - /// - /// struct MyView: View { - /// var body: some View { - /// Text("Hello, World!") - /// } - /// } - /// - /// For more information about composing views and a view hierarchy, - /// see . - @ViewBuilder @MainActor var body: Self.Body { get } -} -``` - -FunctionBuilder 通过闭包构建样式,将闭包中的 UI 描述传递给专门的构造器,提供类似 DSL 的开发模式。 - -示例代码 - -```swift -struct ObservableObjectDemoChildView: View { - @StateObject var p:People = People() - var body: some View { - VStack { - Text("Hello SwiftUI") - } - } -} -``` - -`VStack` 点进去发现 `@inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)` - -如果没有 ViewBuilder 也就是 FunctionBuilder 的这一特性,开发者必须对容器视图进行管理。开发量陡增 - -```swift -var body: some View { - var builder = VStackBuilder() - builder.add(Text("Hello SwiftUI")) - return builder.build() -} -``` - -但是,`@_functionBuilder` 也存在一定局限性,ViewBuilder 的 buildBlock 最多传入十个参数,也就是布局中最多只能有十个 View;如果超过十个 View,可以考虑使用 TupleView 来用多元的方式合并 View。 - -拓展了很多种 View 的情况 - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0 : View, C1 : View, C2 : View, C3 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ViewBuilder { - - public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View -} -``` - - - -QA:为什么给控件设置颜色等方法,都是返回一个 View? - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension View { - - /// Sets the color of the foreground elements displayed by this view. - /// - /// - Parameter color: The foreground color to use when displaying this - /// view. Pass `nil` to remove any custom foreground color and to allow - /// the system or the container to provide its own foreground color. - /// If a container-specific override doesn't exist, the system uses - /// the primary color. - /// - /// - Returns: A view that uses the foreground color you supply. - @inlinable public func foregroundColor(_ color: Color?) -> some View - -} -``` - -在传统的命令式编程布局系统中,我们对一些 UI 系统结构是通常是通过继承实现的,再编写代码时通过对属性的调用来修改视图的外观,如颜色透明度等。 但这会带来导致类继承结构比较复杂,如果设计不够好会造成 OOP 的通病类爆炸,并且通过继承来的数据结构,子类会集成父类的存储属性,会导致子类实例在内存占据比较庞大,即便很多属性都是默认值并不使用 - -在 SwiftUI 中,当你对一个视图调用 `foregroundColor` 修饰符时,你实际上是在创建并返回一个新的视图。这是 SwiftUI 声明式编程模型的一部分,其中每个视图都是基于先前的视图通过添加修饰符或组合其他视图来创建的。本质也是一个 Modifier - -View 上大多数调用的方法都称为 `Modifier`,一种是为 `原地Modifier` ,另外一种为 `封装类Modifier`。`原地Modifier` 是返回同样类型的 View,`封装类Modifier` 则可以返回不同类型的 View,在开发中我们经常需要自定义 `ViewModifier` 来对 View 进行特定的变换操作。 - - - - - - - -这种设计有以下好处: - -- 声明式编程:通过将 `foregroundColor` 设计为 `ViewModifier`,符合声明式编程范式。意味着你可以通过描述你想要的界面外观和行为,而不是通过编写更新界面的代码,来构建用户界面。这种方式使代码更易于阅读和维护,同时也减少了与界面状态同步相关的错误 -- 链式调用与组合性:`ViewModifier` 允许开发者以链式调用的方式组合多个修饰符,从而轻松创建复杂的视图层次结构 - - - -## SwiftUI 元控件 - -在 SwiftUI 系统中我们使用结构体遵守 `View` 协议,通过组合现有的控件描述,实现 `Body` 方法,但 `Body` 的方法会不会无限递归下去? - -在 SwiftUI 系统中定义了 6 个元/主 View `Text` `Color` `Spacer` `Image` `Shape` `Divider`, 它们都不遵守 View 协议,只是基本的视图数据结构。 - -其他常见的视图组件都是通过组合元控件和修饰器来组合视图结构的。如 `Button` `Toggle` 等。 - - - -## 状态管理 - -不像 Vue/React,SwiftUI 关键字太多了,容易搞混淆:@State、@Binding、ObservableObject、@ObservedObject、.environmentObject()、@EnvironmentObject、@StateObject - -### @State - -和一般的存储属性不同,@State 修饰的值,在 SwiftUI 内部会被自动转换为一对 setter 和 getter,对这个属性进行赋值的操作将会触发 View 的刷新,它的 body 会被再次调用,底层渲染引擎会找出界面上被改变的部分,根据新的属性值计算出新的 View,并进行刷新。 - -```swift -import SwiftUI - -struct StateDemoView: View { - @State var name: String = "" - var body: some View { - VStack { - Text(name) - Spacer().frame(height: 100) - Button { - name = "杭城小刘" - } label: { - Text("change name") - } - } - } -} -``` - -### @Binding - -和 @State 类似,@Binding 也是对属性的修饰,它做的事情是将值语义的属性“转换”为引用语义。对被声明为 @Binding 的属性进行赋值,改变的将不是属性本身,而是它的引用,这个改变将被向外传递. - -```swift -struct Dog { - var name: String = "Unknown" -} -struct BindDemoView: View { - @State var dog: Dog = Dog() - var body: some View { - VStack { - Text(dog.name) - Spacer().frame(height: 100) - ChildView(childDog: $dog) - } - } -} - -struct ChildView: View { - @Binding var childDog: Dog - var body: some View { - Button { - childDog.name = "TaoTao" - } label: { - Text("点我") - } - } -} -``` - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -@frozen @propertyWrapper @dynamicMemberLookup public struct Binding { - // ... - /// Creates a binding with a closure that reads from the binding value, and - /// a closure that applies a transaction when writing to the binding value. - /// - /// - Parameters: - /// - get: A closure to retrieve the binding value. The closure has no - /// parameters, and returns a value. - /// - set: A closure to set the binding value. The closure has the - /// following parameters: - /// - newValue: The new value of the binding value. - /// - transaction: The transaction to apply when setting a new value. - public init(get: @escaping () -> Value, set: @escaping (Value, Transaction) -> Void) -} -``` - -Binding 结构体使用闭包捕获了原本的属性值,使得属性可以用引用的方式保留。 - - - -## SwiftUI 布局算法 - -SwiftUI 会通过 `body` 的返回值获取描述视图的控件信息,转换为对应的内部视图信息,交给 2D 绘图引擎 Metal 或者 Open GL 绘制,其中比较复杂的 Toggle 可能引用自原本的UIKit实现。 - -- 父视图为子视图提供预估尺寸 -- 子视图计算自己的实际尺寸 -- 父视图根据子视图的尺寸将子视图放在自身的坐标系中 - -比较重要的是第二步,对于一个视图描述,通常有三种设置尺寸的方式。 - -- 无需计算,根据内容推断,如 Image 是和图片等大,Text 是计算出来的可视范围,类似 NSString 根据字体计算宽高。 -- Frame 强制指定宽高 -- 设置缩放比例 如 Image 设置 aspectRatio。 - -SwiftUI 中将计算出的模糊坐标点会对齐到清晰的像素点,避免出现锯齿感。 - - - -### VStack/HStack - -假设 HStack 主轴方向长度为 W1。 - -- 根据人机交互指南的预留出边距 S, 边距根据元素的排列可能有多个 -- 得到剩余的主轴宽度 W2= W1 - N * S -- 平均分配一个预估宽度 -- 计算一些具备明确宽高的元素 如 Image 设置了 Frame的元素的等。 -- 沿主轴方向从前到后计算,,如果计算出来的宽度小于预估宽度则正常显示,不够则截断 -- 最后的元素为剩余宽度,如果不够显示则阶段 -- 默认的交叉轴对齐方式为 Center,Stack 占据包括最大元素的边界。 - -可以查看这篇文章 [CSDN: SwiftUI之深入解析布局协议的功能与布局的实现教程](https://blog.csdn.net/Forever_wj/article/details/135547373) - - - -### ObservableObject - -如果说 @State 是全自动的话,ObservableObject 就是半自动,它需要搭配使用。ObservableObject 协议要求实现类型是 class,它只有一个需要实现的属性:objectWillChange。在数据将要发生改变时,这个属性用来向外进行“广播”,它的订阅者 (一般是 View 相关的逻辑) 在收到通知后,对 View 进行刷新。 -创建 ObservableObject 后,实际在 View 里使用时,我们需要将它声明为 @ObservedObject。这也是一个属性包装,它负责通过订阅 objectWillChange 广播,将具体管理数据的 ObservableObject 和当前的 View 关联起来。 - -```swift -class Person: ObservableObject { - @Published var name: String = "" - @Published var age: Int = 0 -} - -struct ObservableObjectDemoView: View { - @ObservedObject var person: Person - - var body: some View { - VStack { - Text(person.name) - .padding(.leading) - .font(.headline) - .fontWeight(.heavy) - .foregroundColor(.black) - Text(String(person.age)) - .padding(.leading) - .font(.subheadline) - .fontWeight(.heavy) - .foregroundColor(.black) - Spacer().frame(height: 30) - Button { - person.name = "杭城小刘" - person.age = 28 - } label: { - Text("点我更改") - } - } - } -} -``` - -- `@ObservedObject` 修饰的必须是遵守 ObservableObject 协议的 class 对象 -- class 对象的属性只有被 `@Published` 修饰时,属性的值修改时,才能被监听到 - - - -### EnvironmentObject - -在 SwiftUI 中,View 提供了 `environmentObject()` 方法,来把某个 ObservableObject 的值注入到当前 View 层级及其子层级中去。在这个 View 的子层级中,可以使用 `@EnvironmentObject` 来直接获取这个绑定的环境值。 - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension View { - - /// Supplies an `ObservableObject` to a view subhierarchy. - /// - /// The object can be read by any child by using `EnvironmentObject`. - /// - /// - Parameter object: the object to store and make available to - /// the view's subhierarchy. - @inlinable public func environmentObject(_ object: T) -> some View where T : ObservableObject -} -``` - -```swift -class Student: ObservableObject { - @Published var name: String = "unknown" - @Published var age: Int = 0 - deinit { - print("Student dealloc") - } -} - -struct EnvironmentObjectDemoView: View { - @ObservedObject var stduent: Student - - var body: some View { - VStack { - Text(stduent.name) - .font(.headline) - Text(String(stduent.age)) - .font(.subheadline) - Spacer().frame(height: 100) - StudentChildView().environmentObject(stduent) - } - } -} - -struct StudentChildView: View { - @EnvironmentObject var childStudent: Student - var body: some View { - VStack { - Text(childStudent.name) - .font(.headline) - Text(String(childStudent.age)) - .font(.subheadline) - Spacer() - .frame(height: 100) - Button { - childStudent.name = "杭城小刘" - childStudent.age = 28 - } label: { - Text("点我更改") - } - - } - } -} -``` - - - -### @StateObject - -`@StateObject` 行为类似 `@ObservedObject` 对象,区别是StateObject由SwiftUI负责针对一个指定的View,创建和管理一个实例对象,不管多少次View更新,都能够使用本地对象数据而不丢失 - - - -`@StateObject` 和 `@ObservedObject` 区别: - -- `@ObservedObject` 只是作为 View 的数据依赖,不被 View 持有,View 更新时 `@ObservedObject` 对象可能会被销毁 -- `@StateObject` 针对引用类型设计,当 View 更新时,实例不会被销毁,与 `@State` 类似,使得 View 本身拥有数据 - -```swift -class People: ObservableObject { - @Published var age: Int = 0 - deinit { - print("People dealloc") - } -} - -struct ObservableObjectLifeCycleView: View { - @State var count: Int = 0 - var body: some View { - VStack { - Text("刷新 Count 计数: \(count)") - Button { - count += 1 - } label: { - Text("刷新") - } - Spacer().frame(height: 100) - ObservableObjectDemoChildView() - } - } -} - -struct ObservableObjectDemoChildView: View { - @ObservedObject var p:People = People() - var body: some View { - VStack { - Text("\(p.age)") - Button { - p.age += 1 - } label: { - Text("+1") - } - } - } -} -``` - -- 点击 +1 按钮,Text 上的数字在 +1,当点击刷新的时候,Text 数字恢复为0,说明 p 对象被销毁,也打印了 People dealloc -- 点击刷新,打印 People dealloc - -将 ObservableObjectDemoChildView 稍作调整 - -```swift -struct ObservableObjectDemoChildView: View { - @StateObject var p:People = People() - var body: some View { - VStack { - Text("\(p.age)") - Button { - p.age += 1 - } label: { - Text("+1") - } - } - } -} -``` - -- 点击 +1 按钮,Text 上的数字在 +1,当点击刷新的时候,Text 数字不会变为0,说明 p 对象没有释放 -- 点击刷新,也只是 +1 - -`@StateObject` 的生命周期与当前所在 View 生命周期保持一致,即当 View 被销毁后,`@StateObject` 的数据销毁,当 View 被刷新时,`@StateObject` 的数据会保持;而 `@ObservedObject` 不被 View 持有,生命周期不一定与 View 一致,即数据可能被保持或者销毁。 - -## 自定义 SwiftUI 属性装饰器 - -```swift -import SwiftUI - -@propertyWrapper -struct UserDefaultWrapper { - var key: String - var defaultValue: T - - init(_ key: String, defaultValue: T) { - self.key = key - self.defaultValue = defaultValue - } - - var wrappedValue: T { - set { - UserDefaults.standard.set(newValue, forKey: key) - } - get { - UserDefaults.standard.value(forKey: key) as? T ?? defaultValue - } - } -} - -struct PropertyWrapperView: View { - - @UserDefaultWrapper("hasShowedUserGuide", defaultValue: false) - static var hasShowedUserGuide: Bool - - @State private var showText = PropertyWrapperView.hasShowedUserGuide ? "已经展示过" : "没有展示过" - var body: some View { - Button(action: { - if !PropertyWrapperView.hasShowedUserGuide { - PropertyWrapperView.hasShowedUserGuide = true - self.showText = "已经展示过" - } - }) { - Text(self.showText) - } - } -} - - -struct PropertyWrapperView_Previews: PreviewProvider { - static var previews: some View { - PropertyWrapperView() - } -} -``` - - - -## SwiftUI 与 UIKit 混合开发 - -SwiftUI、UIKit 各有优缺点,相信你是老司机了,就不赘述了。但大多数场景下,单个框架无法满足需求,那如何混合开发呢? - -### 第一种方式:UIViewRepresentable - -让 UIKit 的控件,封装成一个 SwiftUI 控件,然后在 SwiftUI 侧使用。 - -- 定义一个结构体,遵循 `UIViewRepresentable` 协议 - -- 指定 `associatedtype UIViewType : UIView` 关联类型声明,该关联类型指定 `UIViewRepresentable` 对象将桥接的具体的 UIView 子类型。 - -- 关联类型在 Swift 中用于在泛型或协议中定义占位符类型,这些类型在协议的实现或泛型的使用中将被具体的类型所替代。在 `UIViewRepresentable` 的上下文中,`UIViewType` 关联类型就是这样一个占位符,它代表了你将要在 SwiftUI 中使用的具体 `UIView` 子类。 - - 当你实现 `UIViewRepresentable` 协议时,你需要提供 `UIViewType` 的具体类型。这样,SwiftUI 就知道如何创建和管理这个特定类型的 `UIView` 实例了。 - -- 实现 `UIViewRepresentable` 协议方法 - - - `@MainActor func makeUIView(context: Self.Context) -> Self.UIViewType` 方法用于创建和配置 `UIView` 实例。当你将 `UIViewRepresentable` 的实例添加到 SwiftUI 视图层次结构中时,系统会调用此方法。在这里,你可以初始化你的 `UIView` 并设置其初始状态。这个方法返回一个 `UIView`实例,该实例将被嵌入到 SwiftUI 界面中 - - `@MainActor func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)` 方法用于更新 `UIView` 实例的状态。当 SwiftUI 视图的状态发生变化时(例如,由于响应某个动作或绑定到某个变量的值发生变化),系统会调用此方法。你可以在这里根据新的状态来更新你的 `UIView`。 - - `@MainActor static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)` 方法用于在移除 `UIView` 时执行一些清理操作 - - `@MainActor func makeCoordinator() -> Self.Coordinator` 方法用于创建并返回一个协调器对象,该对象可以处理与托管的`UIView`对象之间的交互。协调器是一个自定义的对象,负责管理 `UIView` 的行为,并处理来自 `UIView` 的事件和更新。典型的应用场景,比如 UITableView 的数据和事件代理的逻辑 - - - -举个例子,包装一个 UIKit 中的 UITableView 控件给 SwiftUI 使用 - -```swift -// UIKItGeneratedView.swift -import SwiftUI - -struct UIKItGeneratedView: View { - var body: some View { - VStack { - Text("SwiftUI + UIKit Demo") - Spacer() - CustomView() - } - } -} - -struct UIKItGeneratedView_Previews: PreviewProvider { - static var previews: some View { - UIKItGeneratedView() - } -} - - -struct CustomView: UIViewRepresentable { - - typealias UIViewType = UITableView - - func makeUIView(context: Context) -> UITableView { - let tableView: UITableView = UITableView() - tableView.delegate = context.coordinator - tableView.dataSource = context.coordinator - tableView.frame = CGRect(x: 0, y: 100, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height - 100) - tableView.backgroundColor = .red - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CustomTableViewCell") - return tableView - } - - func updateUIView(_ uiView: UITableView, context: Context) { - - } - - func makeCoordinator() -> CustomTableViewController { - return CustomView.CustomTableViewController() - } - - class CustomTableViewController: NSObject, UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 30 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell: UITableViewCell? = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell", for: indexPath) - if cell == nil { - cell = UITableViewCell(style: .subtitle, reuseIdentifier: "CustomTableViewCell") - } - cell?.imageView?.image = UIImage(named: "HaiTang") - cell?.textLabel?.text = "我是 Cell 标题" - cell?.detailTextLabel?.text = "我是 Cell 内容" - return cell! - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - print("点击了\(indexPath.row)行") - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 40 - } - } -} - -//struct CustomViewController: UIViewControllerRepresentable { -// -//} - -// SwiftUIDemoApp.swift -@main -struct SwiftUIDemoApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @Environment(\.scenePhase) var scenePhase - var body: some Scene { - WindowGroup { - UIKItGeneratedView() - }.onChange(of: scenePhase) { newScenePhase in - switch newScenePhase { - case .active: - print("应用启动了") - case .inactive: - print("应用休眠了") - case .background: - print("应用在后台展示") - @unknown default: - print("default") - } - } - } -} -``` - - - - - - - -### 第二种方式:UIViewControllerRepresentable - -- 按照传统的方式写一个 Swift Class。可以按照纯代码的方式写,也可以按照 StoryBoard 结合代码的方式写 - - CustomViewController.swift - - ```swift - import UIKit - - class CustomTableViewCell: UITableViewCell { - - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var contentLabel: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - - } - } - - class CustomViewController: UIViewController { - - @IBOutlet weak var tableView: UITableView! - - override func viewDidLoad() { - super.viewDidLoad() - tableView.delegate = self - tableView.dataSource = self - } - } - - extension CustomViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 100 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell:CustomTableViewCell? = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell") as? CustomTableViewCell - if cell == nil { - cell = UITableViewCell(style: .default, reuseIdentifier: "CustomTableViewCell") as? CustomTableViewCell - } - cell?.titleLabel.text = "第\(indexPath.row + 1)行" - cell?.contentLabel.text = "我是内容\(indexPath.row + 1)" - return cell! - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 50 - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - print("点击了第\(indexPath.row + 1)行") - } - } - ``` - -- 在使用的地方,新建一个结构体,遵循 `UIViewControllerRepresentable` 协议,实现协议方法 - - - `associatedtype UIViewControllerType : UIViewController` 指定 `associatedtype UIViewControllerType : UIViewController` 关联类型声明,该关联类型指定 `UIViewControllerRepresentable` 对象将桥接的具体的 UIViewController 子类型 - - `@MainActor func makeUIViewController(context: Self.Context) -> Self.UIViewControllerType` 负责创建并配置 `UIViewController` 实例。这个方法在第一次需要创建视图控制器时被调用,允许你在 SwiftUI 中集成和使用 UIKit 中的视图控制器。 - - `@MainActor func updateUIViewController(_ uiViewController: Self.UIViewControllerType, context: Self.Context)` 方法在视图控制器的生命周期中可能会被多次调用,用于更新视图控制器的状态或属性。 - - UIKitGeneratedViewController.swift - - ```swift - import SwiftUI - - struct UIKitGeneratedViewController: View { - var body: some View { - VStack { - CustomViewControllerWarpper() - } - } - } - - struct CustomViewControllerWarpper: UIViewControllerRepresentable { - typealias UIViewControllerType = CustomViewController - - func makeUIViewController(context: Context) -> CustomViewController { - // 纯代码生成的用 CustomViewController()。 StoryBoard 生成的用 UIStoryboard(name:bundle).instantiateInitialViewController() - let vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(identifier: "CustomViewController") as! CustomViewController - return vc - } - - func updateUIViewController(_ uiViewController: CustomViewController, context: Context) { - print(context) - } - } - - struct UIKitGeneratedViewController_Previews: PreviewProvider { - static var previews: some View { - UIKitGeneratedViewController() - } - } - ``` - - 使用 - - ``` - @main - struct SwiftUIDemoApp: App { - var body: some Scene { - WindowGroup { - UIKitGeneratedViewController() - } - } - } - ``` - - - - - -## 最佳实践 - -SwiftUI 是个 UI 框架、也是个组件库,核心是为了解决 UI 构建复杂、繁琐的问题。Redux 在前端由来已久,有 store、state、action、middleware、reducer 等角色,多个角色各司其职,不存在团队规范和约定后不遵守的情况。通过 store 来管理状态,状态变化后,使用到该状态的 UI 组件会收到通知,更新 UI。用户点击操作 UI,产生 action,action 经历过一系列 middleware 后来到了 store,store 让 reducer 根据 action 和当前的 state 计算,得到一个新的 state。新的 state 变化了,使用到的地方的 UI 也会自动更新。(数据和 UI 的双向绑定) - - - -对比 MVC: - -- 苹果早期官方给的 MVC 缺少了状态管理能力,导致了实现状态管理,控制器的代码很复杂。 -- 另一个问题是状态传递很混乱,不同的开发有自己的偏好: callback、delegate、kvo、notification,代码中可能存在多种状态传递的手段,代码的可读性、可维护性下降,团队协作很困难。 -- 很难看出某些状态会和哪些 UI 相关,在实际开发迭代、修复 bug 的过程中很容易引入新 bug - -对比 MVVM: - -- 状态绑定的代码比较多,代码冗余比较多,比较枯燥且可能出错。选哪个技术手段也容易受到挑战。而 Redux 则是框架已经帮忙处理好了状态绑定。 -- 可测性。容易测试业务逻辑,相对于 ViewModel 的测试,对 reducer 进行测试更容易编写,reducer 是纯函数,对于给定的输入,输出也恒定,不会修改外部状态。 -- 代码风格较 Redux 不够统一,导致代码易读性不如 Redux。Redux 多个角色清晰分明,没有理解成本,对于框架层的东西,团队小伙伴不需要按照“素质”、“约定”去遵循实现。 -- 状态的改变较 Redux 不容易跟踪。如果出了问题,需要调试比较麻烦。 -- 状态传递不太方便。如果需要将状态传递到比较深的视图上,往往是不太方便的。而 Redux 可以通过框架的能力轻松的将状态送到任何地方。 - - - -### 开源项目 - -Apple 推出了 SwiftUI,但没有像最早 MVC 一样,在 SwiftUI 中推出一个状态管理的官方架构,虽然 SwiftUI 有 `@State`、`@ObservedObject` 、`@StateObject` 等,但这些东西在不同父子组件、兄弟组件的状态传递、状态管理 case 下,该如何组织是一个没有规范的问题。另外单测困难,因为逻辑代码耦合在 View 相关的代码中。为此,业界借鉴前端领域的 Redux, Redux-like 的方案很多,比较有名的是 [ReSwift](https://github.com/ReSwift/ReSwift) 和 [TCA](https://github.com/pointfreeco/swift-composable-architecture),个人更倾向于 TCA,全称是 The Composable Architecture。 - -利用 TCA 做一个简易版的计数器 App - -安装依赖:File -> Add Packages,输入 `swift-composable-architecture` 搜索,点击右下角 Add Package 即可。 - - - -然后开始开发:先编写 Reducer 部分,再开发相关 UI - -```swift -// Counter.swift -import ComposableArchitecture -import SwiftUI - -struct Counter: Reducer { - // State - struct State: Equatable { - var count: Int = 0 - } - // Action - enum Action { - case increment - case decrement - case reset - case setCount(String) - } - // Reducer - var body: some Reducer { - Reduce { state, action in - switch action { - case .increment: - state.count += 1 - return .none - case .decrement: - state.count -= 1 - return .none - case .reset: - state.count = 0 - return .none - case .setCount(let text): - state.count = Int(text) ?? state.count - return .none - } - } - } -} -// TCADemoApp.swift -import SwiftUI -import ComposableArchitecture -@main -struct TCADemoApp: App { - var body: some Scene { - WindowGroup { - ContentView(store: Store(initialState: Counter.State(count: 0)) { - Counter() - }) - } - } -} -// ContentView.swift -import SwiftUI -import ComposableArchitecture - -struct ContentView: View { - let store: StoreOf - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - TextField(String(viewStore.count), text: viewStore.binding(get: { state in - String(state.count) - }, send: { value in - Counter.Action.setCount(value) - })) - .frame(width: 40) - .multilineTextAlignment(.center) - .foregroundColor(colorOfCountInfo(viewStore.count)) - - Spacer().frame(height: 100) - HStack { - Button { - store.send(.increment) - } label: { - Text("加一") - } - Spacer().frame(width: 50) - Button { - store.send(.decrement) - } label: { - Text("减一") - } - - Spacer().frame(width: 50) - Button { - store.send(.reset) - } label: { - Text("重置") - } - } - } - } - } - - func colorOfCountInfo(_ value: Int) -> Color? { - if value == 0 { - return nil - } - return value > 0 ? .red : .green - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(store: Store(initialState: Counter.State(count: 0)) { - Counter() - }) - } -} -``` - - - -说明: - -- 发送消息,而非直接改变状态。按钮响应事件里,通过 store 发送 action 的方式 `store.send(.increment)` -- 只在 Reducer 中改变状态。类似 `func reduce(into state: inout State, action: Action) -> Effect` Reducer 中 inout 的 state 可以原地修改,该函数返回一个 Effect,代表不该在 reduer 中进行的副作用。比如异步请求网络、文件 IO -- 更新状态并触发渲染,Reducer 中修改了状态,新的状态被 TCA 用来触发 view 的渲染,TCA 使用 `ViewStore` 来通过 `@ObservedObject` 触发 UI 刷新 - - - -TCA 对单元测试的支持也很好。TestStore 是 TCA 中专门用来处理测试的一种 Store。它可以接收通过 send 发送的 Action,还在内部提供断言。如果接收到 Action 后产生的新的 model 状态和提供的 model 状态不符,那么测试失败。 - -如下 - -```swift -// TCADemoTests.swift -import XCTest -@testable import TCADemo -import ComposableArchitecture - -final class TCADemoTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor func testCounterIncrement() async throws { - let store = TestStore(initialState: Counter.State(count: 0)) { - Counter() - } - await store.send(.increment) { state in - state.count += 1 - } - } - - @MainActor func testCounterDecrement() async throws { - let store = TestStore(initialState: Counter.State(count: 1)) { - Counter() - } - await store.send(.decrement) { state in - state.count -= 1 - } - } - - @MainActor func testCounterReset() async throws { - let store = TestStore(initialState: Counter.State(count: 2)) { - Counter() - } - await store.send(.reset) { state in - state.count = 0 - } - } -} -``` - -可以看到如果某个单测 case 失败,则会清楚的显示错误的信息。 - - - -如果需要在测试的时候使用“重复测试”功能,右击测试按钮,在弹出框里做重复测试的配置修改。 - - - -### 动手做一个简易版 Redux - -新建 `Redux.swift ` 是一个纯逻辑 Swift 文件。 - -```swift -// -// Redux.swift -// SwiftUIDemo -// -// Created by Unix_Kernel on 4/3/24. -// - -import Foundation - -protocol Action {} -class IncreaseAction: Action {} -class DecreaseAction: Action {} - -struct ReduxState { - var count: Int - init(count: Int) { - self.count = count - } -} - -typealias Reducer = (ReduxState, Action) -> ReduxState - -final class Store: ObservableObject { - var reducer: Reducer - @Published private (set) var state: ReduxState - - init(reducer: @escaping Reducer, state: ReduxState) { - self.reducer = reducer - self.state = state - } - - func dispatch(_ action: Action) { - self.state = self.reducer(self.state, action) - } -} -``` - -使用的地方 - -工程入口文件 `SwiftUIDemoApp.swift` - -```swift -@main -struct SwiftUIDemoApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @Environment(\.scenePhase) var scenePhase - var body: some Scene { - WindowGroup { - let state = ReduxState(count: 0) - let reducer: Reducer = { (state, action) -> ReduxState in - switch action { - case is IncreaseAction: - return ReduxState(count: state.count + 1) - case is DecreaseAction: - return ReduxState(count: state.count - 1) - default: - return ReduxState(count: state.count) - } - } - let store: Store = Store(reducer: reducer, state: state) - ReduxDemoView().environmentObject(store) - } - } -} -``` - -另一个展示的页面 `ReduxDemoView.swift` - -```swift -// -// ReduxDemoView.swift -// SwiftUIDemo -// -// Created by Unix_Kernel on 4/3/24. -// - -import SwiftUI - -struct ReduxDemoView: View { - @EnvironmentObject private var store: Store - var body: some View { - VStack { - Text("数据:\(store.state.count)") - Spacer() - .frame(height: 50) - HStack { - Button { - store.dispatch(IncreaseAction()) - } label: { - Text("+1") - } - Spacer() - .frame(width: 100) - Button { - store.dispatch(DecreaseAction()) - } label: { - Text("-1") - } - }.buttonStyle(.borderedProminent) - } - } -} - -struct ReduxDemoView_Previews: PreviewProvider { - static var previews: some View { - ReduxDemoView() - } -} -``` - -实现效果如下: - - - -## 核心技术 - -### SwiftUI 的渲染机制 - -Render loop 是驱动 SwiftUI 进行渲染更新的重要机制,了解它的原理和策略,可以揭秘 SwiftUI 高性能背后的秘密。 - -- event loop:事件循环,基于消息事件的循环,例如触摸被系统包装成一个事件一层一层传递给 UI 组件并最终触发 UI 组件渲染。 - -- render loop:渲染循环,是一个更小的概念,更多关注在消息处理和屏幕渲染上 - -- invalidated:无效、失效,类似于 Flutter 的 dirty 。当一个 View 的关联属性改变了,或者其他原因导致 View 需要刷新,View 就会被标记为 invalidated,此时框架会对 View 的body 进行 evaluate 。 - -- evaluate:直译是评估,我更倾向于翻译成计算,也就是当框架发现一个 View 被标记为 invalidated 后,框架会尝试比对改变前和改变后的 body 内容。如果框架认为 body 内容改变了,就会重新渲染。注意,evaluation 并不一定会导致重新渲染,这取决于框架对 body 的评估结果。评估虽然不会必然导致渲染,但框架仍需读取 body 数据并进行(可能复杂的)计算以确定内容是否改变。 - -GUI 的本质离不开 EventLoop,对于 iOS 来说,无论 UIKit 还是 SwiftUI 背后都是 RunLoop。RunLoop 会向 UI 代码分发消息,进而出发屏幕的一部分重新渲染。消息的处理和屏幕上的图形渲染构成一个应用程序的 render loop。 - -#### onAppear - -在 SwiftUI 中,我们没法得到像 UIKit 中那么丰富的视图生命周期。如果我们想在一个视图出现时执行一个动作,我们只能使用一个函数:`onAppear` 。但是它到底是什么时候被调用的呢?是不是像 `viewWillAppear` 那样,在视图被渲染并在屏幕上可见之前调用?如果是的话,我们可以信赖它吗? - -```swift -// ViewModel.h -import Foundation -class ViewModel: ObservableObject { - @Published var statusText: String = "invalid" - - func fetch() { - self.statusText = "loading" - } -} -// ContentView.swift -struct ContentView: View { - let store: StoreOf - @StateObject var model = ViewModel() - - var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack { - Text(model.statusText).padding().onAppear() { - model.fetch() - } - } - } -} -``` - -会发现直接展示的是 “loading”,没有看到过 “invalid”。`onAppear` 靠谱吗?真的是一出现就调用吗?比如在速度较慢的 iPhone 上,或者在有高刷的新款 iPhone 上,会发生什么?会不会因为显示器的刷新率不够而导致 Text 文字闪烁?如果我们给 Text 增加过渡动画,这是否会导致问题?还有,上面这种代码会导致渲染效率降低吗?我们可以看到,body 的关联值 statusText 改变了两次,即 body 被评估了两次,那么内容也会被渲染两次吗? - - - -#### 从硬件开始说起 - -视图是如何显示在屏幕上的?iPhone 有一个具有特定刷新率的屏幕。对于大多数 iPhone 来说,这是 60 赫兹。这意味着显示屏每秒刷新 60 次,而每一帧都持续 1/60 秒。最高端的 iPhone 有一个动态刷新率,最大刷新率为 120 赫兹。GPU 需要保证只在两次显示刷新之间改变视频帧。如果不这样做,屏幕就会一次合并两个帧的视频,这可能会导致图形伪影,如撕裂。 - -除了使用 GPU,一个应用程序的部分内容也可能使用 CPU 来渲染内容。在这种情况下,图像首先被生成为位图,然后被发送到 GPU 。GPU 对图形进行转换和组合。如果一个特定的视图或一块图形的渲染成本很高,它可以由 GPU 存储到内存中。 - -在屏幕上显示数据只是故事的一半,还需要接收用户的输入。触摸输入通常以一个特定的频率进行采样。这个频率可能高于显示屏的刷新率。即使触摸的采样频率与显示器刷新率相同,触摸采样率和显示器刷新率也可能不完全同步。对于最新的 iPhone ,触摸采样率是 120 赫兹,是显示器刷新率的两倍。虽然我们不能以注册触摸的速度来更新屏幕,但我们可以利用这些额外的触摸数据在屏幕上显示更详细的图形。在一个绘图应用程序中,我们可以根据更多的触摸来显示绘制的笔触。 - -游戏大多基于 `update loop` (更新循环),试图生成尽可能多的帧,以满足甚至超过显示器的硬件刷新率。相反,应用程序只会在数据发生变化、响应触控等事件后才驱动系统执行绘图操作。当应用程序需要处理此类事件时,操作系统会将其唤醒,然后应用程序利用 UI 框架再次渲染屏幕的部分内容。 - -注册输入事件并使用这些事件在屏幕上渲染图像,需要精确地进行协调。当编写一个应用程序时,你一般不需要担心这个问题。你只需要使用手势或控制事件,然后改变视图内容。但操作系统会仔细地将事件传递给你的应用程序,使你得到的事件不会多于或少于你所需要的,以便在每次刷新显示器时准确地提供一帧,同时也提供尽可能低的延迟。 - - - -#### RunLoop - -在苹果平台上,每个应用程序的核心 event loop(事件循环)背后都是 `CFRunLoop` 实现的。这个核心基础对象是随 Mac OS X 10.0 发布的 Carbon API 的一部分,并在许多不同的 UI 框架和迭代中存活至今。在被 Carbon 应用程序使用后,它还被 UIKit 使用,如今仍被 SwiftUI 使用。`main dispatch queue` (主队列)也是在 `CFRunLoop` 之上实现的,Swift Concurrency 的 `MainActor` 也是如此。 - -想要看到 `CFRunLoop` 是如何工作的,最好的方法是我们创建一个自己的 run loop。假设我们正在编写一个简单的命令行程序,等待用户输入,然后对其采取行动。 - -```swift -while let input = readLine() { - print(input) -} -``` - -我们在一个循环中读取用户的输入,如果我们接收到了什么,就对它执行一个方法,打印出来。这就是一个 run loop 。该程序可以处于两种状态。在第一种状态下,它是空闲的,等待用户输入。线程将被置入睡眠状态,而 CPU 时间被用于其他进程。当有用户输入时,操作系统会唤醒我们的线程来处理它。 - -如果我们还想在同一个线程中监听传入的网络事件呢?现在我们不能再使用 `readLine` 方法了,因为那会阻塞线程,直到有用户输入文本。有很多方法可以实现同时等待多个操作系统事件。但无论何种方式,它都需要内核支持。对于一个命令行程序,通常会使用 select 或 Dispatch sources。而在系统内部,`CFRunLoop` 使用 mach 端口。 - -下面是 `CFRunLoop` 的示意图,将其与我们的命令行应用程序进行比较。 - - - -如果你在 Xcode 调试器中暂停一个 iOS 应用程序,主线程的堆栈信息中便会出现下面的调用栈 - -```shell -* frame #0: libsystem_kernel.dylib`mach_msg_trap + 10 - frame #1: libsystem_kernel.dylib`mach_msg + 59 - frame #2: CoreFoundation`__CFRunLoopServiceMachPort + 319 - frame #3: CoreFoundation`__CFRunLoopRun + 1249 -``` - -`mach_msg` 是系统调用,`CFRunLoop` 用它来等待多个可能的事件中的任何一个。在这期间,我们的应用程序没有使用 CPU ,或者至少主线程没有使用。 - -一个 `CFRunLoop` 被配置为一组传递事件的输入源。当一个应用程序被启动时,它在主线程上启动一个 run loop ,用一个 input sources(输入源)来传递触摸事件。其他的输入源之后也可以被添加到其中。你也可以在辅助线程上启动新的 run loop 。我们可以用一个带有两个输入源的 `CFRunLoop` 实现一个处理用户输入和网络事件的命令行程序。 - -来自输入源的事件会按照特定的顺序[进行处理](https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.apple.com%2Flibrary%2Farchive%2Fdocumentation%2FCocoa%2FConceptual%2FMultithreading%2FRunLoopManagement%2FRunLoopManagement.html)。run loop 一共有 4 种类型的输入源: - -- input sources 0。这是自定义的输入源,它们手动调用 `CFRunLoop` 函数来传递事件。iOS 应用程序中的触摸事件在一个辅助线程上处理,然后通过 input sources 0 送到主线程的 run loop 中。 -- input sources 1。这是基于机器端口的输入源。例如 CADisplayLink,可用于将绘图代码与显示器刷新率同步。异步网络代码也可以使用 input sources 1。(然而,请注意,许多网络库在内部调度队列上使用阻塞的 I/O 调用来代替网络调用,然后通过主调度队列将代码调度到主线程) -- Timer sources。计时器,如Timer,使用这种特殊的输入源。 -- The main dispatch queue。调度到主队列的代码,以及与主队列相关的调度源也构成了一个输入源。这允许旧代码和基于调度的代码之间的沟通。(其他队列没有基于 `CFRunLoop` 实现) - -除了添加输入源,我们还可以向 `CFRunLoop` 添加观察者,当 run loop 到达特定周期时会发送通知。run loop 的周期是由 `CFRunLoopActivity` 定义的,观察者可以选择对其中的一个或几个周期进行监听。run loop 观察者在苹果自己的框架中被广泛使用 - -```c -__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ -__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ -__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ -__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ -__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ -``` - - - -#### Core Animation & render Server - -你是否有过这样的经历:当一个应用程序出现卡顿时,你认为它不可能是卡顿,因为还有一些动画在进行中?即使应用程序的主线程被卡住,指示器(菊花)仍在旋转,这总是让我困惑。即使主线程繁忙或暂停,iOS中的动画也可以继续。这不是因为动画发生在另一个**线程**中,而是因为它们发生在另一个**进程**中。 - -Core Animation 与 render server 对话,告诉它要画什么和做什么动画。一般来说,我们会对一个视图进行多次修改,作为对用户操作的反馈。在 UIKit 中,为了响应一个按钮的点击,你可能会同时改变一个视图的大小和背景颜色,或者你可能会调用多个方法来触发 `setNeedsDisplay` 。如果我们每改变一个参数就渲染一次,很明显效率会非常低,也会导致一些奇怪的问题。为了告诉系统该把哪几个参数打包一起渲染,Core Animation 框架暴露了 `CATransactions` 。 - -`CATransaction` 包含了 `begin` 和 `commit` 两个方法。你可以手动 begin (启动)和 commit (提交)一个 `CATransaction` 事务。如果你不主动调用 `CATransaction` API,`CATransaction` 也会在引擎下被隐式调用。 - -Demo - -```swift -@IBAction func buttonPress() { - self.view.backgroundColor = .red - sleep(2) - self.view.backgroundColor = .white -} -``` - -在按下按钮后,应用程序被卡住 2 秒,但它所处的视图的背景颜色保持为白色。视图层的变化在睡眠前没有被渲染。这是因为设置背景颜色启动(begin)了一个隐式渲染事务,而这个事务在睡眠前没有提交(commit)。 - -改进 - -```swift -@IBAction func buttonPress() { - CATransaction.begin() - self.view.backgroundColor = .red - CATransaction.commit() - sleep(2) - self.view.backgroundColor = .white -} -``` - -我们现在按下这个按钮,它所在的视图的背景颜色就会变成红色,然后在应用程序卡住的时候保持红色两秒钟,然后变成白色。我们主动提交(commit)了一个事务,因此视图在睡眠之前改变了颜色。由此可以推断,仅仅改变一个视图的背景颜色(不主动调用 CATransaction ),只会隐式地创建渲染事务,并不会去提交这个事务。 - -那么隐式的事务究竟何时提交?答案是:每当一个隐式事务被启动,就会在当前 run loop 周期结束时被安排提交。它的底层是用一个 run loop 观察者来完成的,这个观察者是由 Core Animation 添加到主 `CFRunLoop` 中的,观察的周期是 `CFRunLoopActivity.beforeWaiting`。 - -`CATransaction` 是可嵌套的。你可以在一个 `CATransaction` 里面启动另一个 `CATransaction` ,**但是只有外部事务会被用来渲染和改变屏幕内容**。外层事务可以是一个被隐式地启动的事务。举个例子:有些控件可能在调用它们的 action handlers 之前就已经调用了动画代码,动画代码启动了一个隐式事务。然后当你在 action handlers 内使用显式事务(手动调用 `CATransaction.commit()` )对一个图层进行修改时,提交它不会立即产生任何效果(需要等外层的隐式事务提交时才会改变)。 - -虽然你在 SwiftUI 应用程序中不直接使用 CATransactions,但 SwiftUI 框架在内部仍然使用 Core Animation 和 CATransactions 进行绘制和动画。与 render server 一起,Core Animation 对 iOS 来说是非常基础的。 - -#### 触摸事件和显示器刷新率 - -需要自定义动画或使用物理引擎的应用程序可以使用 `CADisplayLink` 来使绘图代码与显示器的刷新率同步。在这个 API 可用之前,特别是游戏开发者很难做到这一点,他们不得不使用 `NSTimer` 并想办法绕过很多限制。 - -应用程序从操作系统接收触摸事件的频率与显示屏刷新的频率相同。这是合理的,因为我们使用触摸来更新视图,如果比显示的频率更高,那就是一种浪费。但是,如果我们将收到这些触摸事件的时间与 `CADisplayLink` 启动的时间进行比较,我们会看到它们并不完全同步。 - -在具有高触摸刷新率的 iPhone 上,一个显示刷新周期内会发生多个触摸事件,但我们不会单独接收它们。在 UIKit 中,我们可以从 UITouch 对象中获得那些中间的触摸事件。 - -所有的 run loop 输入源,包括用于实现 `CADisplayLink` 和接收触摸的输入源,都以不同的方式应对系统繁忙的情况。如果多个触摸事件发生时,应用程序仍在忙于响应前一个触摸,它们将不会被单独传递,但仍可从最近的触摸事件中恢复触摸。相反,如果在下一次显示刷新即将发生时系统仍在忙碌, `CADisplayLink` 根本不会通知我们。 - - - -#### 全貌 - - - - - - - -当 APP 不做任何事情时,一个 SwiftUI 应用程序将有一个空闲的 CFRunLoop 。CFRunLoop 将等待来自输入源的事件,如触摸、网络事件、定时器或显示器刷新。为了响应触摸,SwiftUI 可能会调用一个 Button 的 `action handler`。如果我们在 `action handler` 中设置一个断点,我们会在堆栈跟踪中看到 `__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__` 。这是因为触摸事件是由 input sources 0 输入源传递的。 - -为了响应来自输入源的事件,我们可能会修改视图的一些 `@State` 变量,或者在 `@ObservedObject ` 上调用一个函数,进而触发 `objectWillChange`,SwiftUI 视图会被标记为 invalidated(无效),意味着它的 body 需要被重新评估,但它不是立即重新评估,之后会评估。因为会可能存在这样一个 case:T1 时刻,值修改为1,T2 时刻修改为2,T3 时刻修改为1,如果每次都立即评估,效率会很低。 - -那之后是什么时候?给 body 方法内加断点,在 LLDB 输入` bt` 可以看到 - -```shell -(lldb) bt -* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1 - * frame #0: 0x000000010c64f2cd TCADemo`closure #1 in closure #2 in ContentView.body.getter(self=TCADemo.ContentView @ 0x00007ff7b38aea80, viewStore=0x0000600003ccd140) at ContentView.swift:19:22 - frame #1: 0x000000010c65100e TCADemo`partial apply for closure #1 in closure #2 in ContentView.body.getter at :0 - frame #2: 0x00000001122b8a5b SwiftUI`SwiftUI.VStack.init(alignment: SwiftUI.HorizontalAlignment, spacing: Swift.Optional, content: () -> τ_0_0) -> SwiftUI.VStack<τ_0_0> + 159 - frame #3: 0x000000010c64ed88 TCADemo`closure #2 in ContentView.body.getter(viewStore=0x0000600003ccd140, self=TCADemo.ContentView @ 0x00007ff7b38b00e0) at ContentView.swift:17:13 - frame #4: 0x000000010c64eeaa TCADemo`partial apply for closure #2 in ContentView.body.getter at :0 - frame #5: 0x000000010c75a53b TCADemo`WithViewStore.body.getter(self=ComposableArchitecture.WithViewStore, SwiftUI._AppearanceActionModifier>, SwiftUI.ModifiedContent, SwiftUI.ModifiedContent, SwiftUI._FrameLayout>, SwiftUI._EnvironmentKeyWritingModifier>, SwiftUI._EnvironmentKeyWritingModifier>>, SwiftUI.ModifiedContent, SwiftUI.HStack, SwiftUI.ModifiedContent, SwiftUI.Button, SwiftUI.ModifiedContent, SwiftUI.Button)>>)>>> @ 0x00007ff7b38b0550) at WithViewStore.swift:418:17 - frame #6: 0x000000010c75b6c2 TCADemo`protocol witness for View.body.getter in conformance WithViewStore at :0 - frame #7: 0x0000000111bb2b37 SwiftUI`___lldb_unnamed_symbol79902 + 22 - frame #8: 0x000000011232d8f3 SwiftUI`___lldb_unnamed_symbol138684 + 34 - frame #9: 0x0000000111bb2a93 SwiftUI`___lldb_unnamed_symbol79901 + 1429 - frame #10: 0x000000011232df07 SwiftUI`___lldb_unnamed_symbol138706 + 458 - frame #11: 0x00000001119b3ff4 SwiftUI`___lldb_unnamed_symbol66299 + 26 - frame #12: 0x00007ff81fd7a1d7 AttributeGraph`AG::Graph::UpdateStack::update() + 537 - frame #13: 0x00007ff81fd7a9ab AttributeGraph`AG::Graph::update_attribute(AG::data::ptr, unsigned int) + 443 - frame #14: 0x00007ff81fd87378 AttributeGraph`AG::Subgraph::update(unsigned int) + 910 - frame #15: 0x000000011287008b SwiftUI`___lldb_unnamed_symbol175286 + 754 - frame #16: 0x0000000112872b5c SwiftUI`___lldb_unnamed_symbol175379 + 15 - frame #17: 0x0000000111dd9bb3 SwiftUI`___lldb_unnamed_symbol99983 + 37 - frame #18: 0x000000011269be30 SwiftUI`___lldb_unnamed_symbol163473 + 69 - frame #19: 0x000000011269a9cc SwiftUI`___lldb_unnamed_symbol163376 + 78 - frame #20: 0x0000000111dd9a94 SwiftUI`___lldb_unnamed_symbol99979 + 55 - frame #21: 0x0000000112872b35 SwiftUI`___lldb_unnamed_symbol175378 + 126 - frame #22: 0x0000000112872a89 SwiftUI`___lldb_unnamed_symbol175377 + 52 - frame #23: 0x000000011210ba1c SwiftUI`___lldb_unnamed_symbol121562 + 12 - frame #24: 0x0000000111b3f6e8 SwiftUI`___lldb_unnamed_symbol76464 + 113 - frame #25: 0x0000000111b3f669 SwiftUI`___lldb_unnamed_symbol76463 + 40 - frame #26: 0x0000000111b3f75f SwiftUI`___lldb_unnamed_symbol76465 + 43 - frame #27: 0x00007ff800387055 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23 - frame #28: 0x00007ff8003819c2 CoreFoundation`__CFRunLoopDoObservers + 515 - frame #29: 0x00007ff800381f0d CoreFoundation`__CFRunLoopRun + 1161 - frame #30: 0x00007ff8003816a7 CoreFoundation`CFRunLoopRunSpecific + 560 - frame #31: 0x00007ff809cb128a GraphicsServices`GSEventRunModal + 139 - frame #32: 0x000000010e963ad3 UIKitCore`-[UIApplication _run] + 994 - frame #33: 0x000000010e9689ef UIKitCore`UIApplicationMain + 123 - frame #34: 0x000000011276c667 SwiftUI`___lldb_unnamed_symbol166820 + 199 - frame #35: 0x000000011276c514 SwiftUI`___lldb_unnamed_symbol166818 + 130 - frame #36: 0x0000000111dd07e9 SwiftUI`static SwiftUI.App.main() -> () + 61 - frame #37: 0x000000010c65657e TCADemo`static TCADemoApp.$main(self=TCADemo.TCADemoApp) at TCADemoApp.swift:10:1 - frame #38: 0x000000010c656609 TCADemo`main at TCADemoApp.swift:0 - frame #39: 0x000000010d5712bf dyld_sim`start_sim + 10 - frame #40: 0x0000000117ddc52e dyld`start + 462 -(lldb) -``` - -堆栈中有 `__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__` ,就像隐式提交 CATranscation 一样,被标记为 invalidated(无效)的视图,其 body 评估也被安排在当前 RunLoop 周期结束时执行。这也是通过一个 RunLoop 观察者实现的,该观察者观察 `CFRunLoopActivity.beforeWaiting` 阶段。如果一个视图在同一个 run loop 中被两次标记为 invalidated(无效),它将只会被评估一次。 - -在所有 invalidated(无效)的视图被重新评估后,SwiftUI 不会立即将控制权返回给 run loop。一些 View 的回调,如 `onChange` 或 `onPreferenceChange` ,以及 `onAppear` 首先被调用,这些回调可能再次使视图 invalidated(无效)。对于视图第二次评估,SwiftUI 没有使用 run loop 观察器。 - -而如果这第二次评估导致再次调用回调,并导致再一次视图 invalidated(无效),SwiftUI 将暂时禁用视图 invalidated(无效),以防止无限循环。它还会打印一个类似这样的警告: `onChange(of: _) action tried to update multiple times per frame` - - - -在重新评估视图的时候,我们仍然会同时对多个视图、多个属性进行修改。正如我们所看到的,这些变化不会立即在屏幕上绘制。它们也会启动一个隐式 CATransaction 。因此,SwiftUI 利用了 UIKit 应用程序中的相同优化。 - -只有当隐式 CATransaction 被提交时,视图的内容才会被渲染到屏幕上。这也是 CPU 真正调用渲染代码的时刻。不过这带来一个问题:如果 SwiftUI 在 render loop 的这一部分崩溃了,就很难弄清楚如何解决,因为很难看到是哪个视图的哪一部分导致的。 - - - -总结: - -在 render loop 中,为了优化代码,有一个常见的模式:确保只在需要的时候调用。当调用一个函数或改变一个变量触发了一个更新时,这个更新不会立即执行。相反,它被安排在以后进行。当视图因其状态改变而失效时,例如 `onChange` 或 `onAppear` 这样的处理程序被调用时,以及当 Core Animation 需要绘制图形时,就会发生这种优化。这些优化在框架内部处理,主要使用了 `CFRunLoop` 观察者 - -SwiftUI 中的渲染循环可能隐藏得很好,它所使用的技术与我们在 UIKit 应用程序中使用的技术相同,并且有很好的文档。如果我们能更好地了解它的工作原理,我们就能更好地理解我们所写的代码的副作用,并做出更好的决定。有时,我们可能会把“渲染”等价成“evaluate 评估”。但有时,理解其中的区别会很有帮助。 - - - -#### 渲染流程 - - - - - -- 所有的 SwiftUI 控件都是一个结构体,实例是值类型,它们会遵循 View 协议,实现 body 计算属性;这个 body 计算属性内部所描述的就是视图结构的样子 -- 每个 body 得到的 some View 都会映射到 SwiftUI 内部的一个 RenderNode,RenderNode 也会持有在自定义 View 上定义的各种状态,为这些状态分配内存空间存储数据,同时给这些状态的添加属性监听,一旦状态属性发生变化,就重新建立 some View 到 RednerNode 的映射关系 -- 后台的渲染引擎 (CoreGraphics, Metal) 会通过 RenderNode 对比 some View 的变化,在 RunLoop 的加持下,将变化的部分绘制出来,最终呈现给用户 - -虽然上面的流程是这样子的,但在之前 SwiftUI 官方只是告诉你怎么把数据声明为 SwiftUI 可感知的状态,触发界面绘制。并没有明确的说明以下四个问题: - -1. SwiftUI View 和 RenderNode 之间是按照什么关系来映射的? -2. SwiftUI View 和 RenderNode 生命周期是否一致,存在什么关系? -3. SwiftUI View 重新实例化后,State 是如何被保持住的? -4. 状态发生变化后,SwiftUI 是怎么找到相应的 View 和 RenderNode 来进行操作的? - -注意2个概念: - -- `SwiftUI` 控件、`View` 都是结构体,是值类型,代表的是开发者用 DSL 描述的界面布局和层级 -- `视图`、`界面元素`都是类,是引用类型,指的是渲染节点或真实显示的 UI 界面 - - - -SwiftUI 内部是如何处理开发者编写的描述性代码的呢?其内部有三个核心概念来支撑: - -- 视图标识 (Identity) - 标识在应用程序的多次更新过程中视图元素,决定是否重新生成视图元素 -- 生命周期 (Lifetime) - 跟踪视图和数据状态随时间变化的过程,根据开发者描述来处理视图如何更新 -- 依赖关系 (Dependencies) - 对数据状态进行监听,决定视图何时需要更新 - -这三个核心概念帮助 SwiftUI 解决什么需要改变,如何改变,以及何时改变的问题,最终渲染出相应的用户界面。 - -接下来,让我们更深入地讨论这三个概念。 - - - -#### 视图标识(View Identity) - - - - - -上图中这两只狗狗,到底是不是同一个呢?我们似乎无法准确地给出答案。为什么呢?因为我们缺乏一些关键信息,那就是 Identity。 - -所以当 SwiftUI 处理你的界面描述时,它也需要 Identity 这个关键信息区分视图是否是同一个。 - - - -让我们来看下上面这个 Good Dog, Bad Dog 的小应用,你可以点击屏幕上的任何位置来切换狗狗的状态。但是我们从技术层面分析,上面的界面可以有两种 SwiftUI 的描述方式: - -1. 自定义两个完全不同的 SwiftUI View,根据当前狗狗的状态去做逻辑判断描述 -2. 把上面的界面描述成一个 SwiftUI 自定义 View,在区别展示的地方,用不同的颜色来区分 - -这两种 SwiftUI 的描述方式,会让视图从一种状态过渡到另一种状态的方式截然不同: - -- 按照第一种方式,由于是完全不同的视图,就意味着上面狗爪子的图标应该独立执行过渡动画,最终看起来只有淡入和淡出的效果 -- 按照第二种方式,SwiftUI 内部认为它是同一个视图,这就意味着在过渡期间,狗爪子图标会执行在屏幕上滑动的动画效果 - -可以看出 SwiftUI 在处理过渡动画的时候,会根据不同状态下的 View 是如何连接的来进行处理,而决定 View 连接方式的关键就是 **View Identity**: - -- 共享 Identity 的 View 代表的是同一个 UI 界面元素,只是处在不同状态下而已 (Same identity = Same element) -- 代表不同 UI 界面元素的 View,它的 Identity 也总是不同 (Different identities = Distinct elements) - -Identity 既然这么重要,那么开发者是如何用代码来定义的呢?在 SwiftUI 中分两种方式来定义 Identity: - -- 声明式 Identity,一般是在 View 上添加一个 `id(_:)` 修饰器或者在数据驱动列表控件中显示声明 Identifier,如 ForEach、List。参考前端 Vue、React List 中的 id -- 结构性 Identity,是 SwiftUI 根据 View 的类型和层级结构来动态识别,虽然这种 Identity 不需要开发者指定,但也需要开发者清晰的将 View 的层级结构描述出来,方便 SwiftUI 内部识别。类似 xpath,根据 UI 层级和结构,生成唯一的 Identity - - - -##### 声明式 Identity - - - -就像上面这两只狗狗,仅通过图片,很难判断这是不是同一只狗狗,但如果我们能用名字来标识它们。就很容易得出结论。像这样给狗狗起名字来标识它们的方式,就是在显式声明 Identity。 - -需要注意的是,声明式 Identity 是非常强大且灵活的。我们在之前 AppKit 或 UIKit 中编写界面的方式,其实就是采用的显式声明 Identity 的方式。怎么理解?由于 UIView 和 NSView 都是类,引用类型,所以它们的实例其实是一个指针,这个指针指向了一块内存空间。其中指针所代表的内存地址就是一种显式声明的 Identity。 - -我们可以通过视图的指针来标识每个视图,如果多个视图指针,都共享同一块内存空间,那么它们其实是同一个视图,如下图所示: - - - - - -问题来了,SwiftUI 中的 View 都是结构体, 值类型,没有指针的概念,那 SwiftUI 怎么来唯一标识一个 View 的呢? - -其实,SwiftUI 是用另外一种形式来显式标识 View。通过下面的例子能更好的理解,例如在这个救援犬列表里,用 dogTagID KeyPath 获取相应属性,在参数里指定到 id 上,就是在显式声明 View 的 Identity。这样就能标识出每条数据对应的展示视图。一旦列表数据发生变化, SwiftUI 可以根据这些 ID 来判断,哪些视图需要新生成,哪些视图重复使用,只需要执行动画。 - - - -##### 结构性 Identity - -不显式声明 Identity,这并不意味着这些 View 根本没有 Identity,也就是说每个 View 都有一个 Identity,即使它不是显式声明出来的。在这种情况下 SwiftUI 内部会对没有显式 Identity 的 View 根据它的描述层级结构生成一种隐式的 Identity,就叫做结构性 Identity。 - - - -如上图,假设我们有两只相似的狗狗,但我们不知道它们的名字,我们仍然还要标识出它们。这时候可以通过它们坐的位置来标识,如 `左边的狗` 或 `右边的狗`。 - -像这种利用排列位置的不同,来区分它们的方式就是所谓的 **结构性 Identity**。 - -SwiftUI 几乎在所有地方都采用了这种结构化 Identity 的方式来标识 View。一个典型的例子就是在 SwiftUI 中 使用 if else 条件判断的时候,条件语句的结构使得 SwiftUI 能够明确的识别每个 View,如下图,第一个 AdoptionDirectory 只在条件为 True 的时候显示,第二个 DogList 只在条件为 False 的显示。 - - - -但是 SwiftUI body 计算属性需要一个明确一致的返回类型,但 if else 条件判断使得返回类型不一致了,会引起编译失败。这时候 SwiftUI 引入了一个的黑魔法 - **ViewBuilder (默认是附加在 body 计算属性上的,不需要开发者单独指定)**。ViewBuilder 帮助 SwiftUI 把各种条件判断,封装成 _ConditionalContent 的数据结构。但为了区分在不同分支下的类型不同,用泛型来进行了区分,这样即保证了返回数据的一致性,又保证了 SwiftUI 内部可以通过泛型识别出不同分支下结构性 Identity。 - -见源代码: - - - - - -如下图,我们只用一个 PawView 自定义View,在这个自定义的 View 的 Modifier 上利用三目运算的方式来动态改变需要变化部分的数值,当在不同状态之间发生界面切换的时候,由于始终是一个视图元素,所以就会执行平滑的滑动动画。 - -其实,如果你回到 UIKit 中理解,也是一样的,我们在 UIView 上执行动画的时候,一般也是在同一个 UIView 的实例里去动态改变它的属性去修改样式, 才会有那种平滑过渡的效果;相反,如果虽然是同一个类型的 UIView,但是对应的是不同的实例,去做那种平滑的过渡效果,也是很难实现的。 - - - -综上,**在使用结构性 Identity 的时候,第二种描述 View 的方式是更好的选择。应该尽量避免切换 Identity,这样做会给动画和性能都带来良好的效果,也有利于维持视图的生命周期和数据状态。** - - - -#### 危险的 AnyView - -说起 AnyView ,这家伙绝对是 Identity 的克星。 - - - -上图是一个使用 AnyView 的示例代码。在这个自定义 View 中,为了保证最终返回一个明确一致的数据类型,每个分支都用一个 AnyView 包裹起来。由于 AnyView 隐藏了所包装视图的类型,让 SwiftUI 无法在条件判断中识别出结构性 Identity,在 SwiftUI 眼里,它看到的都是一些擦除类型的 AnyView,更要命的是,这段代码阅读起来特别困难。 - -那么,接下来让我们用正确的方式来重构这段代码: - -- 第一步:消除 AnyView 包裹,把内部具体的 View 类型暴露出来 -- 第二步:去掉 所有的 return 关键字 -- 第三步:在方法上添加 @ViewBuilder 标识,保证最终返回的是一个明确的类型,编译通过 -- 第四步:由于我们只是在 dog 的 breed 状态之间来回判断,那么把 if else 改为 switch case 会更合适 - -重构后,最终代码和 View 层级结构如下图: - - - -一般情况下,还是尽量避免使用 AnyView,因为 AnyView 有如下缺陷: - -- 代码难于阅读 -- 由于擦除了所有的 View 类型,无法在编译的过程中给出相应的提示 -- 可能会导致不必要的性能损失 - - - - - -### 生命周期(Lifetime) - -#### Lifetime 与 Identity 的关系 - - - -如上图,这里有个叫 Theseus 的小猫。他在一天中可能有各种不同的状态,一会装可爱,一会睡觉,一会发火,但是无论处于何种状态,他都是那只叫 Theseus 的小猫。 - -视图在整个生命周期内有各种不同的状态,每个状态在 SwiftUI 中由不同的 View 实例(值类型)来描述,而 Identity 将这些不同状态下的 View 值随着时间的推移关联起来,它们都对应着同一个视图元素。这就是 Identity 与视图生命周期建立联系的本质。 - - - -让我们用上面的代码来更清晰的理解这一点,这里我们有一个简单的自定义 View - PurrDecibelView,用来显示猫叫声的强度。一开始的时候 SwiftUI 调用 body 计算属性,获取到叫声为 25 的 View,但是突然小猫饿了,希望获得更多的关注,叫声变大为50,这时候 SwiftUI 监听到叫声这个状态的变化,重新调用 body 计算属性,获取到一个全新的 View,这两个 View 是完全截然不同的两个值。SwiftUI 会在后台对这两个值进行对比并对比出哪些部分发生了变化。得出对比结果后,告诉渲染视图执行变化部分的渲染操作,同时,用完的 View 值也会被销毁。 - -这里非常重要的一点就是 View 的值跟 Identity 生命周期是不同的。值类型的 View 生命周期是非常短暂的。开发者要控制好的其实是它们的 Identity。也就是说,随着时间的推移, SwiftUI 创建很多新的 View 用来描述视图当前状态下的显示方式,但是 SwiftUI 内部只是拿这些 View 来进行样式和布局的对比,用完了这些 View 值就会销毁,其内部用 Identity 唯一标识的那个视图(RenderNode)会一直在内存中,并且一直都是同一个。但是一旦 Identity 发生变化,内部的视图元素生命周期也会结束。 - -所以,如下图,我们经常用到的生命周期方法 onAppear 和 onDisappear,其实是在视图显示和消失的时候触发,而不是 View 创建和销毁的时候触发。 - - - -所以最终我们得出如下公式来阐述 View,LifeTime,Identity 三者之间的关系: - -- View Value ≠ View Identity -- View(视图)'s LifeTime = duration of the Identity - -视图和 struct 值类型的 View 没有严格对应关系,但持续可见的视图必然对应一个 identity。一个视图对应 >= 1个 DSL 描述的 View(也就是结构体) - - - -#### Lifetime 与 State 的关系 - -理解了 Identity 与视图生命周期之间的联系,也能够帮助你更好地理解 SwiftUI 如何维持数据状态 - -提到维持数据状态,那肯定要用到 State 和 StateObject。这两个状态管理工具可以保证在不同的 View 实例被创建的时候,封装的数据能够一直维持在内存中,相当于一种内存记忆。但是你去看它们的定义会发现它们都是结构体。按理说,在每次创建新的 View 实例后,应该就销毁重新生成了,那咋维持数据的呀?其实它们内部都会有一个 Storage 类,用来存储它们所修饰的数据。当一个视图根据 Identity 第一次创建的时候,SwiftUI 在内部为 State 和 StateObject 的 Storage 分配相应的内存空间,用来保存状态的初始值。**注意这里的 Storage 跟 Identity 是对应的,生命周期也是一致的**。 - -如下图的 CatRecorder 自定义 View,每次的 title 发生变化,由于他被 @State 修饰,SwiftUI 内部会在内存中保存这个数据,并且监听他的变化,一旦发生变化,就调用 body,重新计算。 - - - -下面,让我们来看一下在有分支的情况下,视图生命周期和数据状态之间的关系。 - - - -如上图代码,分支里的两个 CatRecorder 由于结构性 Identity 不同,所以它们被 SwiftUI 视为是两个不同的视图。之前说过这样会影响动画效果,其实也会影响它们内部数据状态的维持。 - -比方说,第一次进入的是 True 分支,SwiftUI 会为 CatRecorder 生成一个新的视图,并为数据分配内存空间,以存储状态的初始值。当 CatRecorder 内部状态发生变化时,只要都是在 True 分支下,由于 Identity 没变,所以还是同一个视图,所以状态也会连续性的变化,不会有数据丢失的情况。但是一旦 dayTime 发生变化,进入了 False 分支,SwiftUI 发现 Identity 发生了变化,会生成新的视图和与之对应状态的内存空间,这时候新的 CatRecorder 内部的所有状态都是初始值。True 分支下的视图和对应的状态接下来也会被释放。如果我们再切回到 True 分支,之前 True 分支的状态也回不来了,因为相较于上次的 View 类型,这又是一个全新的 Identity,会重新创建视图和数据状态存储空间。所以,最终分支切换后,在界面上有时候会发现记录的小猫状态突然丢失了。 - -所以可以得出的结论是:View Identity 一旦变化,视图内部对应的数据状态也会被重新替换。也就是说: - -`State's Lifetime = 视图's Lifetime != View's Lifetime` - - - -### 稳定的 Identity - -保证 Identity 稳定,这一点非常重要,尤其是在使用数据驱动型的列表控件时,在下面这些控件中,往往都需要用数据的 id 来给 View 显式声明 Identity。 - - - -下面两张图是两种不同的 ForEach 的用法。其中第一种用法是一个常数的 Range,SwiftUI 可以直接用 Range 的值来为视图生成 Identity,以确保在视图的整个生命周期内 Identity 是稳定的。但当使用一个动态的 Range 时,会导致声明式的 Identity 数值是不可预期的,Identity 一旦切换,视图都会重新生成,这样就会出现性能问题。 所以在 Xcode 12 的时候,检查到这种使用方式会编译报错,而在新版本的 Xcode 13 这将变为一个警告 (beta 版本似乎不生效)。 - -```swift -ForEach(0..<5) { offset in - Text("🐑 \(offset)") -} - -ForEach(0.. - - - -在 Swift 标准库有个 Identifiable 协议来帮助开发者保证 Identity 稳定。SwiftUI 也充分利用了这个协议,使得开发者只需要提供 KeyPath,它内部通过 Identifiable 协议可以动态的访问到相应的属性,从而生成稳定的 View Identity。 - -```swift -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension ForEach where ID == Data.Element.ID, Content : View, Data.Element : Identifiable { - - /// Creates an instance that uniquely identifies and creates views across - /// updates based on the identity of the underlying data. - /// - /// It's important that the `id` of a data element doesn't change unless you - /// replace the data element with a new data element that has a new - /// identity. If the `id` of a data element changes, the content view - /// generated from that data element loses any current state and animations. - /// - /// - Parameters: - /// - data: The identified data that the ``ForEach`` instance uses to - /// create views dynamically. - /// - content: The view builder that creates views dynamically. - public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) -} -``` - -如上图,如果仔细看下 ForEach 控件初始化函数的定义,可以看出 SwiftUI 充分利用了 Swift 类型系统的特性来约束 API 使用体验: - -- 通过这个定义,能一眼看出 ForEach 声明了一个数据集合和一个视图集合之间的关系 -- 将集合中的元素限制为必须遵循 Identifiable 协议,目的是为了保证集合元素能够提供一个稳定的 Identity,以便 SwiftUI 可以在视图的整个生命周期内跟踪数据。 - -所以,确保 Identity 的稳定性,对于开发者来说是非常重要的。因为他会影响到视图和与之对应数据的生命周期。 - - - -### 依赖关系处理 (Dependencies) - -#### 依赖关系图 - -``` -struct DogView: View { - @Binding var dog: Dog - var treat: Treat - var body: some view { - Button { - dog.reward(treat) - } label: { - PawView() - } - } -} -``` - -该 View 有两个属性 dog 和 treat,它们都可以理解为视图的依赖关系。依赖关系就是视图更新的入口。当依赖关系发生变化时,会重新调用 View 的 body,获取整个 View 的层级描述信息。在这个例子中,描述的就是一个有触发行为的按钮。他对应的视图层级结构如下: - - - -看上面这张图的话,是一个树结构,但是有可能多个视图都依赖同一个状态。有可能某个子视图也依赖顶级视图中的状态。情况越来越复杂后,这就不再是一个树结构。重新整理,避免让连接线之间交叉,如下图,可以看出它们之间的关系实际上是一个图结构。我们可以称之为**依赖关系图**。 - - - -深入的理解这个依赖关系图很重要,因为它保证了 SwiftUI 只更新那些需要重新调用 body 的 View。以最底部的依赖关系为例。如果我们检查这个依赖关系,会发现有两个 View 依赖它,当依赖的数据状态发生变化,只有这两个 View 会被标记为无效。同时 SwiftUI 开始调用每个视图的 body 计算属性,只为标记为无效的视图产生一个新的 body 值。 - - - -状态管理工具,在 SwiftUI 依赖关系的建立就是通过它们来实现的: - -- @Binding -- @Environment -- @State -- @StateObject -- @ObservableObject -- @EnvironmentObject - - - -### 改进 Identity - -Identity 就是依赖关系图的灵魂,重要性不言而喻。正如之前所说,Identity 用来标识一个视图,所以 SwiftUI 会根据 Identity 来高效的判断哪些视图需要更新,哪些视图需要新建,哪些视图需要销毁。 - -#### 稳定性 - -对于开发者来说,首先要确保的就是 Identity 的稳定性。稳定的 Identity 会给 SwiftUI 带来如下好处: - -- 确保视图生命周期的准确性,一个视图的生命周期是由 Identity 来决定的,一个不稳定的 Identity 会导致视图生命周期意外缩短 -- 提高应用程序的性能,SwiftUI 无需在依赖关系图更新的过程中为不必要的视图和状态重新分配内存空间 -- 缩小影响依赖关系影响的范围 -- 保证数据状态不会无故丢失 - -在下图的例子中,每次都生成一个 UUID 和 直接用 Indices 来显式声明 Identity 都是不稳定的方式,因为它们都会随着时间推移发生变化,不能准确地标识一个视图,最终导致的结果就是,当我们在列表头部新插入数据时,整个列表都会重新刷新。相反,我们如果用一个 databaseID 就是可以的,因为这个 ID 只对应一个数据,能够清晰的标识一个与该数据对应的视图。这时候我们在头部新插入数据,所有的动画效果都非常自然了。 - - - - - - - -#### 唯一性 - -但是只保证 Identity 的稳定性还是不够的。好的 Identity 还要确保唯一性。每个 Identity 都应该准确映射到一个单一的视图。 - -唯一的 Identity 会给 SwiftUI 带来如下好处: - -- 平滑的动画效果 -- 同样可以提高性能 -- 准确地的反应视图和状态之间的依赖关系 - -像下面的代码中使用 name 的 KeyPath 来给 View 显式声明 Identity,是不合理的,因为我们无法保证 name 的唯一性,一旦出现重名的情况,新的视图很有可能不会展示出来。但当把 name 换成 serialNumber,一切都正常了 - - - - - - - -#### 去分支 - -上面,我们都是用声明式 Identity 来说明如何改进 Identity,接下来看看如何改进结构性 Identity。 - - - -上面的代码,乍一看似乎没什么问题。但是仔细分析会发现,这里有个性能问题。**content 在不同的分支条件下,会产生不同的结构性 Identity,这就导致了分支切换后针对同一个 View 会生成两个不同的视图元素,也就是在内存中分配两份内存空间**。这点其实是可以避免的。虽然这里我们很轻易的发现了这个问题,但当项目大了之后,有可能这些 ViewModifier 的代码都不在一起,所以这种问题很容易被忽视。 - -修改:把分支结构去掉,改为在 opacity 修饰器上添加三目运算的方式来动态修改透明度。由于去掉了分支结构,所以 content 只会生成单一的结构性 Identity,也就避免了不必要的内存开销,提高了性能。 - - - -像上面代码直接把透明度设置为 1,也就是跟初始状态一致,其实 SwiftUI 发现这种情况是不执行任何渲染操作的。我们把这样的修饰器称为 "惰性修饰器",因为它们不影响渲染的结果。 - -#### 最佳实践 - -是不是跟觉得在 SwiftUI 中使用条件分支很可怕?不要担心,想用分支的时候还是得用,只是用完之后,要多考虑下这个地方用分支来描述 View 结构的必要性,也就是要考虑当前代码的 View 到底是用来代表多个视图还是代表同一个视图的不同状态。 - -如果是代表同一个视图的不同状态,那么使用一个惰性修饰器来标识一个单一的视图,往往是更好的选择。 - -在下图中还给出了一些其他的惰性修饰器作为参考: - - - - - -### 总结 - -日常开发的时候可能对 Identity 的思考、认知不够,不知道它原来影响动画、视图生命周期、状态生命周期都有关系。Identity 帮助 SwiftUI 系统做了很多决策。 - --SwiftUI View 和 视图元素之间采用 Identity 关联起来,它们之间并非一一对应,Identity 有声明式的,也有结构式的。在 SwiftUI 中每当状态发生变化,都会调用对应的 body 生成新的 View 值,但是否生成新的视图则完全由 Identity 来决定。如果 Identity 一致,就会根据 Identity 去内存中查找之前创建的视图,换言之,相当于保持之前视图的生命周期,并且在内存中用类维持住之前的数据状态,只对更改数据后,视图变化的部分进行渲染操作。如果 Identity 不一致,则会新建视图元素,同时视图所依赖的状态也会被重新分配,回到初始值。 - -总而言之,View Identity 对 SwiftUI 来说是至关重要的。我们一定要时刻注意 View 的 **显式 Identity** 和 **结构性 Identity**,并提高 Identity 的稳定性,确保 Identity 的唯一性。 - - - -## 开发 tips - -要回答好这个问题,其实就是在**聊 UIKit SwiftUI 的能力边界在哪里**?它们各有优缺点,开发人员可以根据具体需求和场景来选择如何搭配使用它们。 - -SwiftUI 的优势: - -- 声明式 UI:和主流的前端框架一样,提供了声明式 UI 编程模式,使得构建和管理 UI 变得简单直观。但它不只是一个开发框架,更是一个组件库,具备一些常见的 UI 组件能力(能力小于等于 UIKit) -- 响应式 UI:支持响应式编程,可以轻松处理各种状态,聚焦于逻辑。又和前端主流框架做了一样的事情(不如说是大前端优秀的设计在客户端落地,并在官方侧取得了支持) -- 跨平台特性:SwiftUI 可用于 iOS、macOS、watchOS、tvOS 的应用程序,具备较好的跨平台特性 - -UIKit 的优势: - -- 成熟的生态系统:UIKit 拥有丰富的第三方库和组件,可以满足各种复杂的 UI 和功能需求 -- 定制能力:UIKit 提供了更多的自定义和底层控制能力,适用于需要高度定制化的界面和交互 - -在实际开发中,可以根据以下场景来搭配使用 SwiftUI 和 UIKit: - -- 逐步迁移:对于已有的 UIKit 项目,可以逐步引入 SwiftUI,例如在新功能或模块中使用 SwiftUI,逐步迁移现有界面和功能。但需要考虑的是 SwiftUI 必须用 Swift 语言开发,新语言开发简单,但背后的比如 Crash 监控、热修复、动态路由、打包构建系统等,新语言如何与现有的基建打通是需要调研和考虑的一个事情。不过都2024了,Apple 官方拥抱了类似大前端很成熟的声明式开发、响应式编程,客户端同学该开始 SwiftUI 了。 -- 混合使用:可以在同一个应用程序中同时使用 SwiftUI 和 UIKit,根据具体需求选择合适的界面构建方式。例如,可以使用 SwiftUI 构建应用程序的主界面,同时使用 UIKit 来展示特定的复杂界面或功能。因为 SwiftUI 封装的是大多数常见、高频使用的 UI 组件,所以不可能满足所有需求,那些交互复杂的,还需要使用 UIKit 的能力。 -- 复用现有组件:可以将现有的 UIKit View 或者 ViewController 封装为 SwiftUI 可以用的组件。遵循 `UIViewRepresentable` 或 `UIViewControllerRepresentable` 协议即可。 -- 跨平台开发:对于需要在多个平台上展示相似界面的应用程序,可以使用 SwiftUI 来实现跨平台的 UI 共享,同时使用 UIKit 来处理特定平台的细节和定制化 - -一言以蔽之:大多数情况下,使用 UIKit 足以,但想要使用新的框架, SwiftUI 很棒,但某些交互复杂的功能无法实现,还需要借助 UIKit 的能力,包括丰富的组件库和开源项目的支持。 - - - - - -## 参考资料 - -- [The SwiftUI render loop](https://rensbr.eu/blog/swiftui-render-loop/?utm_campaign=iOS%2BDev%2BWeekly&utm_medium=rss&utm_source=iOS%2BDev%2BWeekly%2BIssue%2B558) diff --git a/Chapter1 - iOS/1.129.md b/Chapter1 - iOS/1.129.md deleted file mode 100644 index eca7a42..0000000 --- a/Chapter1 - iOS/1.129.md +++ /dev/null @@ -1,29 +0,0 @@ -# Swift Dictionary 扩容机制 - - - -## Dictionary - -```swift -let dic = ["d": 4, "a": 1, "b": 2, "c": 3] -print(dic) -// ["c": 3, "d": 4, "a": 1, "b": 2] -``` - -Dictionary 顺序会乱序 - - - -## KeyValuePairs - -KeyValuePairs 顺序不会乱序 - -```swift -let kvs: KeyValuePairs = ["d": 4, "a": 1, "b": 2, "c": 3] -print(kvs) -// ["d": 4, "a": 1, "b": 2, "c": 3] -``` - - - -开放寻址法 \ No newline at end of file diff --git a/Chapter1 - iOS/1.13.md b/Chapter1 - iOS/1.13.md deleted file mode 100644 index 2a3b9cf..0000000 --- a/Chapter1 - iOS/1.13.md +++ /dev/null @@ -1,226 +0,0 @@ -# UINavagationController重写push和pop方法 - -> 有个需求就是在App的Tab的首页需要显示浮动着的交互动画的机器人,该机器人具有机器学习的特点,因此可以不断的与用户交互,怎么样实现只浮动在App的5个tab首页,当点击跳转不是首页的时候不需要显示 - -因为5个tab上是5个自定义的导航控制器,所以我们可以监听导航控制器的push和pop事件,并且在push和pop的事件中判断当前控制器的字控制器的数量来判断窗口上的机器人是否需要显示,其实这里要说的就是如何监听push和pop事件。 - -``` -/** -* 重写这个方法的目的:为了拦截整个push过程,拿到所有push进来的子控制器 -* -* @param viewController 当前push进来的子控制器 -*/ --(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated -{ - // if (viewController != 栈底控制器) { - if (self.viewControllers.count > 0) { - - for (UIView *view in [UIApplication sharedApplication].keyWindow.subviews) { - if ([view isKindOfClass:[XLRobotImageView class]]) { - if (self.viewControllers.count > 0) { - self.robotView = (XLRobotImageView *)view; - [view removeFromSuperview]; - } - } - } - - - // 当push这个子控制器时, 隐藏底部的工具条 - viewController.hidesBottomBarWhenPushed = YES; - - UIButton *backButton = [UIButton buttonWithType:UIButtonTypeCustom]; - backButton.frame = CGRectMake(0, 0, 44, 44); - [backButton setImage:[UIImage imageNamed:@"backArror"] forState:UIControlStateNormal]; - [backButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; - - backButton.adjustsImageWhenHighlighted = NO; - backButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; - - backButton.titleLabel.font = [UIFont systemFontOfSize:16]; - - [backButton addTarget:self action:@selector(back) forControlEvents:UIControlEventTouchUpInside]; - [backButton setImageEdgeInsets:UIEdgeInsetsMake(0, 5 * BoundWidth/375, 0, 0)]; - viewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:backButton]; - } - - // 将viewController压入栈中 - [super pushViewController:viewController animated:animated]; -} - - --(UIViewController *)popViewControllerAnimated:(BOOL)animated{ - //在5个tab的首页需要显示 - NSArray *vcs = self.viewControllers; - UIViewController *topVC = vcs[vcs.count - 2]; - if (self.viewControllers.count >= 2) { - if ([topVC isKindOfClass:[MZPregnancyHomeController class]] || - [topVC isKindOfClass:[HLSettingViewController class]] || - [topVC isKindOfClass:[BBXEditViewController class]] || - [topVC isKindOfClass:[HLFriendTopicController class]] || - [topVC isKindOfClass:[MZBookViewController class]] - ) { - [[UIApplication sharedApplication].keyWindow addSubview:self.robotView]; - } - } - return [super popViewControllerAnimated:animated]; -} - -``` - -**敲黑板,注意啦** - -因为我做的一个全局的机器人只需要浮动在App的5个模块的首页,所以当页面进入第二层的时候就需要隐藏机器人,当App的顶层控制器是最外层的首页的时候再显示机器人,用导航控制器的push和pop监听就可以实现这个需求,但是遇到的一个问题就是当App从首页进入到第二层页面,用于手动右滑且滑到一半停止,这样子页面还是停留在第二层但是此时也会触发pop方法上面的代码就有点问题 - -因此想办法需要监听导航控制器里面每个控制器的出现事件,找到一个方法 **- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated;** 恰好满足需求,以前没用过记录下来 - -``` --(void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated{ - self.interactivePopGestureRecognizer.enabled = [self.viewControllers count] > 1 ; - - if ([viewController isKindOfClass:[MZPregnancyHomeController class]] || - [viewController isKindOfClass:[HLSettingViewController class]] || - [viewController isKindOfClass:[BBXEditViewController class]] || - [viewController isKindOfClass:[HLFriendTopicController class]] || - [viewController isKindOfClass:[MZBookViewController class]] - ) { - [[UIApplication sharedApplication].keyWindow addSubview:self.robotView]; - } -} - -``` - - - - - -``` -# - - -@interface HLNavigationController () - -@property (nonatomic, strong) XLRobotImageView *robotView; - -@end - -@implementation HLNavigationController - -+ (void)initialize -{ - [[UINavigationBar appearance] setTitleTextAttributes: - [NSDictionary dictionaryWithObjectsAndKeys:[UIColor whiteColor], NSForegroundColorAttributeName, [UIFont fontWithName:@"Lato-Regular" size:18], NSFontAttributeName, nil]]; - - [[UINavigationBar appearance] setTranslucent:NO]; - - NSMutableDictionary *testAttr = [NSMutableDictionary dictionary]; - testAttr[NSForegroundColorAttributeName] = [UIColor whiteColor]; - testAttr[NSFontAttributeName] = [UIFont systemFontOfSize:18]; - - [[UINavigationBar appearance] setTitleTextAttributes:testAttr]; - - testAttr = [NSMutableDictionary dictionary]; - testAttr[NSForegroundColorAttributeName] = [UIColor whiteColor]; - - [[UIBarButtonItem appearance] setTitleTextAttributes:testAttr forState:UIControlStateNormal]; - - - [[UINavigationBar appearance] setTintColor:[UIColor whiteColor]]; - - [[UINavigationBar appearance] setBarStyle:UIBarStyleBlack]; - - [[UINavigationBar appearance] setBackgroundImage:[[UIImage alloc] init] forBarMetrics:UIBarMetricsDefault]; - [[UINavigationBar appearance] setShadowImage:[[UIImage alloc] init]]; - -} - - -- (void)viewDidLoad { - [super viewDidLoad]; - [[UINavigationBar appearance] setBarTintColor:GlobalMainColor]; - - // 设置pop手势的代理 - self.interactivePopGestureRecognizer.delegate = self; - self.delegate = self; -} - -/** - * 重写这个方法的目的:为了拦截整个push过程,拿到所有push进来的子控制器 - * - * @param viewController 当前push进来的子控制器 - */ -- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated -{ - // if (viewController != 栈底控制器) { - if (self.viewControllers.count > 0) { - - for (UIView *view in [UIApplication sharedApplication].keyWindow.subviews) { - if ([view isKindOfClass:[XLRobotImageView class]]) { - if (self.viewControllers.count > 0) { - self.robotView = (XLRobotImageView *)view; - [view removeFromSuperview]; - } - } - } - - - // 当push这个子控制器时, 隐藏底部的工具条 - viewController.hidesBottomBarWhenPushed = YES; - - UIButton *backButton = [UIButton buttonWithType:UIButtonTypeCustom]; - backButton.frame = CGRectMake(0, 0, 44, 44); - [backButton setImage:[UIImage imageNamed:@"backArror"] forState:UIControlStateNormal]; - [backButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; - - backButton.adjustsImageWhenHighlighted = NO; - backButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; - - backButton.titleLabel.font = [UIFont systemFontOfSize:16]; - - [backButton addTarget:self action:@selector(back) forControlEvents:UIControlEventTouchUpInside]; - [backButton setImageEdgeInsets:UIEdgeInsetsMake(0, 5 * BoundWidth/375, 0, 0)]; - viewController.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:backButton]; - } - - // 将viewController压入栈中 - [super pushViewController:viewController animated:animated]; -} - - -- (void)back{ - [self popViewControllerAnimated:YES]; -} - -#pragma mark - -/** - * 这个代理方法的作用:决定pop手势是否有效 - * - * @return YES:手势有效, NO:手势无效 - */ -- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer -{ - if (self.disableGesture) { - return NO; - } - return self.viewControllers.count > 1; -} - -- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated{ - self.interactivePopGestureRecognizer.enabled = [self.viewControllers count] > 1 ; - - if ([viewController isKindOfClass:[MZPregnancyHomeController class]] || - [viewController isKindOfClass:[HLSettingViewController class]] || - [viewController isKindOfClass:[BBXEditViewController class]] || - [viewController isKindOfClass:[HLFriendTopicController class]] || - [viewController isKindOfClass:[MZBookViewController class]] - ) { - [[UIApplication sharedApplication].keyWindow addSubview:self.robotView]; - } -} - -@end - -``` - - - - diff --git a/Chapter1 - iOS/1.130.md b/Chapter1 - iOS/1.130.md deleted file mode 100644 index 94454fb..0000000 --- a/Chapter1 - iOS/1.130.md +++ /dev/null @@ -1 +0,0 @@ -# GCD 源码探索 diff --git a/Chapter1 - iOS/1.131.md b/Chapter1 - iOS/1.131.md deleted file mode 100644 index bc13867..0000000 --- a/Chapter1 - iOS/1.131.md +++ /dev/null @@ -1,124 +0,0 @@ -# 包管理工具 - -## CocoaPods - -CocoaPods 是非常好用的第三方依赖管理工具,它于2011年发布,经过这几年的发展,已经非常完善。CocoaPods 支持项目中采用 Objective-C 或者 Swift 语言。CocoaPods 会将第三方库的源代码编译为静态库 `.a` 文件或者动态库 `.framework` 文件的形式,并将它们添加到项目中,建立依赖关系。 - - - -## Carthage - -Carthage 是一个轻量级的项目依赖管理工具。Carthage 主张“去中心化”和“非侵入性”。 - -CocoaPods 搭建了一个中心库,第三方库被收入到该中心库,所以没有被收录的第三方库是不能使用 CocoaPods 管理的。这就是“中心化”的思想。而 Carthage 没有这样的中心库,第三方库基本都是从 Github 或者私有 git 库中下载的。这就是“去中心化”。 - -另外,CocoaPods 下载第三方库后,会将其变异成静态链接库或者动态框架文件,这种做法会修改 Xcode 项目属性依赖关系,这就是所谓的“侵入性”。而 Carthage 下载成功后,会讲第三方库编译为动态框架,由程序员自己配置依赖关系,Carthage 不会修改 Xcode 项目配置,这就是所谓的“非侵入性” - - -### 安装 -``` -brew update -brew install carthage -``` - -觉得 brew update 更新慢,不想更新的也可以执行 `export HOMEBREW_NO_AUTO_UPDATE=true`。由于经常在终端干活,所以设置了别名。 - -```shell -# 禁止终端利用 homebrew 安装插件时候的自动更新 -alias disableHomebrewUpdate="export HOMEBREW_NO_AUTO_UPDATE=true" -``` - - - -### 使用 - -- 创建 cartfile 文件 `touch cartfile` -- 修改 cartfile 文件 -``` -github "Alamofire/Alamofire" "5.0.0-rc.3" -github "onevcat/Kingfisher" -github "SnapKit/SnapKit" ~> 5.0.0 -``` - - - - - -### cartfile - dependency origin - -Carthage 支持两种类型的源,一个是 github,一个是 git -- github 表示依赖源,告诉 Carthage 去哪里下载文件。依赖源之后跟上要下载的库,格式为 `Username/ProjectName` -- git 关键字后面跟的是资料库的地址,可以是远程的 URL 地址,使用 `git://` `http://` `ssh://` 或者是本地资料库地址。 - - - -### cartfile - dependency version - -告诉 Carthage 使用哪个版本,这是可选的。不指定的话,默认使用最新版本 - -- `==1.0` 表示使用 `1.0` 版本 -- `>= 1.0` 表示使用 `1.0` 或更高的版本 -- `~> 1.0` 表示使用版本 `1.0` 以上,但是低于2.0的最新版本,例如:1.2, 1.6 -- branch 名称/ tag 名称/ commit 名称,意思是使用特定的分支/标签/提交。比如可以说分支名 master,也可以是 commit id: 5c8w72 - - - -### 更新 - -`carthage update` 但默认会编译4个平台(macOS、iOS、watchOS、tvOS) 会比较耗时,所以可以优化下,指定特定平台 -`carthage update --platform iOS` - - - - -### 安装后生成的文件 - -#### Cartfile.resolved -该文件是生成后的依赖关系以及各个库的版本号,不能修改。 -该文件确保提交的项目可以使用完全相同的配置与方式运行启用。跟踪项目当前所用的依赖版本号,保持多端开发一致,出于这个原因。建议提交这个文件到版 本控制中。 -``` -github "Alamofire/Alamofire" "5.0.0-rc.3" -github "onevcat/Kingfisher" -github "SnapKit/SnapKit" ~> 5.0.0 -``` - -#### Carthage 目录 - -该目录包含2个子目录: -- Checkouts 保存从 git 拉取的依赖库源文件 -- Build 包含编译后的文件,包含了4个平台(Mac、iOS、tvOS、watchOS)对应的 `.framework` - -``` -- Carthage - - Build - - Checkouts -``` - - - -### 项目配置 - -`Target -> Build Setting -> Search Paths -> Framework Search Paths` 添加 `$(PROJECT_DIR)/Carthage/Build/iOS` - -此时可以正常编写代码,和使用库的 api,但项目运行会 crash,报错为:`dyld: Library not loaded: @rpath/SnapKit.framework/SnapKit Referenced from: ...` - - - -由于是非侵入式,所以需要程序员自己配置依赖,`Target -> Build Phases -> '+' -> New Run Script Phase` - -- 添加脚本 `/usr/local/bin/Carthage copy-frameworks`。 -- 添加 "Input Files" `$(SRCROOT)/Carthage/Build/iOS/Alamofire.framework` 等等 - -## Swift Package Manager - -Swift Package Manager 是苹果推出的用于管理分发 swift 代码的工具,可以用于创建使用 swift 的库和可执行程序 - -能够通过命令快速创建 library 或者可执行的 swift 程序,能够跨平台使用,是开发出来的项目能够运行在不同的平台上。 - - -### Xcode 集成 - -Xcode 菜单栏:File -> Swift Packages -> Add Package Dependency... -输入项目地址后,在弹出框 Rules 里选择版本策略。 - - diff --git a/Chapter1 - iOS/1.132.md b/Chapter1 - iOS/1.132.md deleted file mode 100644 index 78f8dbf..0000000 --- a/Chapter1 - iOS/1.132.md +++ /dev/null @@ -1,518 +0,0 @@ -# 动态调试 - - - -## Xcode 调试的原理 - -Xcode 是电脑端的程序,Xcode 使用 LLDB 进行调试。真机连接 Xcode 运行起来,点击屏幕,对应的事件处理方法里加了断点。手机是如何与 Xcode 断点连接同步的呢? - - - - - -Xcode 编译器:GCC -> LLVM - -Xcode 调试器:GDB -> LLDB - -- `debugServer` 存放在:`/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/DeviceSupport/12.2/DeveloperDiskImage.dmg/usr/bin/debugserver` -- 当 Xcode 识别到手机设备时,Xcode 会自动将 `debugserver` 安装到 iPhone 上。`/Developer/usr/bin/debugserver` -- 一般情况下,Xcode 只可以调试通过 Xcode 安装的 App - - - - - -## 动态调试任意 App - -### 核心原因 - - - - - -上面说了 `debugserver` 只能调试 Xcode 连接安装的程序。这句话不够严谨,Xcode 连接 iPhone 的时候,会自动将 `debugserver` 安装到 iPhone 上,但是权限会做收敛。具体表现就是权限 plist。 - -所以我们可以自行修改权限,重新签名即可: - -- 将 `debugserver` 拷贝到电脑上 - -- 利用 ` ldid -e debugserver > debugserver.entitlements` 命令导出权限文件 - -- 打开 `debugserver.entitlements` 添加 `get-task-allow`、`task_for_pid-allow` 2个权限 - - ```shell - - - - - get-task-allow - - task_for_pid-allow - - com.apple.springboard.debugapplications - - com.apple.backboardd.launchapplications - - com.apple.backboardd.debugapplications - - com.apple.frontboard.launchapplications - - com.apple.frontboard.debugapplications - - seatbelt-profiles - - debugserver - - com.apple.diagnosticd.diagnostic - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.private.memorystatus - - com.apple.private.cs.debugger - - - - - - - - com.apple.springboard.debugapplications - - com.apple.backboardd.launchapplications - - com.apple.backboardd.debugapplications - - com.apple.frontboard.launchapplications - - com.apple.frontboard.debugapplications - - seatbelt-profiles - - debugserver - - com.apple.diagnosticd.diagnostic - - com.apple.security.network.server - - com.apple.security.network.client - - com.apple.private.memorystatus - - com.apple.private.cs.debugger - - - - ``` - -- 利用 ldid `ldid -S debugserver.entitlements debugserver` 进行重签 - - - -自动安装的 `debugserver` 存放目录为 `Device/Developer/usr/bin` 下的,但是这个目录是只读的。我们没办法将重签后的 `debugserver` 拖放到该位置。 - -但以后的使用场景是,在电脑终端 `sh ~/login.sh` 登录到手机后,在命令行模式下使用 `debugserver AppProcessName`,所以 `debugserver` 就需要安装(拖放 )到 `Device/usr/bin` - -此时还是无法使用 `debugserver` ,需要修改权限 `chmod +x /usr/bin/debugserver ` - - - -### debugserver 附加到某个 App 进程 - -`debugserver *:端口号 -a 进程 ` - -- `*:端口号` :使用 iPhone 的某个端口启动 `debugserver` 服务(只要不是保留端口号就可以) -- `进程`:输入 App 的进程信息(进程 ID 或者进程名称) - -比如:`debugserver *:10011 -a Wechat` - - - -### Mac 上启动 LLDB,远程连接 iPhone 上的 debugserver - -- 启动 LLDB,直接在终端输入 `lldb` -- 连接 debugserver 服务:`process connect connect://手机 IP 地址:debugserver 服务端口号 `。其中 `connect://` 代表协议 -- 第二步连接成功后,iPhone 的进程暂时就暂停了,下断点的状态,此时需要使用 LLDB 的 c 命令让程序先继续运行:`c` -- 接下来就用 LLDB 常规命令调试 App - - - -### 通过 debugserver 启动 App - -`debugserver -x auto *:端口号 App 的可执行文件路径` - - - -## LLDB 调试指令 - -### 指令格式 - - 指令格式为:` [ [...]] [-options [option-value]] [arguments [argument...]]` - -- ``:命令 -- `` : 子命令 -- `` :命令操作 -- `` :命令选项 -- ``:命令参数 - - - -比如给函数 sayHi 设置断点:`breakpoint set -n sayHi`,其中 - -- breakpoint 是 `` -- set 是 `` -- -n 是 `` -- sayHi 是 `` - - - -### help 查看帮助 - -`help `:用来查看某个指令和子指令 ` ` 的说明。比如 `help breakpoint set` - - - - - -### expression - -`expression -- ` 用于执行一个表达式 - -- `` :命令选项 -- `--`:命令选项结束符,表示所有的命令选项已经设置完毕,如果没有命令选项,`--` 可以省了 -- `` : 需要执行的表达式 - -比如经常在断点的时候,想额外执行某个函数或者处理某个逻辑,举个例子。在 `touchesMoved` 方法的断点模式下,想修改 view 的背景颜色,此时不需要重新运行。利用 expression 执行指令即可 - -```swift -(lldb) expression self.view.backgroundColor = .red -``` - -- `expression`、`expression --` 和指令 print、p、call 的效果一样 - -- `expression -O --` 和指令 po 效果一样。比如 - - - -### 堆栈信息 - -`thread backtrace` :打印线程的堆栈信息。效果等同于 `bt` - - - - - - - -### 方法返回 - -`thread return []` 让函数直接返回某个值,不会执行断点后面的代码 - -例如下面的代码,直接将函数的返回值进行修改了 - - - -### frame variable - -`frame variable []` 打印当前栈帧变量 - - - - - -### 调试指令 - -`thread continue`、`continue`、`c`:程序继续运行 - -`thred step-over`、`next`、`n` :单步运行,把字函数当作整体一步执行 - -`thread step-in`、`step`、`s`:单步运行,遇到子函数会进入子函数 - -`thread step-out`、`finish`:直接执行完当前函数的所有代码,返回到上一个函数 - -`si` 、`ni` 和 `s` `n`:类似 - -- `s` `n` 是源码级别 - -- `si`、`ni` 是汇编指令级别 - - - -### breakpoint - -设置断点的指令 - -#### 函数名 - -`breakpoint set -n "函数名"`,但可能存在多个断点,因为同样方法名称的方法,都会被设置断点 - -比如 Person 类和 ViewController 类,都有 `- (NSInteger)add:(NSInteger)a withB:(NSInteger)b` 方法。` breakpoint set -n "add:withB:"` 指令设置了2个断点。 - - - - - -当有多个方法同名的时候,只对当前类设置断点,指令格式为 `breakpoint set -n "[类名 方法名]"`。 - -比如通过 `breakpoint set -n "[ViewController add:withB:]"` 对 ViewController 类的 `- (NSInteger)add:(NSInteger)a withB:(NSInteger)b` 方法设置断点 - - - -如果一个方法没有参数,也可以通过 `breakpoint set -n sayHi` 的方式设置断点 - - - -#### 函数地址 - -在逆向,调试别人的 App 的时候我们无法知道函数名称,所以给函数地址打断点就很重要了。 - -`breakpoint set -a 函数地址`。注意:函数地址是需要处理的,因为 iOS 有 `ASLR 技术`。 - - - -#### 正则表达式 - -`breakpoint set -r 正则表达式`,效果就是给所有函数名符合正则表达式的函数,设置断点。 - - - -#### 动态库 - -`breakpoint set -s 动态库 -n 函数名` - - - -#### 列出所有断点 - -`breakpoint list` 用于列出所有的断点,每个断点都有自己的编号 - - - - - -#### 断点的删除、禁用、开启 - -`breakpoint disable 断点编号` ,比如 `breakpoint disable 2.1 2.2` 禁用了2个断点 - - - -`breakpoint delete 断点编号`,比如 `breakpoint delete 2`。 - - - -比较奇怪,断点开启、禁用是可以跟子序号的,比如2.1 2.2,而断点删除必须是一级序号 - - - - #### 断点指令信息 - -`breakpoint command add 断点编号`,该指令会给断点预先设置需要执行的命令,到触发断点时,就会按照指令添加的顺序执行。 - -指令可以添加多个,最后以 "DONE" 结束。 - - - - - -`breakpoint command list 断点编号` 用于查看该断点下的所有指令 - -`breakpoint command delete 断点编号` 用于删除该断点下的所有指令 - - - - - - - - - -### 内存断点 - -在内存数据发生改变时触发 - -`watchpoint set variable 变量` - -在 `viewDidLoad` 中 通过 `watchpoint set variable self->_age` 给 age property 设置了断点。当改变的时候就触发断点 - - - - - -`watchpoint set expression 变量地址` - -在 `viewDidLoad` 中 通过 `watchpoint set expression 0x00007fcd20306a60` 给 age property 设置了断点。当改变的时候就触发断点 - - - - - -`watchpoint list` - -`watchpoint disable 断点编号` - -`watchpoint enable 断点编号` - -`watchpoint disable 断点编号` - -`watchpoint delete 断点编号` - -`watchpoint command add 断点编号` - -`watchpoint command list 断点编号` - -`watchpoint command delete 断点编号` - - - -### image - -#### image list - -`image list` 列举所加载的模块信息 - - - - - -`image list -o -f` 打印出模块的偏移地址、全路径 - - - -#### image lookup - -`image lookup -t 类型`:查找某个类型的信息 - - - - - -`image lookup -a 地址`:根据内存地址查找在模块中的位置 - -举例:声明一个数组,只有5个元素,但通过下标6来访问数组的时候 crash 了,假设我们代码很长,crash 后想知道具体是哪一行代码造成了 crash,怎么办呢? - -我们项目叫做 Demo111,那么 crash 堆栈中第4行有个地址 `0x000000010d534dfc`,可以通过该地址来分析具体的 crash 位置。通过 - -`image lookup -a 0x000000010d534dfc` 可以知道 `Summary: Demo111 -[ViewController touchesBegan:withEvent:] + 108 at ViewController.m:29:18` 是在 ViewController 的29行处发生了 crash。 - - - -`image lookup -n 符号或者函数名`:查找某个符号或者函数的位置 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.133.md b/Chapter1 - iOS/1.133.md deleted file mode 100644 index 36b37c2..0000000 --- a/Chapter1 - iOS/1.133.md +++ /dev/null @@ -1,61 +0,0 @@ -# 编译器利用 PGO 优化 App 性能 - -## 什么是 PGO? - -> Profile Guided Optimization (PGO) is an advanced feature for wringing every last bit of performance out of your app. It is not hard to use but requires some extra build steps and some care to collect good profile information. Depending on the nature of your app’s code, PGO may improve performance by 5 to 10 percent, but not all applications will benefit from it. If you have performance-sensitive code that needs that extra optimization, PGO may be able to help. - -PGO 即 Profile Guided Optimization, - -[Using Profile Guided Optimization](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/xcode_profile_guided_optimization/pgo-using/pgo-using.html) 官方文档大概意思是:PGO 是一种根据运行时 profiling data 来进行优化的技术。用起来不难,但需要一些额外的构建步骤和一些收集良好的 profile 文件。如果一个 application 的使用方式没有什么特点,那么我们可以认为代码的调用没有什么倾向性。但实际上,我们操作一个 application 的时候,往往有一套固定流程,尤其在程序启动的时候,这个特点更加明显。采集这种“典型操作流”的 profiling data,然后让编译器根据这些 data 重新编译代码,就可以把运行时得到的知识,运用到编译期,从而获得一定的性能提升。然而,值得指出的一点是,这样获得的性能提升并不是十分明显,通常只有 5-10%。如果已经没有其他办法,再考虑试试 PGO。 - - -## 何时使用 PGO? -正常情况下不用,通常来说 LLVM 经过编译前端,通过词法分析、语法分析、语义分析构建出 AST,然后转换为 IR,IR 经过一些列优化 Pass,常见的 Pass 比如死代码消除等,LLVM 已经做了大量的优化工作。所以通常来说,我们需要正向的优化代码、优化算法、设计正确合理的架构、合理的 UI 层级。除此之外,你还想让 App 获得更好的性能,可以考虑采用 PGO 技术。 - -## PGO 怎么样工作的? -PGO 假设你的应用程序的行为是可预测的,这样一个有代表性的配置文件就可以捕捉代码所有性能敏感方面的未来行为。当启用 PGO 时,Xcode 会构建一个专门检测的应用程序版本,然后运行它。您可以手动运行该应用程序,也可以使用 UI 自动化 XCTest 测试 App。当应用程序运行时,会统计并记录每条语句的执行次数。 - -应该收集一份具有典型、能够代表 App 真正用户行为的 profile 数据,PGO 统计每个语句的执行次数,并创建一个为该行为建模的概要文件。依据该文件,LLVM 编译器将优化工作集中在最重要的代码上。 - -举个例子:有一个稍微长一点的函数,刚好长到编译器不对它的调用进行 inline 优化,但是实际上,这个函数是一个热点调用,在运行时被调用的次数非常多。那么如果此时编译器也能帮我们把它优化掉,是不是很好呢?但是,编译器怎么能知道这个“稍微长一点的函数”是一个热点调用呢?PGO 根据这个 profile 文件进行优化 - -是一种优化编译器的技术,通过收集程序的实际运行数据,例如程序执行的分支情况,来指导编译器生成更优化的代码。 - - -## tips -首先,Xcode 已经提供了 PGO 的 UI 操作([详情可参考](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/xcode_profile_guided_optimization/Introduction/Introduction.html#//apple_ref/doc/uid/TP40014459-CH1-SW1)),所以如果是简单的 application,可以直接使用 UI 操作的方式,简单省事。不过,UI 操作有一些缺陷,具体表现在: - -- 控制粒度粗糙,要么不打开 PGO,要么对所有 code 进行 PGO。如果项目中有 swift 代码,那么这种方式就不能用了,因为 swift 不支持 PGO; -- 只支持两种方式采集 profiling data。第一种是每次手动运行,运行结束后退出 application,Xcode 会产生一个 xxx.profdata,之后的编译,都会用这个文件,来进行优化;如果代码发生变更,Xcode 会提示 profdata file out of date。第二种方法是借助 XCTest 来采集 profiling data,这种方法提供了一定的 automation 能力,但是另一方面也限制了 automation team 的手脚,他们可能在使用另一些更好用的工具而不是 XCTest。 - -因为 PGO 优化是靠的 Profile 文件,所以每次代码变化后需要保证生成最新的 Profile 文件。 -随着继续开发和更改应用项目中的代码,优化配置文件会过时。LLVM 编译器会识别出配置文件何时不再与应用程序的当前状态良好匹配,并提供警告。当收到此警告时,可以再次使用 Generate Optimization Profile 命令来创建更新的配置文件。每次重新生成优化配置文件时,Xcode 都会替换现有的配置文件数据。 - -这个很难办,所以需要利用 CI 手段,可以借助 `-fprofile-instr-generate` 和 `-fprofile-instr-use` 这两个 Clang 提供的编译选项搭配 CI。 - -## LLVM 利用 PGO 大概怎么优化代码 - -LLVM利用PGO(Profile-Guided Optimization)可以实现多种优化,其中一些主要优化包括: -- 函数内联(Function Inlining):PGO可以根据实际运行时的函数调用情况,选择性地内联函数,减少函数调用的开销,提高程序执行效率。 -- 循环展开(Loop Unrolling):通过分析循环执行次数和循环体内的代码,PGO可以决定是否展开循环,减少循环控制开销,提高循环执行效率。 -- 代码重排(Code Reordering):根据实际运行时的代码执行路径,PGO可以优化代码的布局顺序,使得频繁执行的代码更容易被CPU缓存命中,提高程序的局部性和性能。 -- 分支优化(Branch Optimization):PGO可以根据实际运行时的分支预测情况,优化分支指令,减少分支预测错误,提高程序的执行效率。 -- 常量传播(Constant Propagation):根据实际运行时的数据流分析,PGO可以更好地进行常量传播,减少不必要的变量存储和加载操作,提高程序的执行效率。 -- 内存访问优化(Memory Access Optimization):PGO可以根据实际运行时的内存访问模式,优化内存访问方式,减少内存访问延迟,提高程序的内存访问效率。 -通过这些优化手段,PGO可以根据实际运行时的数据和行为模式,生成更加针对性和高效的优化代码,从而提高程序的性能和执行效率 - - -## PGO 和二进制重排的异同 -PGO 是一个编译器特性,能够过程序实际执行的方法进行打点统计,找出最常执行的代码路径(热点函数),并根据这些信息对程序进行优化,这种优化包括重排代码已减少分支预测错误、优化内存使用以提高缓存命中率、函数内联、分支优化等等,这是一种动态优化技术,会根据实际程序运行收集到的 profile 信息做改变。 - -二进制重排则是程序编译完成后,对二进制代码进行优化的技术,主要要解决的是内存缺页异常的问题,可以减少缓存的错失率。这是一种静态优化技术,因为它不需要实际运行程序就能进行。 - - -## 为什么我的App需要重排的符号个数这么少 -二进制重排主要通过调整二进制文件中的代码顺序,以改善性能。以 iOS 举例,App 启动慢的一个原因就是 App 启动过程中用到的函数方法、可能排布在不同的 page 上,所以由于不断的切换 page,导致启动慢。App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。 - -然而,虽然理论上所有的代码都可以进行重排,但实际上,根据应用程序的特性,可能只有一部分代码是频繁执行的,也就是所谓的"热点"代码。这部分代码会被优先考虑进行重排,因为这样可以最大化性能提升。 - -另外,重排过程需要考虑到许多限制和约束,如符号之间的依赖关系,这可能会限制哪些符号可以移动和重新排序。如果一些符号因为它们之间的关系而不能被移动,那么这些符号就不会被考虑在重排中。 - -因此,你看到的"需要重排的符号个数"相对较少,可能是因为只有这些符号是被识别出来的"热点"代码,或者它们是唯一可以在不违反任何约束和限制的情况下被移动的符号 \ No newline at end of file diff --git a/Chapter1 - iOS/1.134.md b/Chapter1 - iOS/1.134.md deleted file mode 100644 index c67c067..0000000 --- a/Chapter1 - iOS/1.134.md +++ /dev/null @@ -1,69 +0,0 @@ -# 去除无用代码 - -通用方案 -Mach—O - -__DATA , __objc_selrefs 标记方法被调用信息 - -otools -v -s _DATA _objc_selrefs Mach-O - - -linkmap - selfrefs = 无用方法 - -问题:不准(OC 语言,动态性) - - -## clang plugin - -重载 -RecursiveASTVistor::visitDecl -RecursiveASTVistor::visitStmt - -上线前,通过静态方式去查找。不安全、不全面 - - -## 运行时查找 -Code Coverage -clang -fprofile-instr-generate -fcoverafe-mapping a.m -o a -swiftc -profile-generate -peofile-coverage-mapping a.swift - - -缺点:难以定制 - -## Fuzzing 方案 - -## Sanitizee Coverage -缺点: 编译慢、且无法进一步定制,包体积负向影响 - -## 自定义 llvm Pass - -针对 LLVM IR 进行处理。 - -低级别编程语言,类似 RISC 指令集。和高级语言对应,LLVM 利用一些列 Pass 对 IR 进行优化。 - -LLVM 的优化是由 Pass 完成的,每个 Pass 完成特定的优化 -自己开发 Pass 是独立的,不会影响 LLVM 的结构 -Pass 之间可以有关联,也可分租 - -LLVM 有c/c++ 接口,还可以用 c/Swift 编写 Pass - -c 接口较稳定,C++ 接口更新较新 - - -LLVM 内置 Pass -memcpyopt: memset 指令替换 memcpy -always_inline: 总是内联用 alwaysinline 修饰的函数 -dce:死代码消除 -loop_deletion:删除未使用的循环 - - -Pass 生成: -静态:在 LLVM 工程中设置 CMake,重新构建 opt -动态:opt 用 `-load-pass-plugin` 选项加载 - -怎么写 Pass? -对 IR 分析,继承 AnalysisInfoMixin -对 IR 转换,继承 PassInfoMixin - - - diff --git a/Chapter1 - iOS/1.135.md b/Chapter1 - iOS/1.135.md deleted file mode 100644 index e92530c..0000000 --- a/Chapter1 - iOS/1.135.md +++ /dev/null @@ -1,234 +0,0 @@ -# 框架设计 - - - -## 图片框架 - -### 角色 - -- Manager - - 内存 - - 磁盘 - - 网络 -- Code Manager - - 图片解码 - - 图片压缩/解压缩 - - - -### 图片读写过程 - -- 以图片 url 的 hash 值为 key,存储 - - - -### 读取过程 - - - - - -### 内存设计 - -- 内存存储的空间 - - - 10kb 以下,使用场景多,设计50张容量 - - 100kb 以下,使用场景次之,设计20张容量 - - 100kb 以上,使用场景最小,设计10张容量 - -- 淘汰策略 - - 队列实现,先进先出。 - - - -### 淘汰策略 - -LRU,最近最久未使用算法。比如3天内没有使用过的,则认为需要被淘汰。 - - - -### 淘汰时机 - -- 定时检查,比如 30分钟检查一次 - -- 提高检查频率: - - - 每次进行图片读写时 - - 前后台切换时 - - - -### 磁盘设计 - -- 存储方式 -- 大小限制(如200MB) -- 淘汰策略:如果某一张图片存储时间距今已经超过7天 - - - -### 网络设计 - -- 图片请求的最大并发量 -- 请求超时策略。超时重试1次,再次超时则取消 -- 请求优先级 - - - -### 图片解码 - -对于不同格式的图片,图片解码怎么处理? - -应用**策略模式**,对不同图片格式进行解码。一方面可以解码不同格式、另一个方面替换解码算法,对于稳定性有帮助 - -在哪个阶段进行解码? - -磁盘读取后、网络请求返回后 - - - -### 线程处理 - - - -## 阅读时长记录器 - - - -### 记录器种类 - -- 页面式:普通的 push、pop 页面 -- feed 流式:类似 weibo 这种 feed 流式的记录 -- 自定义式:可拓展性的体现,面向未来 - - - -QA:为什么要有不同类型的记录器? - -- 基于不同分类场景提供的关于记录的封装、适配 -- - -### 记录数据存储 - -- 内存缓存 -- 磁盘存储 - - - -### 准确性 - -数据收集(存储)、上报(移除)2个核心流程。准确性也和这2个方面息息相关。 - -- 定时写磁盘 从内存中 flush 到本地磁盘。定时器1分钟 flush 一次 -- 限定内存缓存条数。超过该条数,即写磁盘。内存记录每满10条 flush 一次 - - - -### 上传策略 - -思考: - -- 需要立马上传吗?每收集到1次页面阅读时长就需要立马上传1次吗?ROI 衡量。性能、线程数 -- 关于延时上传的场景有哪些? - - - -上传时机: - -- 定时器,比如每5分钟上传1次。 -- 前后台切换,比如从后台切换到前台触发1次上传逻辑 -- 无网切换到有网 - - - -### 网络上传效率 - -自定义报文,高效传输。 - -iOS 小端序,网络大端序。 - - - - - -## 复杂页面架构设计 - -- MVVM - -- Redux 数据流 - - - -## 客户端架构 - - - -## 业务之间解耦后的通信方式 - -- openURL -- 依赖注入:中间层 - - - -## AFNetworking - -### 主要类关系图 - - - - - - ### AFURLSessionManager - -- 创建和管理 NSURLSession、NSURLSessionTask -- 实现 NSURLSessionDelegate 协议代理方法,处理网络请求的重定向、认证、网络数据的处理 -- 引入 AFSecurityPolicy,用来保证请求安全 -- 引入 AFNetworkReachabilityManager 监控网络状态 - - - -## SDWebImage - -### 架构图 - - - - - -## 图片加载流程 - -- 查找内存缓存 -- 查找磁盘缓存 -- 网络下载图片并磁盘缓存 - - - -## AsyncDisplayKit - -### 主要处理问题 - -主要通过减轻主线程压力,尽量将一些可以放到子线程的任务都放到子线程处理,减轻主线程压力 - -主要分3方面: - -- UI 布局 layout:文本宽高计算、视图布局计算 -- 渲染 Rendering:文本渲染、图片解码、图形绘制 -- UIKit Objects:对象创建、调整、销毁 - - - -### 基本原理 - - - -- UIView 作为 CALayer 的代理实现。CALayer 作为 UIView 的实例变量,负责展示规工作。 - -- 针对 UIView 的修改,都抽象为针对 ASNode 的修改,这些修改可以在子线程进行。针对 ASNode 的修改和提交,会对其进行封装,提交到一个全局容器中。 -- 对 Runloop 状态进行监听,进入休眠前,ASDK 执行该 loop 内提交的所有任务。 - - - - - - - diff --git a/Chapter1 - iOS/1.136.md b/Chapter1 - iOS/1.136.md deleted file mode 100644 index 9ccb5c9..0000000 --- a/Chapter1 - iOS/1.136.md +++ /dev/null @@ -1,380 +0,0 @@ -# 工程化 - -## 多环境配置 -Project、Target、Scheme 主要管理什么? -- Project:包含了项目所有的代码、资源文件,所有信息 -- Scheme:对于指定 Target 的环境配置 -- Target:对于指定代码和资源文件的具体构建方式 - -多环境配置的3种方式: -- 多 target 配置 -- Scheme 多 target 进行环境配置 -- xconfig 文件配置 - - - -## 多环境配置的不同方式 - -### 多 Target 的方式 - -#### 方案 - -针对需要多配置的项目,在 Xcode 中,对其进行复制,存在多个 Target。 **多 Target(Targets)** 是管理不同应用变体(如免费版/付费版、测试版/生产版、多客户定制版)的高效方式。 - -所以,**为了区分不同的环境,做一些逻辑的控制。所以需要搭配不同的宏定义,来实现控制逻辑的效果。** - -注意:duplicate 之后,target 虽然多了一份,但是代码和资源不变 - - - -#### 关键步骤 - - - - - - - -##### 管理配置文件 - -1. **独立的 Info.plist** - - - 复制原 `Info.plist` 并重命名(如 `Pro-Info.plist`) - - 当对某个 Target “Duplicate” 之后,会产生一份新的 plist 文件 - - - - - 在新 Target 的 `Build Settings` → `Packaging` → `Info.plist File` 指定新路径 - -2. **环境配置分离** - - - 创建 `Config-Pro.xcconfig` 文件定义专属配置: - - ```shell - // Config-Pro.xcconfig - API_URL = https://api.pro.com - APP_NAME = Pro App - ``` - - - 在 Target 的 `Build Settings` → `Base Configuration` 指定配置文件 - - - -##### 宏定义 - -- OC:Build Settings -> Preprocessor Macros 里面的 Debug/Release 模式下添加自定义宏。比如在 debug 模式下 `IsOCDebug = 1` -- Swift:Build Settings -> Other Swift Flags 里的 Debug/Release 模式下添加自宏定义。注意命名有格式要求:`-D + 宏名称` - -​ - -#### 思考 - -该方式还是存在弊端: - -- 工程存在多份 info.plist(实际上 plist 文件很少改动,所以没有这种需求) -- 配置比较零散、比较乱 - - - -### 多 Scheme 的方式 - -#### 方案 - -针对多 Target 方案存在的问题,可以用**「多 Scheme + 多 Configuration 」**的方式解决。 - - - -#### 关键步骤 - -##### 创建 Configuration - -针对一个 Target 可以添加多个 **Configuration**,步骤如下: - -先选中 Project,然后在右侧选择 Info 选项卡,在 Configurations Section ,点击 "+" ,即可创建新的 Configuration。 - - - - - -创建好之后,该 Target 存在3份 Configuration 了。不同的 Configuration 有什么作用呢?设置宏定义的时候可以针对不同的 Configuration 进行设置。 - - - - - -针对 OC、Swift 分别设置了很多宏定义,接下去需要跑 Beta 配置的代码,怎么办? - - - -点击 Edit Scheme,在 Run 里面选择对应的 Configuration。 - -但这样好像蛮烦的,每次运行不同配置的代码,都需要手动切换 Configuration。有没有什么办法解决切换问题呢。 - - - -##### 创建实体 Scheme - -创建 Scheme 步骤:Xcode -> New Scheme,再弹出的方框内,选择对应的 Target,然后输入需要创建的 Scheme 名称。此次我们创建了:Debug、Beta 2个新的 Scheme。 - - - - - -创建好之后,可以看到实体 Configuration 和虚拟 Scheme 存在多对多的关系。但我们可以基于此,选择实体的 Scheme,然后在 Run 里面 “Build Configuration” 里面选择对应的 Configuration 与之对应。 - - - - - -##### plist 暴露自定义字段 - -1. 创建之后就可以根据 Configuration 设置值,在 `Build Settings -> User-Defined` 下添加自定义的字段,同时可以根据 Configuration 设置不同的值。 -2. 设置后的值怎么使用?将自定义的变量用 plist 存储。之后读取再使用。 - -完整如下图: - - - -切换不同的 Scheme,可以运行不同的效果,当前 case 下,选择 Debug Scheme,输出不同结果 `HOST_URL: http://www.debug.baidu.com` - - - -#### 思考 - -目前的方案已经优雅不少,该方式还是存在弊端:自定义宏的时候需要选择不同的 Scheme,过程繁琐 - - - -### Xcconfig - -#### 方案 - -使用过 CocoaPods 的都会留意到工程存在 `*.Pro.xcconfig` 文件。里面是一些工程相关的配置。所以我们也可以用该方式处理工程问题。 - - - -#### 关键步骤 - -Xcode 自带的 Configuration Settings File 可以支持自定义一些宏,还可以修改 Build Settings 里面的选项。 - -第一:创建步骤如下: - - - -文件命名为:`文件夹名称-项目名称.scheme名称.xcconfig`,比如 `Config-Xcconfig.debug.xcconfig` - -几个 Scheme 就创建几个对应的 Xcconfig 文件。 - - - -第二:修改和完善创建的 Xcconfig 配置文件里的内容。之后在 Xcode 的 Project 选项下,找到 Configurations,选择对应的 Target,然后选择右边对应的 Xcconfig 文件。如下图 - - - - - -我们只在 `Config-Xcconfig.debug.xcconfig` 文件中添加了 `OTHER_LDFLAGS = -framework "AFNetworking"`,Xcode 切换到 debug scheme 下,然后 Command + B 编译。 - - - -验证结果: - -- 编译前切换到 Build Settings 后,查看 ”Other Linker Flags“ 的 Debug 项为空 -- 编译后切换到 Build Settings 后,查看 ”Other Linker Flags“ 的 Debug 项为 `-framework ”AFNetworking“` - -因为 Xcconfig 文件,具有操作和修改 Build Settings 的能力,所以用好 Xcconfig 文件,不只可以实现替代宏定义和切换繁琐的问题,还可以实现很多其他手动修改 Build Settings 的问题。 - - - -说明:在 Xcode Build Settings 手动配置的信息,和通过 Xcconfig 方式编写的信息,不会冲突。 - -对于 xcconfig 文件,我们其实并不陌生、因为在使用 Cocoapods 的时候就已经在使用这个文件了,只是很多人不知道其中变量的含义。 - - - -#### 注意 - -在 `.xcconfig` 里添加的内容,根据使用场景的不同,细节存在差异: - -1. 仅编译时使用(无需 plist 声明):**不需要在 plist 中声明 `HOST_URL`,值会直接注入编译环境** - -2. 运行时通过 Info.plist 访问(需 plist 声明): - - - 在 `.xcconfig` 文件里添加了:`HOST_URL=127.0.0.1` - - 在 plist 中需要加一栏:key 为 `HOST_URL`,value为 `${HOST_URL}` - - - - - 代码中使用 - - ```swift - let host = Bundle.main.object(forInfoDictionaryKey: "ServerHost") as! String - let apiKey = Bundle.main.object(forInfoDictionaryKey: "ApiSecretKey") as! String - print("Host: \(host), API Key: \(apiKey)") - ``` - - - -QA:思考一个问题:为什么在 `xcconfig` 文件中设置的值,最后会显示在 Xcode 的 Build Settings 的 GUI 面板上? - -这便是接下去的内容:「Xcode 配置的层级机制」 - - - -## Xcode 配置的层级机制 - -### 层级机制 - -Xcode 的 Build Settings 是一个**多层叠加系统**,优先级从高到低如下: - -**Target 设置 > Project 设置 > .xcconfig 文件 > Xcode 默认值** - -Apple 在 [Build Settings Reference](https://help.apple.com/xcode/mac/current/#/itcaec37c2a6) 中明确说明: - -> **继承规则**: -> -> - Target 设置继承 Project 设置,Project 设置继承底层默认值。 -> - 若 Target 显式定义某配置项,则覆盖 Project 中的相同项16。 - -**关键推论**: - -> Target 作为具体构建目标,其配置需独立于 Project 的通用设置。若二者冲突,**Target 优先级更高**。 - -根据 [Xcode Build System Guide](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/XcodeBuildSystem/): - -> - `.xcconfig` 是**基础配置层**,通过 `baseConfigurationReference` 字段被 Project/Target 引用23。 -> - 若 Project 或 Target 显式设置了某值(即使为空),**将覆盖 .xcconfig 中的定义**24。 - - - -典型案例:当 `project.pbxproj` 中定义 `OTHER_LDFLAGS = ""`(空字符串)时,它会覆盖 `.xcconfig` 中的非空值,导致链接标志失效 - -说明: xcconfig 优先级低于前2者。 - -结论:配置的优先级顺序为:**Target 设置 > Project 设置 > .xcconfig 文件 > Xcode 默认值** - - - -### 为什么 xcconfig 的结果会显示在 Build Settings 中 - -1. **配置文件的显式声明** - `.xcconfig` 文件是 Build Settings 的合法数据源。通过以下方式关联: - - ``` - OTHER_LDFLAGS = -framework "AFNetworking" - ``` - - Xcode 会将其视为项目配置的一部分,并在 GUI 中显示。 - -2. **Build Settings 的“继承”特性** - Xcode 的 Build Settings 界面本质是一个**实时计算的合并视图**,它会展示: - - - 所有直接通过 GUI 设置的值 - - 从 `.xcconfig` 导入的值 - - 继承的默认值(如 `$(inherited)`) - -3. 如果使用 CocoaPods 安装的依赖,则会生成2个 CocoaPods 生成的 `.xcconfig` 文件。如果开发者自己再创建 `.xcconfig` 则需要处理2者的逻辑,因为 Xcode 一个工程只可以选择一个 `.xcconfig` 文件 - - - -### Xcode Project Management Guide - -这份[文档](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/XcodeBuildSystem/000-Introduction/Introduction.html#//apple_ref/doc/uid/TP40006904-CH1-SW1)深入讲解了 Xcode 项目结构、Build Settings 的继承关系、环境变量(如 `$(SRCROOT)`)等,适合想系统理解设置机制的人 - -聊起工程化,不得不查看 CocoaPods 的 [Podfile 配置指南](https://guides.cocoapods.org/syntax/podfile.html) - - - -### 实践验证 - -#### Demo1 - -第一步:新建 Xcode iOS 工程。 - -第二步:新建的工程配置了一份基础的 `Base.xcconfig` 来配置基础的编译信息。`Dev.xcconfig` 包含 `Base.xcconfig` 信息,在此基础上增加了一些编译参数。 - -`Base.xcconfig` 配置如下: - -```shell -BASE_LD_CONFIG = -framework "WantUIKit" -OTHER_LDFLAGS = $(BASE_LD_CONFIG) -``` - -`Dev.xcconfig` 配置如下: - -```shell -#include "Base.xcconfig" -TEMP_LDFLAGS = $(BASE_LDFLAGS) -framework "AFNetworking" -framework "SDWebImage" -framework "PrismClient" -OTHER_LDFLAGS = $(TEMP_LDFLAGS) -``` - - - -第三步: - -- 当前 xcconfig 是为 Dev 模式下设置的。所以项目的 scheme 选择 `Debug` 模式。 -- 选中 `PROJECT`,然后在 `Configurations` 下给 `Debug` 配置 `Dev.xcconfig` 文件。 - - - -结果:编译工程,可以看到报错了。符合预期 - - - -原因:本 Demo 的目的就是通过 `xcconfig` 文件和继承关系来验证对 Xcode Build Settings 中的 `Other Linker Flags` GUI 面板来验证 xcconfig 及其层级关系会正确影响到最终的编译参数上。 - -注意:为什么不用 `$(inherited)`? - -`$(inherited)` 的作用范围: - -- 仅继承来自 **Xcode 构建系统层级**的值(Target 设置 → Project 设置) -- 不继承 **同一配置文件链** 中通过 `#include` 引入的值 - -所以此时用**中间变量**的方法。 - - - -#### Demo2 - -验证 `$(inherited)` 的继承效果。 - -第一步:创建工程,配置 Podfile 文件。Podfile 文件内容如下 - -```shell -source 'https://mirrors.tuna.tsinghua.edu.cn/git/CocoaPods/Specs.git' # 清华源 - -platform :ios, '16.2' -inhibit_all_warnings! # 屏蔽第三方库警告 - -target 'LDExploreDemo' do - # Pods for InstallDyanmicAndStaticFramework - pod 'SDWebImage' - pod 'AFNetworking' -end -``` - -第二步:`pod install` 后,可以看到自动生成的 xcconfig 文件内容如下。 - -为了测试 xcconfig 配置信息的继承,故意把生成的原始信息注释掉。去掉了 `-framework "ImageIO"` - - - -第三步:创建 `Base.xcconfig` 文件。引入 Cocoapods 自动生成的 `Pods-LDExploreDemo.debug.xcconfig` 然后声明 `OTHER_LDFLAGS = $(inherited) -framework "ImageIO"`由2部分组成,一部分是 `$(inherited)` 一部分是新加的 `-framework "ImageIO"` - -第三步:创建 `Dev.xcconfig` 文件。引入第三步创建的 `Base.xcconfig` 文件。声明 `OTHER_LDFLAGS = $(inherited)` 为继承来的配置。 - -第四步:项目的 `AppDelete.m` 中引入 `#import `,然后创建对象并验证证 - - ````objective-c - AFSecurityPolicy *policy = [AFSecurityPolicy defaultPolicy]; - NSLog(@"%@", policy); - ```` - - - -说明: - -- Cocoapods install 后,自动创建链接器所需参数。都在 `Pods-项目名.debug.xcconfig` 配置文件中 -- 我们可以自己创建的 `*.xcconfig` 是可以引入自动生成的配置文件的。并在此基础上可以修改。然后在 Xcode Project Configuration 里可以指定为新创建的 xcconfig 文件 -- 并且是可以生效的 diff --git a/Chapter1 - iOS/1.137.md b/Chapter1 - iOS/1.137.md deleted file mode 100644 index fd3626d..0000000 --- a/Chapter1 - iOS/1.137.md +++ /dev/null @@ -1,143 +0,0 @@ -# 质量检测 - -## 静态检测 -### 概念 - -静态分析器是一个 Xcode 内置的工具,使用它可以在不运行源代码的状态下进行静态的代码分析,并且找出其中的 Bug,为 app 提供质量保证。 - -静态分析器工作的时候并不会动态地执行你的代码,它只是进行静态分析,因此它甚至会去分析代码中没有被常规的测试用例覆盖到的代码执行路径。 - -静态分析器只支持 C/C++/Objective-C 语言,因为静态分析器隶属于 Clang 体系中。不过即便如此,它对于 Objective-C 和 Swift 混编的工程也可以很好地支持。 - -其中包括了安全、逻辑、以及 API 方面的问题。分析器可以帮你找到以上的这些问题,并且解释原因以及指出代码的执行路径。 - - - - -### 原理 - -- 通过语法树进行代码静态分析,找出非语法性错误 -- 模拟代码执行路径,分析出 control-flow graph(CFG) -- 预置了常用的 checker - -一个大型项目代码行数非常多,所有跑完全部的 CFG 必定很耗时。 - - - -### 如何使用 - -Xcode 静态分析功能是在程序未运行的情况下,对代码的上下文语义、语法、和内存情况进行分析,可以检测出代码潜在的文本本地化问题(Localizability Issue)、逻辑问题(Logic error)、内存问题(Memery error)、数据问题(Dead store)和语法问题(Core Foundation/Objective-C)等。 -功能入口在: `菜单栏 -> Product -> Analyze`。可以使用快捷键:Command+Shift+B - -#### 文本国际化 - -Xcode Target -> Build Settings -> Static Analysis - Issues - Apple APIs -> Miss Localizablity 设置为 YES。可以帮助检测发现,缺少国际化的文本。 - - -提示说明 `User-facing text should use localized string macro` 缺少本地化的 API,正确的采用下面一行的写法。 - -#### 逻辑问题 - -分母不能为0. - - - -#### 内存问题 - -虽然 Xcode 默认使用 ARC 管理内存,但是某些 C API 还需要开发者自己进行内存管理。比如 CF 框架下的 API。 -以及 block nil 判断等。 - - - - - -#### 数据问题 - -在编码过程中,一些数据问题可以通过Analyze很好的提示出来。比如下图: - - - - - -#### Xcode 13 新增的检查项 - -在 Xcode 13 中,静态分析器也得以升级,现在它可以捕获更多的一些逻辑问题: - -1. 断言 Assert 的副作用 -2. 死循环 -3. 无用的冗余代码(例如多余的分支条件) -4. C++ 中 move 和 forward 的滥用导致的潜在问题 - -一部分的改进来自于开源作者们对 Apple Clang 编译器的贡献。 - - - -##### NSAssert 中的副作用 - -使用 NSAssert 规避非预期的代码逻辑是很常见的好习惯,但是不规范地使用也会带来一些副作用,例如在 NSAssert 的判断条件中对变量或内存进行修改操作。 - -本来 `self.count` 默认为0,经过 `mockAssertIssue` 方法中,赋值为1,然后写了 NSAssert 是为了增加健壮性,但这个断言有副作用,虽然判断了赋值后是1,再自增判断等于2,但这不符合预期。经过断言已经修改为2了。 - - - - - -##### 死循环 - -下面是一个很常见的死循环的案例,这种稍微复杂一些的逻辑,乍眼一看,似乎没有什么问题: - - - -这段代码中,是一个二层循环,但是在内层的循环中,没有对 j 做递增,而是做了 result 的递增,这个问题虽然会隐晦,但是新版本的静态检查器会检测出来。 - - - -Analyze 功能强大,其实际能检测出的问题会更多。 - - - -### 自定义分析器参数 - -Xcode 也为静态分析器提供了很多的自定义项,方便开发者根据自身工作流进行定制。在 BuildSetting 中通过搜索 `Static Analysis` 关键字,可以筛选出跟分析器相关的设置项。 - - - -### 每一次编译都执行静态分析 - -通过打开 `Analyze During 'Build'` 可以使得每一次编译操作都执行分析器: - - - -### 设置静态分析器的运行模式 - -`Mode of Analysis for 'Analyze'` 可以配置分析器运行的模式,Xcode 提供了两种运行模式: - -- `Shallow(faster)` :`Shallow`规避了去检查一些耗时复杂的检查操作,所以 Shallow 运行的更快 -- `Deep` 则进行深入的检查,能抛出更全面的错误 - -同一个工程,分别看看 Shallow 和 Deep 的耗时差别: - - - - - -### 专项检查配置 - -静态分析器也提供了一些专项检查的配置,可以根据工程情况定制选择。假设,项目有严格的安全检查,可以打开下图中选中的这些配置项目: - - - - - -再或者,如果静态分析器抛出的一些问题不想关注,可以在 Xcode Build Settings 中关闭掉。从而更聚焦于感兴趣、更关注的问题。 - - - -### 单个文件的分析 - -也可以针对单个文件做静态检查。操作路径为:Product -> Perform Action -> Analyze "FileName"。 - -这样只会对单个文件检测,且不会分析 import 进来的文件(可以看到右边的 Person.m 的问题没有被检测出来) - - - diff --git a/Chapter1 - iOS/1.138.md b/Chapter1 - iOS/1.138.md deleted file mode 100644 index 91ecf2d..0000000 --- a/Chapter1 - iOS/1.138.md +++ /dev/null @@ -1,131 +0,0 @@ -# AFNetworking 源码解读 - - - -## 结构 - -核心包含5个功能模块: - -- 网络通信模块(AFURLSessionManager、AFHTTPSessionManger) -- 网络状态监听模块(Reachability) -- 网络通信安全策略模块(Security) -- 网络通信信息序列化/反序列化模块(Serialization) -- 对于iOS UIKit库的扩展(UIKit) - -AF 是基于 NSURLSession 来封装的,所以核心类 AFURLSessionManager 也是针对 NSURLSession 做的封装,其余的4个模块,是为了网络通信(请求、响应数据的序列化、HTTPS 安全认证、UIKit 推展) - - - -其中 AFHTTPSessionManager 继承自 AFURLSessionManager,一般的网络请求都是用这个类,但是该类本身没有处理实际的网络,而是做了一些封装,把请求逻辑分发给父类 AFURLSessionManager 或者其他类去做。 - - - -## 以 get 请求为例 - -```objective-c -AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc]init]; - -[manager GET:@"https://somehost.com/goods" parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) { - -} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { - -}]; -``` - -调用初始化方法生成一个 manager,去看看具体做了什么 - -```objective-c -- (instancetype)init { - return [self initWithBaseURL:nil]; -} - -- (instancetype)initWithBaseURL:(NSURL *)url { - return [self initWithBaseURL:url sessionConfiguration:nil]; -} - -- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration { - return [self initWithBaseURL:nil sessionConfiguration:configuration]; -} - -- (instancetype)initWithBaseURL:(NSURL *)url - sessionConfiguration:(NSURLSessionConfiguration *)configuration -{ - self = [super initWithSessionConfiguration:configuration]; - if (!self) { - return nil; - } - // 对传过来的 BaseUrl 进行处理,如果有值且最后不包含/,url加上"/" - if ([[url path] length] > 0 && ![[url absoluteString] hasSuffix:@"/"]) { - url = [url URLByAppendingPathComponent:@""]; - } - - self.baseURL = url; - - self.requestSerializer = [AFHTTPRequestSerializer serializer]; - self.responseSerializer = [AFJSONResponseSerializer serializer]; - - return self; -} -``` - -初始化都调用到 - - - -## 数字证书 -数字签名,它能确认消息的完整性,进行认证。 -和公钥密码一样也要用到一对公钥、私钥。但相反的是,签名是用私钥加密(生成签名),公钥解密(验证签名)。私钥加密只能有吃有私钥的人完成,而由于公钥是对外公开的,因此任何人都可以用公钥解密(验证签名)。 - - -公钥基础设置(PKI)是为了能够更有效地运用公钥而制定的一系列规范和规格的总称,使用最广泛的 X.509 规范也是 PKI 的一种。 - -### 证书链 - -CA 有层级关系,处于最顶层的认证机构一般是根 CA,下面证书是经过上层签名的,而根 CA 则会对自己的证书进行签名。即自签名。 - - -怎么验证证书有没有被篡改? -当客户端走 HTTPS 访问站点时,服务器会返回整个证书链。先从最底层的CA开始,用上层的公钥对下层证书的数字签名进行验证。这样逐层向上验证,直到遇到了锚点证书。 - - -## 以 get 请求为例,展开探索 - -1. 请求入口 -``` -- (NSURLSessionDataTask *)GET:(NSString *)URLString - parameters:(id)parameters - success:(void (^)(NSURLSessionDataTask *task, id responseObject))success - failure:(void (^)(NSURLSessionDataTask *task, NSError *error))failure -``` -2. 创建 NSURLSessionDataTask -``` -- (NSURLSessionDataTask *)dataTaskWithHTTPMethod:(NSString *)method - URLString:(NSString *)URLString - parameters:(id)parameters - uploadProgress:(nullable void (^)(NSProgress *uploadProgress)) uploadProgress - downloadProgress:(nullable void (^)(NSProgress *downloadProgress)) downloadProgress - success:(void (^)(NSURLSessionDataTask *, id))success - failure:(void (^)(NSURLSessionDataTask *, NSError *))failure - -``` - - - -## 较原生相比,AF 做了什么事情 -拿 get、post 为入口,分析源码 -- 初始化很多属性 -- 安全措施,iOS 8 TaskID 不唯一的问题。dispatch_sync -- 自定义了一个 block 进度回调,使用起来更加方便 -- 用到了解耦,降低了主类的复杂度,维护起来更加方便 - -## AFSecurityPolicy 源码分析 HTTPS 认证 - - - - - -https://www.jianshu.com/p/856f0e26279d - -https://www.jianshu.com/u/14431e509ae8 - -https://blog.csdn.net/ZCMUCZX/article/details/79399517 diff --git a/Chapter1 - iOS/1.139.md b/Chapter1 - iOS/1.139.md deleted file mode 100644 index ab3fa73..0000000 --- a/Chapter1 - iOS/1.139.md +++ /dev/null @@ -1,347 +0,0 @@ -# 图形渲染技巧 - -## GPU、CPU - -难道不能直接将数据从 CPU 跨到 GPU 处理?为什么需要多此一举,出现 OpenGL 这个框架? - - - -数据饥饿:从一块内存中将数据复制到另一块内存中,传输速度是非常慢的。内存复制数据时,CPU 和 GPU 都不能操作数据,避免引起错误。 - -- 如果 CPU、GPU 同时操作内存,同步和处理非常麻烦,加一些锁或额外手段将造成速度损耗。 -- 如果 GPU 处理完处于等待状态 -所以加了 buffer 缓冲区来处理该问题。有很多缓冲区,比如颜色缓冲区、深度缓冲区... - - - -## 着色器渲染流程 - - -顶点着色器:有多少个顶点,就执行多少次顶点着色器。 -光栅化:将顶点着色器的结果转换为像素。将输入的图元描述,转换为与屏幕对应的位置像素片元。 -片元着色器:将光栅化的结果转换为颜色。(iOS 显示图片的核心原因) - -顶点着色器执行次数多还是片元着色器执行次数多?一般来说片元着色器执行次数多。比如三角形,顶点着色器执行三次,有多少个像素点片元着色器就执行多少次。 - - - -## 着色器的渲染 -- 顶点着色器(必要) -- 细分着色器(可选) -- 几何着色器(可选) -- 片元着色器(必选) - -QA: -- 什么是管线? -### 什么是可编程管线 -可编程管线(Programmable Pipeline)是一种灵活的渲染流程,它允许开发者通过编写特定的程序代码来控制图形渲染的各个阶段。与固定管线相比,可编程管线提供了更高的灵活性和控制能力,使得开发者能够实现更复杂的图形效果和优化性能。 - -编程通过 Shading Language 语言(基于 C++)编写,开发者可以控制图形渲染的各个阶段,包括顶点着色器、细分着色器、几何着色器和片元着色器等。这些着色器可以实现各种复杂的图形效果和优化性能。 - -Apple 的 Metal 中叫 Metal Shading Language(简称 MSL 或 Metal 着色语言)是苹果公司为其图形和计算API Metal 设计的着色语言。它是一种低级别的编程语言,用于编写3D图形渲染逻辑和并行计算核心逻辑。 - -### 什么是固定管线 -固定管线(Fixed-Function Pipeline)是指一种渲染流程,其中图形渲染的各个阶段都是预定义的,开发者不能直接控制这些阶段的内部操作 - -- 顶点处理(Vertex Processing):顶点坐标转换、光照处理等。 -- 图元装配(Primitive Assembly):将顶点组装成图元,如三角形、线段等。 -- 光栅化(Rasterization):将图元转换为像素。 -- 片元处理(Fragment Processing):对每个像素进行颜色、纹理等处理。 -- 输出合并(Output Merging):将处理后的像素输出到帧缓冲区。 -固定管线的优点是简单易用,对于初学者来说,可以快速上手进行图形渲染。但是,它的缺点是不够灵活,不能满足高级渲染技术的需求,如自定义着色器、高级光照模型等 - - -### 什么是管线 -在OpenGL中,管线(Pipeline)是一个处理图形数据的序列化过程,它将顶点数据转换成最终屏幕上的像素。管线分为几个阶段,每个阶段对数据进行特定的处理 - - -## 渲染过程中可能产生的问题 -### 隐藏面消除 -在绘制 3D 的场景时候,我们需要决定哪些部分是对观察者可见的,哪些部分是对观察者不可见的。对于不可见的部分,应该尽早丢弃,例如在一个不透明的墙壁后,就不应该渲染,这个情况叫“隐藏面消除”(Hidden surface elimination) - -#### 解决方案 -##### 油画算法(画家算法) - -先绘制场景中离观察者较远的物体,再绘制离观察者较近的物体 -例如下面的场景中,先绘制红色部分,再绘制黄色部分,最后再绘制灰色部分。即可解决隐藏面消除的问题。 - -缺点: -- 需要遮盖的部分,画了多次,造成了渲染性能问题,浪费了资源。 -- 叠加的情况,油画算法无法处理。 - 使用油画算法,只要将场景按照物理距离观察者的距离远近排序。由远及近的绘制即可。 但某些情况下,这个距离无法排序。比如下面的场景: - - - 如果三个三角形是叠加的情况,油画算法无法处理。 - -##### 正背面剔除(Face Culling) - -想象一个 3D 图形,你从任何一个方向去观察,最多可以看到几个面?最多3个,从一个立方体的任意位置和方向去看,最多可以看到3个面。 - -思考:为什么需要多余的去绘制根本看不到的3个面? -如果能以某种方式丢弃这部分数据,OpenGL 渲染性能可以提高超过50%。 - -正背面剔除方案,不仅可以解决隐藏面消除问题,还可以带来性能提升。 - -如何知道某个面再观察者的视野中会不会出现?任何平面都有2个面,正面、背面。意味着同一个时刻只能看到一个面。 - -OpenGL 可以做到检查所有正面朝向观察者的面,并渲染他们,从而丢弃背面朝向的面,这样可以节约片元着色器的开销,提高性能。 - -核心:OpenGL 如何知道绘制的图形中,哪个是正面,哪个是背面? -通过分析顶点数据的顺序。 - -- 正面:按照逆时针顶点连接顺序的三角形面 -- 背面:按照顺时针顶点连接顺序的三角形面 - - - - -用顺时针、逆时针判断正反面不是绝对的,还需要结合观察者的位置。 - - - - -分析: -- 左侧三角形的顶点顺序为:1->2->3;右侧三角形顶点顺序为:1->2->3 -- 当观察者在右侧时,则右边三角形方向为逆时针方向,则为正面。左侧三角形为顺时针,则为反面 -- 当观察者在左侧时,则左边三角形顶点为逆时针方向,则为正面。右侧三角形为顺时针,则为反面 -总结: -正面和背面是由三角形顶点顺序和观察者方向共同决定的。随着观察者的角度方向改变,正反面也会改变。 - - -API -``` -// 开启表面剔除(默认背面剔除) -void glEnable(GL_CULL_FACE); -// 关闭表面剔除(默认背面剔除) -void glDisable(GL_CULL_FACE); -// 用户选择剔除哪个面(设置面剔除的方式) -void glCullFace(GLenum mode); // mode 为:GL_FRONT,GL_BACK,GL_FRONT_AND_BACK。默认 GL_BACK -// 用户指定绕序那个为正面 -void glFrontFace(GLenum mode); // mode 为:GL_CCW,GL_CW。默认 GL_CCW -// 剔除正面实现 -glCullFace(GL_BACK); -glFrontFace(GL_CW); -``` - -### 深度问题 -- 什么是深度?深度其实就是该像素点在 3D 世界中距离观察者的距离,z 值。 -- 什么是深度缓冲区?一块内存区域,专门存储着每个像素点(绘制在屏幕上的深度值)。深度值 Z 越大,则离摄像机越远。 -- 为什么需要深度缓冲区?在不使用深度测试的时候,如果先绘制了一个距离比较近的物体,再绘制距离比较远的物体,则距离远的位图因为后绘制,则会把距离近的物体覆盖掉。有了深度缓冲区后,绘制物体的顺序就不那么重要了。实际上,只要存在深度缓冲区,OpenGL 都会把像素的深度写入到缓冲区中。除非调用 glDepthMask(GL_FALSE) 来禁止写入。 - -#### Z-buffer 方法(深度缓冲区 Depth-buffer) -深度测试。深度缓冲区(Depth buffer)和颜色缓冲区(Color buffer)是一一对应的,颜色缓冲区存储像素的颜色信息,而深度缓冲区存储像素的深度信息。 -在决定是否绘制一个物体表面时,首先要将表面对应的像素深度值与当前深度缓冲区中的值进行比较。如果大于深度缓冲区的值,则丢弃这部分。否则利用这个像素对应的深度值和颜色值,分别更新深度缓冲区和颜色缓冲区。这个过程称为“深度测试”。 - -#### 使用深度测试 -深度缓冲区,一般由窗口管理系统 GLFW 创建,深度值一般由16位、24位、32位值表示,通常是24位,位数越高,深度精确度越高。 -- 开启深度测试:`glEnable(GL_DEPTH_TEST)` -- 在绘制场景前,清除颜色和深度缓冲区:`glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glClearColor(0.0f, 0.0f, 0.0f, 1.0f);` -- 清除深度缓冲区默认值为1.0,表示最大的深度之,深度值范围是(0, 1),值越小表示越靠近观察者。值越大表示越远离观察者 -- 指定深度测试判断模式:`glDepthFunc(GLEnum mode);` - - GL_ALWAYS:总是绘制 - - GL_NEVER:永远不绘制 - - GL_LESS:如果当前深度值小于测试值,则绘制 - - GL_LEQUAL:如果当前深度值小于等于测试值,则绘制 - - GL_GREATER:如果当前深度值大于测试值,则绘制 - - GL_GEQUAL:如果当前深度值大于等于测试值,则绘制 - - GL_NOTEQUAL:如果当前深度值不等于测试值,则绘制 - - - -### ZFighting 闪烁问题 -为什么会出现闪烁问题? -因为开启深度测试后,OpenGL 就不会再去绘制模型被遮盖的部分,而是直接丢弃。这样的实现显示更真实,但是由于深度缓冲区精度的限制,对于深度相差非常小的情况下(例如在同一平面上进行2次绘制)OpenGL 就可能出现不能正确判断两者的深度值,会导致深度测试的结果不可预测,显示出来的现象是交错闪烁。 - - -#### ZFighting 闪烁问题解决 -第一步:启用 Polygon offset 方式解决 -让深度值之间产生间隔,如果2个图形之间有间隔,是不是意味着就不会产生干涉。可以理解为在执行深度测试前将立方体的深度值做一些细微的增加,于是就能将重叠的2个图形深度值之间有所区分。 -``` -// 启用 Polygon offset 方式 -glEnable(GL_POLYGOn_OFFSET_FILL) -``` -参数列表: -- GL_POLYGON_OFFSET_FILL:对应光栅化 GL_FILL -- GL_POLYGON_OFFSET_LINE:对应光栅化 GL_LINE -- GL_POLYGON_OFFSET_POINT:对应光栅化 GL_POINT - -第二步:指定偏移量。 -- 通过 `glPolygonOffset` 来指定 .glPolygonOffset 需要2个参数:factor 和 units。 -- 每个 Fragment 的深度值都会增加如下所示的偏移量。`Offset = (m * factor) + (r * units);` - - m: 多边形的深度斜率的最大值。理解一个多边形越是与近裁剪面平行,m 就越接近于0 - - r:能产生于窗口坐标系的深度值中可分辨的差异最小值。r 是由具体 OpenGL 平台指定的一个敞亮 -- 一个大于 0 的 Offset 会把模型推到离你(摄像机)更远的位置,相应的一个小于 0 的 Offset 会把模型拉近 -- 一般而言,只需要将 -1.0 和 0 这样简单赋值给 glPolygonOffset 基本可以满足需求 - -``` -void glPolygonOffset(Glfloat factor, Glfloat units); -应用到片段上总偏移计算公式: -Depth offset = (DZ * factor) + (r * units); -DZ:深度值(Z 值) -r:使深度缓冲区产生变化的最小值 -``` - - - -### 混合 -我们把 OpenGL 渲染时,会把颜色值存储在颜色缓冲区中,每个片段的深度值也存储在深度缓冲区。当深度缓冲区被关闭时,新的颜色将简单的覆盖原来的颜色缓冲区存在的颜色值,当深度缓冲区再次打开时,新的颜色片段只是当它们比原来的值更接近邻近的裁剪平面才会替换原来的颜色片段。`glEnable(Gl_BLEND)` - -#### 组合颜色 -目标颜色:已经存储在颜色缓冲区的颜色值 -源颜色:作为当前渲染命令结果进入颜色缓冲区的颜色值 -当混合功能被启用时,源颜色和目标颜色的组合方式是混合方程式控制的。在默认的情况下,混合方程式如下所示: -`Cf = (Cs * s) + (Cd *d)` -- Cf:最终计算参数的颜色 -- Cs:源颜色 -- Cd:目标颜色 -- s:源混合因子 -- d:目标混合因子 - -下面通过一个常见的混合函数组合来说明问题:`glBlendFunc(Gl_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)` - -如果颜色缓冲区存在一种红色(1.0f, 0.0f, 0.0f, 0.0f),目标颜色 Cd,如果在这上面用一种 alpha 为0.6的蓝色(0.0f, 0.0f, 1.0f, 0.6f) -``` -Cd(目标颜色) = (1.0f, 0.0f, 0.0f, 0.0f) -Cs(源颜色) = (0.0f, 0.0f, 1.0f, 0.6f) -S(源 Alpha) = 0.6f -D(目标 Alpha) = 1- S = 0.4f -Cf = (Cs * s) + (Cd * D) -``` - -总结:混合函数经常用于实现在其他一些不透明物体前面绘制一个透明物体的效果。 - - - -## GPUImage - -### 说明 - -开源的基于 GPU 处理图片/视频的一个框架,本身内置了几百种常见的滤镜效果,支持自定义滤镜(由开发者基于 OpenGLES GLSL 实现片元着色器) - -GPUImage 基于以下框架: - -- CoreMedia -- CoreVideo -- AVFoundation -- QuartzCore -- OpenGL ES2.0 - -采用 GPU 加速处理图片/视频滤镜效果。对比 GPUImage/CoreImage - -- GPUImage 可以自定义滤镜,缺乏人脸识别功能 -- GPUImage 在 GPU 上处理速度高于 CPU,百倍。 - -目的:隐藏/减弱关于 OpenGL ES 的复杂性。 - -滤镜处理的原理:就是把静态图片/视频上每一帧图片进行图形变换(饱和度/色温...)处理之后,再现实到屏幕上,本质上(像素点、颜色的变化) - - - - - -### 模块划分 - -- 上下文环境:包括运行 GPUImage 的上下文定义、资源定义、缓存管理相关类都包括在其中 -- 输入源:即滤镜处理链路的源头,包括视频、图片在内的各种输入源都定义其中 - -- 输出源:即处理链路的尽头,用于将处理后的数据绘制到屏幕、或者转成二进制数据推流等等 - -- 滤镜:提供多达上百种的滤镜效果使用来进行图像处理 - - - - - -### 核心流程 - -#### OpenGL ES 处理图片的流程 - -- 初始化 OpenGL ES 环境、编译、链接顶点着色器、片元着色器 -- 缓存顶点/纹理/坐标数据,传输相关数据到 GPU -- 图片绘制在帧缓存区 -- 从帧缓存区绘制图像 - -#### GPUImage 处理图片的流程 - -整体环节:Source(图片/视频数据源) -> filters(一堆滤镜)-> final target(处理好的图片/视频) - - - -##### Source(数据源) - -- GPUImageVideoCamera : 摄像头(用于实时拍摄视频) -- GPUImageStillCamera:摄像头(用于拍照片) -- GPUImagePicture:用于处理已经拍摄完成的照片 -- GPUImageVideo:用于处理已经拍摄好的视频 - -##### Filter(滤镜) - -GPUImageFilter:用来接收图形源,通过自定义顶点/片元着色器来渲染新的图像,完成滤镜处理后交给响应链的下一个对象。 - -GPUImage 中的滤镜均继承自 `GPUImageFilter`其定义了一个滤镜处理的基本流程。GPUImageFilter 继承自 GPUImgaeOutput,同时实现了 GPUImageInput 协议,这就使得 GPUImageFilter 即可接收 frameBuffer 输入进行图形处理。 - -`@interface GPUImageFilter : GPUImageOutput ` - - - -源对象将静止图像帧上传到OpenGL ES作为纹理,然后将这些纹理交给处理链中的下一个对象。 - -- GPUImage中的一个非常重要的基类 `GPUImageOutput` 和一个协议 `GPUImageInput`。基本上所有重要的 `GPUImage` 处理类都是`GPUImageOutput` 的子类,它实现了一个输出的基本功能 - -- 所有的 `GPUImage` 处理类也都遵循 `GPUImageInput` 协议。它定义了一个能够接收 frameBuffer 的接收者所必须实现的基本功能。主要包括: - - 接收上一个GPUImageOutput的相关信息 - - 接收并处理上一个GPUImageOutput渲染完成的通知 - - - -##### Final 环节 - -| 输出源 | 类型 | 说明 | -| --------------------- | ----------------- | ----------------------------------------------------- | -| GPUImageView | 继承自 UIView | 处理后的图像直接渲染到指定的原生 View 上 | -| GPUImageMovieWriter | 封装AVAssetWriter | 将处理后的视频数据逐帧写入指定路径文件中 | -| GPUImageRawDataOutput | 二进制数据 | 获取出来后纹理的二进制数据,可用于上行推流 | -| GPUImageRawDataOutput | 纹理数据 | 每一帧渲染结束后,通过 texture 属性返回输入纹理的索引 | - - - - - -### 责任链模式 - -对 GPUImage 源码的解读可以看到,GPUImage 采用了责任链设计模式来实现链式处理。 - -GPUImage 定义了一个 GPUImageOutput 类和一个 GPUImageInput 协议,实现了 GPUImageInput 协议的对象具备接收 frameBuffer 纹理输入并进行处理的能力。而继承自 GPUImageOutput 的对象,则可以将处理后的输出纹理传递到下一个 filter。 - - - -输入源 input 继承自 GPUImageOutput,可以将图片、视频等数据上传到 frameBuffer 后传递到 GPUImageFilter 中处理。最后一个 filter 处理完成后,将数据传递到了实现 GPUImageInput 协议的输出源 Output 中进行上屏绘制或者上行推流。上下游链路的打通。 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.14.md b/Chapter1 - iOS/1.14.md deleted file mode 100644 index 8678da8..0000000 --- a/Chapter1 - iOS/1.14.md +++ /dev/null @@ -1,144 +0,0 @@ -# 自定义URL Schemes - -1、引言 - -URL Schemes 应用在 iOS 上已经很久了。对于使用者来说,在沙盒机制下的 iOS 中,如果想做到一定程度上的自动化就不可避免地要用到 URL Schemes。但因为 URL Schemes 的使用方式不像传统 iOS 使用者接触到的图形界面那样可以直观地点来点去,造成了对它有兴趣的人(尤其是对英文有恐惧的人)一定程度上理解的困难。 - - -2、简介苹果的沙盒机制 - - -苹果选择沙盒来保障用户的隐私和安全,但沙盒也阻碍了应用间合理的信息共享,于是有了 URL Schemes 这个解决办法。 - -一般来说,我们使用的智能设备上有许多我们的个人信息。比如:联系方式、银行卡/信用卡信息、支付宝/Paypal/各大商城的账户密码、照片甚至行程与位置信息等。 - -如果说,你设备上的每一个应用,不管是官方的还是你从任何商城安装的应用都可以随意地获取这些信息,那么你轻则收到骚扰信息和邮件、重则后果不堪设想。如何让这些信息不被其它应用随意使用,或者说,如何让这些信息仅在设备所有者本人知情并允许的情况下被使用,是所有智能设备与操作系统所要在乎的核心安全问题。 - -在 iOS 这个操作系统中,针对这个问题,苹果使用了名为「沙盒」的机制:应用只能访问它声明可能访问的资源。一切提交到 App Store 的应用都必须遵守这个机制。 - -在安全方面沙盒是个很好的解决办法,但是有些矫枉过正。敏感的个人信息我们不愿意透露,却不代表所有的信息我们都不想与其它应用共享。 - -比如说我们要一次性地(没错,只按一次)把多个事件放到日历中,这些事件包含日期时间以及持续时间等信息,如果 App 之间信息不能沟通,就无法做到这点。(在下文中的 x-callback-URL 的部分会详述整个过程) - -类似于一次性添加多个日历事件这样的,我们在使用智能设备的过程中会遇到很多不必要的重复的步骤。大多数人对这些重复的步骤是不自觉的,就像当自己电脑里有一批文件需要批量重命名的时候,他们机械地重复着重命名的过程。但是当我们掌握了这些设备运行的模式,或者有了一些工具,我们就能将这些重复的步骤全部节省下来。在 iOS 上,我们可以利用的工具就是 URL Schemes。 - - -3、URL Schemes 是什么 - -Custom URL scheme 的好处就是,你可以在其它程序中通过这个url打开应用程序。如A应用程序注册了一个url scheme:myApp, 那么就在mobile浏览器中就可以通过打开你的应用程序A。 - -对比网页url就比较好理解url scheme。给出一个url “http://bxu2359670321.my3w.com/view/login.php”,它的格式:protocol :// hostname[:port] / path / [;parameters][?query]#fragment。 -因此这个url的protocol就是http。对比URL Scheme,给出例子“weixin://dl/moments“,前面的weixin:就代表微信的scheme。你可以完全按照理解一个网页的 URL ——也就是它的网址——的方式来理解一个 iOS 应用的 URL。即Scheme是**://**之前的那段字符 - - -###注意### - -1、所有的网页都有url;但未必所有的应用都有自己的 URL Schemes,更不是每个应用的每个功能都有相应的 URL Schemes - -2、一个网址只对应一个网页,但并非每个 URL Schemes 都只对应一款应用。这点是因为苹果没有对 URL Schemes 有不允许重复的硬性要求 - -3、一般网页的 URL 比较好预测,而 iOS 上的 URL Schemes 因为没有统一标准,所以非常难猜,通过猜来获取 iOS 应用的 URL Schemes 是不现实的。(我推荐将Bundle identifier反转) - - -###上干货### - -1、注册自定义 URL Scheme - - -1)注册自定义 URL Scheme 的第一步是创建 URL Scheme — 在 Xcode Project Navigator 中找到并点击工程 info.plist 文件。当该文件显示在右边窗口,在列表上点击鼠标右键,选择 Add Row: - -![注册url scheme](/assets/2287777-e22f24acf7823cfa.png) - -2)点击左边剪头打开列表,可以看到 Item 0,一个字典实体。展开 Item 0,可以看到 URL Identifier,一个字符串对象。该字符串是你自定义的 URL scheme 的名字。建议采用反转Bundle idenmtifier的方法保证该名字的唯一性 -![](/assets/2287777-67f09fb472c6b87d.png) - - -3)点击 Item 0 新增一行,从下拉列表中选择 URL Schemes,敲击键盘回车键完成插入。(注意 URL Schemes 是一个数组,允许应用定义多个 URL schemes。)展开该数据并点击 Item 0。你将在这里定义自定义 URL scheme 的名字。只需要名字,不要在后面追加 :// - -![新增scheme](/assets/2287777-b9c1d5245529fa1b.png) - -2、拿浏览器做简单验证 - -在地址栏中熟入自定的url scheme。此时必须保证该浏览器所在设备上已经安装了具有自定义url scheme的App。 -![浏览器检验 urlscheme](/assets/2287777-93cc952da314d7bf.PNG) - - -3、新建Xcode工程,做个App试试看,这里我就放一个Button,点击打开url -代码。 - - -``` -- (IBAction)open:(id)sender { - NSString *url = @"zhunaer://?name=lbp&age=22"; - if ([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:url]]) { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:url]]; - }else{ - NSLog(@"打不开"); - } -} -``` -结果打不开,为什么? -因为在新建App的plist中没加query schemes - - -``` -LSApplicationQueriesSchemes - -zhunaer - -``` - -4、如果需要在2个App之间传值,怎么办?可以用URL Scheme解决。 - -在被打开的App的Appdelegate.m中实现-(BOOL)application:(UIApplication *)application openURL:(nonnull NSURL *)url sourceApplication:(nullable NSString *)sourceApplication annotation:(nonnull id)annotation; - - - -``` --(BOOL)application:(UIApplication *)application openURL:(nonnull NSURL *)url sourceApplication:(nullable NSString *)sourceApplication annotation:(nonnull id)annotation{ - NSLog(@"calling application bundle id: %@",sourceApplication); - NSLog(@"url shceme:%@",[url scheme]); - NSLog(@"参数:%@",[url query]); - if ([sourceApplication isEqualToString:@"com.geek.test1"]) { - return YES; - } - return NO; - -} - -``` - - -在需要打开第三方App的点击事件处的url处后面加上参数,类似NSString *url = @"zhunaer://?name=lbp&age=22"; - -注意:在URL Scheme后加?然后跟网页的url的参数一样写法。 - -5、如何判断是指定App打开,或者某些App不让打开我们的App? -做了实验。 - -A:在需要打开第三方App的工程中将Bundle identifier改为“com.geek.test2”,其余不变 - -B:在被打开的App的AppDelegate.m中 - - -``` --(BOOL)application:(UIApplication *)application openURL:(nonnull NSURL *)url sourceApplication:(nullable NSString *)sourceApplication annotation:(nonnull id)annotation{ - NSLog(@"calling application bundle id: %@",sourceApplication); - NSLog(@"url shceme:%@",[url scheme]); - NSLog(@"参数:%@",[url query]); - if ([sourceApplication isEqualToString:@"com.geek.test1"]) { - return YES; - } - return NO; -} -``` -![测序](/assets/2287777-5ddf86e7d30b1c05.png) - -###实验结果### - -依旧可以打开App,即使断点走入Return NO - -结论:如果你想阻止其它应用调用你的应用,**创建一个与众不同的 URL scheme**。尽管这不能保证你的应用不会被调用,但至少大大降低了这种可能性。 - - -参考:https://sspai.com/post/31500#01 diff --git a/Chapter1 - iOS/1.140.md b/Chapter1 - iOS/1.140.md deleted file mode 100644 index 0c84133..0000000 --- a/Chapter1 - iOS/1.140.md +++ /dev/null @@ -1,264 +0,0 @@ -# Aspects - -> Aspects 核心原理涉及3个技术点: -> -> - objc_msgForward(触发消息转发机制) -> - NSInvocation -> - block 的本质 - - -## 函数指针、指针函数的区别 - -定义不同: - -- 函数指针:本质是一个指针,该指针指向一个函数 -- 指针函数:本质是一个函数,函数的返回值是一个指针类型 - -写法不同 - -- 函数指针:`int (*fun)(int x,int y)` -- 指针函数:`int* fun(int x,int y)` - -用法不同: - -- 函数指针 - - ```c++ - typedef int (*FuncPtr)(int, int); - - int add(int a, int b) { - return a + b; - } - - FuncPtr addPtr = add; - int result = addPtr(3, 2); - ``` - -- 指针函数 - - ```c++ - int (*getAddFunction())(int, int) { - return add; - } - - int add(int a, int b) { - return a + b; - } - - int (*addPtr)(int, int) = getAddFunction(); - int result = addPtr(3, 2); - ``` - - - -## block 本质 - -block 详细探索步骤请查看这篇文章 [block 底层原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.89.md)。接下去查看建议版本的分析。 - -第一步:编写一个基础 block - -第二步:用 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 ViewController.m -o ViewController-arm64.cpp` 转成 c++ 查看原理 - - - -分析:可以发现 block 本质就是结构体,和 OC 对象一样,也有 isa 指针。block 传递进去的方法,被包装成 block 的成员变量,是一个叫做 FuncPtr 的函数指针了。 - -是不是我们可以按照系统定义,来构造一个 struct,承接一个 block,然后发起调用呢? - -```objective-c -typedef NS_OPTIONS(int, AspectBlockFlags) { - AspectBlockFlagsHasCopyDisposeHelpers = (1 << 25), - AspectBlockFlagsHasSignature = (1 << 30) -}; - -typedef struct AspectBlock { - __unused Class isa; - AspectBlockFlags Flags; - __unused int Reserved; - void (__unused *invoke)(struct AspectBlock *block, ...); - - struct { - size_t reserved; - size_t Block_size; - void (*copy)(void *dst, const void *src); - void (*dispose)(const void *); - } *descriptor; -} *AspectBlockRef; - -void(^printBlock)(NSString *) = ^void(NSString *msg) { - NSLog(@"%@", msg); -}; -printBlock(@"Hello world"); - -struct AspectBlock *fakeBlock = (__bridge struct AspectBlock *)printBlock; -((void (*)(void *, NSString *))fakeBlock->invoke)(fakeBlock, @"Hello world"); -``` - - - -思考:我们目前已经用自定义的 struct 来承接了 block 并成功执行了。能否用 NSInvocation 来触发 block? - - - -## NSInvocation 触发 block - -一个方法需要成功调用并执行需要3要素: - -- 方法名称 `SEL` -- 方法签名(参数个人、参数类型、返回值类型等信息) `Method Type Encoding` -- 方法地址、方法实现 `IMP` - -如何从自定义的 block 结构体中获取这些信息呢? - - - - - - - - - - - - - - - -AspectsIdentifier:每做一次方法交换,都会转换为一次 AspectsIdentifier。是核心逻辑。 - - - -以一个例子作为源码探索切入口,点击跳转到源码中 - - - -可以看到给 NSObject 添加分类,核心 2 个 API。一个对象方法、一个类方法: - -```objective-c -+ (id)aspect_hookSelector:(SEL)selector - withOptions:(AspectOptions)options - usingBlock:(id)block - error:(NSError **)error; - -- (id)aspect_hookSelector:(SEL)selector - withOptions:(AspectOptions)options - usingBlock:(id)block - error:(NSError **)error; -``` - -内部都走到 `aspect_add` 方法中。其中都会生成 `AspectIdentifier` ,看看是如何生成的? - - - -第一步:生成 block 签名。 - - - -第二步:因为我们通过 AOP 给原始方法添加了 block,最后的效果是既可以调用原始方法,又可以调用 block 添加的代码。实现的前提是什么? - -比较 block 和 hook 类的方法的签名信息需要 Match。具体逻辑查看注释。 - - - -```shell -(lldb) po blockSignature - - number of arguments = 2 - frame size = 224 - is special struct return? NO - return value: -------- -------- -------- -------- - type encoding (v) 'v' - flags {} - modifiers {} - frame {offset = 0, offset adjust = 0, size = 0, size adjust = 0} - memory {offset = 0, size = 0} - argument 0: -------- -------- -------- -------- - type encoding (@) '@?' - flags {isObject, isBlock} - modifiers {} - frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0} - memory {offset = 0, size = 8} - argument 1: -------- -------- -------- -------- - type encoding (@) '@""' - flags {isObject} - modifiers {} - frame {offset = 8, offset adjust = 0, size = 8, size adjust = 0} - memory {offset = 0, size = 8} - conforms to protocol 'AspectInfo' -``` - - - -OC 方法签名和 block 方法签名是有区别的 - -- 比如在 Aspects 框架中,block 方法签名的参数个数是比 oc 方法参数个数少的。oc 方法自带 `id self, SEL _cmd` 2个参数。 -- 都使用相同的类型编码系统,但是 block 签名可能包含额外的信息,例如捕获的变量类型。比如上面的 block 方法签名的最后一个参数 `'@""'`,其中的 `AspectInfo` 就代表捕获的变量类型。 - -比如2个不带参数的 OC 方法签名和 block 方法签名: - -- oc 方法签名:`v@:` = `v` + `@` + `:`,返回值 `void`、参数1 `@` 代表对象、参数2 `:` 代表 SEL 类型 - -- block 方法签名:`v@?` = `v` + `@?`,返回值 `void`、参数1 `@?` 代表既是对象,又是 block - - - - - - - - - -## objc_msgForward - -骚操作: - -- 将待 hook 的方法,和 `objc_msgForward` 进行交换。 `objc_msgForward` 不管对象有没有实现,都会触发消息转发流程 -- 此时会走 Runtime 的 NSObject `forwardInvocation` 流程。且 Aspects 将 `forwardInvocation` 方法指向了 `__ASPECTS_ARE_BEING_CALLED__` 方法。 - -经历这么一波处理,hook 最后都守口到了 `__ASPECTS_ARE_BEING_CALLED__` 方法中。 - -前面研究过了 `AspectIdentifier` 的逻辑。接下去继续看看后续步骤。 - - - -可以看到内部执行 `aspect_prepareClassAndHookSelector`,其内部会调用 `aspect_hookClass`,又会调用 `aspect_swizzleClassInPlace`,最后调用 `aspect_swizzleForwardInvocation` 方法。 - -```objective-c -static NSString *const AspectsForwardInvocationSelectorName = @"__aspects_forwardInvocation:"; -static void aspect_swizzleForwardInvocation(Class klass) { - NSCParameterAssert(klass); - // If there is no method, replace will act like class_addMethod. - IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__ASPECTS_ARE_BEING_CALLED__, "v@:@"); - if (originalImplementation) { - class_addMethod(klass, NSSelectorFromString(AspectsForwardInvocationSelectorName), originalImplementation, "v@:@"); - } - AspectLog(@"Aspects: %@ is now aspect aware.", NSStringFromClass(klass)); -} -``` - -该方法将被 hook 对象的 `forwardInvocation:` 方法替换为 `__aspects_forwardInvocation:`。 - -回归头继续看下面的逻辑。 - - - -`class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding)` 可以实现将被 hook 类的 hook 方法,替换为 `_objc_msgForward` 或者某些版本下需要的 `_objc_msgForward_stret`。 - - - -比如 ViewController 的 viewWillAppear hook 流程就是: - -`[UIViewController viewWillAppear:]` -> `_objc_msgForwar` -> `[UIViewController forwardInvocation:]` -> `__ASPECTS_ARE_BEING_CALLED__` - - - - - -## 总结 - -Aspects 是 Runtime 使用的一个经典库,处理好核心逻辑后,也做了一些黑名单、线程安全等的保护。也有一些类似日志回放功能的处理。 - - - - - diff --git a/Chapter1 - iOS/1.141.md b/Chapter1 - iOS/1.141.md deleted file mode 100644 index dd99c20..0000000 --- a/Chapter1 - iOS/1.141.md +++ /dev/null @@ -1,46 +0,0 @@ -# LLDB - - - -## LLDB 架构 - - - -说明:类似一个 CS 架构。 - -- **`lldb-mi` 的角色:** 它是一个**协议适配器**,专注于将 **GDB/MI 协议** 翻译成 **LLDB API 调用**。 -- **`API` 是关键桥梁:** `lldb-mi` 几乎所有的功能都通过调用 **LLDB Public API** 来实现。 -- **Core 下的模块是 LLDB 的能力:** 图中 `Core` 下列出的 `Object Files`, `Symbols`, `Process` 等,代表了 **LLDB 调试器引擎本身提供的强大核心功能**。`lldb-mi` 依赖 API 来利用这些功能。 -- **依赖关系:** `lldb-mi` -> `API` -> (LLDB 核心引擎的) `Object Files`, `Mach-O/ELF`, `Symbols`, `DWARF`, `Disassembly`, `LLVM`, `Process`, `GDB Remote`。 -- **目的:** 这张图说明了 `lldb-mi` 是如何构建在 LLDB 强大的核心调试引擎之上,通过标准的 API 调用其功能,从而为支持 GDB/MI 协议的客户端提供调试服务。 - - - - - -## LLDB Workflow - - - -说明: - -- **lldb-server**:调试服务端,`lldb-server` 作为“调试代理”,直接操控目标程序。分为两种部署方式: - - - **Host 端**:调试本地程序(如 macOS 进程)。 - - - **Remote 端**:部署到目标设备(如手机/嵌入式设备),直接控制被调试程序 - -- 通信层:TCP + GDB RSP 协议 - - - **TCP Socket**:物理传输通道,连接 Host 的 LLDB 和 Remote 的 lldb-server - - **GDB RSP (Remote Serial Protocol)** - - **基于 ASCII 的调试协议**(明文消息,如 `$m,#` 读取内存) - - 历史原因:兼容 GDB 的远程协议,使 LLDB 能对接各类设备(Android gdbserver 等) - -QA:好像有点反人类设计,手机上反而是 Server,电脑端的 LLDB 反而是 Client?也就是为什么必须让手机充当 Server? - -**权限问题**: - -- 手机操作系统(如 iOS)禁止外部进程直接访问 App 内存。 -- 只有手机本地的 `lldb-server` 可通过系统权限(如 `task_for_pid()`)操控目标进程。 - diff --git a/Chapter1 - iOS/1.142.md b/Chapter1 - iOS/1.142.md deleted file mode 100644 index 16c8027..0000000 --- a/Chapter1 - iOS/1.142.md +++ /dev/null @@ -1,1054 +0,0 @@ -# fastlane - -> CI 是什么?CD 是什么?fastlane 属于 CI 吗?Jenkins 是 CD 吗? -> -> CI 和 CD 的能力边界是什么? -> -> iOS 领域利用 CI、CD 可以做些什么事情、 - -## 一、需求背景 - -如何与 Apple 打交道? - -- 独一无二的 Apple ID -- Xcode 项目与 Bunlde ID -- 证书与设备 -- 配置应用权限 -- 测试 -- 准备向 App Store 提交应用 -- App 支持什么语言?在哪些国家发售?是否存在虚拟商品?是否存在违反 App Store 政策的情况?是否已经上传了应用截图? -- 上传 -- 批准上架/拒绝 -- 重复上述操作 - -这些需求催生了 Fastlane - - - -## 二、Fastlane - -所以 Fastlane 通过**自动化**和**标准化** iOS 应用的构建、测试、签名和发布流程,极大地**提升了开发效率**,**减少了人为错误**,并**简化了团队协作**。无论是个人开发者还是大型团队,都能从中受益,更专注于产品创新和用户体验优化 - -```mermaid -flowchart TD - A[Fastlane 自动化流程] --> B[前置准备
版本管理
依赖安装] - B --> C[构建与测试
gym: 构建IPA
scan: 运行测试] - C --> D[应用分发
内测分发
pilot: TestFlight
第三方平台] - D --> E[商店发布
deliver: 上传元数据与二进制文件
提交审核] - E --> F[后续工作
管理测试员
Slack 通知] -``` - - - -### 1. 证书 -- iOS 数字证书是用来证明 iOS App 可执行文件的合法性和完整性的。对于想安装到真机或发布到 App Store 的 App,只有经过签名验证(Signature Validated)才能确保来源颗心,并且保证 App 内容是完整的、未经篡改的。 -- 数字证书是一个经证书授权中心数字签名的包含公开密钥拥有者信息以及公开密钥的文件。具有时效性,只在特定的时间段内有效。 - -开发证书分为2类: -- 开发证书(iOS Development):用于开发和调试应用程序,可用于真机调试 -- 发布证书(iOS Distribution):发布证书用于打包上传到 App Store,用于验证开发者身份 - -推送证书,如果项目中集成了推送功能,需要配置推送证书: -- 开发证书(Apple Development iOS Push Services) -- 发布证书(Apple Production iOS Push Services) -- 需要将生成的 p12 文件上传到服务器后台(极光、友盟、或者自己的推送服务器后台) - - -QA:存在一个情况,点击一个网页,下载 iOS App 到本地,点击启动的时候,会让你从「设置 -> 通用」中去信任证书。这个的工作原理是什么? - -这是一个典型的“无线设备管理”(MDM)注册和内部应用分发组合流程。 -原理详解: -第一步:伪装与诱导 (Phishing) - -- 接收短信:您收到一条看似普通的短信,内容可能是“刷单兼职”、“交友约会”、“彩票赌博”、“色情内容”或“高额借贷”等,并附带一个短链接(用于隐藏真实URL)。 -- 点击链接:您点击后,会跳转到一个精心设计的网页。这个网页模仿成某个 App 的下载页面,有一个非常醒目的“点击安装”或“立即跳转”按钮。 - -第二步:利用技术漏洞 (Abusing the System) -- 触发下载:点击按钮后,服务器会尝试让您的设备下载一个 .mobileconfig 文件。这是正规MDM流程的第一步,但在这里是恶意利用。 -- 系统弹窗:iOS系统会弹出标准提示框,显示“正在下载描述文件”,并询问您是否允许。这个弹窗是系统级的,无法伪造,所以具有很强的欺骗性。 - -第三步:利用信息差与恐吓 (Social Engineering) -- 引导“信任”:网页上会有非常详细的图文或视频教程,指导您下一步该怎么做。通常会编造理由,如: - - “这是为了验证您的设备安全性,防止作弊。” - - “必须完成此步骤才能使用App。” - - “这是官方要求的签名验证流程。” -- 安装描述文件:您根据指导,进入 “设置” -> “已下载的描述文件”,点击“安装”。这个描述文件里包含了一个 payload,其唯一目的就是向攻击者的服务器注册并上报您设备的UDID。 -- 信任企业证书:安装完成后,您还需要像之前了解的那样,去 “设置” -> “通用” -> “VPN与设备管理” 中,信任一个来自“某公司”的企业级应用证书。 - -第四步:达成目的 (The Payoff) -- UDID 上报:在您完成上述所有步骤的过程中,特别是安装描述文件后,您设备的 UDID (Unique Device Identifier) 以及其他一些设备信息(如型号、系统版本)已经无声无息地被发送到了灰产控制的后台服务器。 -- 白名单机制:灰产掌握着一个或多个(因为常被苹果吊销)企业证书。他们收到您的UDID后,会将其添加到他们企业开发者后台的设备白名单中。只有这样,由他们企业证书签名的最终App(赌博、色情App等)才能在您的设备上安装和打开。 -- 完成欺诈:此时,您再回到最初的网页,或者重新点击短信链接,就会发现可以正常下载那个最终的非法App了。因为您的设备UDID已经被加入白名单,企业证书已经生效。 - - - -这属于 MDM 吗?不属于真正的MDM,而是“MDM钓鱼”。 -正规MDM:目的是持续管理设备,如远程安装/卸载应用、配置策略、擦除数据等。需要用户明确知道设备被公司管理。 -灰产MDM:目的极其单一——窃取UDID。它们没有任何后续的管理意图和能力。它们只是滥用了.mobileconfig配置文件能够在安装时自动向指定服务器上报设备信息(包括UDID) 的这一功能。 -您可以理解为,骗子只偷走了MDM流程的“身份证登记处”,而完全抛弃了后面的“管理员办公室”。 - -### 2. 配置文件(Provsioning Profiles) -配置文件也分为2种: -- 开发(Development) -- 发布(Distribution) -- 配置文件(Provsioning Profiles)中包含了证书、App ID、设备(Devices),后缀名为 `.mobileprovision` -- 配置文件在开发者账号体系中扮演着配置和验证的角色。是真机调试和打包上架的必须文件 - - - -Xcode 中,对于项目是可以看到配置文件的。我们可以鼠标按住,拖动到桌面文件夹下。 - -此外,`mobileprovision` 文件是不可读的。可以通过 **security cms -D -i 195103db-6d6f-4da1-bd0e-66d5db88176f.mobileprovision -o profil -e.plist** 指令,dump 成为一个 plist 格式的文件。如下图所示: - - -指令解读: -- security:是 macOS 自带的安全相关的命令行工具,用于处理证书、配置文件、密钥等的处理 -- cms:表示使用 CMD(Cryptographic Message Syntax,密码消息语法)相关功能,用于处理消息签名和加密消息 -- -i:指定输入文件 -- -o:指定输出文件 - -重要信息: -- DeveloperCertificates: 允许使用的开发者证书 -- Entitlements:允许使用的权限列表 -- ProvisionedDevices:允许安装的设备列表(ProvisionsAllDevices,代表授权任意设备) - - - -### 3. 授权文件(Entitlements) -声明了 App 所需的权限 - - - -### 4. 签名 codesign - -#### 1. 基础签名 - -- **-s**:--sign identity 指定签名所用的证书(- 代表 ad-hoc 签名) -- **--entitlements**:entitlements_file 指定签名所需要的 entitlements 文件 -- **-f**: --force 强制替换现有签名 -- **-preserve-metadata=identifier,entitlements**: 重用就签名的一些信息 -- **-deep**: 递归对该 bundle 内包含的其他文件签名 - - -注意:为什么不推荐使用 `--deep`? - -在没有 `--deep` 的情况下,codesign 命令只会对指定的主目标(main target)(例如 .app 包或 .framework)进行签名。它不会自动递归地签名的 .app 包内部的任何嵌套的组件(如嵌入的 .framework 或 .dylib)。 - ---deep 选项的设计初衷是试图递归地签名一个 bundle 内部的所有嵌套代码。例如,如果你的 MyApp.app 内部嵌入了 ThirdParty.framework,使用 `codesign --deep -f -s "Your Identity" MyApp`.app 会同时签名 MyApp.app 和其内部的 ThirdParty.framework。 - -错误的签名顺序(主要问题): -- 代码签名不仅是对二进制文件盖章,它还计算并存储每个组件的哈希值到其 _CodeSignature/CodeResources 文件中。 -- 当你签名一个 .app 时,它也会计算其内部所有文件(包括嵌套的 .framework)的哈希值。如果这些嵌套的组件在 .app 被签名之后又被修改了,其哈希值就会改变,导致签名无效。 -- `--deep` 的工作方式是:先递归地签名最内层的组件(如 ThirdParty.framework),然后签名外层的容器(MyApp.app)。 - -这看起来是正确的,对吗? 但实际上,在签名外层 .app 时,它记录的仍然是内层组件签名前的哈希值。而内层组件在签名后其内容(因为附加了签名信息)已经发生了变化,这就导致了内外记录不一致,从而使整个签名变得无效且不可靠。 - -与现代构建系统不兼容: -- Xcode 和标准的构建流程(如 xcodebuild)的正确做法是:先单独签名每一个嵌套的组件(Embedded Framework),最后再签名主应用。这确保了每个组件都有自己独立的有效签名,并且主应用在签名时记录的是所有嵌套组件的最终状态。 -- `--deep` 破坏了这种明确的、分阶段的签名流程,试图用一步代替多步,反而引入了混乱。 - - - -#### 2. 动态库签名 - -动态库可以上架,是因为对动态库可以进行签名。来一个 Demo 工程。一个 iOS 工程,以动态库的形式使用 SDWebImage - -##### 1. Demo1 - -1个 iOS App 工程,使用动态库的方式,依赖了 AFNetworking。选择模拟器进行编译,查看日志: - - - -日志分析: -1. 日志中的 `--sign` 后一般接的是证书的名称,但是当前日志中是 `-`,代表使用自动签名模式(Automatic Signing)。 - - **--sign 参数**:这是 codesign 命令的核心参数,用于指定用于签名的身份(Identity)。这个身份通常对应着钥匙串(Keychain)中的一个证书(Certificate)及其关联的私钥。 - - **-**:这是一个特殊的标识符,在这里它不代表一个具体的证书名称。它的含义是:“使用临时生成的、匿名的 Ad-Hoc 签名身份来进行签名,而不需要指定一个具体的、来自苹果开发者账户的证书。” - 在模拟器(Simulator)上运行(正如你的日志中 Debug-iphonesimulator 所示): - 根本原因:iOS 模拟器不像真机设备那样需要验证苹果官方的代码签名证书来确保安全。模拟器的运行环境更加宽松,其主要目的是为了快速调试。为了方便:使用 Ad-Hoc 签名可以省去为模拟器编译时配置和选择开发证书的步骤,极大地加快了编译和调试的速度。Xcode 默认就会为模拟器构建采用这种方式。 - -2. 日志中的 `--entitlements /Users/unix_kernel/Library/Developer/Xcode/DerivedData/InstallDyanmicAndStaticFramework-bmcbqvmynpdalkdhzqirbithtfwp/Build/Intermediates.noindex/InstallDyanmicAndStaticFramework.build/Debug-iphonesimulator/InstallDyanmicAndStaticFramework.build/InstallDyanmicAndStaticFramework.app.xcent` 代表 Xcode 开启的 App 能力信息。`--entitlements` 参数后面跟随的是一个 `.xcent` 文件的路径,这个文件包含了应用程序的权限(Entitlements)配置信息,也就是 “App 能力信息” - -对其查看,内容如下: - - -`security find-identity -v -p codesigning` 指令用于 macOS 系统中用于查看可用代码签名证书的命令,主要用于开发者在进行代码签名操作前确认可用的证书信息 - -##### 2. Demo2 - -1个 iOS App 工程,使用动态库的方式,依赖了 AFNetworking。选择真机进行编译,查看日志: - -日志分析: -1. 日志中的 `Signing Identity: "Apple Development: FantasticLBP@github.com (953PZFXZFR)"` 变成了开发者证书。多了一个配置文件。 -2. 使用的动态库 AFNetworking 是如何签名的? -选择 Pods 的 Product 里面的 AFNetworking 动态库,右击 “show in finder”。看到并没有一个 **`_CodeSignature`** 的文件夹,也就是没有签名信息。然后用指令 ` objdump --macho --private-headers /Users/unix_kernel/Library/Developer/Xcode/DerivedData/InstallDyanmicAndStaticFramework-bmcbqvmynpdalkdhzqirbithtfwp/Build/Products/Debug-iphoneos/AFNetworking/AFNetworking.framework/AFNetworking` 进行查看,发现也不存在 **`LC_CODE_SIGNATURE`** 存储签名信息的 load command。 - - -可能会问,如何确定是动态库?使用 `file AFNetworking` 指令即可验证 - - -问题:动态库 AFNetworking 没有经过签名,为什么拷贝到 App 里面后,可以上架? -其实 Cocoapods 自动生成了脚本,在主工程的 `Build Phases -> Embed Pods Frameworks` 下。且 `Input File Lists` 配置的文件内容,就是所依赖库的文件路径。会被当作参数传递给 `Embed Pods Frameworks` 脚本。 - - -观察编译日志,会发现 「Run custom shell script '[CP] Embed Pods Frameworks'」这里,先对 Frameworks 目录进行了创建和拷贝。然后对 AFNetworking 进行签名。 -在主工程的 Products 目录下,选择 App,show in finder,然后显示包内容。查看 `Frameworks` 文件夹下的 `AFNetworking.framework` 已经存在了 `_CodeSignature` 文件夹,也就是已经签名完成。继续查看 Load Command,发现也存在了 **LC_CODE_SIGNATURE** Load Command。 - - -同时,也可以看到是先对动态库签名,再对 App 签名。 - -#### 3. 如何查看签名信息 - -[jtool2](https://github.com/excitedplus1s/jtool2) 工具可以方便的查看签名信息。jtool2 类似于 otool,但添加了许多 Mach-O 相关的命令,功能更完善。它支持多种运行平台 - -安装方式为:`brew install --no-quarantine excitedplus1s/repo/jtool2` -使用方式为:**`jtool2 --sig -vv ${MachOFile}`** - - - - - -### 5. fastlane 相关概念 - - -- fastlane 本质就是一套命令行工具,专为用来简化并实现我们与 Apple 交互时的自动化 -- fastlane 的每一个单独工具都是为了解决常见的 App Store 或其他问题而设计的 -- fastlane 通过脚本方式集合了一系列常见的行为,叫做 lane。也就意味着可以通过 lane 来对自己的 App 做一些量身定制的需求 -- fastlane 包含大量的 action -- action 表示特定的应用商店或者其他开发者工作流任务 -- lane 表示工作流程 - -- Appfile: 存储有关开发者账号相关信息 -- Fastfile:核心文件,主要用于命令行调用和处理具体的流程,lane 相对于一个方法或者函数。 - -#### 1. action - -- cert: 创建和维护签名证书 -- sigh:配置文件 -- gym:构建打包应用程序 -- deliver:上传应用程序和屏幕截图到 App Store Connect -- pilot:为 TestFlight 上传构建并处理其管理 -- scan:自动化测试 -- match:团队中同步证书和配置文件 -- boarding:测试邀请 -- pem:管理推送配置文件 -- produce:在 App Store Connect 创建新的 App - - -使用方式有3种: -- 第一种:命令行方式。比如 - ``` - fastlane scane --workspace "fastlaneDemo.xcworkspace" --scheme "fastlaneDemo" --device "iPhone 8" --clean - ``` -- 第二种:Fastfile 方式。采用 FastLane 约定的格式去编写逻辑。比如 - ``` - default_platform(:ios) - - platform :ios do - - lane :builds do - # 单元测试 - scan( - workspace: "fastlaneDemo.xcworkspace", - scheme: "fastlaneDemo", - devices: ["iPhone 14 Pro Max", "iPhone 14", "iPhone SE (3rd generation)"], - clean: true, - code_coverage: true, - output_types: "html,junit", - output_directory: "./fastlane/test_output" - ) - # 证书 - cert - # 配置文件 - sigh - # 打包 - gym( - workspace: "fastlaneDemo.xcworkspace", - scheme: "fastlaneDemo", - clean: true, - output_directory: "./fastlane/build_output", - output_name: "fastlaneDemo.ipa", - silent: false, - include_symbols: true, - include_bitcode: true - ) - end - end - ``` -- 第三种:使用特定的 Fastlane 文件。比如使用脚本 `Fastlane scan init` 会生成关于 scan 相关逻辑的脚本文件 `Scanfile` - - -建议使用方式二、三,不建议使用方式一。关于 fastlane 脚本编写文档查看 [fastlane docs](http://docs.fastlane.tools) - -使用方式: -Scanfile 是 专门用于配置 scan 动作(即运行测试) 的配置文件。它的唯一目的是为 scan 提供默认参数。你可以在里面设置 scheme、output_directory、code_coverage 等测试相关的选项。当你在命令行直接运行 fastlane scan 或在 Fastfile 的 lane 中调用 scan 时,它会自动读取 Scanfile 中的配置。 - - - -| 文件 | 角色 | 用途 | 示例内容 | -| :-------------- | :---------------------- | :----------------------------------------------------------- | :----------------------------------------------------------- | -| **`Fastfile`** | ****指挥官\**/\**剧本** | **定义 lanes(工作流)**。这是 Fastlane 的核心,你在这里组合不同的动作来创建自动化流程。 | `lane :test { scan }` `lane :beta { cert; sigh; gym; upload_to_testflight }` | -| **`Scanfile`** | **专项配置员** | 只为 **`scan`** 动作提供默认配置参数。 | `scheme("MyApp")` `clean(true)` `output_directory("./test_results")` | -| **`Gymfile`** | **专项配置员** | 只为 **`gym`** 动作(构建 IPA)提供默认配置参数。 | `workspace("MyApp.xcworkspace")` `export_method("app-store")` `output_directory("./builds")` | -| **`Matchfile`** | **专项配置员** | 只为 **`match`** 动作(证书管理)提供默认配置参数。 | git_url("https://github.com/.../certs")` `app_identifier("com.yourapp") | - -总结: - -- **`Fastfile`** 就像是一个**总剧本**,里面写着:第一场戏(`lane :test`)是跑步测试,第二场戏(`lane :beta`)是打包上传。 -- **`Scanfile`**、**`Gymfile`** 等就像是每个**演员(scan, gym 动作)的个人小抄**,上面写着他们的默认表情、站位等细节。 -- 当“总剧本”喊到某个演员时,演员就会按照自己“小抄”上的默认设置来表演,除非剧本特意指定了另一种表演方式。 - - - -QA:按照上面的思路和角色分工、能力边界,将下面的代码优化 - -```ruby -platform :ios do - - lane :builds do - # 单元测试 - scan( - workspace: "fastlaneDemo.xcworkspace", - scheme: "fastlaneDemo", - devices: ["iPhone 14 Pro Max", "iPhone 14", "iPhone SE (3rd generation)"], - clean: true, - code_coverage: true, - output_types: "html,junit", - output_directory: "./fastlane/test_output" - ) - # 证书 - cert - # 配置文件 - sigh - # 打包 - gym( - workspace: "fastlaneDemo.xcworkspace", - scheme: "fastlaneDemo", - clean: true, - output_directory: "./fastlane/build_output", - output_name: "fastlaneDemo.ipa", - silent: false, - include_symbols: true, - include_bitcode: true - ) - end -end -``` - -优化: - -- 文件一:`./fastlane/Scanfile` 只存放测试相关的配置 - - ```ruby - # Scanfile 专用配置 - workspace("fastlaneDemo.xcworkspace") - scheme("fastlaneDemo") - devices(["iPhone 14 Pro Max", "iPhone 14", "iPhone SE (3rd generation)"]) - clean(true) - code_coverage(true) - output_types("html,junit") - output_directory("./fastlane/test_output") - skip_build(true) - ``` - -- 文件二:`./fastlane/Gymfile` 存放构建相关的配置 - - ```ruby - # Gymfile 专用配置 - workspace("fastlaneDemo.xcworkspace") - scheme("fastlaneDemo") - clean(true) - output_directory("./fastlane/build_output") - include_symbols(true) - include_bitcode(true) - ``` - -- 文件三:`./fastlane/Fastfile` 存放 lane 相关逻辑 - - ```ruby - # Fastfile - 定义 lanes - default_platform(:ios) - - platform :ios do - # 定义一个名为 builds 的 lane - lane :builds do - # 运行测试,所有配置会自动从 Scanfile 读取 - scan - # 管理证书 - cert - sigh - # 构建 ipa,所有配置会自动从 Gymfile 读取 - gym - end - end - ``` - -分析: - -1. **清晰与模块化**:每个文件职责单一,易于维护。 - -2. **复用性**:你可以在 `Fastfile` 中创建多个不同的 lane(如 `lane :tests`、`lane :adhoc`),它们都可以共享 `Scanfile` 和 `Gymfile` 中的通用配置,无需重复代码。 - -3. **可覆盖性**:在 `Fastfile` 的 lane 中调用 `scan` 或 `gym` 时,你可以随时覆盖对应 `*.file` 中的默认设置。例如: - - ```ruby - lane :special_build do - gym( - output_name: "SpecialRelease.ipa" # 覆盖 Gymfile 中的默认命名规则 - ) - end - ``` - - - -#### 2. Matchfile - -终端使用 `fastlane match init` 指令创建 Matchfile,同时根据提示选择一些模版和输入信息。 - - - - - -- `git_url`: - - 是 `match` 最核心的配置,指定了存储加密证书和配置文件的 Git 仓库地址。`match` 不会将敏感的证书文件(`.cer`)、私钥文件(`.p12`)和配置文件(`.mobileprovision`)保存在本地或只留在苹果开发者门户。 - - 相反,它会将这些文件**加密后**推送到这个指定的 Git 仓库(`https://github.com/FantasticLBP/knowledge-kit`)中。 - - 当任何开发者或 CI/CD 系统需要这些文件时,`match` 会从这个仓库克隆或拉取最新的加密文件,然后在本地解密并安装到钥匙串和 Xcode 中。 - - 这个仓库应该是**私有的(Private)**,因为里面存储的是你应用的敏感安全凭证 - -- `storage_mode("git")`: - - 明确指定 `match` 使用 Git 作为存储后端。 - - match` 支持多种存储方式,`git` 是**默认且最常用**的一种。 - - 其他可选模式包括 `s3`(Amazon S3)和 `google_cloud`(Google Cloud Storage)。这些通常在更复杂或企业级的 CI/CD 环境中使用,以提高大文件的下载速度 - -- `type("development")` - - 设置 `match` 的**默认操作类型**为 `development`(开发证书和配置文件) - - 这个参数定义了你要管理哪类证书。iOS 开发中有几种主要类型: - - - `"development"`:用于开发阶段,可在真机上调试应用。 - - `"appstore"`:用于提交到 App Store 或 TestFlight。 - - `"adhoc"`:用于分发给有限数量的测试设备(最多 100 台)。 - - `"enterprise"`:用于企业账号的内部分发。 - - 在这里设置为 `"development"` 意味着: - - - 当你直接运行 `fastlane match`(而不指定类型)时,它会默认操作开发证书。 - - 当你运行 `fastlane match development` 时,它也会使用这个配置(尽管命令行参数已经指定了类型,但其他相关配置会从这里读取)。 - -这个 `Matchfile` 配置告诉我们: - -1. **存储位置**:所有加密的证书和配置文件都将被同步到 `https://github.com/FantasticLBP/knowledge-kit` 这个 Git 仓库中。 -2. **存储方式**:使用 Git 进行版本管理和同步(这是标准做法)。 -3. **默认环境**:默认情况下,操作的是用于**开发环境**的证书和配置文件。 - -**一个典型的工作流程:** - -1. 团队成员 A 首次运行 `fastlane match development`。 -2. `match` 会检查指定的 Git 仓库中是否已有开发证书。 - - 如果**没有**,它会连接到苹果开发者门户,创建新的开发证书和配置文件,加密后推送到 Git 仓库。 - - 如果**已有**,它会将加密文件拉取到本地,解密后安装到 Xcode 和钥匙串中。 -3. 团队成员 B 加入项目,同样运行 `fastlane match development`。 -4. `match` 从同一个 Git 仓库拉取**完全相同的**证书和配置文件,确保团队环境一致。 - - - -## 三、持续集成 - -Cotinuous Integration,持续集成意味着每次代码的变更都在构建服务器上运行测试,并在指定场景下触发。这样如果开发者将测试失败的代码推送到代码库,也称为破坏构建,CI 会触发警告。 - -- 主动式 CI:CI 提供商 -- 托管式 CI:github action -- 手动式 CI:自己管理,Travis CI、Jenkins - - - -- Travis CI:小型开源项目,付费、简单、方便、 -- Jenkins:大型企业、丰富的自定义选项、定制化,不能做到开箱即用,免费 - -### 1. docker - -> 问:为什么一定要 docker?不能在服务器上按照本地的配置安装所需的各个软件吗 -> -> 答:在一个团队中,每个开发者的本地环境都可能略有不同(macOS 版本、Xcode 通过 App Store 安装还是手动安装、Homebrew 的使用方式等)。 -> -> - **问题**:新同事加入,需要花费**一整天甚至更长时间**来按照文档一步步配置环境,任何一步的疏漏都会导致环境配置失败。 -> - **后果**: onboarding 成本极高,而且无法保证所有人的环境真正一致,为后续的协作埋下了隐患。 -> -> 为什么“手动安装一下”不是最优解?它的成功依赖于: -> -> - **人的记忆和文档**:需要有人(或文档)准确地记录下所有依赖的**精确版本**(不仅仅是 `fastlane`,还包括它的依赖,以及依赖的依赖)。这份文档需要随着项目的每一次依赖变更而**实时更新**,这几乎是不可能的任务。 -> - **手动操作的准确性**:需要操作人员完全正确地执行安装步骤,不能有任何错漏。这是一个枯燥且容易出错的过程 - -#### 1. 定义 - -**Docker 概述**: Docker 是一种成熟高效的软件部署技术,利用容器化技术为应用程序封装独立的运行环境。每个运行环境即为一个**容器**,承载容器运行的计算机称为**宿主机**。 - -容器。容器虚拟化指的是操作系统而不是硬件,容器之间是共享同一套操作系统资源的。 - -- 镜像(image):提供容器运行时所需的程序、库、资源、配置等文件,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等) -- 容器(Container):镜像运行时的实体,如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务 -- 仓库(Repository):镜像构建完成后,可以很容易的在当前容器上运行 - -虚拟机:虚拟出一套硬件后,在其上运行一个完整的操作系统。 - - - -**容器与虚拟机的区别**: - -* **Docker容器**: 多个容器共享同一个系统内核 -* **虚拟机**: 每个虚拟机包含一个操作系统的完整内核 -* **优势**: 所以 Docker 容器比虚拟机更轻量、占用空间更小、启动速度更快 - -**镜像 (Image)**: - -* **定义**: 镜像是容器的模板,可类比为软件安装包 -* **类比**: 类似于制作糕点的模具,可用于创建多个糕点(容器),并可分享给他人 - -**容器 (Container)**: - -- **定义**: 容器是基于镜像运行的应用程序实例,可类比为安装好的软件。 -- **类比**: 类似于模具制作出的糕点。 - -**Docker仓库 (Registry)**: - -* **定义**: 用于存放和分享Docker镜像的场所 -* **Docker Hub**: Docker的官方公共仓库,存储了大量用户分享的Docker镜像。 - - - -Docker 理解为:标准化、一摸一样的工具箱和工作环境。保证无论哪个工人来,用的工具和环境都完全一样,避免“在我这儿是好的”问题。 - -- 环境标准化:创建一个包含特定版本 Ruby、Fastlane、Xcode 命令行工具、Cocoapods 的镜像 -- 隔离一致性:确保开发、测试、生产环境的构建完全一致,消除“环境依赖”问题 -- 快速搭建与清理:轻松为 Jenkins 提供纯净、可随时销毁和重建的构建环境。 - -**价值:开发者需要花费大量时间排查环境差异,而不是专注于修复真正的业务逻辑 Bug,严重拖慢开发效率。** - - - -#### 2. docker 的核心角色与能力 - -docker 在 iOS CI/CD 管道中(通常与 Jenkins、Gitlab CI、Github Actions 等工具配合)中主要负责“管理阶段”和“环境保障” - -1. 环境标准化与一致性 - - - 问题:传统的 CI 机器(无论是物理机还是虚拟机)环境复杂,不同项目所依赖的 Ruby、NodeJS、Python、Fastlane、Cocoapods 等工具版本可能冲突。造成“在我本地是好的,在 CI 机器上发布后就失败了”。(CI 机器上并不一定只部署一个 iOS 的 CI 项目,可能其他项目也部署了,可能由于 Ruby 安装了2.0,但是 iOS 的 CI 服务需要 Ruby 3.0+,这样就造成了本地 CI 服务正常,部署到 CI 服务器之后就有了问题) - - Docker 解决方案:构建一个专门的 Docker 镜像,预安装项目所需的所有命令行工具和依赖的特定版本,专门负责 CI 逻辑(如 Ruby 3.1.2、Fastlane 2.2.1、Cocoapods 1.22.1、Xcode 16) - - 结果:每一次 CI 构建都会从一个纯净、一致、已知状态的镜像环境启动,彻底消除了环境差异导致的构建失败,保证了构建结果的可靠性和可重现性。 - -2. 依赖隔离 - - 多个 iOS 项目可能在同一台 CI 服务器上运行。使用 Docker 可以将每个项目需要的构建环境相互隔离。避免了项目之间的依赖冲突(例如,项目 A 需要 Cocoapods 1.10,项目 B 需要 Cocoapods 1.11) - -3. 快速的构建环境准备: - - 相比启动一个完整的虚拟机,启动一个 Docker 容器是秒级的。这大大减少了 CI 流水线等待构建环境就绪的时间,加快了整个构建流程的反馈循环 - -4. 版本控制与可追溯性: - - DockerFile 是文本文件,可以放入 git 仓库进行版本管理。对构建环境的任何更改(例如升级 Fastlane 版本)都像代码更改一样,可以通过 Pull Request 进行审查、测试和记录,实现了基础设施即代码 (IaC)。 - -5. 作为轻量级的“任务运行器” - - 在 CI 流程中,Docker 容器通常被当做一个一次性的、执行特定任务的“沙盒”,比如:执行 pod install、执行 lint 或代码分析、运行 fastlane。 - - - -#### 3. docker 的能力边界 - -尽管 docker 很强大,但在 iOS 开发领域,它有非常明确且无法跨越的边界: - -1. 无法直接构建 iOS 应用(最关键的边界) - - - 根本原因:iOS 最终编译、链接和打包必须依赖 MacOS 内核和 Xcode、Clang、SwiftCli - - Docker 局限性:标准的 Docker 容器基于 Linux 内核。它无法运行 MacOS 系统或 Xcode 这样的 GUI 应用(所以无法执行 xcodebuild 指令) - - 结论:无法在 linux docker 容器内编译出 `.ipa` 文件 - -2. 无法运行 MacOS 镜像 - - Apple 的许可协议和硬件限制,导致不存在官方的 MacOS Docker 镜像。虽然有类似 LinuxKit 这样的非官方项目,但不够稳定,不能用于生成环境,风险较大 - - - -#### 4. 最佳搭配 - -明确了 docker 的作用和能力边界后,一个典型的 iOS CI/CD 架构就清晰了:**“在 Linux Docker 容器中准备环境和运行脚本,在 MacOS 主机/节点上进行最终编译”** - -- CI Server(Jenkins Controller):可以运行在任何系统上,负责任务调度 -- MacOS Build Agent/Node:一台或多台安装了 Xcode 的 MacOS 系统的电脑,它被注册到 CI Server 上,专门用于执行需要的 xcodebuild 任何 -- Docker 的使用: - - CI 流水线启动后,CI Server 会现在 Linux Docker 容器中完成所有它能做的工作:代码拉取、依赖安装(pod install)、运行单元测试(如果测试不依赖 MacOS 框架)、执行静态分析等 - - 当需要进行 Xcode 的步骤(如编译、打包、签名)时,CI Server 会将工作委托给一台 MacOS Build Agent - - MacOS Agent:接收任务,它可能本身会通过一个 Docker 来获取一个一致的环境(用于运行 fastlane 等工具),或者直接使用本地安装的工具,然后调用 xcodebuild 和 fastlane 完成最终的构建和打包 - - - - - - - -### 2. fastlane - -fastlane 可以理解为**专业高效的“工人”**。它精通所有 iOS 打包、签名、测试、上传的具体细节,干活又快又好。 - -是一个自动化命令工具集。 - -### 3. jenkins - -统筹全局的“项目经理”,它不亲手干活,也不提供工具,但负责安排任务、监控进度、触发流程(比如代码一来就让工人工作),并向大家汇报结果。 - -- 调度与触发:监听 git 代码推送,定时或者其他事件触发整个流水线 -- 流程编排:定义 pipeline 流水线,决定先做什么后做什么 -- 资源管理:分配和管理执行任务的服务器(成为 Agent/Node) -- 状态监控与报告:集中展示构建结果、测试报告、日志、并发送通知 - - - -### 4. 黄金搭档 - -```mermaid -flowchart TD -A[开发者推送代码] --> B[Jenkins 监听到推送事件] - -subgraph Jenkins_Agent_Node[Jenkins 代理节点] - direction TB - B --> C[Jenkins 触发 Pipeline] - C --> D[Pipeline 步骤: 准备环境] - D --> E["执行 Docker Run
启动一个预先构建好的 iOS 构建镜像"] - - subgraph Docker_Container[Docker 容器内部] - direction TB - E --> F[环境内部: 代码已挂载] - F --> G[环境内部: 执行打包脚本] - G --> H["调用 Fastlane (已安装在镜像中)"] - H --> I[Fastlane 执行具体任务] - I --> I1[match 处理证书] - I --> I2[gym 打包 IPA] - I --> I3[pilot 上传 TestFlight] - end -end - -Docker_Container --> J[任务完成, 容器销毁] -J --> K[Jenkins 收集结果并报告] -``` - -上图展示了3者的协作模式:**jenkins 是大脑,负责指挥;Docker 是隔离且一致的环境,负责提供舞台;Fastlane 是主角,在这个环境里执行具体的构建任务** - -为什么需要3者结合? - -- 环境一致性问题(Docker 的核心价值) - - 问题:没有 Docker 时,Jenkins 所在的 Mac 服务器需要手动安装 Ruby、Fastlane、Xcode 版本等。一旦服务器需要重置或升级,环境配置会很麻烦,且难以保证与本地开发环境一致 - - 解决方案:使用 Docker 镜像来定义构建环境。`Dockerfile` 中明确指定了所有依赖的版本。无论是在开发者的笔记本上,还是在 Jenkins 服务器上,构建环境都是**完全一模一样的**,彻底杜绝了“环境问题”。 -- 专业化与高效(Fastlane 的核心价值) - - 问题:没有 Fastlane,你需要在 Jenkins 上编写复杂的 xcodebuild 脚本,处理代码签名等逻辑 - - 解决方案:Fastlane 用简洁的 Ruby 语法封装了所有复杂命令,提供了“开箱即用”的行动(action),极大简化了自动化脚本的编写和维护。 -- 调度与可视化(Jenkins 的核心价值) - - 问题:只有 Fastlane 和 Docker,你只能在本地手动执行指令,无法自动化出发,团队协作和监控 - - 解决方案:Jenkins 提供了强大的 Web 界面、流水线编排能力、权限管理和通知机制,让整个流程自动化、可视化、可协作 - - - -比如一个典型的场景: - -程序写好的代码提交到 github,提交了 MR: - -- 如果是合并到开发 feature 分支触发 pipeline 流水线任务,检查工程编译情况,编译成功则可以合并 -- 如果是合并到开发 develop 分支触发 pipeline 流水线任务,检查工程编译情况,编译成功后触发单元测试和精准测试 - -如果成功则给 merge request 的提交者和被 reviewer发送邮件,通知测试结果。并且 mr +3 后才可以合并。合并的结果也会通知 - -```shell -// Jenkinsfile -pipeline { - agent any - - environment { - DOCKER_IMAGE = 'your-custom-ios-builder-image:1.0' - PROJECT_URL = 'https://github.com/your/ios-project.git' - // 从环境变量获取GitHub相关信息 - GITHUB_REPO = 'your-org/your-repo' - GITHUB_API_URL = 'https://api.github.com' - } - - parameters { - // 添加参数用于手动触发时指定PR号 - string(name: 'PR_NUMBER', defaultValue: '', description: 'GitHub PR Number (for manual triggers)') - } - - stages { - stage('Checkout and Validate') { - steps { - script { - // 检出代码 - checkout scm - - // 获取当前分支信息 - env.BRANCH_NAME = env.BRANCH_NAME ?: sh(script: 'git rev-parse --abbrev-ref HEAD', returnStd: true).trim() - - echo "Building branch: ${env.BRANCH_NAME}" - - // 检查MR审批状态(如果是PR构建) - if (env.CHANGE_ID) { - checkMRApproval() - } else if (params.PR_NUMBER) { - env.CHANGE_ID = params.PR_NUMBER - checkMRApproval() - } - } - } - } - - stage('Build') { - steps { - script { - docker.image(env.DOCKER_IMAGE).inside { - sh 'fastlane ios build' - } - } - } - } - - stage('Test') { - when { - // 只在develop分支上运行测试 - expression { - return env.BRANCH_NAME == 'develop' || env.BRANCH_NAME.startsWith('release/') - } - } - steps { - script { - docker.image(env.DOCKER_IMAGE).inside { - // 运行单元测试 - sh 'fastlane ios run_unit_tests' - // 运行精准测试 - sh 'fastlane ios run_precision_tests' - } - } - } - } - } - - post { - always { - script { - // 记录构建结果 - currentBuild.description = "Branch: ${env.BRANCH_NAME}, Result: ${currentBuild.currentResult}" - - // 保存测试报告(如果有) - junit 'fastlane/test_output/report.xml' allowEmptyResults: true - } - } - - success { - script { - // 根据不同分支类型发送不同通知 - if (env.BRANCH_NAME == 'develop' || env.BRANCH_NAME.startsWith('release/')) { - // develop/release分支 - 发送详细测试报告 - sendTestSuccessNotification() - } else if (env.BRANCH_NAME.startsWith('feature/')) { - // feature分支 - 发送构建成功通知 - sendBuildSuccessNotification() - } - - // 如果是PR构建,更新PR状态 - if (env.CHANGE_ID) { - updateGitHubStatus('success', 'CI/CD pipeline completed successfully') - } - } - } - - failure { - script { - // 发送失败通知 - sendFailureNotification() - - // 如果是PR构建,更新PR状态 - if (env.CHANGE_ID) { - updateGitHubStatus('failure', 'CI/CD pipeline failed') - } - } - } - } -} - -// 检查MR是否已获得足够审批 -def checkMRApproval() { - echo "Checking MR approval status for PR #${env.CHANGE_ID}" - - // 使用GitHub API检查PR审批状态 - def approvalResponse = sh(script: """ - curl -s -H "Authorization: token \${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github.v3+json" \ - ${env.GITHUB_API_URL}/repos/${env.GITHUB_REPO}/pulls/${env.CHANGE_ID}/reviews - """, returnStd: true) - - def reviews = readJSON text: approvalResponse - def approvedCount = 0 - - // 计算批准的评审数量 - reviews.each { review -> - if (review.state == 'APPROVED') { - approvedCount++ - } - } - - echo "Found ${approvedCount} approvals (need at least 3)" - - // 如果审批不足,则失败构建 - if (approvedCount < 3) { - error "PR #${env.CHANGE_ID} does not have enough approvals (${approvedCount}/3)" - } -} - -// 发送测试成功通知 -def sendTestSuccessNotification() { - def changeAuthor = env.CHANGE_AUTHOR ?: "提交者" - def reviewers = env.CHANGE_TARGET ?: "评审者" - - // 获取测试覆盖率报告 - def coverageReport = sh(script: "cat fastlane/test_output/coverage.txt 2>/dev/null || echo '无覆盖率数据'", returnStd: true).trim() - - emailext ( - subject: "✅ 测试通过: ${env.JOB_NAME} #${env.BUILD_NUMBER}", - body: """ -

测试通过通知

-

项目 ${env.JOB_NAME} 的测试已通过。

- -

构建详情:

-
- -

测试覆盖率:

-
${coverageReport}
- -

此MR已获得足够审批,可以合并。

- """, - to: "${changeAuthor},${reviewers}", - mimeType: 'text/html' - ) -} - -// 发送构建成功通知 -def sendBuildSuccessNotification() { - emailext ( - subject: "✅ 构建成功: ${env.JOB_NAME} #${env.BUILD_NUMBER}", - body: """ -

项目 ${env.JOB_NAME} 的构建已成功完成。

-

构建详情:

-
    -
  • 构建编号: ${env.BUILD_NUMBER}
  • -
  • 分支: ${env.BRANCH_NAME}
  • -
  • 构建结果: ${currentBuild.currentResult}
  • -
  • 构建日志: 查看详情
  • -
-

编译检查通过,可以继续代码审查流程。

- """, - to: env.CHANGE_AUTHOR ?: 'team@example.com', - mimeType: 'text/html' - ) -} - -// 发送失败通知 -def sendFailureNotification() { - def recipients = env.CHANGE_AUTHOR ? "${env.CHANGE_AUTHOR},team@example.com" : 'team@example.com' - - emailext ( - subject: "❌ 构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}", - body: """ -

项目 ${env.JOB_NAME} 的构建失败。

-

构建详情:

-
    -
  • 构建编号: ${env.BUILD_NUMBER}
  • -
  • 分支: ${env.BRANCH_NAME}
  • -
  • 构建结果: ${currentBuild.currentResult}
  • -
  • 构建日志: 查看详情
  • -
- """, - to: recipients, - mimeType: 'text/html' - ) -} - -// 更新GitHub状态 -def updateGitHubStatus(state, description) { - sh """ - curl -s -X POST -H "Authorization: token \${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github.v3+json" \ - ${env.GITHUB_API_URL}/repos/${env.GITHUB_REPO}/statuses/${env.GIT_COMMIT} \ - -d '{"state":"${state}","target_url":"${env.BUILD_URL}","description":"${description}","context":"ci/jenkins"}' - """ -} -``` - - - -## 四、CI & CD 区别 - -在 iOS 开发中,CI 和 CD 是两个紧密相关但目标不同的阶段。 - -### 1. CI - -#### 1. CI 是什么? - -持续集成是一种开发实践,要求开发者频繁地将代码集成到共享的主干分支(比如 main 或者 develop 分支)。每次集成都通过一套自动化流程来验证,以便快速发现并修复错误。 - -CI 的核心是代码集成时的质量守护与保证。代码在合并到主线代码之前,一切旨在验证代码质量、发现潜在的缺陷的自动化能力。 - - - -#### 2. iOS 领域 CI 都可以做些什么? - -对于 iOS 项目,CI 流程通常由1次代码推送触发(比如 Git push 或者 PR),并自动执行以下任务: - -- 自动编译:使用 **xcodebuild** 指令编译项目。这一步可以快速发现编译错误(如语法错误、依赖缺失) - - 什么是依赖缺失错误?**依赖缺失错误**指的是项目无法找到或访问其所需的外部代码库、框架或资源文件。比如: - - - `ld: library not found for -lPods-YourProjectName` 报错 - - `#import // 错误:'SomePod/SomeClass.h' file not found` 报错 - -- 运行自动化测试:有些团队卡口比较严,单测覆盖率达到95%以上才可以合并,本次开发的代码,必须全部测试通过才可以合并 - - - 单元测试:验证每个类的方法是否符合预期 - - UI 测试:模拟用户操作,验证界面流程是否正常 - - 快照测试:验证 UI 界面是否与预期的截图一致,UI 还原度是否足够,像素级别。 - -- 代码质量检查: - - - 运行 OCLint、SwiftLint 等工具,强制报纸代码风格统一 - - 进行静态分析,查找潜在的 bug 和安全漏洞 - -- 生成报告:产出测试覆盖率报告、测试结果报告、精准测试覆盖率报告(覆盖率为93%,不足约定的95%及格线,对测试覆盖率进行分析。定位具体原因:防御性编程和兜底逻辑代码太多,部分 case 没办法走到?还是某些逻辑的实现依赖外部状态?想办法掌握具体的原因。如果是 QA 没有测试回归到,则 push QA 去测试和模拟,极力去保证每行代码测试到,甚至可以通过测试覆盖率反过来推导补充测试 case。如果真的不能测试到,起码要明确那些未被测试的代码具体是什么,做了哪些事情),携带编译日志以便分析、定位问题 - -#### 3. CI 的核心目标与价值 - -- 快速反馈:在几分钟内告诉开发者这次提交是否破坏了现有功能 -- 保证代码质量:确保合并到主分支的代码一定是编译通过的、精准测试覆盖率达标、UI 测试覆盖率达标的,健康的 -- 减少集成冲突:频繁集成使得大型团队协作时的合并冲突更早暴露、更容易解决 - -**CI 阶段的终极目标:回答一个问题:这次代码是否是安全,是否可以合并到主分支?** - -### 2. CD 是什么? - -CD 是 CI 的下一步,2者是合作关系,CD 是 CI 的下游,CI 是 CD 的上游。CD 关注的是如何将已验证的代码打包,交付给用户和市场。分为2个概念: - -#### 1. 持续交付 - -指的是通过自动化流程,让代码库随时处于可部署的状态。它要求除了部署到生产环境这一步外,其余流程(构建、测试、打包)全部自动化 - -在 iOS 开发中的体现: - -- 当 CI 流程(编译、测试)通过后,自动触发 CD 流程 - -- 使用 fastlane match 管理证书和配置文件,确保签名一致 - -- 使用 fastlane gym 编译打包生成 `.ipa` 文件 - -- 将 `.ipa` 文件自动上传到分发平台 - -- 持续交付允许在最后一刻(部署到 App Store)手动点击确认,这是个安全网 - - - -#### 2. 持续部署 - -是什么?这是更进一步的实践,它要求所有通过 CI 的变更自动部署到生产环境,无需任何人干预。 - -在 iOS 开发中的体现: - -- 在持续交付的基础上,流程不会停止 -- 自动使用 fastlane deliver 将构建好的版本提交到 App Store Connect -- 自动完成元数据上传,截图管理 -- 由于 App Store 的审核机制,**iOS 应用无法实现真正意义上的“持续部署”**。即使你自动提交了,也需要等待苹果的人工/自动审核。但你可以实现“自动提交审核”。 - -**CD 回答的问题是:我们能否快速、可靠地通过测试的版本交付给用户?** - - - -### 3. 区别 - -| 维度 | **CI - 持续集成 (Continuous Integration)** | **CD - 持续交付/部署 (Continuous Delivery/Deployment)** | -| ------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | -| **核心目标** | **快速发现集成错误,保证代码质量**。
确保新提交的代码能够与主线代码成功合并、正常工作 | **快速、可靠地将测试通过的代码持续交付给用户**
自动发布流程、减少手动操作带来的错误和延迟 | -| **关注点** | **构建和测试**
- 代码能否编译成功?
- 单元测试能否通过?
- UI 测试能否通过?
- 代码风格是否规范 | **分发和部署**
- 如何打包成 IPA 文件?
- 如何分发给 QA?
- 如何提交到 TestFlight 或 App Store? | -| **典型任务** | - 触发时机:代码推送(git push)或提 PR 时
- 编译项目
- 运行单元测试
- 运行 UI 测试
- 执行 Lint 工具(如 SwiftLint)
- 生成代码覆盖率报告 | - 触发时机:CI 阶段通过后,或定时触发,或手动触发
- 生成签名证书和配置文件(`match`)
- 打包生成 IPA 文件(`gym`)
- 分发到测试平台(如 `firebase`、`testflight`)
- 提交到 App Store Connect(`deliver`) | -| **产出物** | **测试报告、精准测试覆盖率**
告诉你代码质量、工程健康度 | **可安装的 IPA 包、发布提交结果**
一个可交付给用户的产品 | -| **比喻** | **一个自动化的质量检测流水线**
每提交一个零件,就检查尺寸、规格是否合格(每一段代码) | **一个自动化的包装和物流系统**
将合格的零件包装成产品,打包、运输、分发到商店。 | - -总结:**CI 和 CD 是软件开发生命周期的2个部分。先 CI 再 CD。CI 是 CD 的基础,代码必须先经过 CI 的各类测试(编译成功、单测试覆盖率保证、精准测试覆盖率保证、Lint 成功)保证质量,才可以交给 CD 流程,进行打包和分发(实现价值)**。 - - - -### 4. CI 和 CD 的能力边界是什么? - -用个例子来描述: - -CI 是质检车间: - -- 输入:新来的原材料(代码提交) -- 过程:自动化流水线进行一系列的质量检查(编译、测试、扫描) -- 输出:通过质检的半成品(编译通过的、测试覆盖率达标的可工作的代码)和一份质检报告 -- 边界:一旦产品贴上“质检合格”的标签,CI 阶段就结束了。不再关心这个半成品接下来的任何状态 - - - -CD 是包装与物流中心: - -- 输入:(CI 车间的输出作为输入)从 CI 车间送来的“合格半成品” -- 过程:将其打包成最终产品(签名、打包 IPA)、贴标签(版本号),然后根据指令分发到不同的目的地(TestFlight、App Store) -- 输出:交付到用户(QA、用户)手中的产品 -- 边界:假设所有的输入都是合格的。核心价值是高效、可靠、无差错地完成分发流程。 - - - -整体流程为: - -```mermaid -graph LR -A[代码提交] --> B[CI流水线] -B --> C{所有检查是否通过?} -C -- 否 --> D[反馈失败 拒绝合并] -C -- 是 --> E[生成合格产物] -E --> F[CD流水线] -F --> G[部署到测试环境] -G --> H[自动化烟雾测试] -H --> I{是否通过?} -I -- 否 --> J[自动回滚] -I -- 是 --> K[人工审批?] -K -- 是 --> L[等待批准] -L -- 批准 --> M[部署到生产环境] -K -- 否 --> M -M --> N[完成交付/部署] -``` - -总结: - -**CI 和 CD 是软件开发生命周期的2个部分。先 CI 再 CD。CI 是 CD 的基础,代码必须先经过 CI 的各类测试(编译成功、单测试覆盖率保证、精准测试覆盖率保证、Lint 成功)保证质量,才可以交给 CD 流程,进行打包和分发(实现价值)** - -**CI 是质量的守门员,其边界在于代码集成阶段的验证和保证;CD 是价值的输送带,其边界在于发布流程的自动化。2者职责分明,先后衔接,各司其职。共同构建了现代软件工程的敏捷、高效的交付流程** - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.143.md b/Chapter1 - iOS/1.143.md deleted file mode 100644 index 87a92a7..0000000 --- a/Chapter1 - iOS/1.143.md +++ /dev/null @@ -1,461 +0,0 @@ -# AI 对端上的赋能 - - -## 一、实时特征回流 -传统智能的问题、弊端是什么? -![MobilePhone AI Capture User Behavior Data](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-DataCapture.png) - -- 推荐系统需要收集用户的行为,将这些行为作为用户的意图表征,表征他的偏好,回流到服务端。 -- 服务端拿到这个数据,在发起一次实时请求的时候,会根据用户的行为特征,去商品池里面召回一批用户喜欢的商品,再返回给端上,给用户做展示 -- 同时,这个用户的行为数据,还会作为训练模型的一个样本 - - -![MobilePhone AI Disadvantage](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-Disadvantage.png) -可以发现传统的推荐系统存在一些瓶颈: -- 实时性不足 - 行为数据回流的时效性,会影响算法对于用户意图变化的感知,会影响推荐的准确性。 - 现有的系统,在手淘这种亿级用户体量下面,想要做到用户数据的实时回流,技术上面挑战很大。 - 另外涉及到用户隐私方面的风险考虑,以及在服务端的存储瓶颈相关的考虑,是不能把用户所有的行为都回流到云端 -- 数据丰富度有限 - 在服务端,整个用户行为数据的丰富度非常有限的。 - 同时我们的一次推荐内容的更新,也是受限于一次新的请求时机的发起。 - 即使我们发现用户的意图,通过数据发现了意图的变化,但是也很难实时对用户的前台界面去做一次干预,去及时调整推荐的内容 -- 但用户算力/存储瓶颈 -- 千人一面的模型 - 目前服务端的算法模型更多的还是千人一面。受到算力和存储的瓶颈,很难针对每一个用户去建立一个属于他自己的模型。 - 去做更加精准的预测。 - -针对上面的这些问题,就是端上的智能可以去发挥的空间所在。 - - - -前面说过,用户的数据回流时效性会影响推荐的准确度。那么是不是可以把用户的特征、用户的数据,做到最实时的回流? -这里,我们做了一些这样的尝试: -![MobilePhone AI Capture User Behavior Data Flow](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-UserDataFlow.png) - -- 首先可以把用户的最原始的行为数据回流。比如用户在逛手淘的过程中产生的一些浏览、点击等行为回流到服务端 -- 同时,也可以把用户的这些原始行为数据做一定的聚合,生成一个信息量含量更高,但是数据量更小的用户特征数据(数据聚合) -比如把用户在商品详情页的一系列的特征,聚合成用户对商品详情的更精炼的一些数据,比如用户有没有对商品点击过收藏、有没有点击过加购,聚合成 -一条商品详情的浏览特征 -- 也可以对这个特征继续做精加工处理,变成一个算法模型可直接使用的特征向量。当然了,它也可以表征用户的行为和意图 -- 还可以把这个向量做进一步精加工处理,生成一些用户的意图打分。比如用户对于某个商品详情的意图分,是强还是弱,用分数去表明。 - -比如用户在手淘里面浏览的过程当中,用户逛着逛着是不是不感兴趣了?是不是疲劳了?这也可以用来表征用户的一个“跳失意图”。 - -这几种数据都可以回流到服务端的,图上数据可以看到: -- 从左往右:数据的加工度越来越高的、数据量是越来越少的 -- 从右往左:数据量大,信息密度低 - -回流到服务端的时候,对于这4种不同类型的数据,一一做过尝试: -- 首先,如果直接回流用户的原始数据,那么这个数据量会非常大,服务端的存储存在压力。另一方面也会涉及到用户的隐私风险 -- 其次,我们也尝试过,将用户的行为数据聚合成一条向量直接回流到服务端,数据量虽然小了,但是会丢失一些信息。另外向量这种数据格式,通用性会非常的受限。智能针对特定的模型去回流特定的向量。 -- 另外,也尝试过直接回流用户的意图分,比如回流一条对于商品详情页的意图分,在整个手淘的流失的意图,这个数据是有效的,但是它丢失的信息也是非常多的 - -所以最后选择的是将用户的数据在端上做一定的标准的加工化处理,聚合成特征回流到服务端,这是实践过比较好的,既能保证数据的有效性又能保证实时性的一种方式。 - - -### 1. 实时特征回流 - -- 数据本地加工,做标准化处理,然后按照需要将需要的那部分数据回传到服务端 - -解决了什么问题? -- 提升了数据的丰富度,能够在服务端拿到用户更多的、更细粒度的一些行为数据。能够让服务端的数据输入变得更丰富 -- 通过对数据加工之后,建立一条实时的特征回流通道,保证了从端到云上的数据实时性 - -遗留了什么问题? -- 通过实时的回流方式,解决了用户实时感知的在实时性方面的问题 -- 但即使感知到了用户意图发生了变化,,也缺少一个实时在前台去干预用户的方式 - - -### 2. 信息流的“回退推荐” -针对实时特征无法具备实时干预能力的问题,在信息流方面做了一种叫“回退推荐的策略” - -想象这样一个场景:用户在商品列表页,对某个商品感兴趣,点击某个商品到达详情页,在详情页看了一番之后,用户点击了收藏或者加入到购物车了。有收藏、加购行为表示用户对这个商品是很感兴趣的。 - -此时,从商品详情页到回退到外面这个商品列表页的时候,会根据用户刚刚的浏览、加购行为,推荐一个相似的商品。 - -![MobilePhone AI Goods Recommended when page back](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-PageBackRecommended.png) - -会根据用户刚刚浏览的这些行为,去给他推荐一个相似的商品。这个过程会发生很多行为,通过滚动、曝光等行为可以推测用户在信息流的浏览意图是逐渐从逛切换到了买。 - -如果页面回退,我们不做干预的情况下,他可能继续往下去浏览,购买这个行为的意图差“临门一脚”就可以转换为一次成交购买。如果不加以干预,可能逛着逛着就丧失购买意愿了,流失一笔潜在交易。 - -选择的策略是:在页面回退的时候,在原来的商品卡片周围,立即推荐一个相似的商品,希望能继续促成,希望能够留住他刚刚这次对商品购买的强意图。这个就是回退推荐。 - -本质上就是抓住了用户从逛到买的这个强意图聚焦。针对强意图的聚焦,做了一次成交转换的促成。 - -商品卡片的回退推荐策略落地上线后,效果是非常好的。比普通商品卡片的转换率高五六倍左右。 - - -![MobilePhone AI Goods Recommended Issue when page back](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-PageBackRecommendedIssue.png) - -存在什么问题? -类似程序员和产品设计沟通出的一种机制,可以理解为命令式编程,是人为先验地找到了一个能够代表用户意图的时机,也就是在回退时机。这种时机靠人去发现梳理,往往是很难覆盖全面的。依赖于对于用户强意图的梳理、选择。那么有没有一种方式可以自动的去预测用户意图的变化? - - - -## 二、应用场景 - -### 1. 信息流的端侧重排 - -在信息流上面做了另外一个尝试:在本地进行了一次端上的重排。 - -![MobilePhone AI Relayout](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-reLayout.png) - - -一个用户在逛信息流的过程中,会产生滚动、曝光、点击、加购、收藏、停留、回退、滚动这些行为。从用户的实时的行为序列中,其实是表征了用户背后的一个隐式的意图表达。 - -可以把用户的实时行为序列去输入到一个意图的模型当中,去计算用户当前的意图是什么?他偏好哪些、不喜欢哪些?得到用户的正负一些意图反馈。去判断用户当前是不是正在一个疲劳的状态,去计算他即将要跳失的可能性。 - -然后将这些用户意图,输入到一个本地的实时决策模型中去,去决定接下去要给这个用户去做什么事情。例如用户是不是对于接下来要滚动浏览的商品兴趣是不是发生了变化的时候,能够根据用户的实时意图,去做一次实时的调整,永远把用户最喜欢的内容放在他排序更靠前的位置。 - -或者当发现对这批商品都不感兴趣的时候,就理解去重新更新一次商品。或者当发现用户即将跳失之前,去做一些强干预,去挽留他继续留在这个页面上。比如通过一些权益去做挽留。 - -做完决策后,就可以将这个决策结果通知到前台,去做相应的响应。 - -还存在一些问题: -![MobilePhone AI Relayout Issue](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-reLayoutIssue.png) - -决策选择还是受限于产品策略。程序员还需要和产品去约定设计产品策略。开发和产品共同约定,在用户的某一时机之下,接下来对应的一个处理是什么。它的整个呈现形式和所处业务域还是存在紧密关系的。 - -这种应用形式,应用在信息流上是非常好的,但是很难迁移到其他业务域。 - -### 2. 智能 Push - -在这样的背景之下,接下来开始下一个尝试,去做一个更加通用的、端上的智能应用。去 Push 业务。 - -传统业务上,Push 是服务端发起的。服务端存在一个任务,不断计算:我需要给什么样的一批用户、推送一批什么内容。服务端跑了这个任务后,会圈选一批人群,定一个任务,给这些人要去发一个推送。客户端收到这个推送,展示这个推送的消息内容。 - -服务端发起的 Push 有啥问题? -- 缺乏感知能力,难以精细化运营 - 服务端不知道用户当前 App 的状态,用户在手淘内还是不在。没有办法知道用户的实时状态,很难针对用户的实时状态去做精细化运营。 -- 被动触达,错失最佳营销时机 - 更希望的是针对用户的某一个精细化行为,去做一次响应的时候,服务端 Push 是做不到的。 - - -完整流程: -![MobilePhone AI Push](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-Push.png) - -用户进入手淘后,会不断收集端上的行为(滚动、曝光、点击、加购、收藏、停留)数据,然后会把行为数据输入到另一个意图模型中,去判断他当前的一些实时意图。不断的根据行为做分析。也会输入到另一个决策模型中去,但这个(智能 Push)场景下的决策模型和前面的端上重排场景的决策模型是不一样的。这个决策模型会**判断用户当前的状态适不适合接受一次干预**,或者适不适合接受某一次营销的推荐。当我们发现用户当前处于一个相对空闲的状态,这个时机更适合接受一条干预的时候,我们就会把这个信号通知到服务端。这时候服务端在海量的内容中去筛选出一条对应着用户当前的意图,有效的一条信息,再推送给客户端。 - -这种从客户端发起的 Push 推送相比于服务端推送来说,对于用户当前的实时状态有着非常强的感知的,用户发生的任何一个行为在端上的决策模型中,可以以毫秒级的速度获取到,当我们真的需要去对用户做一次精准的干预的时候,这种方式相比于服务端推送来说,是有着非常大的优势的。 - -- 从“平台视角”向“用户视角”的转变 - 传统的服务端推送是站在平台视角,来去筛选内容、筛选用户去发送消息的。而“端智能 Push”更多站在用户视角,去分析用户在什么时机下适合接受干预。 -- 解决了什么问题 - 对端上单用户的算力空间的充分利用;同时智能 Push 分离了用户的感知决策。用户的感知可以作为一个独立的模块存在。用户的决策:接下来要做什么响应。也是一个比较独立的模块。在应用性上具备初步的可移植性。可以在多个应用场景,不只是信息流这样一个较为垂直的业务域上去使用。 -- 没有解决什么问题? - 决策依然需要先验制定。 - -我们对于 AI 的期待是美好的,期望 AI 可以帮助我们决定下一步做什么。然而通过这些案例可以发现,现阶段,我们还是只能做到在一个已经决策好的产品框架下面去做。是需要先有一系列的决定(在什么样的情况下面,可以有哪些响应),那么 AI 是帮助这样一个决策的结果更加精准。 - -### 3. 智能预加载 - -根据用户身份、角色、常见行为路径,预测接下去要使用的功能,对可能要进入的页面进行预热、预加载,在用户访问这个页面之前,把页面准备好,来做到秒开的效果。 - -也就是说,如果能精准预测用户下一步将要去往哪里,对于性能来说,提升是会非常明显的。 - -那么最关键问题就是:**如何预测用户下一步将要去往哪里**? - -### 4. 手势热点识别 - -判断用户热点的操作区域是什么?针对这块区域来做一些定制化的特定推荐。 - -### 5.智能营销投放 - -用算法的更加精准的预测,去替代 以往的业务规则的人群圈选。 - - -对于端智能来时,它属于基础能力。它用在哪里,才是能不能用好的一个关键,也就是业务价值能不能提升。 - -从过去的应用来说,我们认为端智能对于客户端的改变主要体现在: - -- 更多的数据 - 在端上做算法模型的预测,可以拿到用户在端上更富丰富、更细粒度的数据,去避免在服务端取不到这样丰富数据的缺陷。 -- 更实时的响应 - 相比于服务端,很多系统称具有小时、分钟、秒级别的实时响应,在端上的实时性是带来本质性的变化。 -- 更低的消耗 - 闲置算力去运算和存储资源,带来本质上的实时性的提升。同时也节约了服务端资源的消耗。 - - -## 三、端智能整体架构 -要素:算法、数据、调度框架、运行环境 -架构如下: -![MobilePhone AI Arch](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAIArch.png) - -1. 围绕着端上的算法分为2种模型: -- 用户意图计算模型:不断分析用户当前所处状态 -- 决策模型:会根据用户意图计算的结果,去判断下一步要做什么样的处理。比如一次本地重排、还是去做一次数据的重新刷新 - -2. 作为端上的算法输入,建立了一个端上的特征中心,用来提供给端上的算法使用:提供标准的用户行为数据、以及一些特征服务。 - -3. 还建立了端上的用户决策框架:接受用户的每条行为数据,然后根据这些行为去决定接下来什么时机要去唤起一个什么样的模型。拿到这个模型的响应结果后再一路回传到我们的客户端应用层。应用层根据这个结果来做前台界面上的渲染。 - -4. 围绕着端上的算法所在的执行环境,是在底层有一个端计算的容器,提供 Python 的运行环境以及 MNN 轻量级的推理引擎 - -5. 对于整个算法研发的 workflow,配套的做了一个端计算的一体化研发平台。算法同学在这个平台完成开发到发布、再到 AB 实验以及模型训练的一系列工作。 - -模型从这个平台发布后,是会下发到客户端,然后在端上跑。 - -### 1. 端上算法方案 - -![MobilePhone AI Algorithm](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-AlgorithmModel.png) - -- Algorhitms Solution On Edge: - - 提供了模型、特征和样本这三大机器学习算法基础组件的端上通用方案 -- Business Solution On Edge: - - 端上推荐算法解决方案,提供了端上实时用户感知和端上智能决策2大模块 - - 通过多任务学习,端上智能决策支持了端上重排、端上智能刷新、端上会话式推荐和端上跳失点预测等任务 -- 千人千模: - - 每个用户训练和部署自己的个人化模型 - - Meta-learning + Federated Learning - -### 2. 端上特征中心 -为端智能应用而设计,提供端侧算法所使用的标准化的全域用户行为数据和特征服务 -![MobilePhone AI User Behavior Data Graph Index](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-DataGraphIndex.png) -- 定义端侧用户行为标准 - 该特征中心会定义端上用户的行为标准,产生什么样的用户行为,比如用户的浏览行为。这个浏览行为会有一些我的浏览区域、浏览停留时长等等标准化属性。其次,也会有一些像用户手势行为。比如滚动、点击等等。 -- 建立行为数据图化索引 - 具体的实现上,为用户的每个行为,去建立了一个行为的图画的索引,将用户的行动点当作一个节点,并且把节点和节点之间建立了一种关联。这样子能够在端上,让算法可以快速拿到这个数据。 -- 数据标准化 - 同时,采集到这个数据之后。也会对数据做标准化处理。把它经过标准化的字段解析和我们的特征加工,给算法提供简单、易用、可用的数据 -- 通用特征接口服务 - - - -数据分层架构: -![MobilePhone AI User Behavior Data Level](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-DataLevel.png) - -- 存储层:将采集到的用户行为数据按照约定的标准,在客户端本地做持久化存储。同时对用户数据进行一次加工处理,生成一份信息密度比较高的基础特征表。比如对详情页的浏览行为、App 页面间操作路径的数据、页面浏览的时序特征,这些数据都会存储在客户端本地。 -- 接口层:实时接口层,提供了 Python 层面的接口服务,给算法侧使用。可以做到数据的实时查询,将下层的通用数据、用户行为数据、环境信息等打包好给算法侧使用。 - 这个数据一部分存在端上,一部分存储在云端,和云端有个数据同步需求的: - - 从端同步到云,将一些必要的基础特征同步到服务端,让服务端可以拿到用户实时的聚合好的特征。 - - 从云到端,也可以把云端特有,客户端没有的数据(比如用户画像、历史行为等等)下发下来,这样子可以让端上的算法也能拿到这部分数据,做出更精准的预测。 - - -### 3. 端上的决策中心 -![MobilePhone AI Judgement Center](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-JudgeCenter.png) -比如:用户在:我的淘宝 -> 我的订单 -> 订单详情页查看了某个订单详情,然后回退到“我的淘宝”页,这时候会对用户的意图进行分析,判断当前是不是处于一个空闲的状态。如果发现是空闲状态,则给他发送一条 Push 消息,引导进入双11主会场。这个就是一个智能 Push 的案例。 - -还有其他的需求: -- 用户打开 App 直接进入“我的淘宝”查看订单信息,1分钟内未打开商品详情页,回退到“我的淘宝”页面,推送弹窗,可以是红包等权益或者低价商品 -- 用户从搜索/导购产品页面进入商品详情页后停留超过2分钟,且有收藏/加购行为,回退到搜索/导购产品页面后会推荐相似商品 - -这些需求,纯客户端视角下很难完成。所以基于用户行为的端侧事件引擎,提供面向全域用户行为的切面开发模式,打破业务间的隔离,实现以用户为中心的跨业务域的决策能力。 - -这样一个切面能力的好处就是: -- 业务开发同学不需要去关心前面的这一串行为是啥时候触发的、怎么发生的。这个行为的匹配由端上的决策框架去做。 -- 实际的开发同学,只需要去关注在切面发生的时候,我需要去做哪些处理。比如:弹层 - - -这种面向用户行为的切面的编程方式,既可以用在运营规则上,也可以用在算法模型上。后续的响应,可以是弹出弹层、发送 Push、发送1次请求等。 - -### 4. 端计算容器 -![MobilePhone AI Compute Container](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-ComputeContainer.png) -端上的算法模型需要跑在容器里,手淘用的是一个轻量级的推理引擎 MNN。MNN 提供了算法在端上跑模型所需要的算子。 - -## 四、云端一体协同 -![MobilePhone AI Local And Server Diffs](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-LocalAndServerDiff.png) -上图是端计算的优势和云计算的劣势。 -未来的端计算并不是完全割裂的。端和云协同才可以迸发最大的效果。 - -![MobilePhone AI Local And Server Diffs](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MobileDeviceAI-LocalAndServerDiff2.png) -可以看到端和云拥有各自擅长的领域。 - -在做云端协同的过程中,会遇到不少问题。比如在端上触发一次重排的时候,会发现端上的数据量是不够的,如果想要提升端上的重排效果,就要扩大候选池,所以增加了**端上的缓存池**。在端上的模型在本地运行过程中,由于模型本身是在服务端训练的,它的模型和特征向量的同步一致性是需要细节方面处理好的。 -同时由于在端上选择用户时机去做一些干预,实际上对于服务端是带来一些 QPS 增量,如何解决处理使其不是负担也需要处理。 - -这些问题处理好,最后需要回答价值多大问题的时候,就需要做实验验证端计算是否真的有效的时候,在实验的设计、以及后续数据分析、结果归因上,也耗费了大量的精力去论证它的有效性。这些是踩坑之处。 - - - -QA: 决策框架与 MNN 推理引擎的区别是什么? - -| 维度 | MNN (推理引擎) | 决策框架 | -| :----------- | :-------------------------------------------------------- | :----------------------------------------------------------- | -| **技术定位** | **底层计算基础设施** | **上层业务调度与编排系统** | -| **核心职能** | **“算”** - 高效执行模型计算,输出预测结果(如用户意图分) | **“判”与“调”** - 判断在什么时机、调用哪个模型、并根据结果执行哪个业务动作 | -| **类比** | **笔墨和运笔技法**(负责把字写出来) | **书法家的头脑和章法**(决定何时写、写什么字、怎么写布局) | -| **关注点** | 计算性能、算子支持、模型兼容性、功耗 | 业务逻辑、决策流程、时机控制、动作执行 | -| **输出物** | 模型的数值化输出(如分数、概率) | 一个具体的业务指令(如:重排、刷新、弹窗) | - -为了更好地理解,我们可以看一个它们如何协同工作的例子,比如“端侧重排”: - -1. **决策框架感知时机**:决策框架监控到用户发生了一系列行为(滑动、点击、停留),判断**此时需要重新计算用户意图**。 -2. **决策框架调用MNN**:决策框架**调度**“用户意图计算模型”开始工作,并将必要的特征数据准备好。 -3. **MNN执行计算**:**MNN引擎**加载并运行该模型,进行高速数学计算,最终输出一个**用户当前的意图分数**。 -4. **决策框架做出决策**:决策框架**接收**MNN返回的意图分数,再结合预设的业务规则(例如:分数高于X则触发重排),**判断**下一步应该执行“本地重排”动作。 -5. **决策框架执行响应**:决策框架**通知**前端的渲染模块,对商品列表进行重新排序。 - - - -## 五、AI 在有赞落地了什么场景 - -### 1. 云打印机秒连接 -云打印机接入 OCR + LLM 技术实现 AI 拍照秒识别,秒连打印机,一拍即用,操作简单 -门店商家很多都会去连接打印机,连接过程中成功率只有54%左右。分析了相关原因,发现有2个主要原因: -- 商家找不到打印机的连接入口 -- 打印机的配置过程中需要输入相关的编号和密钥。很容易输错 - -正好 AI 来了,AI 可以结合到打印机的铭牌,做一些智能识别跟参数的推理,然后拿到相关的结果,就可以智能的去调云打印机相关服务厂商的接口,就可以非常完美的解决这个问题。 - -我们对接了非常多的云打印机的品牌厂商,通过 AI 能力,去设计一些提示词,针对这些不同厂商的差异要做一些不同场景的兼容设计,过程中其实踩了不少的坑,不断的调优,让所有的硬件识别准确率提升了非常多。 - -我们的产品改造完之后,在商家平台网页端,硬件添加入口,让商家上传铭牌照片就可以了。原来的十步变成1步。连接成功率也从原来的不到70%提升到97%。 -思路,以后的业务需求,可以尝试跳出 Web 前端能力、Native 能力,从 AI 侧看看有没有更多的可能和思路。 - - -### 2. 基于图像识别算法的零售移动智能收银方案 - -#### 1. 背景 -生鲜果蔬行业在零售行业中是一个较大且比较有特征性的行业,同时在生鲜果蔬行业中,称重秤为经营的刚需类设备。目前商家主要使用条码秤,通过 PLU(Price Lookup Code) 码进行商品的管理,每个 PLU 码对应一个商品,我们可以想象下在超市购买水果的时候会碰到下面这个流程: - -![Vegetables Goods Purchase Workflow](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Youzan-VegetablesGoodsWorkflow.png) - -所以在门店商品种类比较多时(一个典型生鲜果蔬类商家商品种类大多超过 200 个,随机调研了 5 家有赞果蔬商家,平均 SKU 数量 500+),PLU 码较难记忆清楚,在打秤时需临时查询,称重耗时比较长,为了避免高峰时期排队现象,需在门店增加秤台和打称员,导致商家人力成本较高。 - -因此就前面所提到的场景,我们需要通过更加智能的方式帮助商家加购,那么基于机器学习的图像识别能力就被提上了议程。我们通过条码秤关联的摄像头进行实时拍摄,基于机器学习技术和图像识别技术,将店员放置在秤盘上的商品进行识别,并给出相关商品的列表,减少收银员收银场景中的操作次数,减少商家对新收银员的PLU码的培训并降低熟悉相关商品的培训成本,从而在整体上降低收银员的门槛以及商家的人力成本。所以我们可以得到我们期望的购买流程: - -![Vegetables Goods Purchase Workflow Via AI](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/VegetablesPurchaseWorkflowViaAI.png) - -#### 2. 架构设计 -我们针对于商家的痛点和可行的解决方案绘制了下面的流程图: -![Vegetables Goods Purchase Arch Via AI](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/VegetablesPurchaseViaAIArch.png) - -整个流程中的基础能力: -- 实现摄像头对于商品的拍摄 -- 针对于拍摄能力支持图像转换成为商品的能力 -- 将识别结果进行列表化展示 -- 将用户点击之后的结果进行上报,用于商家个性化画像的绘制,以及机器学习模型的加深 -- 支持机器学习模型的动态下发 - - -#### 3. 框架选择 -首先是整个环节的核心点,对于商家商品的关联以及数据模型进行机器学习并完成定时的增量更新流程。通过对比市面上已有的框架,因为 TensorFlow 有 lite 版本单独支持移动端能力,同时结合有赞算法团队已有的技术沉淀,所以敲定使用 TensorFlow 作为机器学习的基础框架。完成了框架的确定,就需要考虑业务场景上的实现了。 - - -#### 4. PLU 码是什么 -PLU 码是生鲜零售行业的 “商品价格查询码”,核心是用一串数字唯一标识一种散装生鲜商品,方便打秤时快速调取价格 -特点: -- 本质是 “商品与价格的关联码”,替代人工记忆商品价格的繁琐操作。 -- 通常由 4-5 位数字组成,可分为行业通用码和商家自定义码。 -- 仅用于散装生鲜(如蔬菜水果、散装零食),预包装商品多用地条码。 - -比如我买了10斤红富士苹果,怎么体现?PLU码会携带重量、价格信息吗?收银员拿到这个 PLU 码之后的处理流程是什么样的? -PLU 码不只为生鲜类目(主要服务散装非标品),本身不携带重量/价格信息,仅关联 “商品 + 单价”信息。10 斤红富士需通过 PLU 码调单价 + 秤称重算总价,收银员核心流程是 “输码 - 调价 - 称重 - 结算”。 - -##### 1. PLU 码的适用范围:不止生鲜,但聚焦 “散装非标品” -核心适用场景:散装生鲜果蔬(如苹果、生菜)、散装干货(如核桃、枸杞)、散装零食(如糖果、饼干),这些商品无固定包装、需按重量计价。 -非适用场景:预包装商品(如盒装牛奶、袋装面包),这类商品有固定重量 / 价格,用地条码(EAN 码)而非 PLU 码。 -简单说:PLU 码是 “散装称重商品的专属身份码”,生鲜是主要使用场景,但不是唯一场景。 - -##### 2. 10 斤红富士的 PLU 码使用逻辑:重量靠秤、价格靠计算 -PLU 码的核心作用是 “快速调取商品单价”,重量和总价需结合秤的功能实现: -第一步:红富士对应 PLU 码(如通用码 4133),商家已在秤中录入 “4133 = 红富士,单价 8 元 / 斤”。 -第二步:你买 10 斤红富士,打秤员将苹果放在秤上,输入 PLU 码 4133。 -第三步:秤自动读取 “10 斤” 重量,按 “单价 8 元 / 斤 ×10 斤” 算出总价 80 元。 - -关键:PLU 码只负责 “告诉秤这是什么商品、多少钱一斤”,重量是秤测量的,总价是系统实时计算的,三者独立但联动。 - -##### 3. 收银员拿到 PLU 码后的完整流程 -以超市购买 10 斤红富士为例,流程分 5 步: -- 商品上秤:将散装红富士放在条码秤的秤盘上,秤实时显示重量(10 斤)。 -- 输入 PLU 码:收银员手动输入 4133(红富士 PLU 码),或通过扫码枪扫预存的 PLU 码贴纸。 -- 系统调参计算:秤通过 PLU 码调取预设单价(8 元 / 斤),自动计算总价(8×10=80 元)。 -- 打印价签:秤打印含 “PLU 码、商品名、重量、单价、总价、日期” 的价签,贴在商品上。 -- 收银结算:你拿着贴有价签的商品到收银台,收银员扫价签上的条码(或手动输入 PLU 码),收银系统确认价格后完成支付。 - -#### 5. 商品关联 -完成了框架选择,接下来就需要确定如何将商品关联到数据模型上了。 - -有赞的商品有很多的字段,比如说:编码、条码、规格、属性等等。“有赞商品的编码、条码等唯一标识字段,零售场景已支持识别,但这类标识仍需人工关联 PLU 码才能完成称重结算,未能解决‘PLU 码难记忆、打秤查询耗时’的核心痛点。本次 AI 图像识别的核心目标,是通过商品视觉特征直接匹配类目,替代人工查询 PLU 码的操作,实现‘称重 - 识别 - 收银’一体化,而非替代已有条码识别能力 - -我们选择能够区分商品本身存在的差异化的方案——商品类目。 - -有赞的商品类目最大为 4 级,最后基本上已经能够细分到水果的某一个种类中。举个例子:一个苹果,在有赞类目中的选择需要被选择成为 `食品酒水 > 水产肉类/新鲜蔬果/熟食/现做食品 > 新鲜水果 > 苹果`,同时考虑到苹果中仍然存在不同的品种。所以我们在商品类目中追加了水果种类用于区分不同的苹果品种,比如说:金帅、国光、冰糖心…… - - -#### 6. 反馈闭环 -在确定了核心能力的解决方案后,接下来需要解决的是如何将商家本地的数据进行上传,并对于已有模型进行强化。为了更加及时的获得用户本地的选择情况,我们选择了有赞埋点平台作为技术支撑,通过离线缓存,并结合闲时上报的能力,将用户选择图片的整体筛选情况,基于店铺/角色等维护进行拆分,并将最终的选择数据导入 ODS 库中。并在算法前结合用户选择时机的拍摄图片上传 + 用户选择商品情况进行结合,进一步针对于对应店铺的模型进行加强。从而在不断的强化商家模型,从而提高用户准确性。 - -![Vegetables Goods Purchase Data Upload](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/VegetablesGoodsAIDataUpload.png) - -#### 7. 流程优化 -要达到好用的程度。所以我们需要对于数据统计流程/用户交互流程进行更加深入的优化 - -##### 1. 自动化类目关联 -考虑到许多商家在使用零售的过程中,如果需要挂载到对应的类目中成本较高,为了减少商家的操作成本。我们基于商家的商品标题 + 图片提供了默认类目和种类的选择。极大程度上降低了用户的操作成本。 - - -##### 2. 图片上传/优化 -根据分析商家的实际使用场景,我们发现部分商家在售卖生鲜果蔬的同时,仍然会同时售卖一些标品,比如说日常的柴米油盐酱醋茶。所以导致商家在正常选择商品时候,设备相机仍然会采集相关照片。从而导致商家返回中会存在一部分无效图片,从而影响后续的数据分析,所以我们需要在进行机器学习算法前进行空盘图片的判断,从而避免无效数据对于数据集的影响。 - -同时由于电子秤的硬件特性:当物品放置到称上的时候,电子秤中的读数从 0 变化到物品实际重量的流程是非线形的,这就导致了,如果我们在这过程中进行数据采集,可能会采集物品非稳定的状态。所以我们约定在电子秤的读数停止变化的时候才进行数据采集,从而保证数据的有效性。 - -除此之外,由于设备的服务商的不同,导致不同服务商对于设备摄像头的调校结果也不相同。有的厂商为了能够更好地将商品拍摄完全,通过广角镜头在较短的视距内获得更好的视野;有的厂商使用了分辨率较低的摄像头导致图像的精细程度没有那么高;有的商家设备由于在运输过程中的震动,导致摄像头拍摄位置发生了偏移。以上种种问题,我们都需要通过一些图形学的解法,将所有的逻辑处理成为统一的结果。所以在最终进入机器学习的算法前,我们需要通过图像处理,对图片进行裁切、锐化、畸变矫正。从而保证了不同设备的数据一致性。当然为了能实现这些细节,我们需要在用户上报信息的过程中追加设备信息,方便后续处理图片针对于不同的商家的设备进行区分,从而保证结果的稳定输出。 - -##### 3. 离线能力支持 -考虑到零售本身的特殊性,很多商家在真实收银的场景当中的网络环境较差,完全的离线的机器学习可能会影响商家的收银流程。所以我们在本地建立了索引,并在用户点选商品后,优先将商品的图像信息转换成为数据与商品关联后在索引中进行插入,并在下一次识别结果中优先展示,从而既保证了商家在第一次使用的时候,基于有赞类目体系有一个基准模型进行下发,也可以在后续不断收银的过程中进行不断优化机器学习的结果,逐步提高机器学习的准确率。 - - - -## 六、QA - -### 1. 什么是索引? - -#### 通俗化解释 - -想象一下,你是一个新店员,老板给你做培训: - -1. 老板指着一个苹果说:“这个叫‘红富士’,PLU码是4133。” -2. 你的大脑:并不会像相机一样存下苹果的完整图片,而是会提取关键特征——“圆圆的、红色带黄条纹、有个把儿”。你把这些特征和“红富士/4133”这个信息关联起来,记在脑子里。 -这个过程就是 “将商品的图像信息转换成为数据与商品关联”。 -- 商品的图像信息:一张苹果的彩色图片(原始数据,体积大,难以直接比较)。 -- 转换成为数据:通过一个复杂的AI模型(通常是卷积神经网络),从图片中提取出最能代表这个苹果的、最本质的特征向量。这个向量就是一长串数字(比如128或256个数字组成的一个列表),可以理解为这个苹果的 “数字指纹” 或 “特征DNA”。 -- 与商品关联:将这个“数字指纹”(特征向量)与商品信息(如PLU码“4133”、商品名“红富士”)绑定在一起。 -所以,简单来说:它不是存储图片本身,而是存储从图片中提取的、机器可理解的“本质特征”,并把这个特征和商品身份挂钩。 - -#### 索引是什么?为什么是核心? - -现在,你大脑里已经记住了好几种商品的特征。当顾客又拿来一个水果时,你需要快速判断它是什么。 - -**“索引”就是你所记忆的、所有商品的“特征-DNA -> 商品信息”的快速查询表。** - -在计算机中,**它就是一个专门为"相似性搜索"优化的特殊数据库表。** - -| ID | 商品名称 | PLU码 | 特征向量 | -| :--- | :--------- | :---- | :------------------------- | -| 1 | 红富士苹果 | 4133 | [0.12, 0.95, -0.43, 0.67] | -| 2 | 香蕉 | 4017 | [-0.34, 0.21, 0.88, -0.12] | -| 3 | 西兰花 | 4620 | [0.76, -0.55, 0.09, 0.33] | - -**这个表格本身就是"索引"**——它把商品和它们的"数字指纹"(特征向量)关联起来了。 - -### 关键问题:如何快速查找? - -您完全正确——**就是通过计算距离,距离越近越匹配!** - -#### 距离计算的直观理解 - -把特征向量想象成在多维空间中的"坐标点": - -- 红富士苹果:坐标 `[0.12, 0.95, -0.43, 0.67]` -- 香蕉:坐标 `[-0.34, 0.21, 0.88, -0.12]` -- 西兰花:坐标 `[0.76, -0.55, 0.09, 0.33]` - -当新来一个水果,AI也把它转换成一个坐标,比如 `[0.15, 0.89, -0.38, 0.71]`。 - -**系统会计算这个新坐标与索引中所有商品坐标的"距离"**: - -```shell -新水果 vs 红富士苹果:距离 = 0.08 (很近!) -新水果 vs 香蕉:距离 = 1.42 (很远) -新水果 vs 西兰花:距离 = 1.15 (较远) -``` - -#### 实际的查找过程 - -1. **遍历计算**:系统会遍历索引中的每一条记录,计算新向量与该记录特征向量的距离 -2. **排序**:按距离从近到远排序所有商品 -3. **返回Top K**:返回距离最近的3-5个商品作为候选结果 - diff --git a/Chapter1 - iOS/1.144.md b/Chapter1 - iOS/1.144.md deleted file mode 100644 index f39667f..0000000 --- a/Chapter1 - iOS/1.144.md +++ /dev/null @@ -1,325 +0,0 @@ -# Weex 底层原理 - -## Weex 三线程模型 -在查看 Weex 的文章时看到一段文字:JSThread过于繁忙,导致DOM线程和UI线程的堵塞(三者类似于串行的机制运行」该怎么理解?一段跨端引擎不是双线程的吗?UI线程和逻辑线程。为什么这里看上去除了 DOM 线程、还有 UI线程和 js 逻辑线程? - -文章地址:https://developer.aliyun.com/article/69005 - -要理解Weex中“JSThread、DOM线程、UI线程”的关系,需要结合Weex的架构设计和线程分工来分析。虽然多数跨端引擎(如React Native)采用“逻辑线程(JS)+ UI线程(原生)”的双线程模型,但Weex的早期设计中引入了**DOM线程作为中间层**,形成了独特的三线程协作模式,这也是其与其他引擎的核心差异之一。 - - -先明确三个线程的核心分工 -1. **JSThread(JavaScript线程)**: - 负责执行开发者编写的JavaScript业务逻辑(如数据处理、事件响应、生命周期函数等),是业务逻辑的“计算中心”。 - - -2. **DOM线程**: - Weex的设计中保留了“类浏览器DOM”的抽象层(虽然最终渲染的是原生控件,但中间需要通过DOM树来描述界面结构)。DOM线程的核心作用是: - - 维护虚拟DOM树的状态(比如节点增删、属性更新); - - 处理布局计算(如通过CSS样式计算节点位置、大小); - - 将JSThread传来的界面更新指令(如`appendChild`、`setStyle`)转换为可被UI线程理解的“原生渲染指令”。 - - -3. **UI线程(原生主线程)**: - 负责最终的原生控件渲染(如Android的`MainThread`、iOS的`Main Thread`),直接操作原生视图(如`TextView`、`UIView`),是界面真正“可见”的执行者。 - - -### 为什么三者类似“串行机制”? -Weex中三个线程的协作并非完全并行,而是存在**依赖关系的串行流转**: -JSThread的逻辑执行结果(如“更新某个按钮的文本”)需要先传递给DOM线程,由DOM线程处理DOM树更新和布局计算,再将计算后的“原生渲染指令”(如“修改原生Button的text属性”)传递给UI线程,最终由UI线程执行渲染。 - -这个流程可以简化为: -`JSThread处理逻辑 → DOM线程处理DOM/布局 → UI线程执行渲染` - -如果JSThread过于繁忙(比如执行复杂循环、同步计算),会导致它无法及时将更新指令传递给DOM线程,后续的DOM线程和UI线程就会“等米下锅”,陷入阻塞状态——这就是“JSThread繁忙导致DOM和UI线程堵塞”的原因。 - - -### 为什么Weex需要单独的DOM线程? -这与Weex的设计初衷有关:早期Weex希望尽可能复用Web前端的开发习惯(如基于DOM的界面描述、CSS布局),因此保留了DOM层作为“桥接层”。DOM线程的存在是为了隔离JS逻辑与原生渲染,专门处理“Web风格的界面描述”到“原生渲染指令”的转换,降低前端开发者的迁移成本。 - -而像React Native这类引擎则更彻底地抛弃了DOM层,直接通过JS线程生成“虚拟组件树”,再传递给UI线程渲染,因此不需要单独的DOM线程,形成了双线程模型。 - - -### 总结 -Weex的三线程模型是其“兼容Web开发习惯”设计的产物:JSThread负责逻辑,DOM线程负责DOM/布局转换,UI线程负责原生渲染。三者因指令流转的依赖关系呈现串行特性,因此JSThread的阻塞会直接导致后续环节停滞,最终表现为界面卡顿。这与其他双线程跨端引擎的核心差异,本质上是架构设计(是否保留DOM层)导致的。 - - -## 「将 JSThread 传来的界面更新指令(如appendChild、setStyle)转换为可被 UI 线程理解的 “原生渲染指令”」这段逻辑在 Weex 最新源码中是哪个文件哪段代码? - -1. JS 线程指令接收与初步处理(桥接层) - -JS 线程的 appendChild、setStyle 等指令首先通过 WXDomModule 接收(JS 与原生的桥接模块),该类直接对接 JS 调用并解析参数。 -文件:ios/sdk/WeexSDK/Modules/WXDomModule.m -```Objective-C -@implementation WXDomModule - -// 处理 JS 层的 "appendChild" 指令(添加子元素) -- (void)addElement:(NSDictionary *)params callback:(WXModuleCallback)callback { - NSString *pageId = params[@"pageId"]; - NSString *parentRef = params[@"parentRef"]; - NSDictionary *elementData = params[@"element"]; - - // 转发指令到 DOM 处理核心(原 WXDomManager 功能迁移至此) - [[WXSDKManager sharedInstance].domService addElement:elementData - toParentRef:parentRef - pageId:pageId]; - - if (callback) { - callback(@{@"result": @"success"}); - } -} - -// 处理 JS 层的 "setStyle" 指令(更新样式) -- (void)updateStyle:(NSDictionary *)params callback:(WXModuleCallback)callback { - NSString *pageId = params[@"pageId"]; - NSString *ref = params[@"ref"]; - NSDictionary *style = params[@"style"]; - - // 转发样式更新指令到 DOM 处理核心 - [[WXSDKManager sharedInstance].domService updateStyle:style - forRef:ref - pageId:pageId]; - - if (callback) { - callback(@{@"result": @"success"}); - } -} - -@end -``` - -2. DOM 层处理与渲染指令生成(核心转换逻辑) - -JS 指令经 WXDomModule 转发后,由 WXDomService(DOM 服务核心)处理:解析参数、更新虚拟 DOM 树,并生成原生渲染指令(如 “创建视图”“更新样式”)。 -文件:ios/sdk/WeexSDK/DOM/WXDomService.m -```Objective-C -@implementation WXDomService - -// 处理 "添加子元素" 指令,生成原生渲染指令 -- (void)addElement:(NSDictionary *)elementData toParentRef:(NSString *)parentRef pageId:(NSString *)pageId { - // 1. 获取页面上下文的虚拟 DOM 树 - WXDOMTree *domTree = [self _domTreeForPageId:pageId]; - if (!domTree) return; - - // 2. 创建虚拟 DOM 节点(映射 JS 元素) - WXDOMNode *childNode = [WXDOMNode nodeWithData:elementData]; - childNode.ref = elementData[@"ref"]; - - // 3. 更新虚拟 DOM 树(添加子节点) - [domTree addNode:childNode toParentWithRef:parentRef]; - - // 4. 计算布局(转换为原生视图的位置/大小) - [domTree layoutIfNeeded]; - - // 5. 生成原生渲染指令:通知渲染服务创建并添加视图 - [self _renderService createView:childNode - parentRef:parentRef - pageId:pageId]; -} - -// 处理 "更新样式" 指令,生成原生渲染指令 -- (void)updateStyle:(NSDictionary *)style forRef:(NSString *)ref pageId:(NSString *)pageId { - WXDOMTree *domTree = [self _domTreeForPageId:pageId]; - if (!domTree) return; - - // 1. 更新虚拟 DOM 节点的样式 - WXDOMNode *node = [domTree nodeForRef:ref]; - [node updateStyle:style]; - - // 2. 重新计算布局 - [domTree layoutIfNeeded]; - - // 3. 生成原生渲染指令:通知渲染服务更新视图样式 - [self _renderService updateViewStyle:node.computedStyle - forRef:ref - pageId:pageId]; -} - -@end -``` -3. 渲染指令调度到 UI 线程(执行层) - -生成的原生渲染指令由 WXRenderService 接收,并通过 iOS 主线程队列(UI 线程)调度执行,最终转换为 UIView 的操作。 -文件:ios/sdk/WeexSDK/Render/WXRenderService.m -```Objective-C -@implementation WXRenderService - -// 调度 "创建视图" 指令到 UI 线程 -- (void)createView:(WXDOMNode *)node parentRef:(NSString *)parentRef pageId:(NSString *)pageId { - // 封装原生渲染任务(包含 UI 线程所需的视图类型、样式、父节点等信息) - WXRenderTask *task = [[WXRenderTask alloc] init]; - task.action = ^{ - // 获取父组件(原生视图容器) - WXComponent *parentComponent = [self _componentForRef:parentRef pageId:pageId]; - if (!parentComponent) return; - - // 创建原生组件(对应 UIView 实例) - WXComponent *childComponent = [WXComponentFactory componentWithType:node.type - ref:node.ref - style:node.computedStyle - parent:parentComponent - pageId:pageId]; - - // 执行原生视图操作:添加子视图(UI 线程直接调用) - [parentComponent.view addSubview:childComponent.view]; - }; - - // 提交任务到 UI 线程(主线程) - [self _dispatchToMainThread:task]; -} - -// 调度 "更新样式" 指令到 UI 线程 -- (void)updateViewStyle:(NSDictionary *)style forRef:(NSString *)ref pageId:(NSString *)pageId { - WXRenderTask *task = [[WXRenderTask alloc] init]; - task.action = ^{ - // 获取目标原生组件(对应 UIView) - WXComponent *component = [self _componentForRef:ref pageId:pageId]; - if (!component) return; - - // 执行原生样式更新(直接修改 UIView 属性) - component.view.backgroundColor = [WXConvert UIColor:style[@"backgroundColor"]]; - component.view.frame = CGRectMake( - [WXConvert CGFloat:style[@"left"]], - [WXConvert CGFloat:style[@"top"]], - [WXConvert CGFloat:style[@"width"]], - [WXConvert CGFloat:style[@"height"]] - ); - // 其他样式属性(如字体、边框等)的转换... - }; - - // 提交任务到 UI 线程 - [self _dispatchToMainThread:task]; -} - -// 确保任务在 UI 线程执行 -- (void)_dispatchToMainThread:(WXRenderTask *)task { - if ([NSThread isMainThread]) { - task.action(); - } else { - dispatch_async(dispatch_get_main_queue(), task.action); - } -} - -@end -``` -总结:最新代码中,原 WXDomManager 的功能被拆分为: -- WXDomModule:接收 JS 指令并转发; -- WXDomService:处理 DOM 逻辑并生成原生渲染指令; -- WXRenderService:调度指令到 UI 线程并执行原生视图操作。 -这三个文件共同完成了从 JS 线程指令到 UI 线程原生渲染指令的转换,核心逻辑集中在 WXDomService.m(指令转换)和 WXRenderService.m(UI 线程执行)中。 - - -## 为什么 core_side_in_multi_process.cpp 只有 Android 有?按理说 c++ 实现的逻辑 iOS 和 Android 通用的,为什么 Android 侧用 c++,iOS 侧相关逻辑还是 OC 代码实现的。为什么 Weex 团队没有做成共用的,阿里的工程师的水平是够的,为什么这么做 - -Weex 中 Android 侧用 C++ 实现 `core_side_in_multi_process.cpp` 这类核心逻辑,而 iOS 侧仍以 OC 为主,并非“技术能力不足”,而是基于 **平台特性差异、历史演进路径、性能开销权衡、原生生态兼容性** 四大核心因素的理性选择——阿里工程师的技术选型,本质是“优先适配平台最优解”,而非单纯追求“跨平台代码统一”。 - - -### 一、核心前提:Weex 的“跨平台”≠“全平台代码完全一致” -跨平台框架的核心目标是“一次编写、多端运行”,但**底层实现是否用统一语言**,取决于“平台特性是否允许”和“统一后的收益是否大于成本”。Weex 早期设计时,Android 和 iOS 团队是并行推进的,初期更追求“快速落地”而非“底层完全统一”,后续的差异则是“基于平台特性的持续优化”,而非“技术断层”。 - - -### 二、为什么 Android 侧优先用 C++ 实现核心逻辑? -Android 侧选择 C++(尤其是多进程/线程相关逻辑),本质是为了解决 **Android 平台特有的痛点**: -#### 1. Android 对“多进程”的依赖远高于 iOS -早期 Android 设备普遍存在 **单进程内存限制**(如 4.4 之前单进程内存上限仅 128MB),而 Weex 加载复杂页面时(如包含大量图片、列表),JS 引擎(如 V8)和 DOM 计算会占用大量内存,容易触发 OOM。因此 Weex Android 侧必须支持 **“JS 进程与 UI 进程分离”**(多进程模式),而: -- 多进程通信需要高效的 **序列化/反序列化**(如传递 JS 指令、DOM 数据),C++ 的内存操控能力和二进制序列化效率(如 Protobuf 底层)远高于 Java; -- `core_side_in_multi_process.cpp` 本质是“多进程通信的 C++ 抽象层”,负责 JS 进程与核心进程(DOM 线程)的指令转发,这是 Android 多进程模式的刚需,而 iOS 几乎用不到。 - -#### 2. Android NDK 生态更适合“C++ 与 Java 混合开发” -Android 的 NDK(原生开发工具包)对 C++ 的支持非常成熟: -- 通过 JNI(Java Native Interface),C++ 可以高效调用 Java 层 API(如 UI 线程调度、原生视图创建),且 Android 团队对 JNI 优化多年,桥接开销可控; -- 早期 Android 上的 JS 引擎(如 V8)是 C++ 实现的,用 C++ 写核心逻辑可以直接对接 V8 的 C++ 接口,避免“Java → JNI → C++”的多层桥接损耗(如果用 Java 写核心逻辑,调用 V8 反而需要额外 JNI 开销)。 - -#### 3. Android 对“性能极致优化”的需求更迫切 -早期 Android 设备硬件性能差异极大(从低端机到旗舰机),而 DOM 计算、布局排版(如 Flex 布局)是高频耗时操作: -- C++ 是编译型语言,执行效率比 Java 高 30%~50%(尤其是循环计算、内存密集型操作),用 C++ 实现 DOM 树维护、布局计算,可以缓解低端机的卡顿; -- Android 侧的“核心线程”(DOM 线程)需要处理大量并发任务,C++ 的线程库(如 `std::thread`)比 Java 的 `Thread` 更轻量,调度开销更小。 - - -### 三、为什么 iOS 侧仍用 OC 实现核心逻辑? -iOS 侧不依赖 C++ 做核心逻辑,是因为 **iOS 平台特性完全不需要,且 OC 更适配 iOS 原生生态**: -#### 1. iOS 几乎不需要“多进程模式”,C++ 多进程逻辑无意义 -iOS 有两大特性决定了 Weex 无需多进程: -- **单进程内存限制宽松**:iOS 对单进程内存的限制远高于同期 Android(如 iPhone 6 单进程内存上限达 1GB),Weex 单进程运行(JS 线程+UI 线程)完全足够,无需拆分多进程; -- **多进程限制严格**:iOS 的沙盒机制和后台进程管理极严,除了系统应用(如 Safari、微信),第三方框架启用多进程会面临“审核不通过”“后台进程被强杀”等问题,Weex 作为嵌入框架,根本无法使用多进程模式——因此 `core_side_in_multi_process.cpp` 这类多进程相关的 C++ 代码,在 iOS 上完全是“无用代码”。 - -#### 2. OC 与 iOS 原生生态的“零成本对接”,C++ 反而增加开销 -iOS 的 UI 框架(UIKit)、JS 引擎(JavaScriptCore)都是 OC 原生支持的: -- **UIKit 是 OC 接口**:如果用 C++ 写渲染逻辑,需要通过“C++ → OC 桥接”(如 `extern "C"` 封装)才能调用 `UIView`、`AutoLayout` 等 API,桥接过程会产生额外的内存拷贝和类型转换开销(高频 UI 更新时,这种开销会被放大);而用 OC 写,能直接调用 UIKit API,零桥接损耗; -- **JavaScriptCore 与 OC 无缝交互**:iOS 的 JS 引擎(JavaScriptCore)原生提供 OC 接口(如 `JSContext`、`JSValue`),JS 线程的指令可以直接通过 OC 传递到 DOM 层,无需 C++ 中转——如果强行用 C++,反而需要“JS → C++ → OC”的多层转换,效率更低。 - -#### 3. iOS 硬件性能更均匀,OC 性能完全够用 -iOS 设备的硬件生态高度统一(仅苹果自研芯片),性能差异远小于 Android: -- 即使是低端 iOS 设备(如 iPhone SE 第一代),OC 处理 DOM 计算、布局排版也能满足流畅度需求(OC 是编译型语言,性能接近 C++,且苹果对 Clang 编译器优化极深); -- 阿里工程师曾做过测试:iOS 侧用 OC 实现 DOM 核心逻辑,比用 C++ 少了 15% 的桥接开销,反而更流畅——既然 OC 能满足性能需求,且开发效率更高,就没必要强行用 C++。 - - -### 四、为什么不做成“C++ 统一核心”?—— 统一的成本远大于收益 -阿里工程师并非没能力做 C++ 统一核心,而是“做了反而亏”: -#### 1. 跨平台适配成本极高 -iOS 和 Android 的底层差异太大: -- **线程模型不同**:Android 用 Looper/Handler 调度线程,iOS 用 GCD/RunLoop,C++ 统一线程调度需要封装两层适配(如 C++ Thread → Android Looper / iOS GCD),反而增加复杂度; -- **UI 调用方式不同**:Android 的 View 可以通过 JNI 从 C++ 调用,iOS 的 UIKit 必须在主线程用 OC 调用,C++ 统一渲染逻辑需要额外写“C++ → OC”的桥接层,代码量比单独用 OC 还多。 - -#### 2. 开发与调试效率下降 -- **iOS 开发者更熟悉 OC/Swift**:Weex iOS 团队早期以 OC 开发者为主,用 OC 写核心逻辑能快速迭代,而用 C++ 会增加学习成本和调试难度(如 Xcode 对 C++ 的调试支持不如 OC,内存泄漏排查更复杂); -- **C++ 与 OC 的混合开发坑多**:比如 C++ 的异常机制与 OC 的 `@try/@catch` 不兼容,内存管理(C++ 的 `new/delete` 与 OC 的 ARC)容易出现野指针,反而增加 Bug 率。 - -#### 3. 性能收益为负 -如前所述,iOS 侧用 C++ 会增加“C++ → OC”的桥接开销,而 OC 本身的性能已足够支撑 Weex 的需求——统一成 C++ 不仅没提升性能,反而可能导致界面卡顿(尤其是高频更新场景,如列表滚动)。 - - -### 五、本质:阿里工程师的“技术选型理性”—— 不唯技术,只唯场景 -Weex 作为阿里内部孵化的框架,核心目标是“服务业务”(如淘宝、支付宝的移动端页面),而非“做技术标杆”。技术选型的唯一标准是: -- **能否解决平台痛点**(Android 用 C++ 解决多进程和性能问题); -- **能否降低开发成本**(iOS 用 OC 适配原生生态,减少调试成本); -- **能否保证业务稳定**(不强行重构成熟的 OC 逻辑,避免线上 Bug)。 - -这种“不追求形式上的统一,只追求场景下的最优”,恰恰是阿里工程师技术成熟的体现——跨平台的核心是“体验统一”,而非“代码统一”。 - - -### 总结 -Weex Android 侧用 C++、iOS 侧用 OC 的差异,是: -- **Android 为解决多进程、性能痛点,选择 C++**; -- **iOS 为适配原生生态、降低开销,选择 OC**; -- 并非“技术能力不足”,而是“基于平台特性的最优解”。 - -如果强行将 iOS 侧核心逻辑改成 C++,只会导致“开发效率下降、性能损耗增加、Bug 率上升”,完全不符合业务需求——这才是阿里工程师不做统一的根本原因。 - - - -## 增强并发 - - -![Weex concurrency enhancement](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Weex-ConcurrencyEnhancement.png) - -上图左侧是未经优化前JS&Native通信流程,可以看出,每当JS发送一个callNative时,Native都会有一个callJS回调,这种方式更类似于JS同Native握手的方式,这种设计方式保证了页面渲染时所需的时序性。 - -图中右侧的通信流程之所以比左侧少很多callJS,是因为在JS Render中进行简单的队列维护既可以满足时序要求。针对特殊的渲染指令,如同步依赖上一渲染完成事件才能开始下一个渲染指令的情况,再对其进行callJS的强制回调;但大部分的渲染指令无需同步的callJS回调约束。 - - -思考:为什么从 native 回调给 js 需要用一个队列去做,但是从 js call native 不用一个队列去做?这样做不是可以起到防抖的目的吗?为什么设计上不对称 - - -js call native 后,对每个方法都进行编号,执行完成后也设计为类似一个 map 的效果,key 为 methodId, value 为回调函数,回调函数携带 methodID,JS 根据 methodID 决定处理哪段逻辑不行吗? - -不行。用 methodId + map 匹配回调)确实能解决 “回调与原始调用的归属匹配” 问题(即 “哪个回调对应哪个 callNative”),但无法解决 “回调执行的时序一致性” 问题。这两者的核心区别在于:methodId 解决的是 “谁的回调”,而队列解决的是 “按什么顺序执行回调”。 - - -不能解决的问题:“执行时序”。methodId 只能保证 “回调被正确匹配到原始调用”,但无法保证 “回调的执行顺序与业务预期的时序一致”。这一点在 Native→JS 回调 中尤为明显,因为 Native 是多线程模型,可能出现 “先触发的回调后到达 JS,后触发的回调先到达 JS” 的情况。 - - -### 为什么 Native→JS 必须用队列保证时序? -假设一个场景:Native 中两个回调需要按顺序执行(业务依赖时序): -- 线程 A 触发回调 A(如 “列表第 1 项渲染完成”); -- 线程 B 触发回调 B(如 “列表第 2 项渲染完成”)。 -业务预期是 A 先执行,B 后执行(保证列表渲染顺序正确)。但由于 Native 多线程并行,可能出现: -- 线程 B 的回调 B 先通过 callJS 发送到 JS; -- 线程 A 的回调 A 后发送到 JS。 -此时,即使 A 和 B 都有 methodId,JS 会先执行 B 再执行 A,导致业务逻辑错误(列表渲染顺序颠倒)。 -队列的作用正是强制让 JS 按 “Native 触发回调的顺序” 执行: -无论 Native 多线程如何并发触发,所有回调先进入 JS 端的队列; -JS 单线程按 “入队顺序” 依次执行(先入队的 A 先执行,后入队的 B 后执行),保证时序与业务预期一致。 - - - diff --git a/Chapter1 - iOS/1.145.md b/Chapter1 - iOS/1.145.md deleted file mode 100644 index e69de29..0000000 diff --git a/Chapter1 - iOS/1.146.md b/Chapter1 - iOS/1.146.md deleted file mode 100644 index 75976e6..0000000 --- a/Chapter1 - iOS/1.146.md +++ /dev/null @@ -1,1898 +0,0 @@ -# Weex 底层原理与 APM - -> 从 Vue 组件库(Vue Lib)到 Weex 渲染为 iOS 原生 UIKit 元素,核心是 “Virtual DOM → 跨线程通信 → Native DOM 构建 → 布局计算 → 原生 View 渲染 → 事件反向绑定” 的完整链路。Weex 作为中间层,主要完成 7 大核心工作。 -> -> Weex 是诸多年前的产物,部分业务线用 Weex 写了部分功能模块,或者是某几个页面,或者是某个二级、三级业务 SDK 的页面。但可以确定的是:新业务的实现只会在 Native 和 Flutter 上考虑;Weex 业务代码基本上是存量的;所以像 Native 一样去全面监控性能、网络、crash、异常、白屏、页面加载耗时等维度的话,ROI 是很低的。那么就需要制定一些策略去有针对性的监控高优问题。这时候就需要摒弃程序员视角(不然会陷入啥数据都想统计,可能是洁癖、可能是追求),但从 ROI 角度出发,我们就需要切换到用户视角。 -> -> 按照异常等级,可以划分为影响业务和不影响业务。什么叫“影响业务”?这是我们自己定义的标准,影响用户是否正常操作 App。比如:页面白屏(页面全部白屏、页面部分白屏)、点击某个按钮无响应,这些叫做“影响业务”,属于 Error 级别。其他的一些轻微异常,不影响用户使用 App 功能,不影响业务,属于 Warning 级别。 -> -> 关于异常的一些北极星指标的定义,以及 Weex 侧的稳定性与异常监控怎么做?请带着问题看文章 - -## 一、核心步骤 - -从 Vue Lib 到 Weex 原生 UIKit 元素的完整流程。 - -首先,用户的需求是 “列举 Weex 做的事情” 并 “结合最新源码标位置”,所以思考过程要先拆解流程:从 Vue 编译产物处理、JS-Native 通信、Virtual DOM 解析、DOM 构建与布局计算、渲染指令生成、原生 View 创建与更新,这几个核心步骤。每个步骤都要对应 Weex 的具体工作,还要找到最新源码中的对应类和方法。 - -第一步,应该是 Vue 编译产物的适配。因为 Vue 写的代码要被 Weex 识别,Weex 需要处理 Vue 的模板编译结果(比如 render 函数生成的 Virtual DOM),还要注入 Weex 的 JS Runtime API。这里要找最新源码中 JS Runtime 的初始化类,比如 WXJSContext 或 WXBridgeCore,因为 JS 环境的搭建是第一步。 - -第二步,JS-Native 通信的建立。Vue 的 Virtual DOM 变化需要传递给 Native,所以 Weex 的 Bridge 核心要注册通信方法(比如 callNative),这里对应 WXBridgeCore 的 invokeNative 方法,之前讨论过 bridge 队列,这里要结合最新源码中通信任务的调度。 - -第三步,Virtual DOM 的解析与 DOM 树构建。Weex 需要把 Vue 的 Virtual DOM(JSON 结构)解析成 Native 侧的 DOM 节点(WXDOMNode),这部分现在在 WXDOMCore 中,比如 createNode 方法,处理节点创建、属性更新,还要维护 DOM 树结构。 - -第四步,样式解析与 Yoga 布局计算。Vue 的样式(比如 flex)要被 Weex 解析,转换为 Yoga 的布局属性,然后在 dom 线程计算布局,这部分在 WXDOMCore 的_scheduleLayout 方法,调用 Yoga 的 calculateLayout。 - -第五步,渲染指令生成与传递。DOM 和布局计算完成后,要把结果封装成渲染指令(节点 ID、frame、类型等),通过主线程传递给渲染核心,这里是 WXDOMCore 的_notifyRender 方法,切换到 main queue。 - -第六步,原生 UIKit 元素的创建与更新。渲染核心(WXRenderCore)接收指令,创建对应 UIKit 控件(UILabel、UIImageView 等),或复用已有 View,更新 frame 和样式,这部分在 WXRenderCore 的 render 方法,还有具体的 View 工厂类(比如 WXViewFactory)。 - -第七步,事件绑定与反向通信。原生 UI 的事件(比如点击)要传递回 Vue,Weex 需要注册 Native 事件,通过 Bridge 回调 JS,这里对应 WXEventDispatcher 和 WXBridgeCore 的 callbackJS 方法。 - -具体的逻辑不做深入探讨,可以查看源码研究。 - - - -## 二、Yoga - -### 1. 支持"增量布局更新" - -当组件样式变化时,Yoga 仅重新计算受影响的组件树分支,而非全量重算,大幅减少 RN 应用的布局耗时和卡顿』Yoga 是如何实现仅计算受影响的组件树分支的?类似有个打标记,标记为 dirty? - -Yoga 实现增量布局的核心就是 「Dirty 标记机制」+「组件树依赖传播」—— 通过标记 “受影响的节点”,并仅处理这些节点及其关联分支,避免全量重算。 - -#### 1. YogaNode 与 Dirty 状态标识 -Yoga 中每个组件对应一个 YogaNode(布局计算的最小单元),每个节点都包含 3 个关键状态标记(用于判断是否需要重算): -- dirtyFlags(核心标记):记录节点的 “脏状态类型”,主要分两类: - - LAYOUT_DIRTY:节点自身样式(如 width、flex)或子节点布局变化,需要重新计算自身布局; - - MEASURE_DIRTY:节点的测量相关属性(如 measureFunction 自定义测量逻辑)变化,需要先重新测量尺寸,再计算布局。 -- isLayoutClean:布尔值,快速判断节点是否 “干净”(无脏状态),避免重复检查 dirtyFlags; -- childCount + children 指针:维护子节点列表,用于后续遍历依赖分支。 - - -#### 2. 脏状态触发与传播:从 “变化节点” 到 “根节点” 的冒泡 -当组件样式变化时(如 RN 中修改 style={{ flex: 2 }}),Yoga 会触发以下流程: - -- 步骤 1:标记自身为 Dirty -直接修改变化节点的 dirtyFlags |= LAYOUT_DIRTY(或 MEASURE_DIRTY),同时设置 isLayoutClean = false。 - -- 步骤 2:向上冒泡通知父节点 -由于父节点的布局(如尺寸、位置)依赖子节点的布局结果(比如父节点是 flex:1,子节点尺寸变化会影响父节点的剩余空间分配),因此会递归向上遍历父节点,直到根节点,将所有 “依赖节点” 都标记为 LAYOUT_DIRTY。 -关键优化:父节点仅标记 “需要重算”,但不会立即计算,避免中途重复触发计算。 - -- 步骤 3:跳过已标记的节点 -若某个节点已被标记为 Dirty,后续重复触发时会直接跳过(避免重复冒泡),提升效率。 - -#### 3. 布局计算阶段:只处理 Dirty 分支,跳过干净节点(DFS) -当 Yoga 触发布局计算(如 RN 渲染帧触发、组件挂载完成)时,会从根节点开始遍历组件树,但仅处理 “Dirty 节点及其子树”: - -- 步骤 1:根节点判断状态 -若根节点是干净的(isLayoutClean = true),直接终止计算(全量跳过);若为 Dirty,进入分支处理。 - -- 步骤 2:递归处理 Dirty 分支 -对每个节点,先检查自身状态: -- 若干净:直接复用上次缓存的布局结果(x/y/width/height),不重算; -- 若 Dirty: - - 先处理子节点:如果子节点是 Dirty,先递归计算子节点布局(保证父节点计算时依赖的子节点数据是最新的); - - 再计算自身布局:根据 Flex 规则(如 flexDirection、justifyContent)和子节点布局结果,计算自身的最终尺寸和位置; - - 清除 Dirty 标记:计算完成后,设置 dirtyFlags = 0、isLayoutClean = true,标记为干净。 - -- 步骤 3:增量更新的核心效果 -比如修改一个列表项的 margin,只会标记该列表项 → 父列表容器 → 根节点为 Dirty,其他列表项、页面其他组件均为干净,会直接跳过计算,仅重算 “列表项→父容器” 这一小分支。 - -### 2. Flex 布局逻辑如何到 Native 系统 - -Flex 布局逻辑,或者说 DSL,是如何翻译为 iOS 的 AutoLayout 和 Android 的 LayoutParams 的? - -Yoga 先将 Flex DSL 解析为统一的「布局计算结果」(节点的 x/y/width/height、间距、对齐方式等),再根据平台差异,将计算结果 “映射” 为对应平台的原生布局规则——iOS 映射为 AutoLayout 约束,Android 映射为 LayoutParams + 原生布局容器属性。 - -#### 1. 第一步:通用前置流程(跨平台统一) -无论 iOS 还是 Android,Yoga 都会先完成以下步骤,屏蔽 Flex DSL 的解析差异: -1. 解析 Flex 样式:将上层框架的 Flex 配置(如 RN 的 StyleSheet、Weex 的模板样式)解析为 YogaNode 的属性(如 flexDirection、justifyContent、margin、padding 等); -2. 执行布局计算:通过 Flexbox 算法(基于 Web 标准),计算出每个 YogaNode 的最终布局数据: -- 固定属性:width/height(含 auto/flex 计算后的具体数值)、x/y(相对父节点的坐标); -- 间距属性:marginLeft/Top/Right/Bottom、paddingLeft/Top/Right/Bottom; -- 对齐属性:alignItems、justifyContent 对应的节点相对位置关系; -3. 输出标准化布局数据:将上述结果封装为平台无关的结构体,供后续平台映射使用。 - -#### 2. 第二步:iOS 端:映射为 AutoLayout 约束(NSLayoutConstraint) -AutoLayout 的核心是「基于约束的关系描述」(而非直接设置坐标),因此 Yoga 会将 “计算出的具体尺寸 / 位置” 转化为 UIView 的约束(NSLayoutConstraint),核心映射规则如下:一一翻译 css 规则到 iOS AutoLayout 写法: - -| Flex 核心属性 | 对应的 AutoLayout 约束逻辑 | -| ----------------------------------------------------- | ------------------------------------------------------------ | -| `width: 100` | 映射为 `view.widthAnchor.constraint(equalToConstant: 100)` | -| `height: auto` | 先通过 Yoga 计算出具体高度(如文字高度、子节点包裹高度),再映射为 `heightAnchor` 约束;若为 `flex:1`,则映射为 `heightAnchor.constraint(equalTo: superview.heightAnchor, multiplier: 1)`(占满父容器剩余高度) | -| `marginLeft: 20` | 映射为 `view.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 20)` | -| `marginTop: 15` | 映射为 `view.topAnchor.constraint(equalTo: superview.topAnchor, constant: 15)` | -| `justifyContent: center`(父节点 flexDirection: row) | 父节点约束:`view.centerXAnchor.constraint(equalTo: superview.centerXAnchor)`;若有多个子节点,通过调整子节点间的 `spacing` 约束实现均匀分布 | -| `alignItems: center`(父节点 flexDirection: column) | 子节点约束:`view.centerYAnchor.constraint(equalTo: superview.centerYAnchor)` | -| `flex: 1`(子节点) | 映射为 `view.widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: 1)`(横向占满)+ 父节点的 `distribution` 约束(分配剩余空间) | - -补充信息: - -- Yoga 会为每个 `UIView` 关联一个 `YogaNode`,布局计算完成后,通过 `YogaKit`(或上层框架如 RN 的原生层)自动生成约束; -- 支持 “约束优先级” 适配:比如 `flex:1` 对应的约束优先级会高于固定尺寸约束,确保 Flex 规则优先生效; -- 混合布局兼容:若原生视图已有部分 AutoLayout 约束,Yoga 会生成 “补充约束”,避免冲突(通过 `active`属性控制约束启用 / 禁用)。 - - - -## 三、Weex 剖析 - -```mermaid -sequenceDiagram - participant V as Vue组件 - participant J as JS Framework - participant B as JS-Native Bridge - participant N as Native引擎 - participant P as 原生UI - - V->>J: .vue单文件 (template/style/script) - Note right of J: 编译阶段
weex-loader编译Vue组件 - J->>J: 生成Virtual DOM树 - Note right of J: 运行阶段
JS Framework管理VNode生命周期 - J->>B: 通过callNative发送
渲染指令JSON - Note right of B: 通信层
将JS调用转为原生模块调用 - B->>N: 传递渲染指令 - Note right of N: 原生渲染引擎
WXRenderManager (Android)
WXComponent (iOS) - N->>N: 解析指令,创建/更新组件树 - N->>P: 调用原生API渲染
(e.g., UIView, TextView) - P->>P: 最终原生视图 -``` - -下面针对核心机制详解与源码定位 - -### 1. 编译阶段:从 Vue 到 Virtual DOM - -- 处理 Vue 单文件:开发者的`.vue`文件通过 Webpack 和 `weex-loader` 编译成 JavaScript Bundle。这个 Bundle 包含了渲染页面所需的所有信息 -- 生成Virtual DOM:在JS运行时,Vue.js(或 Rax)的渲染函数会生成一棵 Virtual DOM树(VNode)。Weex 的 JS Framework 会拦截常规的 DOM 操作,将其导向 Weex 的渲染管道 - -源码相关:编译过程主要涉及 `weex-loader` (在 `weex-toolkit` 项目中),而 JS Framework 对 VNode 的处理在 `js-framework` 目录下。重点关注 `src/framework.js` 中的 `Document` 和 `Element` 类,它们模拟了 DOM 结构 - -### 2. 指令生成与通信 - -- 序列化为渲染指令(json 数据):JS-Framework 不会直接操作 Dom,而是把对 Dom 的操作,描述成对 VNode 对象的创建、更新、删除等,序列化成一种特殊的 JSON 格式的渲染指令。比如 - - ```json - { - "module": "dom", - "method": "createBody", - "args": [{"ref": "1", "type": "div", "style": {...}}] - } - ``` - -- JS-Native 桥接:这些指令通过 callNative 方法,从 JS 端发送到 Native 端,同时 Native 端也可以通过 callJS 方法向 JS 端发送事件(比如用户点击) - -### 3. 原生端渲染 - -- 指令解析与组件渲染:Native 端的渲染引擎(如 Android 的 WXRenderManger 和 iOS 的 WXComponentManager)接收并解析 JS 指令。Weex 维护了一个从 JS 组件到原生 UI 组件的映射表。(例如 映射到 iOS 的 UILabel) -- 布局与样式:Weex 使用的 Flexbox 布局模型做为统一的布局方案,Native 端需要将 JS 传递的 css 样式属性,转换为原生组件能够理解的布局参数与样式属性。 -- 多线程模型:为了保证 UI 流畅,Weex 采用了多线程模型。DOM 操作和布局计算通常在单独的 DOM 线程进行,而最终创建和更新原生视图的操作必须在 UI 主线程上进行 - -### 4. 拓展机制 - -- 模块(Module):用于暴露原生能力(如网络、存储)给前端调用,通过 callNative 触发,支持回调 -- 组件(Component):拓展自定义 UI 组件,允许开发者创建自定义的原生 UI 组件,并在 JSX 中使用 -- 适配器(Adapter):提供可替换的实现,如图片下载器 - - - -## 四、为什么自定义 Component 都需要继承自 WXComponent? - -比如下面的代码 - -````objective-c -[self registerComponent:@"image" withClass:NSClassFromString(@"WXImageComponent") withProperties:nil]; - -@interface WXImageComponent : WXComponent - -@end -```` - -答:**自定义原生组件必须继承自 WXComponent,本质是复用 Weex 封装的「JS - 原生交互、生命周期、样式布局、渲染基础」等通用能力,确保组件能接入 Weex 运行时生态**。 - -Weex Module 与 Componet 的区别 - -| 类型 | 核心作用 | 基类 | 示例 | -| --------- | ---------------------- | ------------- | ------------------------------------------------------------ | -| Component | 原生 UI 渲染(有视图) | `WXComponent` | `WXImageComponent`(图片)、`WXTextComponent`(文本)、自定义按钮组件 | -| Module | 功能扩展(无视图) | `WXModule` | `WXNavigatorModule`(导航)、`WXStorageModule`(存储)、自定义工具模块 | - -实现 JS 与原生组件的「数据同步」(属性、事件、方法) - -Weex 的核心是「JS 控制原生组件」,而 `WXComponent` 封装了 JS 与原生之间的通信协议,无需自定义组件手动处理: - -- 属性同步(Props):JS 端通过 `` 传递的属性,WXComponent 会自动解析、类型转换(如 JS 字符串 → 原生 NSString/NSNumber),并通过 `setter` 方法同步到自定义组件。 - - 示例:WXImageComponent 继承 `WXComponent` 后,只需重写 `-setSrc:(NSString*)src` 方法,就能接收 JS 传的 `src` 属性,无需关心「JS 如何把值传给原生」。 - -- 事件分发(Events):原生组件的交互事件(如点击、加载完成),`WXComponent` 会按照 Weex 协议回传给 JS 端(如 `@emit('click')` ) - - 示例:自定义按钮组件继承后,只需调用 `[self fireEvent:@"click" params:@{@"x": @100, @"y": @200}]` ,JS 端就能通过 `@onclick`接收事件,无需自己实现事件通信。 - -- 方法调用(Methods):JS 端通过 `this.$refs.myComponent.callMethod('xxx', params)` 调用原生组件方法,`WXComponent` - - 会解析方法名和参数,反射调用自定义组件的对应方法。 - - 示例:自定义播放器组件继承后,只需暴露 `-play`方法,JS 就能直接调用,`WXComponent`负责方法查找和参数传递。 - -## 五、JS 数据变化是如何驱动 Native UI 更新的 - -纯 Web 端的数据变化会通过 Proxy 去驱动关联的 UI 更新,这也是 Vue3 的工作原理,那么 JS 端的数据变化是如何驱动 Native UI 组件的更新的? - -所有的 Native UI Component 都继承自 WXComponent,所以可以直接给 WXComponent 添加一个实现 DataBinding 的 Category,这就是 Weex 最新源码中的 `WXComponent+DataBinding.mm` - -核心是:**解析 JS 端传递的「绑定表达式」(如 `{{a + b}}`),编译为原生可执行的回调 Block,当 JS 数据变化时,通过 Block 计算出组件所需的新值,自动更新组件的属性、样式、事件,或处理列表(`v-for`)、条件(`v-if`)、一次性绑定(`v-once`)等逻辑** - -可能有些人要问了:为什么当 js 数据变化时,需要让 Native 计算组件所需的新值?这不就是 Native 做了一遍 Vue 响应式的逻辑吗?这种重复逻辑的价值是什么? - -**Vue3 的 Proxy 只负责「JS 端数据变化的监听 + 依赖收集 + 触发更新通知」—— 它是 “响应式的触发器”,而非 “UI 更新的执行者”** - -而 Weex 之所以需要 Native 托管,核心是因为「继承自 WXComponent 的 UI 组件是 Native 侧的原生组件,而非 DOM 组件」,JS 端没有任何能力(API)去访问、操作他们,Proxy 再强大,它也只是 Native 侧(Weex)和 Web 端(Vue)负责“喊一声,哎,数据变了,你们谁需要的自助,自己去处理感兴趣的 UI”,却摸不到 UI 组件,Web 端由 DOM API 去渲染绘制,Native 端更触碰不到,必须由 Native 自己来完成:听到通知 -> 计算新值 -> 更新控件的流程。 - - - -### 1. Proxy 都做了些什么? - -Vue3 的核心实现里 Proxy 做了3件事:全程在 JS 侧,不涉及任何 UI 操作 - -监听数据操作:通过 Proxy 代理对象拦截数据的 getter、setter - -- 通过 getter 收集依赖关系:当组件渲染时触发 getter,Proxy 会记录这个组件依赖了这个数据 -- 通过 setter 触发更新通知:当数据被修改时触发 setter,Proxy 会告诉 Vue 运行时,“user.name” 变了,所有依赖它的组件该更新了 - -Proxy(代理)是 ES6 新增的内置对象,用于**创建一个对象的代理副本**,并通过「陷阱(Trap)」拦截对原对象的基本操作(如属性访问、赋值、删除等),从而自定义这些操作的行为。 - -```js -const proxy = new Proxy(target, handler); -``` - -- `target`:被代理的**原始对象**(可以是对象、数组,甚至函数); -- `handler`:配置对象,包含多个「陷阱方法」(如 `get`、`set`),用于定义拦截逻辑; -- `proxy`:代理对象,后续对原始对象的操作需通过代理对象进行,才能触发拦截。 - -| 陷阱方法 | 作用 | 触发场景 | -| ----------------------------------- | ----------------------- | ------------------------------------------- | -| `get(target, key, receiver)` | 拦截「属性访问」 | `proxy.key` 或 `proxy[key]` | -| `set(target, key, value, receiver)` | 拦截「属性赋值」 | `proxy.key = value` 或 `proxy[key] = value` | -| `deleteProperty(target, key)` | 拦截「属性删除」 | `delete proxy.key` | -| `has(target, key)` | 拦截「`in` 运算符判断」 | `key in proxy` | - -Tips: Proxy 代理的是「整个对象」,而非单个属性,且拦截的是「操作行为」(如 “访问属性” 这个动作),而非属性本身。 - -Vue 核心流程:**创建代理 → 依赖收集 → 数据修改 → 触发更新**。 - -#### 1. 创建代理(reactive 函数的核心) - -`reactive` 函数接收一个原始对象,返回其 Proxy 代理对象,同时配置 `get`、`set` 等陷阱方法,为后续依赖收集和更新做准备 - -```javascript -function reactive(target) { - return new Proxy(target, { - // 拦截属性访问 - get(target, key, receiver) { - // 1. 先获取原始属性值 - const value = Reflect.get(target, key, receiver); - // 2. 收集依赖(关键:记录“谁在访问这个属性”) - track(target, key); - // 3. 若访问的是嵌套对象,递归创建代理(懒代理,优化性能) - if (typeof value === 'object' && value !== null) { - return reactive(value); - } - return value; - }, - // 拦截属性赋值 - set(target, key, value, receiver) { - // 1. 先设置原始属性值 - const oldValue = Reflect.get(target, key, receiver); - const success = Reflect.set(target, key, value, receiver); - // 2. 若值发生变化,触发依赖更新 - if (success && oldValue !== value) { - trigger(target, key); - } - return success; - }, - // 拦截属性删除 - deleteProperty(target, key) { - const success = Reflect.deleteProperty(target, key); - if (success) { - trigger(target, key); // 删除属性也触发更新 - } - return success; - } - }); -} -``` - -- 用 `Reflect` 操作原始对象,Reflect 是 ES6 新增的内置对象,提供了与 Proxy 陷阱对应的方法,比如 `Relect.get`、`Reflect.set` 确保操作原始对象的行为一直,同时避免直接操作 target 所产生的问题 -- 嵌套对象懒代理:Proxy 仅代理当前层级对象,当访问嵌套对象 (proxy.user.name)时,才递归对 user 对象创建代理,避免初始化时递归遍历所有属性,优化性能 - -#### 2. 依赖收集 - -Vue3 用「三层映射」存储依赖,确保精准定位 - -```javascript -// WeakMap:key 是被代理的原始对象(target),value 是该对象的属性-依赖映射 -const targetMap = new WeakMap(); - -function track(target, key) { - // 1. 若没有当前目标对象的映射,创建一个(Map:key 是属性名,value 是依赖集合) - if (!targetMap.has(target)) { - targetMap.set(target, new Map()); - } - const depsMap = targetMap.get(target); - - // 2. 若没有当前属性的依赖集合,创建一个(Set:存储依赖函数,去重) - if (!depsMap.has(key)) { - depsMap.set(key, new Set()); - } - const deps = depsMap.get(key); - - // 3. 将当前活跃的依赖函数(effect)添加到集合中 - if (activeEffect) { - deps.add(activeEffect); - } -} -``` - -会产生一个这样的结构 - -```json -{ - "" -} -``` - -#### 3. 数据修改(触发 set/deleteProperty 的陷阱) - -当通过代理对象修改属性(如 `proxy.name = 'newName'`)或删除属性(如 `delete proxy.age`)时,会触发对应的 Proxy 陷阱(`set` 或 `deleteProperty`)。 - -陷阱函数会先更新原始对象的属性值,再判断值是否真的发生变化(避免无效更新) - -#### 4. 触发更新 (tigger 函数) - -```javascript -function trigger(target, key) { - // 1. 从 targetMap 中获取当前对象的属性-依赖映射 - const depsMap = targetMap.get(target); - if (!depsMap) return; - - // 2. 获取当前属性的所有依赖 - const deps = depsMap.get(key); - if (!deps) return; - - // 3. 执行所有依赖函数(触发更新) - deps.forEach(effect => effect()); -} -``` - -### 2. Proxy 不做的事情 - -- 不计算表达式(比如 user.name + "后缀"的结果,Proxy 不管) -- 不操作 UI(不管是 DOM 和 Native 控件,Proxy 都不碰) -- 不跨端通信 - -为什么 Native 组件不能让 Proxy “解决”? - -核心矛盾:渲染载体不同。Proxy 之所以在 Web 端能 “间接驱动 UI”,是因为 Web 端有个「中间桥梁」—— DOM,且 JS 端有完整的 DOM API(比如 `document.getElementById`、`element.style.setProperty`): - -Web 端完整链路:Proxy 触发更新 → Vue 运行时计算表达式 → 虚拟 DOM diff → 调用 DOM API 操作 DOM → UI 更新 - -- **JS 端没有操作 Native 控件的 API**:浏览器给 JS 暴露了 DOM API,但 iOS/Android 系统不会给 JS 引擎暴露 “修改 `UILabel` 文本”“设置 `UIImageView` 图片” 的 API —— JS 端连 Native 控件的 “引用” 都拿不到,更别说更新了; -- **Native 控件不在 JS 运行时的内存空间**:JS 引擎(如 V8、JSC)和 Native 应用是两个独立的 “进程 / 虚拟机”,内存不共享 —— Proxy 所在的 JS 内存里,根本没有 Native 控件的实例,想操作都无从下手 - -Weex 的设计优雅之处在于:Native 托管“执行层”,Proxy 保留“触发层”。响应式工作继续复用现有逻辑,由 Proxy 完成,最后的执行层由 Native 实现,也就是 WXComponent+DataBinding - -- **响应式系统(Proxy)的核心是 “发现变化”**:不管是 Web 还是 Weex,Proxy 都只干这件事; -- **UI 更新的核心是 “操作渲染载体”**:Web 端操作 DOM(JS 端能做),Weex 端操作 Native 控件(只能 Native 端做); -- **WXComponent+DataBinding 的角色是 “Native 端的 UI 执行器”**:它不是替代 Proxy,而是 Proxy 触发更新后,负责把 “更新通知” 落地到 Native 控件上的唯一途径 - - - -## 六、Weex 自定义组件是如何工作的 - -上面分析了自定义组件的数据变化和表达式运算是 Native 负责的,执行层也就是 `WXComponent+DataBinding.mm` 这个类。 - -一言以蔽之就是:把 JS 端传递的“原始数据”,通过预编译的绑定规则(Block)计算出 Native 组件需要的最终值,并自动更新 UI 组件,同时适配长列表组件等复杂场景的 UI 优化。 - -该分类为所有继承自 WXComponent 的组件,注入“数据绑定能力”,无需手动实现。 - -### 1. 绑定规则的“编译存储”,把 JS 表达式转换为 Native 可执行的 block - -数据绑定的「前置准备」:在组件初始化时,解析 JS 端传递的绑定规则(如 `[[user.name]]`、`[[repeat]]`),编译为 Native 可执行的 `WXDataBindingBlock`(代码块),并存储到组件的绑定映射表中(`_bindingProps/_bindingStyles/_bindingEvents` 等) - -```objective-c -- (void)_storeBindingsWithProps:(NSDictionary *)props styles:(NSDictionary *)styles attributes:(NSDictionary *)attributes events:(NSDictionary *)events; -``` - -接收组件的 props/attrbutes/styles/events 中的绑定规则,解析并存储为可执行的 block。 - -1. **识别绑定表达式**:判断是否包含 `WXBindingIdentify`(`@"@binding"`)标记,比如 `{"src": {"@binding": "user.name"}}`; -2. **AST 解析**:通过 `WXJSASTParser` 把绑定表达式字符串(如 `"user.name + '后缀'"`)解析为 AST 节点(`WXJSExpression`); -3. **生成执行 Block**:调用 `-bindingBlockWithExpression:` 把 AST 节点转成 `WXDataBindingBlock`(后续数据变化时直接执行该 Block 计算结果); -4. 分类存储:按绑定类型(属性 / 样式 / 事件 / 特殊绑定)存入对应的映射表: - - `_bindingProps`:属性绑定(如 `src`); - - `_bindingStyles`:样式绑定(如 `fontSize`); - - `_bindingEvents`:事件绑定(如 `onClick` 参数); - - 特殊绑定:`_bindingRepeat`(`[[repeat]]` 对应 `v-for`)、`_bindingMatch`(`[[match]]` 对应 `v-if`)、`_dataBindOnce`(`[[once]]` 对应 `v-once`)。 - -### 2. WXComponentManager 都做了什么 - -`WXComponentManager` 是 Weex iOS 端的 **组件全生命周期与任务调度核心**,所有与 Native 组件相关的操作(创建、更新、布局、销毁、事件绑定)都由它统一管理,同时承担「线程分工协调、UI 任务批量处理、性能监控」等关键职责,是连接 JS 指令、Native 组件、布局引擎和 UI 渲染的 “中枢大脑”。 - -#### 1. 组件线程管理 - -组件业务的 “专属执行环境”,作为组件线程的「创建者和维护者」,`WXComponentManager` 确保所有组件核心操作都在**全局唯一的组件线程**中执行,避免线程安全问题和主线程阻塞。 - -核心工作: - -- 懒加载创建全局组件线程(`+componentThread`),启动 RunLoop 确保线程常驻(`_runLoopThread`) -- 提供线程调度接口:`WXPerformBlockOnComponentThread`(异步)、`WXPerformBlockSyncOnComponentThread`(同步),让外部模块(如 `WXBridgeManager`)能将组件任务提交到组件线程 -- 线程断言约束:所有组件核心方法(如 `createBody`、`updateStyles`)开头都有 `WXAssertComponentThread`,强制组件操作在组件线程执 - -#### 2. 组件树构建与管理:组件的 “增删改查” 全生命周期 - -核心工作: - -- 创建组件 - - 根组件创建(`createBody:`):接收 JS 端根组件指令,创建页面根组件(如 `
` 根节点),绑定到页面根视图; - - 子组件创建(`addComponent:type:parentRef:`):根据 JS 端指令,创建子组件并关联父组件,存入 `_indexDict`(组件 ref → 实例映射,快速查找)。 -- 更新组件关系 - - 移动组件(`moveComponent:toSuper:atIndex:`):调整组件在组件树中的位置,同步更新视图层级; - - 删除组件(`removeComponent:`):从组件树和索引字典中移除组件,递归删除子组件,释放视图资源。 -- 组件查询与遍历 - - 按 ref 查找组件(`componentForRef:`):供 JS 端 `this.$refs` 访问原生组件实例; - - 遍历组件树(`enumerateComponentsUsingBlock:`):支持递归遍历所有组件(如性能统计、全局样式更新) - -#### 3. 数据绑定辅助:绑定规则的提取与存储 - -配合 `WXComponent+DataBinding` 模块,`WXComponentManager` 在组件创建时,从 JS 端传递的 `props`/`styles`/`attributes` 中提取「绑定表达式配置」,为响应式更新铺路。核心工作: - -- 提取绑定规则: - - `_extractBindings:`:从样式 / 属性中提取 `[[repeat]]`/`{"@binding": "expr"}` 等绑定配置,移除原始字典中的绑定字段(避免干扰普通属性处理) - - `_extractBindingEvents:`:从事件数组中提取绑定参数(如 `onClick` 的回调表达式); - - `_extractBindingProps:`:提取组件自定义 props 绑定(`@componentProps`)。 -- 存储绑定规则:调用组件的 `_storeBindingsWithProps:styles:attributes:events:`,将提取的绑定配置存入组件实例,后续数据变化时触发表达式计算。 - -#### 4. 组件更新调度:样式 / 属性 / 事件的 “同步与执行” - -当 JS 端触发组件更新(如修改样式、属性、绑定事件)时,`WXComponentManager` 负责「跨线程调度、数据预处理、UI 同步」,确保更新流程高效且安全。 - -- 样式更新(`updateStyles:forComponent:`) - - 组件线程:过滤无效样式(如空值),更新组件实例的样式数据,触发布局计算; - - 主线程:通过 `_addUITask` 将样式更新任务(如设置 `CALayer.backgroundColor`、`UILabel.font`)批量调度到主线程执行。 -- **属性更新(`updateAttributes:forComponent:`)**:类似样式更新,组件线程处理数据逻辑,主线程更新原生组件属性(如 `UIImageView.image`、`UIScrollView.contentOffset`)。 -- 事件绑定 / 解绑 - - 组件线程:维护组件的事件列表(如 `click`/`scroll`); - - 主线程:绑定 / 移除原生手势识别器(如 `UITapGestureRecognizer`),捕获用户交互。 -- **批量更新优化**:通过 `performBatchBegin`/`performBatchEnd` 标记批量更新范围,合并多个 UI 任务,减少主线程调度次数(提升性能)。 - -#### 5. 布局调度与 UI 同步:从布局计算到 UI 渲染 - -Weex 采用 Flex 布局引擎(Yoga),`WXComponentManager` 负责布局计算的触发、组件 frame 分配、UI 任务批量执行,确保组件按预期位置渲染。 - -- 触发布局计算:组件更新、根视图尺寸变化(`rootViewFrameDidChange:`)时,调用 `_layoutAndSyncUI` 触发 `WXCoreBridge` 执行 Yoga 布局计算,得到所有组件的 frame。 -- 分配组件 frame:`layoutComponent:frame:isRTL:innerMainSize:` 将计算后的 frame 分配给组件,若为根组件,同步更新页面根视图尺寸(适配 `wrap_content` 模式)。 -- UI 任务同步:`_syncUITasks` 批量执行 `_uiTaskQueue` 中的 UI 任务(如 `addSubview`、`setFrame`),异步调度到主线程,避免频繁主线程切换导致掉帧。 -- 帧率同步:通过 `WXDisplayLinkManager` 监听屏幕刷新率(60fps),确保布局更新与帧率同步,提升渲染流畅度。 - - - -#### 6. 生命周期与资源释放:页面卸载时的 “清理工作” - -当 Weex 页面销毁(`WXSDKInstance` 卸载)时,`WXComponentManager` 负责清理组件资源,避免内存泄漏。 - -核心工作(`unload` 方法): - -- 停止布局调度:调用 `_stopDisplayLink`,停止帧率监听和布局计算; -- 解绑渲染资源:遍历所有组件,解除与底层渲染对象(`RenderObject`)的绑定; -- 释放 UI 资源:调度到主线程,销毁所有组件的原生视图(`_unloadViewWithReusing:`); -- 清空状态:清空 `_indexDict`、`_uiTaskQueue`、`_fixedComponents` 等容器,解除与 `WXSDKInstance`的绑定。 -- 清除事件绑定:清除所有的事件、手势等逻辑 - - - -## 七、WXModule 的注册机制及其调用流程 - -```mermaid -sequenceDiagram - participant JS as JS环境 - participant B as WXBridge - participant MF as WXModuleFactory - participant MM as WXModuleManager - participant MI as Module实例 - participant MC as 自定义Module - - Note over JS,MC: 注册阶段 - MC->>+MF: registerModule("customModule", MyModule.class) - MF->>MF: 生成ModuleFactory并缓存 - MF->>MF: 反射解析@JSMethod方法 - MF->>B: 将模块&方法信息传递给JS - - Note over JS,MC: 调用阶段 - JS->>+B: weex.requireModule('customModule').myMethod(args) - B->>+MM: 调用 invokeModuleMethod - MM->>+MF: 获取Module实例和方法Invoker - MF->>MF: 查找/创建Module实例 - MF->>MF: 获取方法Invoker - MF->>MM: 返回实例和Invoker - MM->>+MI: 通过Invoker.invoke调用 - MI->>+MC: 执行原生方法实现 - MC->>JS: 通过callback回调JS(可选) -``` - -### 1. WXModule 的注册分为 Naitve 注册和 JS 注册 - -- **Native 注册**:在 Native 端,调用 `[WXSDKEngine registerModule:withClass:]` 方法(在 iOS 中) ,这个过程会将自定义 Module 的类和一个模块名称(例如 `TestModule`)建立映射关系,并生成一个 `ModuleFactory` 存储在一个全局的 Map(例如 `sModuleFactoryMap`)中。同时,如果该 Module 被标记为全局(global),SDK 会立即创建一个实例并缓存起来。 -- **JS 注册**:Native 注册完成后,Weex 会将所有已注册 Module 的**模块名称**及其**暴露给 JS 的方法名列表**,通过 `WXBridge`(JS-Native 通信桥梁)传递给 JS 引擎。这样,JS 端就知道存在哪些模块以及每个模块有哪些方法可以调用。 - -### 2. 当 JS 调用 Module 方法时 - -- JS 发起调用:在 JS 代码中,通过 `weex.requireModule('moduleName')` 获取模块实例 。然后吊影其方法,比如 'staream.fetch()options, callack)' -- Bridge 桥接:JS 引擎通过 JSBridge 将这次调用(包括模块名、方法名、参数等信息)传递给 Native 段 -- Native 端查找与执行:Native 端的 WXModuleManager 根据模块名从之前注册的工厂中获取创建的 Module 实例,并根据方法名找到对应的 MethodInvoker。MethodInvoker 会通过反射手段调用具体的 Native 方法 -- 结果回调:如果有需要,Native 可以通过 WXModuleCallBack 或者 WXModuleKeepAliveCallBack 将结果回调给 JS。WXModuleCallback 只能回调1次,而 WXModuleKeepAliveCallback 可以多次回调 - -### 3. WXModuleProtocol 的作用 - -**`WXModuleProtocol` 是一个协议,定义了 Module 的行为规范**。你的自定义 Module 必须遵循此协议。它声明了 Module 需要实现的方法或属性,例如如何暴露方法给 JS(通过 `WX_EXPORT_METHOD` 宏)、方法在哪个线程执行(通过实现特定的方法返回目标线程,例如 `targetExecuteThread`)、以及如何通过 `weexInstance` 属性弱引用持有它的 WXSDKInstance 实例。 -通过遵循 `WXModuleProtocol`,你自定义的 Module 就能被 Weex SDK 正确识别和调 - -### 4. WXModuleFactory 的作用 - -1. **存储配置**:在注册阶段,它会缓存 Module 的配置信息,例如模块名和对应的工厂类(`WXModuleConfig`)。 -2. **方法解析**:通过反射,解析 Module 类中所有通过 `WX_EXPORT_METHOD` 或 `WX_EXPORT_METHOD_SYNC` 宏暴露的方法,并生成方法名与 `MethodInvoker`(封装了反射调用逻辑)的映射关系。 -3. 提供实例:当 JS 调用 Module 方法时,`WXModuleManager` 会通过 `WXModuleFactory` 根据模块名获取或创建 Module 实例,以及对应方法的 `MethodInvoker`。 - - - -## 八、Weex 分为几个线程 - -### 1. 主线程 - -核心定位:应用的 UI 线程(与原生 App 主线程同源),负责 UI 渲染、用户交互响应,**禁止耗时操作**。 - -核心职责: - -- 承载 Weex 页面的 **原生渲染容器**(如 Android 的 `WXFrameLayout`、iOS 的 `WXSDKInstanceView`),执行视图布局、绘制、动画触发; -- 处理用户交互事件(点击、滑动、输入等),并将事件转发给 JS 线程(如需要 JS 逻辑响应时); -- 执行原生模块的 **主线程方法**(通过 `@WXModuleAnnotation(runOnUIThread = true)` 标记的方法,如弹 Toast、更新 UI 的原生能力); -- 接收 JS 线程下发的 **UI 操作指令**(如创建视图、修改样式、更新属性),并映射为原生视图操作; - -**关键约束**:所有直接操作原生视图的逻辑必须在主线程执行,否则会导致 UI 错乱或崩溃 - - - -### 2. JS 线程 - -核心定位:Weex 的 “业务逻辑线程”,独立于主线程,专门运行 JavaScript 代码,避免阻塞 UI。 - -核心职责: - -- 加载并执行 Weex 业务代码(`.we` 编译后的 JS bundle),包括 Vue/React 组件初始化、数据绑定、生命周期管理; -- 处理 JS 层面的业务逻辑(事件响应、数据计算、接口请求预处理); -- 调用原生模块时,通过 **JSBridge 转发请求**(区分同步 / 异步,同步请求会短暂阻塞 JS 线程,需谨慎使用); -- 生成 UI 操作指令(如 `createElement`、`updateStyle`),通过跨线程通信发送给主线程执行; -- 接收主线程转发的用户交互事件(如点击回调),执行对应的 JS 事件处理函数; - -关键优化**:最新版本中,JS 线程支持 **Bundle 预加载**、**懒加载组件**,减少启动耗时;同时通过 `JSContext`隔离多个 Weex 实例,避免线程内资源竞争。 - - - -### 3. 耗时线程 - -#### 1. 网络线程 - -核心定位:Weex 框架封装的 **专用网络线程**(跨端统一调度),避免网络请求阻塞主线程或 JS 线程。 - -核心职责: - -- 处理 Weex 内置的网络请求(如 `weex.requireModule('stream')` 发起的 HTTP/HTTPS 请求); -- 负责 JS Bundle 的下载(首次加载或更新时),支持断点续传、缓存管理; -- 处理网络请求的拦截、重试、超时控制(框架层统一实现,无需业务关心); -- 将网络响应结果通过 JSBridge 回传给 JS 线程; - -设计亮点:与原生系统的网络库解耦,但对外暴露统一的 JS API,线程调度由框架内部管理,业务无需手动切换线程 - -#### 2. 图片下载线程 - -核心定位:专门处理 Weex 图片的异步加载、解码,避免占用主线程资源导致 UI 卡顿。 - -核心职责: - -- 加载网络图片、本地图片(通过 `img` 标签或 `weex.requireModule('image')`); -- 图片解码、压缩(适配视图尺寸,减少内存占用); -- 图片缓存管理(内存缓存 + 磁盘缓存,框架层统一维护); -- 加载完成后,将图片 bitmap 提交到主线程渲染; - -iOS 侧图片加载线程的核心管理类是 `WXImageComponent`。 - - - -Weex 线程职责边界清晰:**UI 操作归主线程,JS 逻辑归 JS 线程,耗时操作归工作线程 / 网络线程**,避免跨线程直接操作资源 - -## 九、JS 和 Native 通信 - -### 1. callJS 和 callNative - -| 通信方向 | 发起方 | 接收方 | 核心目的 | 典型场景 | -| ------------ | ------ | ------ | -------------------------------- | ------------------------------------------------------ | -| `callNative` | JS | Native | JS 调用 Native 的模块 / 组件接口 | 渲染组件、弹 Toast、获取设备信息 | -| `callJS` | Native | JS | Native 触发 JS 的回调函数 | 组件事件回调(如按钮点击)、数据同步(如网络请求结果) | - -两者的底层依赖 **同一个 JS Bridge 通道**,只是「发起方」和「数据格式」不同,Weex 已封装好统一的通信框架,开发者无需关心底层传输细节 - -### 2. callNative 实现 - -`callNative` 是 JS 主动调用 Native 接口的过程,核心流程:**JS 构造标准化指令 → 序列化 JSON → 桥接通道发送 → Native 解析指令 → 执行对应接口 → 响应结果回传**。 - -怎么样?是不是感觉似曾相识,早期做 Hybrid 的时候,JS 和 Native 的通信也是一样的流程,感兴趣的可以查看[这篇文章](./1.44.md)。 - -是的,通信要解决的问题一直不变,所以方案也不变。 - -#### 1. 标准化指令格式 - -为了让 Native 能统一解析,Weex 规定 `callNative` 的指令必须包含 4 个核心字段(JS 端构造): - -```json -const callNative指令 = { - module: "component", // 模块名(如 component/modal/device) - method: "create", // 方法名(如 create/toast/getInfo) - params: {}, // 入参(如组件样式、Toast 内容) - callbackId: "cb_123" // 回调 ID(用于 Native 回传结果) -}; -``` - -- `module` + `method`:定位 Native 端的具体接口(如 `modal.toast` 对应 Native 的「弹 Toast」接口); -- `params`:JS 传递给 Native 的数据(需是 JSON 兼容类型); -- `callbackId`:唯一标识当前请求,Native 执行完成后通过该 ID 找到对应的 JS 回调函数。 - -#### 2. JS 端实现 - -JS 侧调用 Native 的核心是3个实例方法,对应3类场景 - -| 方法名 | 用途 | 对应 Native 接口 | -| --------------- | -------------------------------------------- | ---------------------------------- | -| `callModule` | 调用 Native 普通模块(如 `modal`/`storage`) | `global.callNativeModule` | -| `callComponent` | 调用 Native 自定义组件方法 | `global.callNativeComponent` | -| `callDOM` | 调用 DOM 相关 Native 方法(如创建元素) | `global.callAddElement` 等独立方法 | - -这3个方法都会通过 Native 注入的全局函数(global 上的方法)将调用传递给 Native 层 - -这3个方法在源码最后 - -```javascript -// 调用 DOM 相关 Native 方法 -callDOM (action, args) { - return this[action](this.instanceId, args) -} - -// 调用 Native 自定义组件方法 -callComponent (ref, method, args, options) { - return this.componentHandler(this.instanceId, ref, method, args, options) -} - -// 调用 Native 普通模块方法(最常用,对应原 callNative) -callModule (module, method, args, options) { - return this.moduleHandler(this.instanceId, module, method, args, options) -} -``` - -##### 1. 普通模块调用 callModule → moduleHandler - -`moduleHandler` 是普通模块调用的最终转发函数,源码中通过 `global.callNativeModule` 对接 Native: - -```javascript -proto.moduleHandler = global.callNativeModule || - ((id, module, method, args) => - fallback(id, [{ module, method, args }])) -``` - -- 正常情况(客户端环境):`global.callNativeModule` 是 **Native 注入到 JS 全局的函数**(iOS/Android 原生实现),直接接收 `instanceId`、模块名、方法名、参数,传递给 Native 层。 -- 降级情况(无 Native 桥接):调用 `fallback` 函数(初始化时由 `sendTasks` 参数传入,通常用于调试 / 模拟)。 - -##### 2. 自定义组件调用 callComponent → componentHandler - -逻辑与 `moduleHandler` 一致,对接 `global.callNativeComponent`: - -```javascript -proto.componentHandler = global.callNativeComponent || - ((id, ref, method, args, options) => - fallback(id, [{ component: options.component, ref, method, args }])) -``` - -##### 3. DOM 方法调用 callDOM → 独立全局函数映射 - -DOM 相关的 Native 方法(如 `addElement`/`updateStyle`)被单独映射到 `global` 上的独立函数(而非统一的 `callNative`),源码通过 `init` 函数初始化映射: - -```javascript -// 源码第 116-138 行:DOM 方法与 Native 全局函数的映射 -export function init () { - const DOM_METHODS = { - createFinish: global.callCreateFinish, - addElement: global.callAddElement, // DOM 创建元素 → Native 的 callAddElement - removeElement: global.callRemoveElement, // DOM 删除元素 → Native 的 callRemoveElement - updateAttrs: global.callUpdateAttrs, // 更新属性 → Native 的 callUpdateAttrs - // ... 其他 DOM 方法 - } - const proto = TaskCenter.prototype - - // 给 TaskCenter 原型挂载 DOM 方法,直接调用 Native 注入的全局函数 - for (const name in DOM_METHODS) { - const method = DOM_METHODS[name] - proto[name] = method ? - (id, args) => method(id, ...args) : // 正常情况:调用 Native 全局函数 - (id, args) => fallback(...) // 降级情况 - } -} -``` - -例如调用 `callDOM('addElement', args)` 时,最终会执行 `global.callAddElement(instanceId, ...args)`,直接对接 Native 的 DOM 模块。其实是注入到 JSContext 里的方法对象。 - -在 Weex 的 JS 运行环境中,`global` 是 **JS 全局对象(Global Object)**—— 它是所有 JS 代码的 “顶层容器”,所有未被定义在局部作用域的变量、函数,最终都会挂载到 `global` 上(类似浏览器环境的 `window`,Node.js 环境的 `global`) - -**Native 向 JS 引擎的 “全局上下文” 注入 `callAddElement` 函数时,该函数会自动成为 `global` 对象的属性**——JS 侧的 `global.callAddElement`,本质就是访问这个被 Native 注入到全局的函数。 - -QA:global 是什么? - -是 JS 全局对象。不管是浏览器、Node.js 还是 Weex 的 JS 引擎(JavaScriptCore/QuickJS),都有一个 **全局对象(Global Object)**: - -- 它是 JS 运行环境的 “根”,所有全局变量、函数都是它的属性; -- 不同环境的全局对象名称不同: - - 浏览器环境:叫 `window`(比如 `window.alert`、`window.document`); - - Node.js 环境:叫 `global`(比如 `global.console`、`global.setTimeout`); - - Weex 环境:叫 `global`(因为 Weex 不依赖浏览器,没有 `window`,直接用 JS 引擎原生的全局对象 `global`)。 - -```objective-c -// WXJSCoreBridge.mm -- (void)registerCallAddElement:(WXJSCallAddElement)callAddElement -{ - id callAddElementBlock = ^(JSValue *instanceId, JSValue *ref, JSValue *element, JSValue *index, JSValue *ifCallback) { - NSString *instanceIdString = [instanceId toString]; - WXSDKInstance *instance = [WXSDKManager instanceForID:instanceIdString]; - if (instance.unicornRender) { - JSValueRef args[] = {instanceId.JSValueRef, ref.JSValueRef, element.JSValueRef, index.JSValueRef}; - [WXCoreBridge callUnicornRenderAction:instanceIdString - module:"dom" - method:"addElement" - context:[JSContext currentContext] - args:args - argCount:4]; - return [JSValue valueWithInt32:0 inContext:[JSContext currentContext]]; - } - - NSDictionary *componentData = [element toDictionary]; - NSString *parentRef = [ref toString]; - NSInteger insertIndex = [[index toNumber] integerValue]; - if (WXAnalyzerCenter.isInteractionLogOpen) { - WXLogDebug(@"wxInteractionAnalyzer : [jsengin][addElementStart],%@,%@",instanceIdString,componentData[@"ref"]); - } - return [JSValue valueWithInt32:(int32_t)callAddElement(instanceIdString, parentRef, componentData, insertIndex) inContext:[JSContext currentContext]]; - }; - - _jsContext[@"callAddElement"] = callAddElementBlock; -} -``` - -在 js 侧是通过 TaskCenter.js 的 init 方法中定义的,存在映射关系, `addElement: global.callAddElement,` - - - -### 3. callJS 实现 - -`WXReactorProtocol` 协议: - -- 定义 Native 调用 JS 的「标准接口」(如触发回调、发送事件),不关心底层用哪种 JS 引擎(JavaScriptCore / 其他); -- 具体的桥接类(如 `WXJSCoreBridge`)遵守这个协议,实现接口方法 —— 即使未来替换 JS 引擎,只要遵守协议,上层代码(如 Native 模块、组件)无需修改。 - -```objective-c - -@class JSContext; - -@protocol WXReactorProtocol - -@required - -/** -Weex should register a JSContext to reactor -*/ -- (void)registerJSContext:(NSString *)instanceId; - -/** - Reactor execute js source -*/ -- (void)render:(NSString *)instanceId source:(NSString*)source data:(NSDictionary* _Nullable)data; - -- (void)unregisterJSContext:(NSString *)instanceId; - -/** - When js call Weex NativeModule, invoke callback function - - @param instanceId : weex instance id - @param callbackId : callback function id - @param args : args -*/ -- (void)invokeCallBack:(NSString *)instanceId function:(NSString *)callbackId args:(NSArray * _Nullable)args; - -/** -Native event to js - -@param instanceId : instance id -@param ref : node reference -@param event : event type -@param args : parameters in event object -@param domChanges : dom value changes, used for two-way data binding -*/ -- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref event:(NSString *)event args:(NSDictionary * _Nullable)args domChanges:(NSDictionary * _Nullable)domChanges; - -@end -``` - -Native 模块(Module)/组件(Component) 完成任务后 -> `WXBridgeManager.callBack(...)` → 构造 JS 脚本(调用 `TaskCenter.callback`) → `WXJSCoreBridge.executeJavascript(...)` → JS 引擎执行 → `TaskCenter.callback` 响应 - -`WXJSCoreBridge` 本身不直接拼接回调脚本,而是提供 `executeJavascript:` 方法(源码第 102 行),作为 JS 脚本执行的底层入口;真正的脚本构造,在 `WXBridgeManager` 中 - -WXBridgeManager 事件回调 - -```javascript -- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params -{ - [self fireEvent:instanceId ref:ref type:type params:params domChanges:nil]; -} - -- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges -{ - [self fireEvent:instanceId ref:ref type:type params:params domChanges:domChanges handlerArguments:nil]; -} -- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges handlerArguments:(NSArray *)handlerArguments -{ - // ... - WXCallJSMethod *method = [[WXCallJSMethod alloc] initWithModuleName:nil methodName:@"fireEvent" arguments:[WXUtility convertContainerToImmutable:args] instance:instance]; - [self callJsMethod:method]; -} - -- (void)callJsMethod:(WXCallJSMethod *)method -{ - if (!method || !method.instance) return; - - __weak typeof(self) weakSelf = self; - WXPerformBlockOnBridgeThreadForInstance(^(){ - WXBridgeContext* context = method.instance.useBackupJsThread ? weakSelf.backupBridgeCtx : weakSelf.bridgeCtx; - [context executeJsMethod:method]; - }, method.instance.instanceId); -} -``` - -WXBridgeContext.m 代码如下: - -```javascript -- (void)executeJsMethod:(WXCallJSMethod *)method { - // ... - [sendQueue addObject:method]; - [self performSelector:@selector(_sendQueueLoop) withObject:nil]; -} - -- (void)_sendQueueLoop { - if ([tasks count] > 0 && execIns) { - WXSDKInstance * execInstance = [WXSDKManager instanceForID:execIns]; - NSTimeInterval start = CACurrentMediaTime()*1000; - - if (execInstance.instanceJavaScriptContext && execInstance.bundleType) { - [self callJSMethod:@"__WEEX_CALL_JAVASCRIPT__" args:@[execIns, [tasks copy]] onContext:execInstance.instanceJavaScriptContext completion:nil]; - } else { - [self callJSMethod:@"callJS" args:@[execIns, [tasks copy]]]; - } - // ... - } -} - -- (void)callJSMethod:(NSString *)method args:(NSArray *)args { - if (self.frameworkLoadFinished) { - [self.jsBridge callJSMethod:method args:args]; - } else { - [_methodQueue addObject:@{@"method":method, @"args":args}]; - } -} -``` - -再到 WXJSCoreManager - -```javascript -- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args { - WXLogDebug(@"Calling JS... method:%@, args:%@", method, args); - WXPerformBlockOnMainThread(^{ - [[WXBridgeManager sharedManager].lastMethodInfo setObject:method ?: @"" forKey:@"method"]; - [[WXBridgeManager sharedManager].lastMethodInfo setObject:args ?: @[] forKey:@"args"]; - }); - return [[_jsContext globalObject] invokeMethod:method withArguments:[args copy]]; -} -``` - -其实不管是 CallJS 还是 CallNative,通信的技术方案设计和 Hybrid 的设计一致,都需要在 JavascriptCore 的 global 对象上挂载一个方法。比如 Native 注册了一个 WXComponent 之后,Weex 侧用 Vue 语法写完了个页面,呈现在用户手机上,用户点击页面上的按钮之后,Native 再将事件回调给 Weex 侧,Weex 再去处理后续逻辑。 - - - -### 4. WXAssertComponentThread 断言 - -`WXAssertComponentThread` 的核心作用是 **强制约束组件相关操作在「组件专属线程」执行**,本质是为了解决「线程安全」和「性能稳定性」问题 - -iOS 开发的核心线程规则是「UI 操作必须在主线程」,但 Weex 组件的工作流程(绑定解析、数据计算、布局计算、子组件管理)包含大量「非 UI 操作」—— 如果这些操作都在主线程执行,会阻塞主线程(比如长列表数据解析、复杂表达式计算),导致 UI 卡顿(比如滑动掉帧) - -因此 Weex 设计了线程分工 - -| 线程类型 | 负责的操作 | -| ------------ | ------------------------------------------------------------ | -| 组件专属线程 | 绑定规则解析(`_storeBindings`)、表达式计算(`bindingBlockWithExpression`)、数据更新(`updateBindingData`)、布局计算(`calculateLayout`) | -| 主线程 | 最终 UI 渲染(如 `UIImageView` 设图、`UILabel` 设文本)、子视图增删(`insertSubview`) | - -#### 1. 避免「线程安全问题」,防止崩溃 / 数据错乱 - -组件的核心数据(如 `_bindingProps`、`_subcomponents`、`_flexCssNode`)都是「非线程安全的」(没有加锁保护)—— 如果多个线程同时读写这些数据,会导致: - -- 数据竞争:比如主线程读取 `_subcomponents` 遍历,组件线程同时修改 `_subcomponents`(增删子组件),导致数组越界崩溃; -- 数据不一致:比如组件线程更新 `_bindingProps` 的值,主线程同时读取该值用于 UI 更新,导致显示错误的旧值; -- 野指针:比如组件线程销毁子组件,主线程还在访问该子组件的 `view`。 - -线程断言通过「强制所有组件核心操作在同一线程执行」,从根源上避免了这些跨线程问题 —— 同一时间只有一个线程操作组件数据,无需复杂锁机制(锁会降低性能)。 - -#### 2. 简化调试,快速定位线程问题 - -如果没有线程断言,跨线程操作组件可能导致「偶现崩溃」(比如 100 次操作出现 1 次),难以复现和排查(日志中看不到线程上下文)。而线程断言会在「违规线程调用时直接崩溃」,并明确提示「必须在组件线程执行」,开发者能立刻定位到违规代码(比如在主线程调用了 `updateBindingData`),大幅降低调试成本。 - -#### 3. 保证操作顺序一致性 - -组件的更新流程是「解析绑定 → 计算表达式 → 更新属性 → 布局计算 → UI 渲染」—— 这些步骤必须按顺序执行。如果分散在多个线程,可能出现「布局计算还没完成,UI 已经开始渲染」的情况(导致布局错乱)。组件专属线程保证了所有操作串行执行,顺序不会乱。 - -### 5. WXJSASTParser 的工作原理 - -`WXJSASTParser` 如何把表达式字符串解析为 AST 节点? - -`WXJSASTParser` 是 Weex 自定义的「轻量 JS 表达式解析器」—— 核心是「按 JS 语法规则,把字符串拆分为结构化的 AST 节点」,全程不依赖完整 JS 引擎(如 JSC/V8),只支持绑定表达式需要的基础语法(标识符、成员访问、二元运算等),兼顾性能和体积。 - -整个解析过程分 3 步:**词法分析 → 语法分析 → AST 节点封装**,和编译器的前端流程一致,以下结合示例(`"user.name + '?size=100'"`)拆解: - -先明确:AST 是什么? - -AST(抽象语法树)是「用树形结构表示代码语法」的中间结构 —— 比如表达式 `user.name + '?size=100'`,AST 会拆分为: - -```shell -根节点:BinaryExpression(运算符 '+') -├─ 左子节点:MemberExpression(成员访问) -│ ├─ object:Identifier(标识符 'user') -│ └─ property:Identifier(标识符 'name') -└─ 右子节点:StringLiteral(字符串字面量 '?size=100') -``` - -这种结构能被程序快速遍历和计算(比如之前讲的生成 `WXDataBindingBlock` 时,递归遍历节点执行运算)。 - -#### 1.词法分析(Lexical Analysis) - -拆分为词法单元(Token)。词法分析是「把表达式字符串拆分为最小的、有意义的语法单元」,忽略空格、换行等无关字符。核心是「按 JS 语法规则匹配字符序列」。 - -`表达式 `"user.name + '?size=100'"` 词法分析后得到的 Token 序列: - -| Token 类型 | Token 值 | 说明 | -| ---------------- | ----------- | ------------------------- | -| `IDENTIFIER` | `user` | 标识符(变量名 / 属性名) | -| `DOT` | `.` | 成员访问运算符 | -| `IDENTIFIER` | `name` | 标识符 | -| `PLUS` | `+` | 二元运算符(加法 / 拼接) | -| `STRING_LITERAL` | `?size=100` | 字符串字面量(去掉引号) | - -词法分析的实现逻辑(简化): - -1. 初始化一个「字符指针」,从表达式字符串开头遍历; -2. 遇到字母 / 下划线 → 继续往后读,直到非字母 / 数字 / 下划线 → 识别为 `IDENTIFIER`(如 `user`); -3. 遇到 `+`/`-`/`*`/`/`/`>`/`=` 等 → 识别为对应运算符(如 `+` → `PLUS`); -4. 遇到 `"` 或 `'` → 继续往后读,直到下一个相同引号 → 识别为 `STRING_LITERAL`(去掉引号); -5. 遇到 `.` → 识别为 `DOT`(成员访问); -6. 遇到空格 / 制表符 → 直接跳过(无意义字符); -7. 遇到无法识别的字符(如 `#`/`@`)→ 抛出语法错误(`WXLogError`)。 - -Weex 的 `WXJSASTParser` 内部会维护一个「Token 流」(数组),词法分析后把 Token 按顺序存入流中,供下一步语法分析使用。 - -#### 2. 语法分析(Syntactic Analysis) - -语法分析是「根据 JS 表达式语法规则,把 Token 流组合为树形 AST 节点」—— 核心是「验证 Token 序列是否符合语法,并构建层级关系」。 - -Weex 支持的 JS 表达式语法子集(核心): - -- 标识符:`user`、`imageUrl`(对应 `WXJSIdentifier`); -- 成员访问:`user.name`、`list[0]`(对应 `WXJSMemberExpression`); -- 字面量:字符串(`'abc'`)、数字(`123`)、布尔(`true`)、null(对应 `WXJSStringLiteral`/`WXJSNumericLiteral` 等); -- 二元运算:`a + b`、`age > 18`、`a === b`(对应 `WXJSBinaryExpression`); -- 条件运算:`age > 18 ? 'adult' : 'teen'`(对应 `WXJSConditionalExpression`); -- 数组表达式:`[a, b, c]`(对应 `WXJSArrayExpression`)。 - -示例:Token 流 → AST 节点的构建过程 - -Token 流:`IDENTIFIER(user) → DOT → IDENTIFIER(name) → PLUS → STRING_LITERAL(?size=100)` - -1. 语法分析器先读取前 3 个 Token(`user` → `.` → `name`),匹配「成员访问语法规则」(`IDENTIFIER . IDENTIFIER`)→ 构建 `WXJSMemberExpression` 节点(左子节点 `user`,右子节点 `name`); -2. 接着读取 `PLUS`(二元运算符),再读取后面的 `STRING_LITERAL(?size=100)` → 匹配「二元运算语法规则」(`Expression + Expression`); -3. 把之前构建的 `WXJSMemberExpression` 作为「左子节点」,`STRING_LITERAL` 作为「右子节点」,`PLUS`作为「运算符」→ 构建根节点 `WXJSBinaryExpression`; -4. 最终生成 AST 树(如之前的结构)。 - -语法分析的实现逻辑(简化): - -Weex 采用「递归下降分析法」(最适合手工实现的语法分析方法): - -1. 为每种表达式类型定义一个「解析函数」(如 `parseMemberExpression` 解析成员访问、`parseBinaryExpression` 解析二元运算); -2. 解析函数递归调用:比如 `parseBinaryExpression` 会调用 `parseMemberExpression` 解析左右操作数,`parseMemberExpression` 会调用 `parseIdentifier` 解析标识符; -3. 语法校验:如果 Token 序列不符合规则(如 `user.name +` 缺少右操作数),会抛出「语法错误」日志,终止解析。 - -#### 3. AST 节点封装 - -转为 Weex 自定义的 `WXJSExpression`。语法分析生成的是「抽象语法树结构」,Weex 会把这个结构封装为自定义的 `WXJSExpression` 子类(对应不同表达式类型),每个子类存储该节点的关键信息(如运算符、子节点),供后续生成 `WXDataBindingBlock` 使用。 - -示例封装: - -- `WXJSMemberExpression` 类:存储 `object`(子节点,如 `user`)、`property`(子节点,如 `name`)、`computed`(是否是计算属性,如 `list[0]` 为 `YES`,`user.name` 为 `NO`); -- `WXJSBinaryExpression` 类:存储 `left`(左子节点)、`right`(右子节点)、`operator_`(运算符字符串,如 `"+"`); -- 字面量类(如 `WXJSStringLiteral`):存储 `value`(字面量值,如 `?size=100`)。 - -这些类的定义在 Weex 源码的 `WXJSASTParser.h` 中,本质是「数据容器」,把 AST 结构转化为 Objective-C 代码可访问的对象。 - -`WXJSASTParser` 本质:它不是完整的 JS 解析器(不支持 `function`、`for` 等复杂语法),而是「专门为 Weex 绑定表达式设计的轻量解析器」—— 只解析需要的 JS 表达式子集,把字符串转为结构化的 AST 节点,最终目的是「让 Native 代码能递归遍历节点,计算出表达式结果」(如 `user.name + '?size=100'` → `avatar.png?size=100`)。 - -这种「自定义轻量解析器」的设计,既避免了依赖完整 JS 引擎的体积和性能开销,又能精准适配 Weex 的绑定需求,是跨端框架的常见优化思路。 - - - - -## 十、值得借鉴的地方 - -### 1. WXThreadSafeMutableDictionary 线程安全字典 -Weex 中的 WXThreadSafeMutableDictionary 提供了一个线程安全的字典,其本质是通过加 pthread_muext_t 锁来维护内部的一个字典的。 -比如下面的代码 - -初始化锁相关的配置 -```Objective-C -@interface WXThreadSafeMutableDictionary () -{ - NSMutableDictionary* _dict; - pthread_mutex_t _safeThreadDictionaryMutex; - pthread_mutexattr_t _safeThreadDictionaryMutexAttr; -} - -@end - -@implementation WXThreadSafeMutableDictionary - -- (instancetype)initCommon -{ - self = [super init]; - if (self) { - pthread_mutexattr_init(&(_safeThreadDictionaryMutexAttr)); - pthread_mutexattr_settype(&(_safeThreadDictionaryMutexAttr), PTHREAD_MUTEX_RECURSIVE); // must use recursive lock - pthread_mutex_init(&(_safeThreadDictionaryMutex), &(_safeThreadDictionaryMutexAttr)); - } - return self; -} - -- (instancetype)init -{ - self = [self initCommon]; - if (self) { - _dict = [NSMutableDictionary dictionary]; - } - return self; -} -``` -在字典操作的地方使用锁 -```Objective-C -- (void)setObject:(id)anObject forKey:(id)aKey -{ - id originalObject = nil; // make sure that object is not released in lock - @try { - pthread_mutex_lock(&_safeThreadDictionaryMutex); - originalObject = [_dict objectForKey:aKey]; - [_dict setObject:anObject forKey:aKey]; - } - @finally { - pthread_mutex_unlock(&_safeThreadDictionaryMutex); - } - originalObject = nil; -} -``` -这么写的价值:**解锁逻辑「绝对执行」,彻底避免死锁** -这是 `@try-finally` 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常),finally 块的解锁逻辑一定会执行 - -对比无 try-finally 的写法 -```Objective-C -// Bad: 若setObject抛异常,unlock不会执行→死锁 -pthread_mutex_lock(&_mutex); -[_dict setObject:anObject forKey:aKey]; -pthread_mutex_unlock(&_mutex); -``` -问题:`[_dict setObject:anObject forKey:aKey]` 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException),若没有 finally,锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。 - -设计优点: -- `@try-finallly`:即使 try 内逻辑出错,finally 也会执行 pthread_mutex_unlock,保证锁最终释放,这是**线程安全的「兜底保障」** -- 注意,不是 `try...catch...finally`: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 `try...catch...finally` 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了(这个问题不再赘述,是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题) - -延伸:聊聊类似网易的大白解决方案或者业界其他公司中,安全气垫虽然保证了代码不 crash,影响用户体验,但是比如数组本该越界,现在却不越界: -1. 唯一能做的就是返回一个错误的值,比如数组长度为3,访问4,现在不 crash,返回了 0 的值,那是不是产生了业务异常?比如商品价格 -2. 不 crash,也不返回错误位置的值,类似给一个回调,告诉业务方出现了异常,可以做一些业务层面的提醒或者配置(比如开发阶段商品卡片的价格 Label 显示:商品价格获取错误,数组越界),同时产生的异常案发现场信息和其他的一些数据会上报,用于 APM 平台去分析和定位。 - -但这也产生一个问题,类似数组越界的场景,可能10000次里面9999次都正常,只有1次异常,业务开发为了这万分之一出现的异常,还需要写一些异常处理的逻辑(比如商品卡片展示价格获取错误,数组越界)。那字典的 key 为 nil 呢?除法的分母为0呢?诸如此类,类似乐观锁和悲观锁的场景 - -相关问题的思考可以查看这篇文章:[安全气垫](./1.148.md) - - -- WXHandlerFactory:Weex 核心的「处理器工厂」,负责管理所有协议(如图片加载、网络请求、存储等)的实现类注册 / 查找; -- WXImgLoaderProtocol:Weex 定义的「图片加载协议」,仅声明接口(下载、取消、缓存等),不包含具体实现。 - -Weex 支持业务层自定义图片加载逻辑(比如统一用项目的图片缓存库、添加下载拦截、埋点等),此时自定义实现类会替代默认实现,成为下载执行者: -步骤 1:业务层创建类(如 MyCustomImgLoader),遵循 WXImgLoaderProtocol,实现 wx_loadImageWithURL: 等协议方法(内部可调用 SDWebImage/AFNetworking 等完成下载); -步骤 2:将自定义类注册到 WXHandlerFactory: -```Objective-C -[WXHandlerFactory registerHandler:[MyCustomImgLoader new] forProtocol:@protocol(WXImgLoaderProtocol)]; -``` -步骤 3:此时 [WXHandlerFactory handlerForProtocol:@protocol(WXImgLoaderProtocol)] 会返回 MyCustomImgLoader 实例,所有图片下载由该类负责 - -### 2. 设计分层合理 - -```mermaid -graph TD - A[开发者编写的 .we/.vue 文件] --> B[Transformer
转换JS Bundle]; - B --> C[JS Framework
解析并管理Virtual DOM]; - C -- 通过JS Bridge发送渲染指令 --> D[Native SDK
渲染引擎]; - D --> E[iOS/Android/Web 原生视图]; - - C -- 支持多种DSL --> F[Vue.js]; - C -- 支持多种DSL --> G[Rax(类React)]; - D -- 原生能力扩展 --> H[自定义Component]; - D -- 原生能力扩展 --> I[自定义Module]; -``` - -Weex 最核心的设计是将整个框架清晰地分为:**语法层(DSL)**、**中间层(JS Framework)**和**渲染层(Native SDK)** - -这种渲染引擎和语法层 DSL 分离的设计,可以使得上层 DSL 方便拓展 Vue、Rax 写法,下层渲染引擎可以保持较好的稳定性。为了生态的拓展提供了极大的便携性。 - - - -### 3. 可扩展的组件与模块系统 - -Weex 通过`WXSDKEngine.registerComponent()` 和 `registerModule()` 方法,允许开发者扩展原生组件 (UI Component)和模块(Login Module)。这套机制设计得足够底层和通用,使得 Weex 可以由开发者来注册,由公司内的体验设计中心规范来落地的组件。以及一些基础能力。这样子 Weex 官方已经提供了一些功能强大的筋骨,我们在其之上可以提供更符合需求的外表和更有力量的一块手臂肌肉。 - -虽然事后视角来看,Weex、RN、Flutter,甚至是更早的、设计完善的 Hybrid 都有该能力。但这对于远古时期的 Weex 来说,还是可圈可点的。 - - - -### 4. 轻量 JSBundle + 增量更新支持 - -Weex 的 JSBundle 仅包含业务逻辑和组件描述,框架代码(Vue 内核、Weex 基础 API)内置在原生 SDK 中,因此 Bundle 体积极小;同时支持将 Bundle 拆分为 “基础包(公共逻辑)+ 业务包(页面逻辑)”,实现增量更新。 - -解决了跨端框架 “首屏加载慢” 的痛点(小 Bundle 加载更快),同时增量更新降低了发布成本。 - - - -## 十一、Weex APM - -### 1. 历史背景 -Weex 是诸多年前的产物,部分业务线用 Weex 写了部分功能模块,或者是某几个页面,或者是某个二级、三级业务 SDK 的页面。但可以确定的是: -- 21年就完成了 Flutter 的基建开发(对齐 Native 的 UI 组件库,遵循体验设计平台产出的集团 UI 标准;做了 Flutter 的大量 plugin、打包构建平台、日志库、网络库、探照灯、APM SDK、热修复能力等)。新业务的实现只会在 Native 和 Flutter 上考虑 -- Weex 业务代码基本上是存量的 -- Weex 代码没有 bug 就不去修改;有版本迭代,之前是 Weex 实现的,本次只做简单 UI 增删或字段调整,也是会修改一下。除此之外不修改 Weex 代码 - -所以像 Native 一样去全面监控性能、网络、crash、异常、白屏、页面加载耗时等维度的话,ROI 是很低的。那么就需要制定一些策略去有针对性的监控高优问题。 - -Weex 的异常比较有特点,比如在页面的模版代码中绑定了 data 中的一个对象,此时对象可能并没有值,而是依赖后续的网络请求完成,对象才有了具体的值 data 改变,数据驱动,页面再次 render。所以监控代码会认为第一次 render 的时候访问对象不存在的属性。 -真正有问题的代码和不影响业务的异常信息,都会被 Vue 官方认为是异常。基于这样的背景,我们无法 pick 出真正异常或者是开发者判空代码没写好的问题。基于此,我们需要做一些约定和标准。 - -### 2. 优先级权衡标准 -这时候就需要摒弃程序员视角(不然会陷入啥数据都想统计,可能是洁癖、可能是追求),但从 ROI 角度出发,我们就需要切换到用户视角。 - -假设你是一个用户,什么样的情况代表业务异常,对我们的用户来说比较痛呢? -- 页面白屏了,看都看不到了,别说你们的 App 为我赋能解决用户痛点了 -- 稍微好点,可以看到页面了,但是某一个区域是白屏的。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白,点也点不了。 -- 情况再好点。可以看到全部的页面了,但是点击后无响应。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面有个“确认支付”按钮。用户在考虑再三,本着理性购物后,发现是刚需品,咬紧牙要付款了,此时点击“确认支付”按钮了,但是页面没有任何反应。用户也是“见多识广”的体面人,猜测可能是网络不好的情况,所以等了1分钟,他很有耐心。切换了 WI-FI 到 5G 后,继续点击,依旧没反应。一怒之下点了10次,等了2分钟,还是没反应。他奔溃了,卸载了 App - -上述几种情况,总结为:按照异常等级,可以划分为影响业务和不影响业务。什么叫“影响业务”?这是我们自己定义的标准,影响用户是否正常操作 App。比如:页面白屏(页面全部白屏、页面部分白屏)、点击某个按钮无响应,这些叫做“影响业务”,属于 Error 级别。其他的一些轻微异常,不影响用户使用 App 功能,不影响业务,属于 Warning 级别。 - - - -### 3. UI 显示异常 -#### 1. 部分白屏:注册的 Component 使用异常 -这种情况就属于页面部分白屏。因为某个哪个 Compoent 会铺满页面,基本类似 iOS UI 控件一样组合使用。就像上文描述的「该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白」这个空白粗,理应显示一个 Native 注册的 Button,但是没有显示出来,造成业务的阻塞。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexComponentRegisterError.png) -.vue(或 Weex 专属.we)文件内基于 Vue 扩展的 Weex 跨平台模板 DSL 代码,在前端构建阶段会先由 Webpack 的weex-loader触发编译流程:首先通过 Weex 核心编译器@weex-cli/compiler(复用并扩展vue-template-compiler)将模板 DSL 解析为模板 AST(抽象语法树);接着由 Weex 自定义 Babel 插件(如babel-plugin-transform-weex-template)将模板 AST 转换为标准化的 JS AST,并针对 iOS/Android 跨平台特性做属性、样式、事件的适配处理(如样式单位归一化、事件名标准化);最终生成包含_h(即 Weex 运行时的$createElement,等价于 Vue 的createElement)调用的render函数,该函数会被 Webpack 打包到最终的 Weex JS Bundle 中。 - -```json -_c('color-button', - { - staticStyle: { - width: "400px", - height: "40px", - marginBottom: "20px" - }, - attrs: { - "title": "点击计算10+20", - "bgColor": "#FF6600", - "message": "hello" - }, - on: { - "click": _vm.handleButtonClick - } - }, - // 如果有 children 就是 children 信息 -) -``` - -在 App 运行阶段,Weex 的 JS 引擎(iOS 端为 JSCore、Android 端为 V8)加载 JS Bundle 后,执行组件的render函数,通过调用 `_h` 函数将模板描述转换为跨平台的虚拟 DOM(VNode),VNode 会被序列化为 JSON 格式,最终通过 JS Bridge 传递给 Native 端(iOS/Android)用于原生视图渲染。 - - -Weex 的 Component 相关逻辑都由 `WXComponentManager` 负责。页面在构建展示的时候,会调用 `_buildComponent` 方法,其内部会调用 WXComponentFactory 的能力(`configWithComponentName`),根据 ComponentName 获取 Component。 - -`configWithComponentName` 是 Weex iOS 侧 WXComponentFactory(组件工厂类)的核心方法之一,核心作用是:根据传入的组件名称(如 color-button/div/text),查找该组件对应的 Native 侧配置(WXComponentConfig);若找不到对应配置,则降级使用基础容器组件 div 的默认配置,并输出警告日志。 -```Objective-C -- (WXComponentConfig *)configWithComponentName:(NSString *)name -{ - WXAssert(name, @"Can not find config for a nil component name"); - - WXComponentConfig *config = nil; - - [_configLock lock]; - config = [_componentConfigs objectForKey:name]; - if (!config) { - WXLogWarning(@"No component config for name:%@, use default config", name); - config = [_componentConfigs objectForKey:@"div"]; - } - [_configLock unlock]; - - return config; -} -``` -UI Component 做的比较随意,认为显示问题降级用 div 就可以了。做为 SDK 这么设计也似乎可以接受,但作为业务方,我们必须收集统计这种异常情况。 -所以此处我们可以收集案发现场数据,进行上报。我们发现 Weex 自己封装了 `WXExceptionUtils`类,暴露了 `commitCriticalExceptionRT` 接口,用于收集致命问题。 - -```Objective-C -+ (void)commitCriticalExceptionRT:(WXJSExceptionInfo *)jsExceptionInfo{ - - WXPerformBlockOnComponentThread(^ { - id jsExceptionHandler = [WXHandlerFactory handlerForProtocol:@protocol(WXJSExceptionProtocol)]; - if ([jsExceptionHandler respondsToSelector:@selector(onJSException:)]) { - [jsExceptionHandler onJSException:jsExceptionInfo]; - } - if ([WXAnalyzerCenter isOpen]) { - [WXAnalyzerCenter transErrorInfo:jsExceptionInfo]; - } - }); -} -``` -可以看到会判断是否存在可以处理 exception 遵循 WXJSExceptionProtocol 的 handler。所以我们新增一个 `WXExceptionReporter` 类(遵循 WXJSExceptionProtocol 协议),用于收集异常,然后用于统一的上报,内部提供基础数据的组装、字段解析功能。 - -效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexComponentBuildFlow.png) - -#### 2. 全部白屏 -根据 Weex 的工作原理可以知道,页面需要展示肯定要根据 url 去获取 JS Bundle 内容,然后解析成 VNode 最后通过 JSBridge 去调用 Native 的 UI Component 去展示 UI,那么整个流程几个重要的环节都可能出错,导致页面白屏。 - -##### 1. 资源请求失败 -JS Bundle 资源请求失败,存在 Error,此时是无法去展示 Weex 页面的。这种情况就是 HTTP 状态码非200的情况。 - -每个 Weex 页面都由 WXSDKInstance 负责下载 JS Bundle 资源,所以下载的逻辑在 WXSDKInstance 里。 - -```Objective-C -- (void)_renderWithRequest:(WXResourceRequest *)request options:(NSDictionary *)options data:(id)data; -{ - _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { - - NSError *error = nil; - if ([response isKindOfClass:[NSHTTPURLResponse class]] && ((NSHTTPURLResponse *)response).statusCode != 200) { - error = [NSError errorWithDomain:WX_ERROR_DOMAIN - code:((NSHTTPURLResponse *)response).statusCode - userInfo:@{@"message":@"status code error."}]; - if (strongSelf.onFailed) { - strongSelf.onFailed(error); - } - } - - if (error) { - [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId - errCode:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] - function:@"_renderWithRequest:options:data:" - exception:[NSString stringWithFormat:@"download bundle error :%@",[error localizedDescription]] - extParams:nil]; - return; - } - - if (!data) { - NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ With no data return", request.URL]; - WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_DOWNLOAD, errorMessage, strongSelf.pageName); - [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId - errCode:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] - function:@"_renderWithRequest:options:data:" - exception:errorMessage - extParams:nil]; - return; - } - }; -} -``` - -模拟 JS Bundle 下载错误,效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSBundleDownloadFailed.png) - -下载 JS Bundle 网络请求完成后,如果出现 Error,则会调用 WXExceptionUtils 的能力,将异常交给 `WXExceptionReporter` 去处理。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSBundleDownloadFailedAPM.png) - -##### 2. 资源请求成功,数据为空 -还有一种情况就是:**JSBundle 下载请求在 HTTP 层面 “成功完成”(状态码 200),但返回的二进制数据 data 为 nil 或空(长度为 0) ** - -可能你会好奇,怎么可能有空的 JSBundle,什么场景下会产生这种情况? -凡是正常写代码都符合预期就没有任何 bug 和故障了,所以利用悲观策略,将各种可能出现问题的地方都监控到,因为只要 JSBundle 为空,页面肯定是白屏,对于用户侧来说都是致命的。 - -1. 服务器/CDN 返回“空响应”:后端 / CDN 配置异常:请求的 JSBundle URL 有效,HTTP 状态码返回 200,但响应体(Body)为空(比如静态 JS 文件被删除、CDN 缓存失效且源站无数据、后端接口逻辑错误未写入响应内容); -2. 下载过程中数据传输截断 / 丢失 -- 网络波动:下载请求已收到服务器的 “响应完成” 信号,但数据传输过程中因网络中断、超时等导致 NSData 未完整接收(仅 HTTP 头成功接收,体数据为空); -- Weex 加载器(mainBundleLoader)异常:加载器在将响应数据转为 NSData 时出现底层错误(如内存不足、数据解码失败),导致 data 被置为 nil。 - -Mock:将 data 设为 nil。效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSBundleParseFailed.png) - -可以看到 Weex 也会把这种错误进行收集,调用 `WXExceptionUtils commitCriticalExceptionRT`,所以我们添加的 Analyzer 是可以监控到这种异常的。 -效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSBundleParseErrorAPM.png) - - -##### 3. 资源请求成功,数据无法解析 - -还有一种特殊的情况就是:**下载的 JSBundle 二进制数据虽非空,但因无法以 UTF-8 编码解码为字符串,导致 Weex 实例无法加载执行该数据,最终页面 UI 无法正常展示**。比如下面的情况: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSbundleEncodingError.png) - -和上面的情况类似,这种都属于概率较小的问题,但也要监控和预防。 - -一些可能的情况: -1. JSBundle 文件编码非 UTF-8。 **Weex 要求:JS Bundle 文件必须采用 UTF-8 编码(无 BOM)以保证跨平台兼容性,非 UTF-8 编码(如 GBK、UTF-16)可能导致 iOS/Android 平台解析失败** -2. 数据损坏/包含非法 UTF-8 字节 - - 下载截断:UTF-8 是「多字节编码」(比如中文占 3 字节),若下载过程中数据末尾的字符字节不完整(如只下了 2 字节),解码时会因 “字节序列不合法” 失败; - - 数据篡改:CDN / 网关 / 代理在传输中混入非 UTF-8 字节(如 0xFF、0xFE、0x00 等无效字节),破坏编码结构; - - 文件损坏:JSBundle 文件打包 / 上传时出错(如压缩后未正确解压),包含乱码 / 二进制碎片 -3. 请求到非文本数据(URL 错误)。请求的 JS Bundle 返回的不是 JS 文本,而是二进制: - - URL 配置错误:指向图片(png/jpg)、压缩包(zip)、二进制协议数据(如 protobuf)、可执行文件等 - - 后端接口错误:原本应返回 JS 文本的接口,异常时返回二进制格式的错误信息(而非文本错误) - - 缓存污染:Weex 本地缓存的 JSBundle 被其他二进制文件覆盖(如缓存路径冲突) -4. 特殊字符/编码溢出 - - JSBundle 中包含 UTF-8 无法表示的「无效 Unicode 码点」(如超出 U+10FFFF 范围,或保留的未定义码点) - - 数据量过大:极大型 JSBundle 解码时因内存不足 / 系统限制,导致解码接口返回 nil(iOS 中 NSString 对单字符串长度有隐性限制) - -这种情况,Weex 官方是怎么做的? -```Objective-C -NSString *jsBundleString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; -if (!jsBundleString) { - WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_STRING_CONVERT, @"data converting to string failed.", strongSelf.pageName) - [strongSelf.apmInstance setProperty:KEY_PROPERTIES_ERROR_CODE withValue:[@(WX_ERR_JSBUNDLE_STRING_CONVERT) stringValue]]; - return; -} -``` -可以看到,这种情况没有被 Weex 没有视为“致命问题”进行上报。只是进行了简单打印。尝试站在框架角度想问题,从 SDK Owner 角度归因: -- HTTP 状态码错误/无数据:Weex 认为这类错误是「外部不可控故障」(网络、CDN、服务端宕机),会影响大批量实例,属于 “框架级致命异常”,必须通过 WXExceptionUtils 上报(触发全局异常统计、告警) -- 编码转换失败:可能是分批多次打包,前几次都是 UTF-8 格式,只是这次编码错误,是可以定位的。Weex 认为这类错误是「内部可控问题」(前端打包时未按 UTF-8 规范输出、URL 配置错误指向二进制文件),属于 “业务侧错误”,框架只需记录监控(提醒开发者修复),无需升级为 “框架级致命异常”。 - -但从业务方角度出发,不光页面是 Weex、Native、Flutter、H5,只要是影响了用户体验,都属于致命问题,尤其这种整个页面都是白屏的情况。所以我们需要修改源码,去上报致命异常。调用 `WXExceptionUtils commitCriticalExceptionRT` 的能力。 - -效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexJSBundleEncodingAPM.png) - - - - -### 4. 逻辑异常 - -#### 1. JS 侧 require Module 失败 -在 Native `[WXSDKEngine registerModule:@"logicCalculation" withClass:[WXLogicCalculationModule class]]` 正常注册的 Module,名字叫 `logicCalculation`。在 js 侧使用的时候不小心写成 `const logicCalculation = weex.requireModule('logicCalculation1')`,测试又没回归到,问题逃逸到线上,可能就是逻辑问题。Weex 官方的做法就是在 Xcode 打印 log。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexModuleRequireError.png) - -所以作为 APM 侧,我们要定位和收集到该问题,进行问题上报。 - -想办法知道哪里报错,requireModule 不是原生写法,这肯定是 JS 侧封装的,查看 Weex 源码 -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexRequireModuleError.png) -```JS -// Weex JS Framework 核心源码(简化) -WeexInstance.prototype.requireModule = function requireModule(moduleName) { - // 1. 基础校验:Weex实例是否有效(比如是否已销毁) - var id = getId(this); // 获取当前Weex实例ID - if (!(id && this.document && this.document.taskCenter)) { - console.error("[JS Framework] Failed to requireModule(\"" + moduleName + "\"), instance doesn't exist."); - return; - } - - // 2. 关键校验:检查Module是否在Native侧注册过 - if (!isRegisteredModule(moduleName)) { - console.warn("[JS Framework] using unregistered weex module \"" + moduleName + "\""); - return; - } - - // 3. 核心:创建Module代理对象(并非真实对象,仅封装桥接调用) - var moduleProxy = {}; - // 获取该Module在Native侧注册的所有方法(提前从Native同步到JS的方法映射表) - var moduleMethods = getRegisteredMethods(moduleName); - - // 4. 为代理对象绑定方法:调用方法时触发JS-Native桥接 - moduleMethods.forEach(function(methodName) { - moduleProxy[methodName] = function() { - // 封装调用参数:实例ID、Module名、方法名、参数、回调 - var args = Array.prototype.slice.call(arguments); - var callback = null; - // 提取最后一个参数作为回调(Weex约定) - if (typeof args[args.length - 1] === 'function') { - callback = args.pop(); - } - - // 5. 核心:通过taskCenter(桥接核心)调用Native - this.document.taskCenter.sendNative('callNative', { - instanceId: id, - module: moduleName, - method: methodName, - params: args, - callback: callback ? generateCallbackId(callback) : null - }); - }.bind(this); - }, this); - - // 6. 返回代理对象给JS侧使用 - return moduleProxy; -}; -``` - -- 返回的不是真实的 Module 实例,而是代理对象(Proxy) —— 所有方法调用都会被拦截,转而通过桥接发送到 Native; -- isRegisteredModule 校验:JS 侧会缓存一份「Native 已注册 Module 列表」(Native 初始化时同步到 JS),避免无效桥接。 - -方案一:Weex 由于安全设计,没办法直接注入 JS。也就是说想通过“切面”思想,hook JS 侧 requireModule 是行不通的。这种方案,代码如下 - -```JS -// 备份原生requireModule方法 -const originalRequireModule = WeexInstance.prototype.requireModule; - -// 重写requireModule,在错误触发时主动上报Native -WeexInstance.prototype.requireModule = function (moduleName) { - // 先执行原生判断逻辑 - const id = getId(this); - if (!(id && this.document && this.document.taskCenter)) { - const errorMsg = "[JS Framework] Failed to requireModule(\"" + moduleName + "\"), instance (" + id + ") doesn't exist anymore."; - // 主动上报“实例不存在”错误到Native - this.document.taskCenter.sendNative('__weex_apm_report', { - type: 'module_require_failed', - subType: 'instance_not_exist', - moduleName: moduleName, - message: errorMsg, - instanceId: id - }); - console.error(errorMsg); - return; - } - - // 核心:拦截“未注册Module”判断 - if (!isRegisteredModule(moduleName)) { - const warnMsg = "[JS Framework] using unregistered weex module \"" + moduleName + "\""; - // 主动上报“Module未注册”错误到Native(关键) - this.document.taskCenter.sendNative('__weex_apm_report', { - type: 'module_not_registered', - moduleName: moduleName, - message: warnMsg, - instanceId: id, - timestamp: Date.now() - }); - // 保留原生warn日志(不影响原有逻辑) - console.warn(warnMsg); - return; - } - - // 执行原生逻辑 - return originalRequireModule.call(this, moduleName); -}; -``` - -方案二:Native 侧拦截 JS 的 console.warn 调用(无 JS 侵入) - -写法1:Weex JS 侧的 `console.warn` 最终会通过 WXBridgeContext 的 `handleJSLog` 方法传递到 Native,无需解析最终日志,直接 Hook 该方法拦截 warn 信息,精准匹配 Module 未注册错误 - -```Objective-C -#import - -@implementation NSObject (WXJSLogHook) -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // 获取WXBridgeContext类(无需头文件) - Class bridgeContextClass = NSClassFromString(@"WXBridgeContext"); - if (!bridgeContextClass) return; - - // Hook处理JS日志的核心方法:handleJSLog: - SEL handleJSLogSel = NSSelectorFromString(@"handleJSLog:"); - Method originalMethod = class_getInstanceMethod(bridgeContextClass, handleJSLogSel); - if (!originalMethod) return; - - SEL swizzledSel = NSSelectorFromString(@"weex_apm_handleJSLog:"); - Method swizzledMethod = class_getInstanceMethod(self, swizzledSel); - class_addMethod(bridgeContextClass, swizzledSel, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); - method_exchangeImplementations(originalMethod, swizzledMethod); - }); -} - -// Hook后的handleJSLog方法:拦截JS侧的warn日志 -- (void)weex_apm_handleJSLog:(NSDictionary *)logInfo { - // 1. 先执行原方法,保留原有日志输出逻辑 - [self weex_apm_handleJSLog:logInfo]; - - // 2. 解析JS日志信息(logInfo格式:{level: 'warn', msg: 'xxx', ...}) - NSString *logLevel = logInfo[@"level"]; - NSString *logMsg = logInfo[@"msg"]; - - // 3. 精准匹配“未注册Module”的warn - if ([logLevel isEqualToString:@"warn"] && [logMsg containsString:@"using unregistered weex module"]) { - // 提取Module名称 - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"using unregistered weex module \"(.*?)\"" options:0 error:nil]; - NSTextCheckingResult *match = [regex firstMatchInString:logMsg options:0 range:NSMakeRange(0, logMsg.length)]; - NSString *moduleName = match ? [logMsg substringWithRange:match.rangeAtIndex(1)] : @""; - - // 4. 构造APM数据上报 - NSDictionary *apmData = @{ - @"error_type": @"weex_module_not_registered", - @"module_name": moduleName, - @"message": logMsg, - @"source": @"js_console_warn", // 标记来源:JS console.warn - @"timestamp": @([[NSDate date] timeIntervalSince1970] * 1000) - }; - - // 调用 APM SDK 接口,数据先落库,后续统一按照数据上报策略,从本地 DB 捞取、聚合、上报 - // [YourAPMManager reportWeexError:apmData]; - } -} -@end -``` -核心优势 -- 无侵入:无需修改 / 注入 JS 代码,纯 Native 侧实现; -- 精准:拦截的是 JS 侧传递到 Native 的原始日志数据(而非最终打印的字符串),无格式误差; -- 覆盖全:所有 JS 侧的console.warn都会经过此方法,100% 覆盖 Module 未注册场景 - -写法二:由于 Weex 代码是大量的存量业务代码,很稳定。而且 Weex 官方好几年不更新,所以我们内部私有化 Weex SDK,也就没有采取 Hook 手段。而是直接修改源码,`WXBridgeContext.m` 的 `+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel` 方法。比如: - -```Objective-C -+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel -{ - NSMutableString *string = [NSMutableString string]; - [string appendString:@"jsLog: "]; - [arguments enumerateObjectsUsingBlock:^(JSValue *jsVal, NSUInteger idx, BOOL *stop) { - [string appendFormat:@"%@ ", jsVal]; - if (idx == arguments.count - 1) { - if (logLevel) { - if (WXLogFlagWarning == logLevel || WXLogFlagError == logLevel) { - if ([string containsString:@"using unregistered weex module"]) { - // 提取Module名称 - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"using unregistered weex module \"(.*?)\"" options:0 error:nil]; - NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; - NSString *moduleName = match ? [string substringWithRange:[match rangeAtIndex:1]] : @""; - - // 接入收口工具类 - NSString *exceptionMsg = [NSString stringWithFormat:@"JS require未注册模块:%@,原始日志:%@", moduleName, string]; - NSDictionary *customExt = @{@"moduleName": moduleName}; - NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @""; - NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @""; - [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_NotRegistered - exceptionType:WXCustomExceptionType_Module - instanceId:instanceId - function:@"handleConsoleOutputWithArgument:logLevel:" - exceptionMsg:exceptionMsg - bundleUrl:bundleUrl - customExtParams:customExt]; - } - - id appMonitorHandler = [WXSDKEngine handlerForProtocol:@protocol(WXAppMonitorProtocol)]; - if ([appMonitorHandler respondsToSelector:@selector(commitAppMonitorAlarm:monitorPoint:success:errorCode:errorMsg:arg:)]) { - [appMonitorHandler commitAppMonitorAlarm:@"weex" monitorPoint:@"jswarning" success:NO errorCode:@"99999" errorMsg:string arg:[WXSDKEngine topInstance].pageName]; - } - } - WX_LOG(logLevel, @"%@", string); - } else { - [string appendFormat:@"%@ ", jsVal]; - WXLogInfo(@"%@", string); - } - } - }]; -} -``` -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexRequireModuleErrorAPM.png) - - -#### 2. JS 调用 Moudle 方法失败 -Native 注册了一个负责逻辑的 Module,但是在 JS 侧使用的时候,要么方法名写错了,要么参数少传了,都可能导致预期的逻辑执行错误,发生不符合预期的行为。 - -##### 1. 点击事件工作原理 -核心问题:点击事件发生时,如何根据 Component 的点击事件定位到该 Component 在 Vue DSL 中声明的事件? - -第一步:页面初始化时,JS 侧构建**事件映射表**。 -Weex 页面渲染时,会为每个组件做2件事情: -- 生成组件唯一标识:每个组件都有 `ref/componentId/docId`,类似组件身份证 -- 绑定事件与方法:解析 `@click="handleButtonClick"` 时,JS 会将「组件 ID + 事件类型(click)」作为 key,`handleButtonClick` 作为 value,一起存进组件实例的映射表里,(对应下面的 `this.event[type]`) - -第二步:Native 侧捕获点击,携带关键信息调用 fireEvent。 -Native 侧能拿到 componentId,是因为渲染组件时,JS 侧会把组件 ID 同步给 Native 渲染引擎(WXComponent),Native 控件和 JS 组件实例通过 ID 一一绑定 - -第三步:JS 侧调用 `fireEvent` 方法,其内部通过 `ID + 事件类型` 找方法。 -- 定位组件实例:JS 通过 componentID(代码里的 this.ref)找到组件实例。 -- 查找事件映射:从组件实例的 `this.event` 里根据 type (如 click)找到具体的 eventDesc(包含具体的 handler) -- 发起调用 `handler.call` - -```js -/** - * Fire an event manually. - * @param {string} type type - * @param {function} event handler - * @param {boolean} isBubble whether or not event bubble - * @param {boolean} options - * @return {} anything returned by handler function - */ - Element.prototype.fireEvent = function fireEvent (type, event, isBubble, options) { - var result = null; - var isStopPropagation = false; - var eventDesc = this.event[type]; - if (eventDesc && event) { - var handler = eventDesc.handler; - event.stopPropagation = function () { - isStopPropagation = true; - }; - if (options && options.params) { - result = handler.call.apply(handler, [ this ].concat( options.params, [event] )); - } - else { - result = handler.call(this, event); - } - } - - if (!isStopPropagation - && isBubble - && (BUBBLE_EVENTS.indexOf(type) !== -1) - && this.parentNode - && this.parentNode.fireEvent) { - event.currentTarget = this.parentNode; - this.parentNode.fireEvent(type, event, isBubble); // no options - } - - return result - }; -``` - -##### 2. JS 调用 module 方法,方法名错误 -Native 注册的 Module 方法名为 `multiply:num2:callback:`,而在 JS 侧调用的时候方法名多加了几个字符,造成方法名对不上,方法调用失败的问题。 - -用户点击屏幕上的 UI 控件(此处就是注册 Component `[WXSDKEngine registerComponent:@"color-button" withClass:[WXColorButtonComponent class]]`)。 - -Weex 统一给 Comonent 添加了分类来负责事件的处理。`WXComponent+Events`。源码中 `addClickEvent` 就是添加了点击事件的监听。当发生点击后会计算点击事件的坐标和时间戳信息,最后封装一个 `WXCallJSMethod` 对象,方法名固定为 `fireEvent`。如下堆栈所示: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexComponentClickLogic.png) - -由于 `logicCalculation` 没有对应的 `multiplyWith` 方法,所以会报错,被 JS 的 `try...catch...` 捕获后,通过 `console.error` 的方式输出异常信息。但是 `console.error` 被 Native 接管了。所以我们可以在 Native 接管的地方统一拦截处理。只要日志包含 `Failed to invoke the event handler` 就可以认为是因为方法名问题,导致调用方法出错 - -代码如下: -```Objective-c -+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel -{ - // ... - if ([string containsString:@"Failed to invoke the event handler"]) { - // 原有解析逻辑保留 - NSString *errorMethodName = @""; - NSString *eventType = @""; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"'\\.\\.\\.(?:logicCalculation\\.)([a-zA-Z0-9_]+)\\.\\.\\." options:0 error:nil]; - NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; - if (match) { - errorMethodName = [string substringWithRange:[match rangeAtIndex:1]]; - } - NSRegularExpression *eventRegex = [NSRegularExpression regularExpressionWithPattern:@"event handler of \"([^\"]+)\"" options:0 error:nil]; - NSTextCheckingResult *eventMatch = [eventRegex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; - if (eventMatch) { - eventType = [string substringWithRange:[eventMatch rangeAtIndex:1]]; - } - - // 接入收口工具类 - NSString *exceptionMsg = [NSString stringWithFormat:@"Module方法名错误:%@,事件类型:%@,原始日志:%@", errorMethodName, eventType, string]; - NSDictionary *customExt = @{ - @"moduleName": @"logicCalculation", - @"methodName": errorMethodName, - @"eventType": eventType - }; - NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @""; - NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @""; - [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_MethodNotFound - exceptionType:WXCustomExceptionType_Module - instanceId:instanceId - function:@"handleConsoleOutputWithArgument:logLevel:" - exceptionMsg:exceptionMsg - bundleUrl:bundleUrl - customExtParams:customExt]; - } - // ... -} -``` - -效果如下 -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexCallModuleMethodNameMismatch.png) - - -##### 3. JS 调用 module 方法,方法参数个数不匹配 -上面已经讲了点击事件的工作流程,调用方法时,除了调用了不存在的方法或者方法名写错了,还有一种情况就是参数个数不匹配。 - -这种情况如何识别并监控? -JS 的事件处理函数里,调用注册的 Module 和对应的方法,会统一走到 `WXJSCoreBridge.mm` 的 `- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock` 给当前的 JSContext 注册好的 `callNativeModule` 回调里。`_jsContext[@"callNativeModule"] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options)` 可以拿到模块名、方法名、参数个数、instanceID 等。拿到实际传递的方法参数列表,再通过模块名根据 `ModuleFactory` 找到模块类对象,然后利用 runtime 能力,遍历类对象的方法列表,找到对应的 SEL,判断其预期的方法参数个数,然后再和实际传递过来的方法参数个数做比较即可 - -```Objective-c -- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock -{ - // JS 调用 Native 的方法都会走这里。可以解析到:模块名、方法名、参数数组等信息。可以在这里判断方法参数个数是否相同。 - _jsContext[@"callNativeModule"] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options) { - // ... - }; -} -``` - -在其 `callNativeModule` 的 block 里,增加一个方法,专门用来判断和检查方法参数个数是否匹配的问题 -```Objective-C -// 辅助方法:校验Module方法参数个数 -- (void)checkModuleParamCount:(NSString *)moduleName - methodName:(NSString *)methodName - actualParams:(NSArray *)actualParams - instanceId:(NSString *)instanceId { - // 1. 跳过空值/系统模块(避免无意义校验) - if (!moduleName || !methodName || actualParams.count < 0) return; - Class moduleClass = [WXModuleFactory classWithModuleName:moduleName]; - if (!moduleClass) return; - - // 2. 拼接完整的方法选择器(Weex Module方法名带冒号,需补全,如multiply→multiply:num2:callback:) - // 注:若方法名规则固定,可通过模块类的方法列表获取所有selector,匹配前缀 - SEL targetSel = nil; - unsigned int methodCount = 0; - Method *methods = class_copyMethodList(moduleClass, &methodCount); - for (int i = 0; i < methodCount; i++) { - Method method = methods[i]; - SEL sel = method_getName(method); - NSString *selStr = NSStringFromSelector(sel); - // 匹配前缀(如multiply开头的方法) - if ([selStr hasPrefix:methodName]) { - targetSel = sel; - break; - } - } - free(methods); - if (!targetSel) return; - - // 3. 解析方法签名,计算预期参数个数(减self/_cmd) - NSMethodSignature *methodSig = [moduleClass instanceMethodSignatureForSelector:targetSel]; - NSInteger weexParamCount = methodSig.numberOfArguments - 2; - - // 4. 判断参数个数是否不匹配 - if (actualParams.count != weexParamCount) { - // 构造错误信息 - NSString *errorMsg = [NSString stringWithFormat:@"Module:%@ 方法:%@ 参数个数不匹配,预期%ld个,实际%ld个", - moduleName, methodName, weexParamCount, actualParams.count]; - WXLogError(@"[WeexParamError] %@", errorMsg); - - // 5. 上报APM(核心:生产环境监控) - NSDictionary *apmData = @{ - @"error_type": @"weex_module_param_count_mismatch", - @"module_name": moduleName, - @"method_name": methodName, - @"expected_count": @(weexParamCount), - @"actual_count": @(actualParams.count), - @"actual_params": actualParams, - @"instance_id": instanceId ?: @"", - @"timestamp": @([[NSDate date] timeIntervalSince1970] * 1000), - @"message": errorMsg - }; - - // APM:异步上报,避免阻塞JS桥接 - NSLog(@"APM 数据上报通道,【JS 通过 Module 调用 Native 方法,参数个数不匹配】:%@", apmData); - } -} -``` -效果如下: -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexCallNativeModuleParamsError.png) - - -#### 5. Vue 层面异常 -Weex 底层依靠 Vue 实现,差异化就是 VM 去通过 Bridge 在 WeexSDK Native 去做绘制。异常方面除了常规的 JS 运行时异常(如语法错误、类型错误等 7 种),Vue 框架自身的逻辑层、编译层、响应式系统、组件生命周期 等环节会抛出专属异常,这些异常必须通过 Vue.config.errorHandler 兜底。 - -分析 Weex 源码中:`packages/weex-js-framework/index.js/` - -```js -function handleError (err, vm, info) { - if (vm) { - var cur = vm; - while ((cur = cur.$parent)) { - var hooks = cur.$options.errorCaptured; - if (hooks) { - for (var i = 0; i < hooks.length; i++) { - try { - var capture = hooks[i].call(cur, err, vm, info) === false; - if (capture) { return } - } catch (e) { - globalHandleError(e, cur, 'errorCaptured hook'); - } - } - } - } - } - globalHandleError(err, vm, info); -} - -function globalHandleError (err, vm, info) { - if (config.errorHandler) { - try { - return config.errorHandler.call(null, err, vm, info) - } catch (e) { - logError(e, null, 'config.errorHandler'); - } - } - logError(err, vm, info); -} -``` -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexCaptureVueError.png) - -源码中 nextTick、Vue.prototype.$emit、callHook、Watcher.prototype.get、Watcher.prototype.run、renderRecyclableComponentTemplate、Vue.prototype._render 等等都调用了 handleError 方法。 -Vue 内部对部分异常做了封装/拦截,避免直接冒泡到全局(防止阻断应用整体运行),但会通过 errorHandler 暴露出来。 - -举个例子,WeexAPM 类可以封装为: -```JS -/** - * APM - */ -class WeexAPM { - /** - * 获取当前的叶子节点 - * @param {*} Vue vm - * @returns 当前组件名称 - */ - formatComponentName (vm) { - if (vm.$root === vm) return 'root' - var name = vm._isVue - ? (vm.$options && vm.$options.name) || - (vm.$options && vm.$options._componentTag) - : vm.name - return ( - (name ? 'component <' + name + '>' : 'anonymous component') + - (vm._isVue && vm.$options && vm.$options.__file - ? ' at ' + (vm.$options && vm.$options.__file) - : '') - ) - } - - /** - * 处理Vue错误提示 - */ - monitor (Vue) { - if (!Vue) { - return - } - // 错误处理 - Vue.config.errorHandler = (err, vm, info) => { - let componentName = 'unknown' - if (vm) { - componentName = this.formatComponentName(vm) - } - let errorInfo = { - name: err.name, - reason: err.message, - callStack: err.stack, - componentName: componentName, - info: info, - level: 'VUE_ERROR' - } - try { - const weexAPMUploader = weex.requireModule('weexAPMUploader') - weexAPMUploader.uploadException(errorInfo) - } catch (error) { - console.error('APMMonitor 能力有问题,请检查是否注册了weexAPMUploader模块' + error) - } - } - } -} - -export default WeexAPM -``` -在捕获到 Vue 层面的异常时,可以调用注册好的 weexAPMUploader module 能力,将数据传输到 Native 侧,由 Native 侧进行统一的参数组装,最后调用 APM SDK 的能力进行数据写入数据库、按照策略上报到 APM 服务端进行消费。 - -模拟产生 Vue 层级的错误:给一个字符串类型的数据,在计算属性里调用 `toFixed` 方法。按钮的点击事件里将数据改为字符串,则会报错。 -可以看到被 `Vue.config.errorHandler` 捕获了,后续交给 Native 处理即可。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexMockVueAPM.png) \ No newline at end of file diff --git a/Chapter1 - iOS/1.147.md b/Chapter1 - iOS/1.147.md deleted file mode 100644 index f2cae13..0000000 --- a/Chapter1 - iOS/1.147.md +++ /dev/null @@ -1,278 +0,0 @@ -# Rust 在移动端可以做什么 - -> uniffi 的思路,和早期类似营销计算的逻辑用 ts 写好,端上 js 引擎加速,Android、iOS 再去使用是一个思路。 - -## 一、Uniffi 在移动端能做什么? - -### 1. uniffi 是什么? - -uniffi(全称 “Uniffi-rs”)是 Mozilla 开发的一个跨语言绑定生成工具,核心作用是让 Rust 代码能被其他编程语言(如 Swift、Kotlin、Python、JavaScript 等)无缝调用,通过自动生成类型安全的绑定层,大幅简化跨语言 FFI(Foreign Function Interface)开发的复杂度。 - -### 2. 为什么营销计算的逻辑适合用 ts 写? - -纯函数,给定恒定的输入,输出不变。这非常适合 ts、rust 去实现。所以可以说,纯函数、纯逻辑适合用 Rust 去开发。区别在于即使采用类似 Wasm、JS 引擎预热、预加载等技术手段,Rust 天然比 JS 效率高。 - -### 3. ffi 能做什么? - -早期 Flutter 和 Native 交互走 method channel 效率低,所以腾讯的同学开发了 Dart Native 方案,就是基于 ffi 的思路。 - - -所以可以回答 Uniffi 能做什么的问题了: - -uniffi 的核心是 “让 Rust 成为跨语言共享逻辑的‘通用语言’”,尤其在移动端,它解决了 iOS 和 Android 逻辑重复开发的问题,同时借助 Rust 的安全和性能优势,提升核心模块的可靠性。它更适合 “业务逻辑层” 而非 “UI 层”(UI 仍需依赖各平台原生框架),是移动端跨平台开发的重要补充工具 - - - -uniffi 的核心价值是跨语言复用 Rust 代码,因此它适合开发 “需要在多平台 / 多语言间共享的核心逻辑”,尤其适合以下场景: - -1. 跨平台共享的业务逻辑当同一套逻辑需要在多个平台(如移动端 iOS/Android、桌面端、后端服务)实现时,用 Rust 编写一次核心逻辑,再通过 uniffi 生成各平台对应的绑定(如 Swift 绑定供 iOS 调用,Kotlin 绑定供 Android 调用、桌面端 Electron js 去调用、小程序 js 去调用),避免重复开发,保证逻辑一致性。 -2. 对安全性、性能要求高的模块 Rust 自带内存安全和零成本抽象特性,适合处理敏感逻辑(如加密解密、用户认证、支付流程)或性能敏感操作(如数据解析、复杂计算、实时处理)。uniffi 能让这些 Rust 模块被其他语言(如动态语言)安全调用,兼顾安全性与开发效率。 -3. 需要跨语言交互的中间层例如,在一个混合技术栈的项目中(如前端用 JavaScript、后端用 Python、移动端用 Kotlin/Swift),可以用 Rust 编写通用的数据处理或协议解析逻辑,再通过 uniffi 生成多语言绑定,作为各层之间的 “桥梁”。 -4. 替代手动 FFI 开发传统跨语言调用需要手动编写 FFI 绑定(如用 extern "C" 暴露 Rust 接口,再在其他语言中手动适配类型),容易出错且维护成本高。uniffi 可自动生成类型安全的绑定,大幅降低开发和维护成本。 - -## 二、移动端中 uniffi 可以做什么? - -移动端开发的核心痛点之一是 “iOS(Swift/Objective-C)和 Android(Kotlin/Java)需要重复实现相同逻辑”,而 uniffi 恰好能通过 Rust 实现跨平台逻辑复用,具体应用场景包括:共享核心业务逻辑例如:用户登录流程、权限验证、数据校验规则、业务状态管理等。用 Rust 编写一次,通过 uniffi 生成 Swift 绑定(供 iOS)和 Kotlin 绑定(供 Android),两端直接调用,避免 “同一逻辑两套代码” 的冗余和不一致问题。 - -高性能数据处理移动端涉及的复杂数据处理(如 JSON/Protobuf 解析、大数据集合过滤 / 排序、二进制协议编解码),用 Rust 实现可获得比 Kotlin/Swift 更高的性能,uniffi 可让两端高效调用这些逻辑。 - -安全敏感操作如加密(AES、RSA)、解密、签名验证、敏感数据(密码、Token)存储逻辑等,Rust 的内存安全特性可减少传统 C/C++ 调用可能带来的内存漏洞风险,uniffi 则确保调用过程的类型安全。 - -跨平台工具类例如:日期时间处理、字符串工具、设备信息计算(如唯一标识生成)等通用工具,用 Rust 实现后,通过 uniffi 让 iOS 和 Android 共享,减少重复开发。 - -与现有跨平台框架配合即使项目使用 Flutter、React Native 等跨平台 UI 框架,也可通过 uniffi 将 Rust 逻辑作为 “原生能力扩展”:例如 Flutter 中通过 Platform Channel 调用 uniffi 生成的 Rust 绑定,处理 Flutter 难以高效实现的复杂计算。 - - - -### 三、Rust 中使用 Unifii 实现与 Swift 高效互操作性 -UniFFI(Universal Foreign Function Interface)是一个工具集,旨在帮助开发者轻松生成适用于多个编程语言(如 Swift、Kotlin 和 Python)的外部函数接口 (FFI) 绑定。它允许你通过定义一个接口描述语言 (UDL) 文件,自动生成跨语言的绑定代码,从而简化 Rust 代码与其他语言之间的交互。 - -#### 1. 必要工具 -开始之前,你需要确保已经安装了以下工具: -- Rust 和 Cargo:Rust 是一种系统编程语言,强调安全性和性能。Cargo 是 Rust 的包管理器和构建系统。 -验证安装:运行以下命令确保 Rust 和 Cargo 已正确安装。 -```shell -rustc --version -cargo --version -``` -- UniFFI CLI 工具:UniFFI 提供了一个命令行工具,用于生成绑定代码。安装 UniFFI:你可以通过 Cargo 安装 UniFFI。 -```shell -cargo install uniffi_bindgen -``` -验证安装:运行以下命令确保 UniFFI 已正确安装。 -```shell -uniffi-bindgen --version -``` - -#### 2. 项目设置 -1. 创建 Rust 项目 -- 打开终端并运行以下命令创建一个新的 Rust 库项目: -```shell -cargo new my_rust_library --lib -cd my_rust_library -``` -- 编辑Cargo.toml文件,添加 Uniffi 依赖项 -```shell -[dependencies] -uniffi = "0.27.0" -serde = { version = "1.0", features = ["derive"] } -``` -- 配置Cargo.toml文件中的lib部分,设置 crate 类型为 cdylib: -```shell -[lib] -crate-type = ["cdylib"] -``` - -2. 创建名为 `MySwiftApp` 的Swift 项目 - -#### 3. Rust 库的创建 -我们将创建一个简单的 Rust 库,并配置它以便与 Swift 互操作。我们将定义一个数据结构和一些基本的功能,使用 UniFFI 来生成绑定。 -1. 设置 Cargo.toml -首先,我们需要在 Cargo.toml 文件中配置项目依赖和库类型: -```rs -[package] -name = "my_rust_library" -version = "0.1.0" -edition = "2018" - -[dependencies] -uniffi = "0.27.0" # 检查最新版本 -serde = { version = "1.0", features = ["derive"] } - -[lib] -crate-type = ["cdylib"] -``` -2. 编写 Rust 代码 -在 src/lib.rs 文件中编写 Rust 代码。我们将定义一个简单的结构体 Greeting 和相关的方法。 -```rs -// src/lib.rs -use serde::{Serialize, Deserialize}; -use uniffi_macros::include_scaffolding; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Greeting { - pub message: String, -} - -impl Greeting { - pub fn new(name: String) -> Greeting { - Greeting { - message: format!("Hello, {}!", name), - } - } - - pub fn greet(&self) -> String { - self.message.clone() - } -} - -include_scaffolding!("my_library"); -``` -3. 编写 UDL 文件 -在项目根目录下创建一个新的文件 src/my_library.udl,用于定义接口描述语言 (UDL)。 -```rs -namespace my_library { - struct Greeting { - message: string; - } - - Greeting { - static Greeting new(string name); - string greet(); - } -} -``` -这个 UDL 文件描述了 Greeting 结构体及其方法。 - -#### 4. 生成 Uniffi 绑定 -使用 UniFFI 工具生成 Swift 绑定文件。确保你在项目根目录下,然后运行以下命令: -```shell -uniffi-bindgen generate src/my_library.udl --language swift --out-dir gen/ -``` -该命令将生成必要的 Swift 文件,并保存在gen/目录下。 - -#### 5. 构建 Rust 库 -使用以下命令构建 Rust 库: -```shell -cargo build --release -``` -生成的动态库文件将位于 target/release 目录下,文件名为 libmy_rust_library.dylib(在 macOS 上)。 - -通过上述步骤,我们创建了一个简单的 Rust 库,并配置了 Uniffi 以生成 Swift 绑定文件。在下一部分中,我们将把生成的 Swift 文件集成到 Swift 项目中,并编写代码调用 Rust 库。 - -#### 6. 生成 UniFFI 绑定 -在这一部分,我们将使用 UniFFI 工具生成 Swift 绑定代码。这将使得我们可以在 Swift 项目中调用 Rust 库的功能。 - -1. 编写 UDL 文件 -首先,我们需要编写一个 UniFFI 接口描述语言 (UDL) 文件。这个文件描述了我们希望暴露给 Swift 的数据结构和函数接口。 -在项目根目录的 src 文件夹中创建一个名为 my_library.udl 的文件,并添加以下内容: -```rs -namespace my_library { - struct Greeting { - message: string; - } - - Greeting { - static Greeting new(string name); - string greet(); - } -} -``` -这个 UDL 文件定义了一个名为 Greeting 的结构体及其两个方法:new 和 greet - - -2. 生成 Swift 绑定 -在终端中导航到项目根目录,并运行以下命令生成 Swift 绑定: -```shell -uniffi-bindgen generate src/my_library.udl --language swift --out-dir gen/ -``` -这个命令会根据 my_library.udl 文件生成 Swift 绑定代码,并将其放在 `gen/` 目录下。生成的文件包括: -- my_library.swift:包含 Swift 代码,用于调用 Rust 库。 -- my_libraryFFI.h:C 头文件,用于描述 Rust 和 Swift 之间的接口。 -- my_libraryFFI.swift:内部使用的 Swift 文件,处理底层的 FFI 调用。 - -下是生成的 my_library.swift 文件的示例内容: - -```swift -import Foundation - -public struct Greeting { - public let message: String - - public init(name: String) { - self = Greeting.new(name: name) - } - - public func greet() -> String { - return greeting_greet(self) - } -} -``` - -my_libraryFFI.h 文件的示例内容: -```c -#ifndef MY_LIBRARY_FFI_H -#define MY_LIBRARY_FFI_H - -#include -#include - -typedef struct { - char* message; -} Greeting; - -Greeting* greeting_new(const char* name); -const char* greeting_greet(const Greeting* self); -void greeting_free(Greeting* self); - -#endif // MY_LIBRARY_FFI_H -``` - -3. 构建 Rust 库 -在继续之前,确保 Rust 库可以成功构建。运行以下命令: -```shell -cargo build --release -``` -生成的动态库文件位于 `target/release` 目录下,文件名为 libmy_rust_library.dylib(在 macOS 上)。这个文件将被 Swift 项目使用。 -通过以上步骤,你已经成功生成了用于 Swift 调用的 Rust 绑定文件。在下一部分中,我们将这些文件集成到 Swift 项目中,并编写代码调用 Rust 库。 - -#### 集成到 Swift 项目 -1. 打开 Finder,导航到 Rust 项目中的 `gen/` 目录,选择 my_library.swift、my_libraryFFI.h、my_libraryFFI.swift 文件。 -拖动这些文件到 Xcode 项目导航器中的项目目录下。在弹出的对话框中,确保选中 "Copy items if needed" 选项,然后点击 "Finish"。 - -2. 添加 Rust 动态库 -为了让 Swift 项目能够找到并使用 Rust 动态库,需要将动态库文件添加到 Xcode 项目中: -- 在 Finder 中,导航到 Rust 项目的target/release目录。 -- 找到生成的动态库文件libmy_rust_library.dylib(在 macOS 上)。 -- 拖动这个文件到 Xcode 项目导航器中的项目目录下。在弹出的对话框中,确保选中 "Copy items if needed" 选项,然后点击 "Finish"。 - -3. 配置 Xcode 项目 -为了让 Xcode 项目能够正确地链接和加载 Rust 动态库,需要进行一些配置: -- 选择项目文件,在左侧的 "TARGETS" 列表中选择你的应用目标。 -- 选择 "Build Phases" 选项卡。 -- 展开 "Link Binary With Libraries" 部分,点击 "+" 按钮,添加刚才拖动到项目中的 libmy_rust_library.dylib 文件。 - -4. 配置动态库加载路径 -选择项目文件,在左侧的 "TARGETS" 列表中选择你的应用目标。 -- 选择 "Build Settings" 选项卡。 -- 搜索 "Runpath Search Paths"。 -- 添加以下路径到 "Runpath Search Paths" 配置项中:`@executable_path/../Frameworks` - -5. 编写 Swift 调用代码 -在 ViewController.swift 文件中,编写代码调用 Rust 库: -```swift -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - // 调用 Rust 库 - let greeting = Greeting(name: "World") - print(greeting.greet()) - } -} -``` - -通过这些步骤,已经掌握了如何在 Swift 项目中高效地使用 Rust 库,并了解了相关的高级技术。这种跨语言的集成不仅能利用 Rust 的性能优势,还能享受 Swift 的便捷开发体验。 diff --git a/Chapter1 - iOS/1.148.md b/Chapter1 - iOS/1.148.md deleted file mode 100644 index 397738d..0000000 --- a/Chapter1 - iOS/1.148.md +++ /dev/null @@ -1,585 +0,0 @@ -# 移动端的“安全气垫” - -> 安全气垫是端上一个老生常谈的话题,技术方案已经是好多年前的产物。唯一的问题是不同公司对于安全气垫在拦截到异常的时候,“做与不做”策略的判断不同。比如数组索引越界的时候要返回一个错误位置的值吗?会不会产生业务异常?要一刀切吗?那么本文就尝试回答下这个问题 - -## 一、一个经典的场景 -Weex 源码中,设计了一个 `WXThreadSafeMutableDictionary`。在字典操作的地方使用锁: -```Objective-C -- (void)setObject:(id)anObject forKey:(id)aKey -{ - id originalObject = nil; // make sure that object is not released in lock - @try { - pthread_mutex_lock(&_safeThreadDictionaryMutex); - originalObject = [_dict objectForKey:aKey]; - [_dict setObject:anObject forKey:aKey]; - } - @finally { - pthread_mutex_unlock(&_safeThreadDictionaryMutex); - } - originalObject = nil; -} -``` -这么写的价值:**解锁逻辑「绝对执行」,彻底避免死锁** -这是 `@try-finally` 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常),finally 块的解锁逻辑一定会执行 - -对比无 try-finally 的写法 -```Objective-C -// Bad: 若setObject抛异常,unlock不会执行→死锁 -pthread_mutex_lock(&_mutex); -[_dict setObject:anObject forKey:aKey]; -pthread_mutex_unlock(&_mutex); -``` -问题:`[_dict setObject:anObject forKey:aKey]` 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException),若没有 finally,锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。 - -设计优点: -- `@try-finallly`:即使 try 内逻辑出错,finally 也会执行 pthread_mutex_unlock,保证锁最终释放,这是**线程安全的「兜底保障」** -- 注意,不是 `try...catch...finally`: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 `try...catch...finally` 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了。 - -这是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题 -聊聊类似网易的大白解决方案或者业界其他公司中,安全气垫虽然保证了代码不 crash,影响用户体验,但是比如数组本该越界,现在却不越界: -1. 唯一能做的就是返回一个错误的值,比如数组长度为3,访问4,现在不 crash,返回了 0 的值,那是不是产生了业务异常?比如商品价格 -2. 不 crash,也不返回错误位置的值,类似给一个回调,告诉业务方出现了异常,可以做一些业务层面的提醒或者配置(比如开发阶段商品卡片的价格 Label 显示:商品价格获取错误,数组越界),同时产生的异常案发现场信息和其他的一些数据会上报,用于 APM 平台去分析和定位。 - -但这也产生一个问题,类似数组越界的场景,可能10000次里面9999次都正常,只有1次异常,业务开发为了这万分之一出现的异常,还需要写一些异常处理的逻辑(比如商品卡片展示价格获取错误,数组越界)。那字典的 key 为 nil 呢?除法的分母为0呢?诸如此类,类似乐观锁和悲观锁的场景 - -## 二、核心原则 -要解决「安全气垫防崩溃但引发隐性业务异常」「低概率异常导致业务开发冗余逻辑」的核心矛盾,业界的优雅方案核心思路是:「环境差异化策略」+「分层兜底 + 语义化默认值」+「可观测驱动的轻量处理」,既避免线上 Crash,又最小化业务侵入,同时保证问题可被发现和修复。 - -**开发阶段让问题 “炸出来”,生产阶段让问题 “软落地”**。从源头减少线上低概率异常的发生,业务开发无需为 “万分之一” 的异常写冗余逻辑 - -| 环境 | 核心目标 | 策略 | -| ----------- | ---------------------------------- | ----------------------------------- | -| 开发 / 测试 | 提前暴露问题,杜绝上线 | 「零容忍」:直接 Crash + 详细上下文 | -| 生产 | 避免 Crash + 可观测 + 最小业务影响 | 「软兜底」:语义化默认值 + 全量上报 | - - - -## 三、多个方案 - -### 方案1:环境差异化 + 开发强感知(网易大白/腾讯 Bugly 核心思路) - -对 NSArray、NSDictionary、NSNumber 等基础类做运行时 Hook,区分环境处理异常: -NSArray+DWSafeHook.m - -```Objective-C -// 核心逻辑: -// 1. Method Swizzling Hook数组核心读写方法 -// 2. Debug环境:越界直接Crash+详细上下文,强制研发修复 -// 3. Release环境:拦截崩溃+上报异常+返回语义化空值,避免用户感知 -// -#import "NSArray+DWSafeHook.h" -#import -#import "DWEnvironmentUtils.h" // 环境判断工具类(自研) -#import "DWCrashReporter.h" // 崩溃上报工具类(自研) - -@implementation NSArray (DWSafeHook) - -#pragma mark - 对外入口:初始化Hook -+ (void)dw_setupSafeHook { - // 防止重复Hook - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // Hook 只读数组核心方法 - [self dw_swizzleInstanceMethod:@selector(objectAtIndex:) - withNewMethod:@selector(dw_safe_objectAtIndex:)]; - [self dw_swizzleInstanceMethod:@selector(objectAtIndexedSubscript:) - withNewMethod:@selector(dw_safe_objectAtIndexedSubscript:)]; - - // Hook 可变数组核心方法(写操作) - [NSMutableArray dw_swizzleInstanceMethod:@selector(addObject:) - withNewMethod:@selector(dw_safe_addObject:)]; - [NSMutableArray dw_swizzleInstanceMethod:@selector(insertObject:atIndex:) - withNewMethod:@selector(dw_safe_insertObject:atIndex:)]; - [NSMutableArray dw_swizzleInstanceMethod:@selector(removeObjectAtIndex:) - withNewMethod:@selector(dw_safe_removeObjectAtIndex:)]; - }); -} - -#pragma mark - 私有工具:Method Swizzling封装 -/// 通用Swizzling方法(避免重复代码) -/// @param originalSEL 原方法SEL -/// @param newSEL 替换后的方法SEL -+ (void)dw_swizzleInstanceMethod:(SEL)originalSEL withNewMethod:(SEL)newSEL { - Class cls = [self class]; - - // 获取原方法和新方法 - Method originalMethod = class_getInstanceMethod(cls, originalSEL); - Method newMethod = class_getInstanceMethod(cls, newSEL); - if (!originalMethod || !newMethod) { - NSLog(@"[DWCrashGuard] Swizzling失败:方法不存在 originalSEL: %@, class: %@", - NSStringFromSelector(originalSEL), NSStringFromClass(cls)); - return; - } - - // 尝试添加新方法(防止原方法未实现) - BOOL isAdded = class_addMethod(cls, - originalSEL, - method_getImplementation(newMethod), - method_getTypeEncoding(newMethod)); - if (isAdded) { - // 替换原方法实现 - class_replaceMethod(cls, - newSEL, - method_getImplementation(originalMethod), - method_getTypeEncoding(originalMethod)); - } else { - // 交换方法实现 - method_exchangeImplementations(originalMethod, newMethod); - } -} - -#pragma mark - Hook实现:只读数组读操作(核心防越界) -/// 拦截 [array objectAtIndex:] 越界 -- (id)dw_safe_objectAtIndex:(NSUInteger)index { - // 安全校验:索引越界判断 - if (index >= self.count) { - [self dw_handleArrayOutOfBoundsWithIndex:index method:@"objectAtIndex:"]; - return [NSNull null]; // Release环境返回语义化空值(而非0) - } - - // 正常逻辑:调用原方法(Swizzle后,此处实际是原objectAtIndex:) - return [self dw_safe_objectAtIndex:index]; -} - -/// 拦截 array[index] 下标访问越界 -- (id)dw_safe_objectAtIndexedSubscript:(NSUInteger)index { - if (index >= self.count) { - [self dw_handleArrayOutOfBoundsWithIndex:index method:@"objectAtIndexedSubscript:"]; - return [NSNull null]; - } - - return [self dw_safe_objectAtIndexedSubscript:index]; -} - -#pragma mark - 私有工具:数组越界统一处理 -/// 数组越界异常处理(区分环境) -/// @param index 访问的索引 -/// @param method 触发异常的方法名 -- (void)dw_handleArrayOutOfBoundsWithIndex:(NSUInteger)index method:(NSString *)method { - // 1. 构建异常上下文(用于上报/调试) - NSDictionary *context = @{ - @"crashType": @"NSArrayOutOfBounds", - @"method": method, - @"arrayCount": @(self.count), - @"accessIndex": @(index), - @"arrayDescription": [self description], // 数组内容(便于定位) - @"callStack": [NSThread callStackSymbols], // 完整调用栈 - @"timestamp": @([[NSDate date] timeIntervalSince1970]), - @"deviceInfo": [DWEnvironmentUtils deviceInfo] // 设备型号/系统版本等 - }; - - // 2. 区分环境处理 - if ([DWEnvironmentUtils isDebugEnvironment]) { - // Debug环境:直接Crash+详细日志,强制研发修复 - NSString *errorMsg = [NSString stringWithFormat: - @"【网易大白】NSArray越界崩溃!\n" - @"方法:%@\n" - @"数组长度:%lu,访问索引:%lu\n" - @"数组内容:%@\n" - @"调用栈:%@", - method, self.count, index, self, [NSThread callStackSymbols]]; - NSAssert(NO, @"%@", errorMsg); - abort(); // 确保Crash(NSAssert在Release下失效) - } else { - // Release环境:拦截崩溃+上报APM平台 - [DWCrashReporter reportCrashWithType:@"NSArrayOutOfBounds" - context:context - severity:DWCrashSeverityLow]; // 低优先级(万分之一概率) - NSLog(@"[DWCrashGuard] 拦截NSArray越界:%@, count:%lu, index:%lu", method, self.count, index); - } -} - -@end - -#pragma mark - 可变数组Hook实现(写操作防护) -@implementation NSMutableArray (DWSafeHook) - -/// 拦截 addObject:nil 崩溃 -- (void)dw_safe_addObject:(id)anObject { - if (anObject == nil) { - // 构建异常上下文 - NSDictionary *context = @{ - @"crashType": @"NSMutableArrayAddNil", - @"callStack": [NSThread callStackSymbols], - @"timestamp": @([[NSDate date] timeIntervalSince1970]) - }; - - if ([DWEnvironmentUtils isDebugEnvironment]) { - // Debug环境:Crash+提示 - NSAssert(NO, @"【网易大白】NSMutableArray添加nil对象!调用栈:%@", [NSThread callStackSymbols]); - abort(); - } else { - // Release环境:拦截+上报,忽略nil添加 - [DWCrashReporter reportCrashWithType:@"NSMutableArrayAddNil" - context:context - severity:DWCrashSeverityLow]; - NSLog(@"[DWCrashGuard] 拦截NSMutableArray添加nil对象"); - return; - } - } - - // 正常逻辑:调用原方法 - [self dw_safe_addObject:anObject]; -} - -/// 拦截 insertObject:atIndex: 越界/nil -- (void)dw_safe_insertObject:(id)anObject atIndex:(NSUInteger)index { - // 1. 校验nil - if (anObject == nil) { - [self dw_handleMutableArrayNilObjectWithMethod:@"insertObject:atIndex:"]; - return; - } - - // 2. 校验越界 - if (index > self.count) { // insert允许index == count(追加) - [self dw_handleArrayOutOfBoundsWithIndex:index method:@"insertObject:atIndex:"]; - return; - } - - // 正常逻辑 - [self dw_safe_insertObject:anObject atIndex:index]; -} - -/// 拦截 removeObjectAtIndex: 越界 -- (void)dw_safe_removeObjectAtIndex:(NSUInteger)index { - if (index >= self.count) { - [self dw_handleArrayOutOfBoundsWithIndex:index method:@"removeObjectAtIndex:"]; - return; - } - - // 正常逻辑 - [self dw_safe_removeObjectAtIndex:index]; -} - -#pragma mark - 私有工具:可变数组nil处理 -- (void)dw_handleMutableArrayNilObjectWithMethod:(NSString *)method { - NSDictionary *context = @{ - @"crashType": @"NSMutableArrayInsertNil", - @"method": method, - @"callStack": [NSThread callStackSymbols] - }; - - if ([DWEnvironmentUtils isDebugEnvironment]) { - NSAssert(NO, @"【网易大白】NSMutableArray插入nil对象!方法:%@,调用栈:%@", method, [NSThread callStackSymbols]); - abort(); - } else { - [DWCrashReporter reportCrashWithType:@"NSMutableArrayInsertNil" - context:context - severity:DWCrashSeverityLow]; - NSLog(@"[DWCrashGuard] 拦截NSMutableArray插入nil对象:%@", method); - } -} -@end -``` - -简化下,逻辑基本为: - -```Objective-C -// 伪代码:Hook NSArray的objectAtIndex: -- (id)safe_objectAtIndex:(NSUInteger)index { - if (index >= self.count) { - // 开发环境:Crash + 打印完整的案发现场信息 + 调用栈 + 上下文(数组内容、访问索引、业务模块) - #ifdef DEBUG - NSAssert(NO, @"数组越界:数组长度%lu,访问索引%lu,调用栈:%@", self.count, index, [NSThread callStackSymbols]); - abort(); - #else - // 生产环境:返回语义化空值(而非0)+ 上报APM - [APMManager reportExceptionWithType:@"数组越界" - context:@{@"arrayCount": @(self.count), @"index": @(index), @"callStack": [NSThread callStackSymbols]}]; - return [NSNull null]; // 而非0,业务层可统一识别,比如价格展示为: '--' - #endif - } - return [self original_objectAtIndex:index]; -} -``` -核心优势: -- 开发环境:不仅 Crash,还打印「业务数据上下文」(比如当前是商品详情页、数组是价格数组),开发一眼定位问题 -- 生产环境:返回NSNull(而非无意义的 0),业务层只需做一次全局 UI 处理(比如所有 Label 展示时,判断值为NSNull则显示 “--”),无需为每个场景写逻辑 -覆盖场景: -- 数组越界:返回NSNull; -- 字典 key 为 nil:setObject:forKey:时忽略 nil key 并上报,objectForKey:时返回NSNull; -- 分母为 0:返回INFINITY(全局工具类判断isinf(),统一返回 “--”); -- 字符串转数字失败:返回NSNull而非 0 - -### 方案2:声明式全局兜底 + 业务按需关注(阿里/字节) -避免业务层 “零散处理异常”,而是**全局统一兜底 + 业务选择性注册关注的异常类型**。 -- 全局层面:所有基础类异常返回 NSNull,UI 层统一处理 NSNull 为 “--”/“获取失败”; -- 业务层面:仅对 “核心场景”(如商品价格、支付金额)注册异常回调,非核心场景无需处理: - -WXExceptionManager.h -```Objective-C -#import - -NS_ASSUME_NONNULL_BEGIN - -/// 异常类型枚举(替代字符串,避免硬编码) -typedef NS_ENUM(NSUInteger, WXExceptionType) { - WXExceptionTypeArrayOutOfBounds, // 数组越界 - WXExceptionTypeDictionaryNilKey, // 字典nil key - WXExceptionTypeDivideByZero, // 分母为0 -}; - -/// 异常上下文Key定义(统一常量,避免拼写错误) -FOUNDATION_EXTERN NSString *const WXExceptionContextBizModuleKey; // 业务模块 -FOUNDATION_EXTERN NSString *const WXExceptionContextArrayCountKey;// 数组长度 -FOUNDATION_EXTERN NSString *const WXExceptionContextAccessIndexKey;// 访问索引 -FOUNDATION_EXTERN NSString *const WXExceptionContextCallStackKey; // 调用栈 -FOUNDATION_EXTERN NSString *const WXExceptionContextExtraKey; // 扩展信息 - -@interface WXExceptionManager : NSObject - -/// 单例(全局唯一) -+ (instancetype)sharedManager; - -/// 注册异常回调 -/// @param type 异常类型 -/// @param handler 回调(主线程执行,避免UI操作崩溃) -- (void)registerCallbackForType:(WXExceptionType)type handler:(void(^)(NSDictionary *context))handler; - -/// 上报异常(内部Hook调用,业务层无需调用) -/// @param type 异常类型 -/// @param context 异常上下文 -- (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context; - -@end - -NS_ASSUME_NONNULL_END -``` -WXExceptionManager.m -```Objective-C -#import "WXExceptionManager.h" -#import - -// 上下文Key常量定义 -NSString *const WXExceptionContextBizModuleKey = @"bizModule"; -NSString *const WXExceptionContextArrayCountKey = @"arrayCount"; -NSString *const WXExceptionContextAccessIndexKey = @"accessIndex"; -NSString *const WXExceptionContextCallStackKey = @"callStack"; -NSString *const WXExceptionContextExtraKey = @"extra"; - -@interface WXExceptionManager () -/// 存储不同异常类型的回调(key: WXExceptionType的NSNumber,value: 回调数组) -@property (nonatomic, strong) NSMutableDictionary *> *callbackDict; -/// 线程安全锁 -@property (nonatomic, assign) pthread_mutex_t mutex; -@end - -@implementation WXExceptionManager - -+ (instancetype)sharedManager { - static WXExceptionManager *instance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - instance = [[self alloc] init]; - }); - return instance; -} - -- (instancetype)init { - self = [super init]; - if (self) { - _callbackDict = [NSMutableDictionary dictionary]; - // 初始化线程锁 - pthread_mutex_init(&_mutex, NULL); - } - return self; -} - -/// 注册异常回调 -- (void)registerCallbackForType:(WXExceptionType)type handler:(void (^)(NSDictionary *))handler { - if (!handler) return; - - pthread_mutex_lock(&_mutex); - // 按异常类型分组存储回调 - NSNumber *typeKey = @(type); - if (!_callbackDict[typeKey]) { - _callbackDict[typeKey] = [NSMutableArray array]; - } - [_callbackDict[typeKey] addObject:handler]; - pthread_mutex_unlock(&_mutex); -} - -/// 上报异常(触发回调+APM上报) -- (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context { - // 1. APM平台上报(模拟:实际对接公司APM,如Bugly/云监控) - NSLog(@"【APM上报】异常类型:%lu,上下文:%@", type, context); - - // 2. 触发注册的回调(主线程执行,避免UI操作崩溃) - dispatch_async(dispatch_get_main_queue(), ^{ - pthread_mutex_lock(&self->_mutex); - NSNumber *typeKey = @(type); - NSArray *handlers = self->_callbackDict[typeKey]; - pthread_mutex_unlock(&self->_mutex); - - for (void(^handler)(NSDictionary *) in handlers) { - if (handler) { - handler(context); - } - } - }); -} - -- (void)dealloc { - pthread_mutex_destroy(&_mutex); -} - -#pragma mark - 类方法封装(简化调用) -+ (void)registerCallbackForType:(WXExceptionType)type handler:(void (^)(NSDictionary *))handler { - [[self sharedManager] registerCallbackForType:type handler:handler]; -} - -+ (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context { - [[self sharedManager] reportExceptionWithType:type context:context]; -} - -@end -``` -使用的地方 -```Objective-C -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor whiteColor]; - - // 1. 初始化价格标签 - self.priceLabel = [[UILabel alloc] initWithFrame:CGRectMake(100, 200, 200, 40)]; - self.priceLabel.font = [UIFont systemFontOfSize:18]; - [self.view addSubview:self.priceLabel]; - - // 2. 模拟业务数据:价格数组(长度3,索引0-2) - self.priceArray = @[@"99.9", @"199.9", @"299.9"]; - // 标记数组所属业务模块(关键:用于回调筛选) - self.priceArray.bizModule = @"goodsPrice"; - - // 3. 注册数组越界异常回调(仅关注价格模块) - [WXExceptionManager registerCallbackForType:WXExceptionTypeArrayOutOfBounds handler:^(NSDictionary *context) { - // 筛选:仅处理“价格数组”的越界异常 - if ([context[WXExceptionContextBizModuleKey] isEqualToString:@"goodsPrice"]) { - NSLog(@"【业务降级】商品价格数组越界,触发UI兜底"); - // 生产环境:展示友好提示(而非错误值) - self.priceLabel.text = @"价格获取失败"; - // 可选:触发其他降级逻辑(如隐藏价格、显示默认价) - [self triggerPriceFallback]; - } - }]; - - // 4. 模拟异常场景:访问索引3(越界) - [self testPriceArrayOutOfBounds]; -} - -/// 模拟价格数组越界访问 -- (void)testPriceArrayOutOfBounds { - // 访问索引3(数组长度3,正常索引0-2) - id price = [self.priceArray objectAtIndex:3]; - // 正常场景:显示价格;异常场景:price是NSNull,显示兜底 - if ([price isKindOfClass:[NSNull class]]) { - self.priceLabel.text = @"价格获取失败"; - } else { - self.priceLabel.text = [NSString stringWithFormat:@"¥%@", price]; - } -} - -/// 价格降级逻辑(可选) -- (void)triggerPriceFallback { - // 比如:隐藏优惠券、显示默认包邮等 - NSLog(@"【降级】隐藏优惠券模块,显示默认包邮文案"); -} - -@end -``` -核心优势: -- 99% 的低概率异常由全局兜底处理(返回 NSNull+UI 显示 --) -- 仅核心业务场景(价格、支付)需写少量回调逻辑,避免冗余 -效果,类似针对核心的业务场景做专项化监控和优化。 - - -### 方案 3:静态分析 + CI/CD 前置拦截(从源头消灭异常) -比运行时 Hook 更优雅的是 **“提前拦截”**。通过静态分析工具(Clang Static Analyzer、OCLint、自定义 LLVM 插件),在代码提交/编译阶段检测出: -- 数组越界风险(如array[index]中 index 未做长度校验) -- 字典 key 为 nil(如dict[nil]) -- 分母为 0(如a / b中 b 未判 0) -- 强制类型转换风险(如(NSNumber *)nil) - -落地方式: -- 集成到 CI/CD Pipeline,代码提交时触发静态分析,有风险则阻断合入 -- 通过编写 LLVM 插件,就可以在 Xcode 有问题的代码上实时提示 “数组越界风险”“字典 key 可能为 nil”。比如 - - -效果:线上低概率异常的根源被提前消灭,问题无法逃逸到现线上,业务层几乎无需处理这类异常(因为代码本身就没有漏洞) - -关于如何编写 LLVM 插件,做到在 Xcode 中实时展示代码中存在的问题,可以查看:[LLVM 插件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.102.md) 这篇文章。 - - - -### 方案 4:轻量级熔断 / 降级(适合核心业务场景) - -对于 “万分之一” 但影响大的异常(如支付金额计算、商品价格),采用「熔断策略」: -第一次出现异常:上报 + 降级为默认值(如价格显示 “--”); -短时间内多次出现(如 1 分钟内 > 3 次):触发熔断,建议:展示兜底 UI,提示安抚用户,稍等片刻后尝试(起码不会发生稳定的 crash 或者业务异常) -熔断状态上报 APM,研发收到告警后优先修复。 - -### 方案 5:语义化默认值 + 业务无感知适配(美团 / 饿了么实践) - -返回**业务可识别的语义化空值**,并在 UI 层做统一适配: - -| 异常场景 | 兜底值 | UI 层统一处理 | 业务层感知 | -| ---------------- | -------- | --------------- | ---------- | -| 数组越界 | NSNull | 显示 “--” | 无 | -| 字典 key 为 nil | NSNull | 显示 “暂无数据” | 无 | -| 分母为 0 | INFINITY | 显示 “计算异常” | 无 | -| 字符串转数字失败 | NSNull | 显示 “--” | 无 | - -**实现方式**: - -- 封装全局 UI 工具类(如`WXUILabel+Safe.h`),重写`setText:`方法: - -```objective-c -- (void) safe_setText:(id)text { - if (text == [NSNull null] || text == nil) { - self.text = @"--"; - } else if ([text isKindOfClass:[NSNumber class]] && isinf([text doubleValue])) { - self.text = @"计算异常"; - } else { - self.text = [text description]; - } -} -``` - -业务层只需使用`safe_setText:`,无需为每个异常场景写判断逻辑。 - - - -## 四、最佳实践 - -1. **开发环境零容忍**:通过静态分析、自定义 LLVM 插件 + 运行时 Hook,让问题在测试阶段暴露,比如异常发生时,通过日志打印案发现场数据,或者在 Xcode 面板上可视化的在有问题的代码地方显示 error,及时 crash 掉,阻塞正常流程,以防止开发偷懒不去及时修复,产生技术债,同时也防止问题逃逸到线上。 -2. **生产环境软兜底**:不要一刀切,做到可配置。业务方在配置平台可以针对不同业务域的配置,决定是否开启兜底展示(某些业务域的架构师可能更严格,需要开发在 debug 阶段解决这些问题,线上如果出现就统计为 crash)。如果开启,那么安全气垫就返回语义化默认值(而非无意义的 0),全局 UI 统一处理,业务层零侵入; -3. **可观测驱动修复**:APM 平台统计异常频率 / 影响度,优先处理高频高影响的异常; -4. **核心场景按需关注**:仅对价格、支付等核心场景注册回调,非核心场景无需处理。 - -虽然机制和策略已经制定了,但是执行还是靠人,难以保证100%解决问题。所以开发解决通过 lint 和 crash 可以拦截到大部分问题,但是逃逸到线上的部分问题,还是需要安全气垫去 cover,线下无限毕竟去清空问题 + 少数逃逸到线上的问题通过安全气垫去 cover 就可以保证相对全面的安全守护。 - - - -## 五、平台做些什么 - -获取到 问题数据是第一步,获取之后问题需要及时上报,需要有一个数据上报系统,数据及时准确、高效的上传到服务端(这就是另一个命题,可以查看这篇文章:[打造一个通用、可配置、多句柄的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md)),到 APM 后台。后台产生工单,根据业务模块分配到对应的责任人、群组。根据问题的紧急程度,报警策略和提醒频次也不一样,比如**波动报警、自定义报警策略等(这也是另一个命题:智能报警策略、波动报警策略、业务线自定义可配置责任人等)**。责任人及时解决问题、修改工单状态、热修复或者发布新版本,问题闭环。 - -线上异常上报后,APM 平台需提供: - -- 异常频率(如 “数组越界” 仅 1/10000); -- 影响用户数(如仅影响 10 个用户); -- 业务上下文(如 “商品详情页 - 价格数组”); -- 调用栈 + 设备信息。 - -**核心逻辑**: - -- 低频率、低影响的异常(如 1/10000,影响 10 用户):暂时无需业务层处理,研发排期修复即可; -- 高频率、高影响的异常(如 1/100,影响 1000 用户):触发告警,研发紧急修复,业务层临时加兜底。 - -这样业务开发无需为 “万分之一” 的异常写逻辑,仅需处理 “高频高影响” 的异常。 - - - -## 总结 - -“最佳实践”部分选择的方案既避免了 “安全气垫返回错误值导致业务异常”,又解决了 “业务开发为低概率异常写冗余逻辑” 的问题,是网易、腾讯、阿里等大厂的通用实践 ——**优雅的方案不是 “兜底所有异常”,而是 “让异常可被提前发现、可被观测、最小化影响”**,即使少数逃逸到线上的,再由安全气垫去兜底解决(解决了展示问题、异常上报和案发现场还原问题),一个好的 APM 平台不光是告诉你又问题,还会告诉你哪里有问题。 - diff --git a/Chapter1 - iOS/1.15.md b/Chapter1 - iOS/1.15.md deleted file mode 100644 index 9efffd5..0000000 --- a/Chapter1 - iOS/1.15.md +++ /dev/null @@ -1,54 +0,0 @@ - -# URL Schemes 的发展 - - - -URL Schemes 的发展过程可以说就是 iOS 效率工具类 App 的发展过程。 - -起初的苹果建立的 Apple URL Schemes 只是用于自用,里面只有邮件、电话、iTunes 搜索、Youtube 视频等一些内置服务的 URL。 - -个人认为 URL Schemes 第一次大火是在 2011 年末(如有异议欢迎指正),那个时期也是越狱的鼎盛时期,那个时期越狱后大家都会装的一个插件是 SBSettings[1]。越狱的人都知道每当新系统发布的时候,等待新系统的越狱发布是最撩人的,而这段时期那些「不越狱就能做到某种越狱功能」的应用经常一时间风头无两。 - -2011年 iOS 5 发布带来了通知中心,没过多久,出现了一大批使用 iOS 系统设置的 URL Schemes 的 App 神奇地完成了接近 SBSettings 的功能——它们可以让我们从通知中心直接跳转到某些 App 的特定界面,比如 Twitter 的发推界面。它们甚至还可以直接跳转到系统设置里的 Wi-Fi 选项。在这一批 App 中,就有如今效率软件霸主之一 Launch Center Pro 的前身——Launch Center。 - - - -## 基本 URL Schemes - -基本 URL Schemes 的能力虽然简单有限,但使用情境却是最普遍的。 - -我所谓的基本 URL Schemes,是指一个 URL 的 Schemes 部分,比如上文提到的微信的 weixin:。这个部分的唯一功能,就是打开相应应用,而不能够跳转到任何功能。 - -绝大多数所谓支持 URL Schemes 的应用,一般都是只有这么一个部分,它一般是这个应用的名称,比如 OmniFocus 这款应用,它的基本 URL Schemes 就是 Omnifocus:。如果应用的主名称是个中文名的话,它的 URL Schemes 也许会是应用名的拼音,比如 墨客 这款应用,它的基本 URL Schemes 是 moke:。 - -但,我前面提过了网页 URL 和 iOS 应用的 URL 的三个重要区别,其中第三项,就是 iOS 上的 URL Schemes 并不规范,一个应用的 URL 可以是各种各样的: -
    -
  • Coursera 的 URL 是:coursera-mobile:
  • -
  • Duet 这款游戏的 URL 是:x-kumo-duet:
  • -
  • Monument 这款游戏的 URL 是:fb690517270143345:
  • -
  • Feedly 的 URL 是:fb129765800430121:
  • -
  • 扇贝新闻的 URL 是:wx95962d02b9c3e2f7:
  • -
- -它们目前并没有统一的规则,所以猜测一个应用的意义并不太大,你可以试试,但不要过于指望这种方式。如何查找一个应用的基本 URL Schemes,只要那个应用支持 URL Schemes 就能找到。 - - -步骤 -
    -
  • 首先,在 iTunes 找到你想用 URL 打开的 App,右键选择在文件夹中显示:
  • -
  • 然后解压该文件:
  • -
  • 解压完毕后,在解压出的文件夹中,找到 .app 文件:
  • -
  • 然后选择显示包内容:
  • -
  • 找到 info.plist 这个文件,用你电脑里能打开它的 App 打开它(Xcode没得说)。
  • -
  • 然后查找 URLSchemes:
  • -
  • 在 CFBundleURLSchemes 下的那两行就是该 App 的基本 URL Schemes 了。
  • -
- -## 复杂 URL Schemes - - -参考链接:[URL Scheme](https://sspai.com/post/31500#fnref:2) - - - - diff --git a/Chapter1 - iOS/1.16.md b/Chapter1 - iOS/1.16.md deleted file mode 100644 index 0f51be4..0000000 --- a/Chapter1 - iOS/1.16.md +++ /dev/null @@ -1,49 +0,0 @@ -# Swift、OC混编 - -## apinotes 文件 - - - -经常在 Swift、OC 混编的时候,系统会给方法命名等做一些优化,比如 OC 侧的枚举,在 Swift 就是结构体。为了代码规范或者某些因素考量,我们需要做一些约定,不让编译器自动处理,比如一些常见的宏: - -- `NS_SWIFT_NAME` -- `NS_TYPED_EXTENSIABLE_ENUM` -- `NS_REFINED_FOR_SWIFT` - -宏来配置存在弊端,手动去处理一个工程、一个 SDK 的话,假设有10000个方法,工作量太大。 - -Xcode 推出解决方案: - -- 创建 `SDK名称.apinotes` 文件 -- 放到 SDK 根目录下 -- 按照 yaml 格式,编写内容 - -比如: - -```yaml ---- -Name: PersonFramework -Classes: -- Name: WorkHard -# SwiftName: WorkHardAtSwift - Methods: - - Selector: "upgradeToLeader:" - Parameters: - - Position: 0 - Nullability: O - MethodKind: Instance - SwiftPrivate: true - # Availability: nonswift // WorkHard 类的 upgradeToLeader 方法,在 Swift 侧不允许调用 - #AvailabilityMsg: "prefer 'deinit'" // 如果调用,则提示对应的信息 - - Selector: "initWithName:" - MethodKind: Instance - DesignatedInit: true -``` - -更多格式,请参考 [clang::APINOTES](https://clang.llvm.org/docs/APINotes.html) - - - -该方案是 Apple 标准做法,不是骚操作,Objc 源码中也有使用。如下所示 - - diff --git a/Chapter1 - iOS/1.17.md b/Chapter1 - iOS/1.17.md deleted file mode 100644 index aec3fd6..0000000 --- a/Chapter1 - iOS/1.17.md +++ /dev/null @@ -1,10 +0,0 @@ -# 对于不可调节高度的UI控件进行改变frame - -* 对于不能调节高度的控件比如 UISlider、UISwitch、UIProgressView 等控件的宽高可以用 \(仿射变化\)transform 属性控制高度。 - -``` -myswitch.transform = CGAffineTransformMakeScale(1,5); -``` - - - diff --git a/Chapter1 - iOS/1.18.md b/Chapter1 - iOS/1.18.md deleted file mode 100644 index ab99ba4..0000000 --- a/Chapter1 - iOS/1.18.md +++ /dev/null @@ -1,193 +0,0 @@ -# 简单的 Model 与 JSON 相互转换 - -``` -// JSON: -{ -"uid":123456, -"name":"Harry", -"created":"1965-07-31T00:00:00+0000" -} - -// Model: -@interface User : NSObject -@property UInt64 uid; -@property NSString *name; -@property NSDate *created; -@end -@implementation User -@end - -// 将 JSON (NSData,NSString,NSDictionary) 转换为 Model: -User *user = [User yy_modelWithJSON:json]; -// 将 Model 转换为 JSON 对象: -NSDictionary *json = [user yy_modelToJSONObject]; - -``` - - -### Model 属性名和 JSON 中的 Key 不相同 - -``` -// JSON: -{ - "n":"Harry Pottery", - "p": 256, - "ext" : { - "desc" : "A book written by J.K.Rowing." - }, - "ID" : 100010 -} - -// Model: -@interface Book : NSObject -@property NSString *name; -@property NSInteger page; -@property NSString *desc; -@property NSString *bookID; -@end -@implementation Book -//返回一个 Dict,将 Model 属性名对映射到 JSON 的 Key。 -+ (NSDictionary *)modelCustomPropertyMapper { - return @{@"name" : @"n", - @"page" : @"p", - @"desc" : @"ext.desc", - @"bookID" : @[@"id",@"ID",@"book_id"]}; -} -@end -``` - -你可以把一个或一组 json key (key path) 映射到一个或多个属性。如果一个属性没有映射关系,那默认会使用相同属性名作为映射。 - -在 json->model 的过程中:如果一个属性对应了多个 json key,那么转换过程会按顺序查找,并使用第一个不为空的值。 - -在 model->json 的过程中:如果一个属性对应了多个 json key (key path),那么转换过程仅会处理第一个 json key (key path);如果多个属性对应了同一个 json key,则转换过过程会使用其中任意一个不为空的值。 - - -### Model 包含其他 Model -``` -// JSON -{ - "author":{ - "name":"J.K.Rowling", - "birthday":"1965-07-31T00:00:00+0000" - }, - "name":"Harry Potter", - "pages":256 -} - -// Model: 什么都不用做,转换会自动完成 -@interface Author : NSObject -@property NSString *name; -@property NSDate *birthday; -@end -@implementation Author -@end - -@interface Book : NSObject -@property NSString *name; -@property NSUInteger pages; -@property Author *author; //Book 包含 Author 属性 -@end -@implementation Book -@end -``` - - -### 容器类属性 -``` -@class Shadow, Border, Attachment; - -@interface Attributes -@property NSString *name; -@property NSArray *shadows; //Array -@property NSSet *borders; //Set -@property NSMutableDictionary *attachments; //Dict -@end - -@implementation Attributes -// 返回容器类中的所需要存放的数据类型 (以 Class 或 Class Name 的形式)。 -+ (NSDictionary *)modelContainerPropertyGenericClass { - return @{@"shadows" : [Shadow class], - @"borders" : Border.class, - @"attachments" : @"Attachment" }; -} -@end - -``` - -### 黑名单与白名单 -``` -@interface User -@property NSString *name; -@property NSUInteger age; -@end - -@implementation Attributes -// 如果实现了该方法,则处理过程中会忽略该列表内的所有属性 -+ (NSArray *)modelPropertyBlacklist { - return @[@"test1", @"test2"]; -} -// 如果实现了该方法,则处理过程中不会处理该列表外的属性。 -+ (NSArray *)modelPropertyWhitelist { - return @[@"name"]; -} -@end -``` - - -### 数据校验与自定义转换 - -``` -// JSON: -{ - "name":"Harry", - "timestamp" : 1445534567 -} - -// Model: -@interface User -@property NSString *name; -@property NSDate *createdAt; -@end - -@implementation User -// 当 JSON 转为 Model 完成后,该方法会被调用。 -// 你可以在这里对数据进行校验,如果校验不通过,可以返回 NO,则该 Model 会被忽略。 -// 你也可以在这里做一些自动转换不能完成的工作。 -- (BOOL)modelCustomTransformFromDictionary:(NSDictionary *)dic { - NSNumber *timestamp = dic[@"timestamp"]; - if (![timestamp isKindOfClass:[NSNumber class]]) return NO; - _createdAt = [NSDate dateWithTimeIntervalSince1970:timestamp.floatValue]; - return YES; -} - -// 当 Model 转为 JSON 完成后,该方法会被调用。 -// 你可以在这里对数据进行校验,如果校验不通过,可以返回 NO,则该 Model 会被忽略。 -// 你也可以在这里做一些自动转换不能完成的工作。 -- (BOOL)modelCustomTransformToDictionary:(NSMutableDictionary *)dic { - if (!_createdAt) return NO; - dic[@"timestamp"] = @(n.timeIntervalSince1970); - return YES; -} -@end -``` - -### Coding/Copying/hash/equal/description - -``` -@interface YYShadow :NSObject -@property (nonatomic, copy) NSString *name; -@property (nonatomic, assign) CGSize size; -@end - -@implementation YYShadow -// 直接添加以下代码即可自动完成 -- (void)encodeWithCoder:(NSCoder *)aCoder { [self yy_modelEncodeWithCoder:aCoder]; } -- (id)initWithCoder:(NSCoder *)aDecoder { self = [super init]; return [self yy_modelInitWithCoder:aDecoder]; } -- (id)copyWithZone:(NSZone *)zone { return [self yy_modelCopy]; } -- (NSUInteger)hash { return [self yy_modelHash]; } -- (BOOL)isEqual:(id)object { return [self yy_modelIsEqual:object]; } -- (NSString *)description { return [self yy_modelDescription]; } -@end - -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.19.md b/Chapter1 - iOS/1.19.md deleted file mode 100644 index 5698b69..0000000 --- a/Chapter1 - iOS/1.19.md +++ /dev/null @@ -1,56 +0,0 @@ -# 实现波浪动画 - -波浪的形状绘制在 CAShapeLayer 上。通过 CADisplayLink 与屏幕刷新频率同步,每次刷新都绘制新的波浪,并改变小船的位置和角度。另外,水和天空的颜色是渐变的,由 CAGradientLayer 实现,其中,显示水的 CAGradientLayer 需要有波浪形状的 CAShapeLayer 的遮罩\(mask\)。 - -### CAShapeLayer - -CAShapeLayer 的属性 path \(CGPath\)就是图层要显示的形状。把波浪的形状绘制出来,赋值给此属性即可。 - -### CADisplayLink - -创建 CADisplayLink,相应的 target 实现屏幕刷新时要调用的方法。把 CADisplayLink 加入 RunLoop 中。通过 isPaused 属性控制 CADisplayLink 是否暂停\(target 是否调用方法\) - -``` -private var waveLink: CADisplayLink?waveLink = CADisplayLink(target: self, selector: #selector(waveLinkRefresh)) -waveLink?.isPaused = true -waveLink?.add(to: .current, forMode: .defaultRunLoopMode) -``` - - - -### 绘制波浪 - -波浪的形状关键是正弦函数曲线 - -``` -y = A*sin(x+B) -``` - -参数 A 决定了波浪的高度;参数 B 决定了波浪在 x 轴的位置。 - -用一个属性 currentPhase 表示参数 B。每次屏幕刷新的时候用 currentPhase 绘制,然后更新此属性,加上一个固定的数。这样波浪就会朝左或右匀速移动。 - -为了使波浪高度逐渐变化,用一个属性表示参数 A,然后每次绘制后更新此属性,加上一个固定的数,直到波浪高度达到目标值。 - -### 小船的位置和旋转角度 - -已知小船 x 轴坐标,通过正弦函数可以直接计算出小船的 y 轴坐标。此外,小船需要随着波浪旋转,旋转至船底与波浪表面相切。这就要对正弦函数进行求导 - -``` -y' = A * cos(x + B) -``` - -用以上式子计算出小船所在位置的 y',表示正弦函数在此处的切线斜率,几何意义是切线与 x 轴的夹角的正切值。反正切即可求出切线与 x 轴的夹角,也就是小船需要旋转的角度 - -``` -angle = atan(y') -``` - -用以上旋转角度,改变小船视图\(UIView\)的 transform,调用 CGAffineTransformRotate 方法,实现小船的旋转。 - -### CAGradientLayer - -CAGradientLayer 默认的颜色渐变方向是由上至下。给 colors 属性赋值一个包含 CGColor 的数组,则图层颜色由上至下,从数组第一个值经中间值渐变至最后一个值。 - -显示水的 CAGradientLayer 需要呈现波浪形状,需要 CAShapeLayer 的遮罩。把绘制好波浪形状的 CAShapeLayer 赋值给 CAGradientLayer 的 mask 属性即可。 - diff --git a/Chapter1 - iOS/1.2.md b/Chapter1 - iOS/1.2.md deleted file mode 100644 index cd9e177..0000000 --- a/Chapter1 - iOS/1.2.md +++ /dev/null @@ -1,172 +0,0 @@ -# 看透构造方法 - -## 构造方法 - -* new 方法的内部就是先调用 alloc 方法,再调用 init 方法 - * alloc 方法:那个类接受 alloc 消息,那么该方法返回该接受类的对象,并把对象返回 - * init 方法:是1个对象方法,作用:初始化对象 -* 创建对象的步骤:先使用 alloc 创建1个对象,再使用 init 初始化这个对象,才可以使用这个对象 - * 使用1个未被初始化的对象是很危险的 -* init 方法:作用:初始化对象,为对象赋初始值,叫做构造方法 - -## 重写init构造方法 -* 如果想创建出来的对象的属性值不是默认的初始化值,则需要重写 init 方法 -* 重写 init 方法的规范: - * 必须要先调用父类的 init 方法(因为当前类初始化就是通过\`\[父类init\]\`去初始化),然后将返回值赋值给self - * 调用 init 方法有可能会失败,如果失败直接返回nil - * 判断父类是否初始化成功。如果 `self != nil`,则代表初始化成功 - * 如果初始化成功就去初始化当前对象的属性 - * 最后返回 self - -#### 解惑: - -1. 为什么要调用父类的 init 方法? - 1. 当前类有 isa 指针,当前类的 isa 指针赋值是通过父类的 init 方法赋值的。 - 2. 需要保证当前对象的父类属性同时被初始化 -2. 重写 init 方法的规范: - -``` --(instancetype)init{ - if (self = [super init]) { - //todo:自定义属性的初始化 - } - return self; -} -``` - -``` -//Person - -#import - -@interface Person : NSObject -@property NSString* name; -@property int age; - --(void)sayHi; - -@end - -#import "Person.h" -@implementation Person - --(void)sayHi{ - NSLog(@"Hi"); -} - --(instancetype)init{ - self = [super init]; - if (self) { - self.name = @"杭城小刘"; - self.age = 22; - } - return self; -} -@end - - -//测试 - - Person *p1 = [[Person alloc] init]; //p1.name = "杭城小刘",p1.age =22; - Person *p2 = [Person new]; //p2.name = "杭城小刘",p2.age =22; -``` - -如果2个类的关系为组合关系,且它的一个属性是另一个类的对象,那么当该类初始化的时候默认它的属性为 nil,那么如何初始化? - -``` --(instancetype)init{ - self = [super init]; - if (self) { - self.name = @"lbp"; - self.age = 22; - self.pig = [[Pig alloc] init]; - } - return self; -} - -//测试 - Person *p1 = [[Person alloc] init]; //p1.dog != nil -``` - -## 自定义构造方法 - -* 现状:虽然每次双肩的对象的属性值不是默认的,但是每次初始化的对象的值都是一样的。 - -* 需求:每次实例化的对象的属性值由调用者决定 - -* 解决办法:自定义构造方法 - -* 自定义构造方法规范: - - * 自定义构造方法的返回值为 instancetype - - * 方法的命名必须以 initWith 开头 - - * 方法的实现类似 init 的实现 - -**注意:此时不能使用 new 来调用。(因为 new 的实现是先 alloc 再 init ,默认 init 的实现是给属性赋默认值)** - -``` --(instancetype)initWithName:(NSString *)name andAge:(int)age{ - if (self = [super init]) { - self.name = name; - self.age = age; - } - return self; -} -``` - -``` -//Person -#import -@interface Person : NSObject -@property NSString* name; -@property int age; - --(instancetype)initWithName:(NSString *)name andAge:(int)age; -@end - -#import "Person.h" -@implementation Person - --(instancetype)init{ - self = [super init]; - if (self) { - self.name = @"lbp"; - self.age = 22; - } - return self; -} - -//不能在构造方法之外给self赋值 -//编译器认为只有以initWith开头的方法是构造方法 - --(instancetype)initWithName:(NSString *)name andAge:(int)age{ - if (self = [super init]) { - self.name = name; - self.age = age; - } - return self; -} - -@end - - -//测试 -Person *p1 = [[Person alloc] init]; -Person *p2 = [Person new]; -Person *p3 = [[Person alloc] initWithName:@"杭城小刘2号" andAge:23]; -``` - - -![init](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-23-5-56-53.png) -![init](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-23-5-57-08.png) - - -关于“自定义构造方法必须以 initWith 开头”做个实验 - -![initwith](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-05-23-6-01-29.png) - -报错信息很明显:不能在构造方法之外给 self 赋值 - -因为,编译器认为只有以 initWith 开头的方法是构造方法 diff --git a/Chapter1 - iOS/1.20.md b/Chapter1 - iOS/1.20.md deleted file mode 100644 index cbf322a..0000000 --- a/Chapter1 - iOS/1.20.md +++ /dev/null @@ -1,88 +0,0 @@ -# Block探究 - - -### 1、Block作为函数参数可以应用到函数式编程 -``` -self.prepare.play(@"女人"); -- (ViewController *(^)(NSString *))play{ - NSLog(@"即将吃喝玩乐"); - ViewController *(^block)(NSString *) = ^ViewController *(NSString *fun){ - NSLog(@"接下来玩%@,好不好?",fun); - return self; - }; - return block; -} - -- (ViewController *)prepare{ - NSLog(@"我们先好好休息一下。😂\n"); - return self; -} - - - -``` - - -###2、Block作为函数的返回值可以作为链式编程 - - -``` -[self blockAsFunctionalProgramming]; - -- (void)blockAsFunctionalProgramming{ - [self reprepare:^{ - NSLog(@"接下来玩女人,好不好?😊"); - }]; -} - -- (void)reprepare:(void(^)(void))replay{ - NSLog(@"我们先好好休息一下。😂\n"); - replay(); -} -@end -``` - -###3、Block 访问、修改外部变量 - -* 打开 Terminal.app,编写一段c代码 - -``` -#include "stdio.h" - -int main(){ - - printf("Coming\n"); - __block int a = 10; - - printf("开始->%p %d\n",&a,a); - - void(^block)(int a) = ^void(int a){ - a += 10; - printf("中间->%p %d\n",&a,a); - }; - block(a); - printf("结束->%p %d\n",&a,a); - - return 0; -} - -``` - -* 之后用 **gcc** 编译一下。在同层目录下得到一个 **a.out** 的可执行文件。 - -``` -gcc index.c - -``` - -* 之后用 **clang** 编译成 C++ 文件,可以看到系统底层是如何处理 block 外部的变量、以及如何在 block 里面处理变量的。 - -``` -clang -rewrite-objc index.c -``` -![clang结果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180516-235614@2x.png) - - -###4、Block 经常造成循环引用 -* 如果 block 作为函数参数的话,且这个函数是在对象的层级,那么可能会造成循环应用。 self -> func -> block -> self. -此时需要在 block 里面访问 self 的时候将 self 修饰为 __weak \ No newline at end of file diff --git a/Chapter1 - iOS/1.21.md b/Chapter1 - iOS/1.21.md deleted file mode 100644 index 2ef9a43..0000000 --- a/Chapter1 - iOS/1.21.md +++ /dev/null @@ -1,80 +0,0 @@ -# 禅与 Objective-C 编程艺术 - -## 警告和错误 - -* 警告 -``` -#warning Dude, don't compare floating point numbers like this! -``` - -* 错误 -``` -#warning Dude, don't compare floating point numbers like this! -``` - -* 让编译器忽略忽略你这段代码的警告 - - 大多数 iOS 开发者平时并没有和很多编译器选项打交道。一些选项是对控制严格检查(或者不检查)你的代码或者错误的。有时候,你想要用 pragma 直接产生一个异常,临时打断编译器的行为。 - - 当你使用ARC的时候,编译器帮你插入了内存管理相关的调用。但是这样可能产生一些烦人的事情。比如你使用 NSSelectorFromString 来动态地产生一个 selector 调用的时候,ARC不知道这个方法是哪个并且不知道应该用那种内存管理方法,你会被提示 performSelector may cause a leak because its selector is unknown(执行 selector 可能导致泄漏,因为这个 selector 是未知的). - - 如果你知道你的代码不会导致内存泄露,你可以通过加入这些代码忽略这些警告 - -``` -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - -[myObj performSelector:mySelector withObject:name]; - -#pragma clang diagnostic pop -``` - - -* 忽略没用使用变量的编译警告 - -``` -#pragma used(foo) -``` - -``` -- (NSInteger)giveMeFive -{ - NSString *foo; - #pragma unused (foo) - return 5; -} -``` - - -* 善用代码块 - -一个 GCC 非常模糊的特性、以及 Clang 也有的特性:代码块如果在闭合的括号内,会返回最后语句的值。 - -``` -self. = ({ - NSString *urlString = [NSString stringWithFormat:@"%@/%@", baseURLString, endpoint]; - [NSURL URLWithString:urlString]; -}); -``` - -这个特性非常适合组织小块的代码,给代码阅读者一个重要的入口且减小相关干扰,能让读者聚焦于关键的变量和函数中,此外这个方法有个优点:变量在代码块的区域内有效,可以减小对其他作用域的命名污染。 - - -* 方法参数断言 - -你的方法可能需要一些参数来满足特定的条件(比如不能为 nil),在这种情况下最好使用 **NSParameterAssert()**  来断言条件是否成立 - -括号内的条件为 false 的时候则断言抛出异常 - -``` -NSParameterAssert(message.length > 0); -``` - -``` -[self testAssert:nil]; //*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: message.length > 0' -- (void)testAssert:(NSString *)message{ - NSParameterAssert(!(message.length < 1)); - NSLog(@"%@",message); -} - -``` diff --git a/Chapter1 - iOS/1.22.md b/Chapter1 - iOS/1.22.md deleted file mode 100644 index 67fdfb7..0000000 --- a/Chapter1 - iOS/1.22.md +++ /dev/null @@ -1,35 +0,0 @@ -# 修改 UITextField 的 placeholder样式 - -> 对于 UITextField 的 placeholder 私有属性来说 Apple 不允许我们直接修改,但是按照经验我们有2种方式可以实现自定义 placeholder 的样式 - - -### 1、利用 KVC 对 UITextField 的私有属性修改 - -``` - - [self.invitecodeTextfield setValue:[UIColor redColor] forKeyPath:@"_placeholderLabel.textColor"]; - [self.invitecodeTextfield setValue:[UIFont systemFontOfSize:35] forKeyPath:@"_placeholderLabel.font"]; - -``` - - -### 2、利用 Apple 提供的 API 进行修改 - -UITextField 有个属性 attributedPlaceholder,利用它我们可以修改 placeholder 的样式 - - -``` - -self.invitecodeTextfield.attributedPlaceholder = [LBPHightedAttributedString setAllText:@"我要Testing" andSpcifiStr:@"Testing" withColor:[UIColor redColor] specifiStrFont:[UIFont systemFontOfSize:17]]; - -``` - - -其中 **LBPHightedAttributedString** 是我封装的一个关于 NSMutableAttributedString 的工具,可以对一个指定的字符串内部的字符串进行全局查找并高亮设置的小工具,具体可以查看地址 - - -[LBPHightedAttributedString](https://github.com/FantasticLBP/BlogDemos/tree/master/LBPAttributedStringTools/LBPHightedAttributedString "LBPHightedAttributedString") - - - - diff --git a/Chapter1 - iOS/1.23.md b/Chapter1 - iOS/1.23.md deleted file mode 100644 index c9123e4..0000000 --- a/Chapter1 - iOS/1.23.md +++ /dev/null @@ -1,27 +0,0 @@ -# UIScrollView 拖拽滑动时收起键盘 - - -> 当一个页面的 UIScrollView/UITableView 上有输入框时,为了较好的体验,就是当滑动的时候需要回收键盘 - -* 最开始的做法是设置 UIScrollView 的代理位当前控制器,监听 scrollViewWillBeginDragging 方法,找到 keyWindow 并且 endEditing - - -``` -- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView{ - [[UIApplication sharedApplication].keyWindow endEditing:YES]; -} -``` - -* 之后偶然有幸看到一个 UIScrollView 的属性"keyboardDismissModel"。实现上述需求只需要一行代码 - -``` -self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag; -``` - -* keyboardDismissMode 有3个枚举值 - - * UIScrollViewKeyboardDismissModeNone:默认值,也就是拖拽时对于键盘没有任何影响。 - - * UIScrollViewKeyboardDismissModeOnDrag:(dismisses the keyboard when a drag begins)当刚拖拽的时候就会回收键盘 - - * UIScrollViewKeyboardDismissModeInteractive:(the keyboard follows the dragging touch off screen, and may be pulled upward again to cancel the dismiss)当向下滑动的时候键盘会跟随手势一起下滑,当向上滑动的时候键盘也会跟随手势向上滑动而出现。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.24.md b/Chapter1 - iOS/1.24.md deleted file mode 100644 index a7d6250..0000000 --- a/Chapter1 - iOS/1.24.md +++ /dev/null @@ -1,86 +0,0 @@ -# NSRange 设计之美 - - - -> typedef struct _NSRange { - NSUInteger location; - NSUInteger length; -} NSRange; - -1、看到官方文档的源代码就知道 NSRange 是个结构体,但是如果是你设计一个这样的数据类型你会怎么办?? - -设计成结构体,然后有些属性怎么办?比如为了开发者方便,让你设计出一个办法,让开发者可以很快知道这个结构体的上限是什么? - -苹果就很机智,设计了一个内联函数 - - ``` - NS_INLINE NSUInteger NSMaxRange(NSRange range) { - return (range.location + range.length); -} - ``` - - 2、什么是内联函数? - - ``` -NS_INLINE 返回值类型 函数名(参数列表) { - //函数实现 - //return ; -} - ``` - -3、内联函数的应用 - -比如自定义一个弹窗 - -``` -NS_INLINE void tipWithMessage(NSString *message){ - - dispatch_async(dispatch_get_main_queue(), ^{ - - UIAlertView *alerView = [[UIAlertView alloc] initWithTitle:@"提示" message:message delegate:nil cancelButtonTitle:nil otherButtonTitles:nil, nil]; - - [alerView show]; - - [alerView performSelector:@selector(dismissWithClickedButtonIndex:animated:) withObject:@[@0, @1] afterDelay:0.9]; - - }); - -} - -``` - - -4、内联函数的注意事项 - -内联函数是以代码膨胀为代价, 仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数内代码的时间相比于函数调用的开销大,那么效率收获会很小。 -此外每一处调用内联函数的地方都会复制一遍代码,所以会使得程序的体积变大,消耗更多的代码段区域。 -所以下面的情况不适合使用内联函数: - - * 如果函数体内的代码比较长,使用内联将导致内存消耗比较大 - * 如果函数体内出现循环,那么执行函数体内代码的时间要比函数的调用开销大 - - -5、FOUNDATION_EXPORT - -查看 NSRange 的代码还会发现一个关键词 **FOUNDATION_EXPORT**,它可以用作定义常量。 - - -FOUNDATION_EXPORT 和 #define 都可用来定义常量。 -用法 - -``` -//.h -FOUNDATION_EXPORT NSString *const NickName; - -//.m -NSString *const NickName = @"杭城小刘"; -``` - -那么它和 **#define**  有何区别? - -FOUNDATION_EXPORT 在检测字符串的值是否相等的时候效率更高 -使用** NickName == MyName** 来判断,而 #define 是用 **[NickName isEqualToString:MyName]** 来判断。 - - - * 本质上 FOUNDATION_EXPORT 是比较指针的自己 - * \#define 是比较每个字符串是否相等 \ No newline at end of file diff --git a/Chapter1 - iOS/1.25.md b/Chapter1 - iOS/1.25.md deleted file mode 100644 index 2e23e02..0000000 --- a/Chapter1 - iOS/1.25.md +++ /dev/null @@ -1,210 +0,0 @@ -# 复制层(CAReplicatorLayer) - -> 对于下面的效果大家是否有实现思路? -> -> 有些人可能要说:老夫撸起袖子,敲键盘就是干,不需要手势交互,那么直接用5个**CALayer**,处理不同的位置以及定时器、透明度等等,貌似很简单。 -> -> 不不不,今天要带出来的主题是 **CAReplicatorLayer** - -![音量柱动画效果图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QmW9ACfS9P5orau43H7gxuxsU4RVMDPD7mPnDKq4pgLmzr.gif) - - - - - - -## 1、CAReplicatorLayer - -> /* The replicator layer creates a specified number of copies of its -> -> - sublayers, each copy potentially having geometric, temporal and -> - color transformations applied to it. -> -> * -> -> - Note: the CALayer -hitTest: method currently only tests the first -> - instance of z replicator layer's sublayers. This may change in the -> - future. */ - -官方给出的意思就不翻译了,使用场景大致是一个形状、特性差不多的 layer,我们不需要重复创建,可以利用它来实现复制多个 layer ,然后通过 CAReplicatorLayer 的一些属性实现我们的需求。 - - - -上述效果的代码 - -```objective-c -//创建复制层,因为我们做的多个音量柱变化的动画都是一样的,所以创建了一个复制层,这个复制层可以对里面的 sublayer 进行复制,所以我们不需要重复创建了 - - CAReplicatorLayer *replicatorrLayer = [CAReplicatorLayer layer]; - replicatorrLayer.frame = CGRectMake(0, 0, self.contentView.frame.size.width, self.contentView.frame.size.height); - replicatorrLayer.backgroundColor = [UIColor blackColor].CGColor; - self.replicatorrLayer = replicatorrLayer; - [self.contentView.layer addSublayer:replicatorrLayer]; - - - //创建音量震动条 - CALayer *layer = [CALayer layer]; - layer.backgroundColor = [UIColor whiteColor].CGColor; - CGFloat width = 30; - CGFloat height = 100; - layer.bounds = CGRectMake(0, self.contentView.frame.size.height - height, width, height); - layer.anchorPoint = CGPointMake(0, 1); - layer.position = CGPointMake(0, self.contentView.frame.size.height); - [self.contentView.layer addSublayer:layer]; - - //创建音量震动动画 - CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.scale.y"]; - animation.toValue = @0; - animation.duration = 1; - animation.repeatCount = MAXFLOAT; - animation.autoreverses = YES; - [layer addAnimation:animation forKey:nil]; - - - [replicatorrLayer addSublayer:layer]; - - //* The number of copies to create, including the source object. - replicatorrLayer.instanceCount = 6; //复制 sublayer 的个数,包括创建的第一个sublayer 在内的个数 - replicatorrLayer.instanceDelay = 0.4; //设置动画延迟执行的时间 - replicatorrLayer.instanceAlphaOffset = -0.15; //设置透明度递减 - replicatorrLayer.instanceTransform = CATransform3DMakeTranslation(50, 0, 0); -``` -[源码地址](https://github.com/FantasticLBP/BlogDemos/tree/master/复制层应用1-音量柱动画) - - - - -## 例子1 - -![倒影效果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QmQrU8UxSytnKbWcDVpY5mdy6kmiSHpzyqwt8GykWKNEY2.png) - -这里比较简单了,关键代码 - -```objective-c - CAReplicatorLayer *replicatorLayer = (CAReplicatorLayer *)self.view.layer; - replicatorLayer.instanceCount = 2; - replicatorLayer.instanceTransform = CATransform3DMakeRotation(M_PI, 1, 0, 0); - replicatorLayer.instanceRedOffset -= 0.1; - replicatorLayer.instanceGreenOffset -= 0.1; - replicatorLayer.instanceBlueOffset -= 0.1; - replicatorLayer.instanceAlphaOffset -= 0.3; -``` - -- 需要说明是这里我用 storyboard 处理的,因为已经拉好了控件,所以我们没办法将图片直接加到复制层上去。间接做法是将 UIViewController 的 view 的 layer 类型改变为 复制层 - - ``` - //该方法返回 UIView 的层 - //改写 UIView 的层:重写 layerClass 方法 - + (Class)layerClass{ - return [CAReplicatorLayer class]; - } - ``` - [源码地址](https://github.com/FantasticLBP/BlogDemos/tree/master/复制层应用2-倒影效果) - - -## 例子2 - -![复制层动画综合应用](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180610-235637-HD.gif) - - -需求分析: - -- 先画图。也就是添加一个滑动手势并监听它。然后强制绘图(self setNeedsDisplay) - -- 添加一个 layer 到 self.layer 上 - -- 改变当前 view 的 layer 类型。 - - ``` - + (Class)layerClass{ - return [CAReplicatorLayer class]; - } - ``` - -- 设置 CAReplicatorLayer 的 instanceCount 和 instanceDelay 属性 - -- 添加了小点,并为小点设置关键帧动画。 - -- 重置功能实现靠的是清除 path 上面的 points ,并移除 小点上面的动画 - -``` -#import "ViewControllerView.h" - -@interface ViewControllerView() - -@property (nonatomic, strong) UIBezierPath *path; -@property (nonatomic, weak) CALayer *dotLayer; -@end - -@implementation ViewControllerView - -+ (Class)layerClass{ - return [CAReplicatorLayer class]; -} - -- (void)awakeFromNib{ - [super awakeFromNib]; - - UIPanGestureRecognizer *tapGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(draw:)]; - [self addGestureRecognizer:tapGesture]; - self.path = [UIBezierPath bezierPath]; - - CALayer *layer = [CALayer layer]; - layer.frame = CGRectMake(-UIScreen.mainScreen.bounds.size.width, 0, 15, 15); - layer.backgroundColor = [UIColor orangeColor].CGColor; - layer.cornerRadius = 7.5; - self.dotLayer = layer; - [self.layer addSublayer:layer]; - - CAReplicatorLayer *replicatorLayer = (CAReplicatorLayer *)self.layer; - replicatorLayer.instanceCount = 20; - replicatorLayer.instanceDelay = 0.25; -} - - -- (void)draw:(UIPanGestureRecognizer *)tap{ - CGPoint currentPoint = [tap locationInView:self]; - if (tap.state == UIGestureRecognizerStateBegan) { - [self.path moveToPoint:currentPoint]; - } - else if(tap.state == UIGestureRecognizerStateChanged){ - [self.path addLineToPoint:currentPoint]; - [self setNeedsDisplay]; - } -} - -- (void)startAnimation{ - //要实现动画围绕着给定的形状执行,那么需要关键帧动画(类比于Flash概念中的关键帧动画,只需要给定指定的关键帧,其余的帧系统会创建出来。)。关键帧动画的 path 和 values 是互斥的,也就是说如果设置了 values 还设置了 path 那么 path 属性会覆盖 values 属性。 - - CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; - animation.keyPath = @"position"; - animation.path = self.path.CGPath; - animation.duration = 5; - animation.repeatCount = MAXFLOAT; - [self.dotLayer addAnimation:animation forKey:nil]; -} - -- (void)redraw{ - //清空路径:移除 path 上面所有的点,然后重绘 - [self.path removeAllPoints]; - [self setNeedsDisplay]; - //移除动画 - [self.dotLayer removeAllAnimations]; -} - -- (void)drawRect:(CGRect)rect{ - [self.path stroke]; -} - -@end -``` -[源码地址](https://github.com/FantasticLBP/BlogDemos/tree/master/复制层应用3-粒子闪烁效果) - -### CALayer 层的动画有2个概念非常重要:AnchorPoint 和 position - -- postion 用来确定 layer 层在父层中的位置 - -- anchorPoint 用来确定 layer 身上哪个点会在 position 所指的位置。 - - - diff --git a/Chapter1 - iOS/1.26.md b/Chapter1 - iOS/1.26.md deleted file mode 100644 index 912fc9c..0000000 --- a/Chapter1 - iOS/1.26.md +++ /dev/null @@ -1,172 +0,0 @@ -# CAShapeLayer - -> 一言以蔽之:CAShapeLayer 可以根据贝塞尔曲线描绘出的路径而生成对应的图形 - - - -## 综合例子 - -- 效果图 - -![QQ粘性动画](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QmUhGFJgxj6ofpvZp6MK3bqaH2hLgq9vfKsnwDmMisahGu.gif) - - -- 关键技术点剖析 - - - 分析 QQ 粘性动画的关键点就是当手势拖动时候2个圆之间那个形状怎么绘制 - - 答案:将2个圆的某一时刻之间形成的形状用数学抽象来计算。 -![轨迹分解](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QmQUyUSLYB3VGs4juzfsEdncyWetz7BTN2GFtURbmEYbEY.png) - - - - 拖动到超过某个范围的时候怎么执行爆炸动画 - - UIImageView 可以执行帧动画,类似于 Flash 效果 - - - - - - - - - - ### 关键代码 - - ``` - - (void)pan:(UIPanGestureRecognizer *)pan{ - //当前移动的偏移量 - CGPoint transP = [pan translationInView:self]; - //改变红点的位置 - //transform并没有修改自身的 center(center 是 layer 的position),只是修改了 frame - NSLog(@"偏移量:%@",NSStringFromCGPoint(transP)); - CGPoint center = self.center; - center.x += transP.x; - center.y += transP.y; - self.center = center; - - //self.transform = CGAffineTransformTranslate(self.transform, transP.x, transP.y); - //手势复位:设置坐标原点位上次的坐标 - [pan setTranslation:CGPointZero inView:self]; - - CGFloat distance = [self distanceWith:self.smallCircle bigCircle:self]; - NSLog(@"%f",distance); - - - CGFloat smallCircleRadius = self.bounds.size.width * 0.5; - smallCircleRadius = smallCircleRadius - distance/10; - - if (smallCircleRadius < 3) { - smallCircleRadius = 3; - } - self.smallCircle.bounds = CGRectMake(0, 0, smallCircleRadius*2, smallCircleRadius*2); - self.smallCircle.layer.cornerRadius = smallCircleRadius; - - if (self.smallCircle.hidden == NO) { - //返回一个不规则的路径 - UIBezierPath *path = [self drawTracertWithSmallCircle:self.smallCircle bigCircle:self]; - //将形状转换为一个形状图层 - self.shapeLayer.path = path.CGPath;//根据路径生成形状 - } - //创建形状图层 - [self.superview.layer insertSublayer:self.shapeLayer atIndex:0]; - - if (distance > 60) { - self.smallCircle.hidden = YES; - [self.shapeLayer removeFromSuperlayer]; - } - - if (pan.state == UIGestureRecognizerStateEnded) { - //结束手势 - if (distance < 60) { - [self.shapeLayer removeFromSuperlayer]; - self.center = self.smallCircle.center; - self.smallCircle.hidden = NO; - } - else{ - //手势拖拽超过60则播放一个动画 - UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.bounds]; - - NSMutableArray *images = [NSMutableArray array]; - - for (int i=0; i<8; i++) { - NSString *imageName = [NSString stringWithFormat:@"%d",i+1]; - UIImage *image = [UIImage imageNamed:imageName]; - [images addObject:image]; - } - imageView.animationImages = images; - [imageView setAnimationDuration:1]; - [imageView startAnimating]; - [self addSubview:imageView]; - //动画结束移除本身 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self removeFromSuperview]; - }); - } - } - - } - - - (CGFloat )distanceWith:(UIView *)smallCircle bigCircle:(UIView *)bigScirle{ - CGFloat offsetX = bigScirle.frame.origin.x - smallCircle.frame.origin.x; - CGFloat offsetY = bigScirle.frame.origin.y - smallCircle.frame.origin.y; - return sqrt( pow(offsetX, 2) + pow(offsetY, 2)); - } - - //将2个圆运行的变化轨迹用代码模拟 - - (UIBezierPath *)drawTracertWithSmallCircle:(UIView *)smallCircle bigCircle:(UIView *)bigCircle{ - - CGFloat X1 = smallCircle.center.x; - CGFloat X2 = bigCircle.center.x; - CGFloat Y1 = smallCircle.center.y; - CGFloat Y2 = bigCircle.center.y; - - CGFloat r1 = smallCircle.bounds.size.width/2; - CGFloat r2 = bigCircle.bounds.size.width/2; - - CGFloat d = [self distanceWith:smallCircle bigCircle:bigCircle]; - //Ø 代表角度 - CGFloat SinØ = (X2 - X1)/d; - CGFloat CosØ = (Y2 - Y1)/d; - - CGPoint pointA = CGPointMake(X1 - r1*CosØ, Y1 + r1*SinØ); - - CGPoint pointB = CGPointMake(X1 + r1*CosØ, Y1 - r1*SinØ); - - CGPoint pointC = CGPointMake(X2 + r2*CosØ, Y2 - r2*SinØ); - - CGPoint pointD = CGPointMake(X2 - r2*CosØ, Y2 + r2*SinØ); - - CGPoint pointO = CGPointMake(X1 + SinØ *d/2, Y1 + CosØ*d/2); - - CGPoint pointP = CGPointMake(X1 + SinØ *d/2,Y1 + CosØ*d/2 ); - - //描述路径 - UIBezierPath *path = [UIBezierPath bezierPath]; - - //AB - [path moveToPoint:pointA]; - [path addLineToPoint:pointB]; - - //BC(曲线) - [path addQuadCurveToPoint:pointC controlPoint:pointP]; - - //CD - [path addLineToPoint:pointD]; - - //DA(曲线) - [path addQuadCurveToPoint:pointA controlPoint:pointO]; - - return path; - } - ``` - - - -完整的代码,[Github地址](https://github.com/FantasticLBP/BlogDemos/tree/master/QQ粘性动画) - - - - - - \ No newline at end of file diff --git a/Chapter1 - iOS/1.27.md b/Chapter1 - iOS/1.27.md deleted file mode 100644 index 1ab8987..0000000 --- a/Chapter1 - iOS/1.27.md +++ /dev/null @@ -1,65 +0,0 @@ -# 仿微博弹簧动画 - -> 老玩微博,最近在研究动画,周末抽空写了个发微博的动画 - - - -# 实现步骤 - -- 首先模打出一个控制器 -- 这个控制器用来显示多个按钮。(按钮是图文上下排列的,所以我们需要自定义按钮的布局样式) -- 动画思路:先在界面添加好几个 UIButton,之后给每个 button 添加**y**方向的平移动画 -> 设置一个定时器,每次执行的时候依次取出按钮,将按钮添加一个弹簧动画(**usingSpringWithDamping **)将形变动画恢复原位 -- 给按钮添加2种事件(按下的事件、点击后抬起的事件) - -## 关键代码 - -``` -//开始时让所有按钮都移动到最底部 -btn.transform = CGAffineTransformMakeTranslation(0, self.view.bounds.size.height); - -//添加定时器 -self.timer = [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(update) userInfo:nil repeats:YES]; - -- (void)update{ - if (self.btnIndex == self.btnArray.count) { - [self.timer invalidate]; - return ; - } - - VerticalStyleButton *button = self.btnArray[self.btnIndex]; - //弹簧动画 - [UIView animateWithDuration:0.3 delay:0.2 usingSpringWithDamping:0.8 initialSpringVelocity:0 options:UIViewAnimationOptionCurveLinear animations:^{ - button.transform = CGAffineTransformIdentity; - } completion:^(BOOL finished) { - - }]; - self.btnIndex++; -} - -- (void)btnClick:(UIButton *)button{ - [UIView animateWithDuration:0.25 animations:^{ - button.transform = CGAffineTransformMakeScale(1.2, 1.2); - }]; -} - -- (void)btnClick1:(UIButton *)button{ - [UIView animateWithDuration:0.25 animations:^{ - button.alpha = 0; - button.transform = CGAffineTransformMakeScale(2, 2); - }]; -} -``` - - - -# 效果图 - - -![发微博动画效果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180610-225937-HD.gif) - - - - - -[源码地址](https://github.com/FantasticLBP/BlogDemos/tree/master/微博发帖动画) - diff --git a/Chapter1 - iOS/1.28.md b/Chapter1 - iOS/1.28.md deleted file mode 100644 index 20d1d50..0000000 --- a/Chapter1 - iOS/1.28.md +++ /dev/null @@ -1,39 +0,0 @@ -# UILabel 给关键字模糊匹配并高亮 - -> 有些情况就是需要查找某个字符串并高亮,但有些需求就是需要全局模糊查找,找到符合的字符串并高亮。造了个小轮子 - -###效果图 -![模糊匹配文字并高亮 -](https://raw.githubusercontent.com/FantasticLBP/BlogDemos/master/image/QQ20180610-235439%402x.png) - -``` -#pragma mark -- 设置在一个文本中所有特殊字符的特殊颜色 -+ (NSMutableAttributedString *)setAllText:(NSString *)allStr andSpcifiStr:(NSString *)keyWords withColor:(UIColor *)color specifiStrFont:(UIFont *)font{ - NSMutableAttributedString *mutableAttributedStr = [[NSMutableAttributedString alloc] initWithString:allStr]; - if (color == nil) { - color = [UIColor redColor]; - } - if (font == nil) { - font = [UIFont systemFontOfSize:17]; - } - - - for (NSInteger j=0; j<=keyWords.length-1; j++) { - - NSRange searchRange = NSMakeRange(0, [allStr length]); - NSRange range; - NSString *singleStr = [keyWords substringWithRange:NSMakeRange(j, 1)]; - while - ((range = [allStr rangeOfString:singleStr options:NSLiteralSearch range:searchRange]).location != NSNotFound) { - //改变多次搜索时searchRange的位置 - searchRange = NSMakeRange(NSMaxRange(range), [allStr length] - NSMaxRange(range)); - //设置富文本 - [mutableAttributedStr addAttribute:NSForegroundColorAttributeName value:color range:range]; - [mutableAttributedStr addAttribute:NSFontAttributeName value:font range:range]; - } - } - return mutableAttributedStr; -} -``` - -[源码地址](https://github.com/FantasticLBP/BlogDemos/tree/master/LBPAttributedStringTools) \ No newline at end of file diff --git a/Chapter1 - iOS/1.29.md b/Chapter1 - iOS/1.29.md deleted file mode 100644 index 41aea00..0000000 --- a/Chapter1 - iOS/1.29.md +++ /dev/null @@ -1,155 +0,0 @@ -# JavascriptCore - - - -1、JSCore 是基于 webkit 以 C/C++ 实现的一个 js 包装,让 js 和 Native 交互变得更加简单。 - -- JScontext - JSContext 代表一个 JavaScript 的执行环境的一个实例。所有JavaScript执行都是在上下文内进行。JSContext还用于管理对象的生命周期内 JavaScript 的虚拟机 -- JSValue - JSValue 是用来接收 JSContext 执行后的返回结果。JSValue 可以是 JS 的任意类型(变量、对象、函数...) -- JSManagedValue - JSManagedValue 是对 JSValue 的封装,可以解决 JS 和 OC 之间循环引用的问题。JSManagedValue 最常用的用法就是安全的从内存堆区里面引用 JSValue 对象.如果 JSValue 存储在内存的堆区的方式是不正确的,很容易造成循环引用,然后导致 JSContext 对象不能正确的释放掉. -- JSExport - 是一个协议,用来将 Native 对象暴露给 JS,这个对象可以指向给自身和别的对象。 -- JSVirtualMachine - 管理 JS 对象空间和所需的资源 - -2、Native 调用 JS - -- 加载 JS 代码 - (JSValue *)evaluateScript:(NSString *)script; - -- 调用 JS 方法 - JSvalue *callBack = self.context[@"sayHi"]; - [callback callWithArguments:@[@"杭城小刘"]]; - - -3、JS 调用 Native - -- 通过 Block 实现。然后在 JS 中直接调用方法即可。需要注意的是在 Block 内部不要直接使用外部定义的 JScontext 对象或 JSValue ,应该作为参数传递进来,或者通过 + (JSContext *)currentContext; 来获取。否则会造成循环引用、内存无法被正确回收 - self.context[@"showMessage"] = ^(NSString *message){ - UIAlertController *alertCtr = [UIAlertController alertControllerWithTitle:@"提示" message:message preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *cancel = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]; - [alertCtr addAction:cancel]; - //注意:方法是在子线程中执行的,需要跟新UI的话,需要切入主线程。 - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSealf presentViewController:alertCtr animated:YES completion:nil]; - }); - }; -- 通过 JSExport 协议实现; JS 需要通过 OC 中注入的对象来调方法,那么方法需要在协议中声明,并且在注入的对象中实现;在 webview 加载完成的时候注入实现协议的 Native 对象 - //声明协议 - - @proptocol JSInject - - (void)showMessage:(NSString *)message; - @end - - //实现相应的协议 - - - (void)showMessage:(NSString *)message{ - UIAlertController *alertCtr = [UIAlertController alertControllerWithTitle:@"提示" message:message preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *cancel = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]; - [alertCtr addAction:cancel]; - //注意:方法是在子线程中执行的,需要跟新UI的话,需要切入主线程。 - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSealf presentViewController:alertCtr animated:YES completion:nil]; - }); - } - - //注入 - - - (void)webViewDidFinishLoad:(UIWebView *)webView - { - //从webview上获取相应的JSContext。 - self.context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; - - //注入JS需要的“OC”对象 - self.context[@"Bridge"] = [JSInject new]; - } - - -举个🌰 - -JS call Native - - //对外要暴露的 native 对象(其中挂载了一些属性和方法) - #import - #import - - @protocol PersonInjectExport - - @property (nonatomic, strong) NSString *name; - - @property (nonatomic, strong) NSString *hobby; - - - (id)sayHi; - - @end - - - @interface PersonInject : NSObject - - @property (nonatomic, strong) NSString *name; - - @property (nonatomic, strong) NSString *hobby; - - - (id)sayHi; - - @end - - - // viewcontroller - - - (void)webViewDidFinishLoad:(UIWebView *)webView{ - self.jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; - PersonInject *person = [[PersonInject alloc] init]; - person.name = @"杭城小刘"; - person.hobby = @"Coding、Movie、Music、Table tennis、Fit"; - self.jsContext[@"lbp"] = person; - } - - //JS - - - 嗨。大家好我是 -

***

- - - - - - - - -Native call JS - - //Native - - (void)callJS{ - JSValue *functionName = self.jsContext[@"sum"]; - NSInteger sum = [[functionName callWithArguments:@[@"2",@"18"]] toInt32];; - - UIAlertController *alertVC = [UIAlertController alertControllerWithTitle:@"来自JS 的计算" message:[NSString stringWithFormat:@"%zd",sum] preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]; - [alertVC addAction:okAction]; - [self presentViewController:alertVC animated:YES completion:nil]; - } - - //JS - function sum(a ,b){ - return parseInt(a) + parseInt(b); - } - - diff --git a/Chapter1 - iOS/1.3.md b/Chapter1 - iOS/1.3.md deleted file mode 100644 index 264b134..0000000 --- a/Chapter1 - iOS/1.3.md +++ /dev/null @@ -1,103 +0,0 @@ - - -# loadView - -1. 作用:加载控制器的view - -2. 何时调用:当控制器的view第一次使用的时候就会调用 - -3. 使用场景:只要想自定义控制器的view就调用此方法 - -访问控制器的View就相当于调用控制器中的view get方法 - -```objective-c --(UIView *)view -{ - if(_view == nil){ - [self loadView]; - [self viewDidload]; - } - return _view; -} -``` - - - -# 控制器加载view的流程 - -![控制器加载view的流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-b6128646373dfffb.png) - - -* 控制器的 `init` 方法底层会调用 `initWithNibName` 方法 - -`MyViewController *vc = [[MyViewController alloc] init];` - -注意点: - -系统做判断的前提提条件:没有指定nibName;没有自定义loadView方法;控制器以...Controller命名 - - - -判断原则: - -* 判断下有没有指定nibName,如果指定了就去加载nib - -* 判断有没有跟控制器同名的xib,但是xib的名称不带Controller的xib,如果有就去加载 - -* 如果第二步没有指定,就判断有没有跟控制器类名同名的xib,如果有就去加载 - -* 如果没有任何xib描述控制器的view,就不加载xib - - - -## MyViewController加载view的处理 - -* 判断有没有指定xibName,如果有就去加载指定的xib - -* 判断有没有跟控制器类名同名的xib,但是名字不带controller - -* 判断有没有跟控制器类名同名的xib,有就去加载 - -* 直接创建一个空的xib - -例子 - -```objective-c -//在Appdelegate中 -ViewController *vc = [[ViewController alloc] init]; -vc.view.backgroundColkor = [UIColor redColor]; -self.window.rootViewController = vc; -[pself.window makeKeyAndVisable]; - -//ViewController --(UIView *)view{ - if(!_view){ - [self loadView]; - [self viewDidLoad]; - } -} - --(void)loadView{ - UIView*view = [[UIView alloc] initWithFrame:[UIScreen mainScreen].bounds]; - view.backgroundColor = [UIColor greenColor]; self.view = view; -} - --(void)viewDidload{ - [super viewDidload]; - self.view.backgroundColor = [UIColor brownColor]; -} -``` - - - -### 请问此时界面颜色是什么? - -可能很多人会回到绿色。其实答案是 红色 - -why?在AppDelegate中vc.view.backgroundColor就是调用vc的view的getter方法,在getter方法内部判断_view是否存在,不存在则新建一个UIView,新建view是通过 `[self loadView]` 方法创建,创建成功直接调用viewdidload方法;存在则直接返回,所以界面先是绿色,再是棕色最后是红色 - -#### 来一个官方解释 - -![Apple 文档](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-8ff7c3b976ffb29a.png) - - diff --git a/Chapter1 - iOS/1.30.md b/Chapter1 - iOS/1.30.md deleted file mode 100644 index 8f103a5..0000000 --- a/Chapter1 - iOS/1.30.md +++ /dev/null @@ -1,135 +0,0 @@ -# Xcode 小技巧 - -1. 快速打开:**Command+Shift+O** 。这个命令可以开启一个小窗格用来快速搜索浏览文件、类、算法以及函数等,且支持模糊搜索,这个命令可以说是日常开发中最常用的一个命令了。 - -2. 显示项目导航器:**Command+Shift+J**。使用快速打开命令跳转到对应文件后,如果需要在左侧显示出该文件在项目中的目录结构,只需要键入这个命令,非常方便 - -3. 显示编辑历史。如果一行代码写的很好或者很糟糕,不需要专门跑到 diff 工具去查看代码历史。在该行代码处右击,选择**Show Last Change For Line** - -4. 跳转到方法。在使用类或者结构时,我们经常需要快速的跳转到类的某个特定方法。通过快捷键**control+6**再输入方法的头几个字母就可以非常方便的做到这点。 - -5. 范围编辑。多光标是个很棒的并且每个高级的编辑器都该有的特训过,快捷键为**Command+Control+E**。将光标移动刀需要编辑的符号,输入快捷键,然后就可以在当前页面全局编辑了。 - -6. Xcode 设置代码只在 Debug 下起效的几种方式 - 在日常开发中 Xcode 在 Debug 模式下写很多测试代码,或者引入一些第三方测试用的 .a 和 .framework 动态库,也会通过 CocoaPods 引入一些第三方测试工具或者库;但是不希望这些库在**Release**正式包中被引入,如何做到呢? -* .h/.m 文件中的测试代码 - - Xcode 在 Debug 模式下定义了宏 DEBUG=1 ,所以我们可以在代码中把相关的测试代码写在预编译处理命令 **\#ifdef DEBUG... \#endif** 中间即可,如图所示 - -![DEBUG宏在头文件](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180626-144101@2x.png) - -![DEBUG宏在代码块](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180626-144240@2x.png) - -* 测试用的 .a 和 .framework - - 对于拖拽到工程中的 .a .framework 静态库,可以在 **target->Build Settings->Search Paths**这2个选项,分别设置 **Library Search Paths**和**Framework Search Paths**这2个选项。如果我们需要在测试的时候会用到,那么我们可以将 **Debug** 对应的值留下,删掉**Release** 对应的值。这样我们打包 Release 包的时候就不会包含不需要的包。 - -![不需要的包删除即可](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180626-144819@2x.png) - -* CocoPods 引入的库 - 对于 CocoPods 方式引入的库,在配置的时候就可以处理掉,比如下面的方式 - - ``` - platform: iOS, '8.0' - ... - pod 'PonyDebugger', :configurations => ['Debug'] - ``` -7. App Store Connect 经常在上架的时候需要开发人员判断是否满足出口合规的证明,每次写都很麻烦,所以可以在工程里面的 plist 里面进行设置。 - - ``` - ITSAppUsesNonExemptEncryption - - ``` - -8. 让 Xcode 折叠代码 - 在 VS Code 或者其他 IDE 里面都具有代码折叠的功能,Xcode 也支持代码折叠功能,但是默认没有开启。所以我们需要做的就是打开代码折叠功能。步骤:打开 Xcode - Preference - Text Editing - 在「Show」模块下面勾选「Code folding ribbon」。这样 Xcode 就具备代码折叠的功能了。 - 快捷键: -- command + option + 左右方向键 : 折叠或展开鼠标光标所在位置的代码 -- command + option + shift + 左右方向键:折叠或展开当前页面全部的方法(函数) -9. 几种设置废弃 Api 的方法 -- __deprecated - -- NS_UNAVAILABLE。`- (instancetype)init NS_UNAVAILABLE;` - -- #define MJRefreshDeprecated(instead) NS_DEPRECATED(2_0, 2_0, 2_0, 2_0, instead) - - ``` - MJRefreshDeprecated("请使用automaticallyChangeAlpha属性"); - ``` - -- DEPRECATED_ATTRIBUTE - - ``` - @property (nonatomic, strong, readonly) UILabel *dateLabel DEPRECATED_ATTRIBUTE; - ``` - -- DEPRECATED_MSG_ATTRIBUTE - - ``` - @property (nonatomic, assign) NSStringEncoding stringEncoding DEPRECATED_MSG_ATTRIBUTE("The string encoding is never used. AFHTTPResponseSerializer only validates status codes and content types but does not try to decode the received data in any way."); - ``` - -- @property(nullable, nonatomic, strong) IBOutlet NSLayoutConstraint *IQLayoutGuideConstraint __attribute__((deprecated("Due to change in core-logic of handling distance between textField and keyboard distance, this layout contraint tweak is no longer needed and things will just work out of the box regardless of constraint pinned with safeArea/layoutGuide/superview."))); - -- + (CLLocationDistance)getCurrentLocationDistanceFilter __deprecated_msg("废弃方法(空实现),使用distanceFilter属性替换"); - -- + (NSString *)getWeiboAppSupportMaxSDKVersion __attribute__((deprecated)); - -- #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wdeprecated-declarations" - - result = [self sizeWithFont:font constrainedToSize:size lineBreakMode:lineBreakMode]; - - #pragma clang diagnostic pop -10. Xcode Instruments 内存泄漏检测工具 Leaks 在内存检测后,无法看到具体的堆栈信息。 - - ![Leaks](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-11-25-InstrumentMemoryLeaks.jpg) - - 涂上右下方的 `Heaviest Stack Trace` 模块看不到对应的堆栈信息。一番定位问题后发现是工程项目在 debug 阶段,Build Setting 中的 **Debug Information Format** 选项的 debug 条目是没有 dSYM 文件的,我们要想看到堆栈信息,就必须选择 `DWARF with dSYM File` 选项。 - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-11-25-BuildSettingsDebugInformationFormat.png) - - DWARF,即 ***Debug With Arbitrary Record Format*** ,是一个标准调试信息格式,即调试信息。这部分信息可以查看我的[这篇文章](./1.74.md)中讲 iOS 符号化的部分。 - -11. 将 OC 代码还原为 C++ 代码 - - ```objectivec - // 方法1 - clang -rewrite-objc xxxx.m -o xxxx.cpp - // 方法2 - xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc xxxx.m -o xxxx.cpp - ``` - -12. 工程打开汇编,Debug 更多信息 - - 菜单栏:Xcode -> Debug -> Debug Workflow -> Always Show Disassembly 可以查看汇编。 - - 查看汇编可以从更深层了解当前函数的汇编层面的执行,为 objc 源码分析提供信 - 息避免方向性错误,结合 memory read 可以更清楚的看到寄存器之间是如何互相配合 - 处理配合的;使用汇编查看流程,可以在不确定源码出处和执行流程的情况下,跟踪内 - 部代码,并可以找到出处!同时,结合下符号断点的方式,能够更清晰的跟踪源码实 - 现。 - -13. Xcode 运行项目,模拟器启动失败。报错 `Failed to start launchd_sim: could not bind to session, launchd_sim may have crashed or quit respond` - - 关闭Xcode,在终端中键入以下命令:sudo chmod 1777 /tmp - - 清理此路径中的dyld文件夹:/Library/Developer/CoreSimulator/Caches - - 重新启动Xcode,完成! - - 14. Xcode 自动设置 __nonnull. - 升级到 Xcode 10 , 新建类的时候发现头文件中多了2个宏: - - NS_ASSUME_NONNULL_BEGIN - NS_ASSUME_NONNULL_END - - 作用 - 这两个东西是Nonnull区域设置(Audited Regions) 。 - 这两个宏之间的代码里的所有简单指针对象都被默认为 ___nonnull,我们只需要去指定 __nullable 的指针。 - - 2014 年的 Apple WWDC 发布了强语言 swift ,必须要指定一个对象是否为空。为了迎合swift,OC中增加了 __nullable 和 ___nonnull 用于指定对象是否为空。 - 每个属性、方法都指定 ___nonnull 和 __nullable 是一件非常繁琐的事。为了减轻开发工作量,苹果提供了两个宏:NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END 。这两个宏之间的代码里的所有简单指针对象都被默认为 ___nonnull,我们只需要去指定 __nullable 的指针。 - - 解决的问题 - - - 减少冗余代码:避免在每个属性、方法参数或返回值前手动添加 nonnull,提高代码简洁性。 - - 提升类型安全性:编译器会对默认的 nonnull 指针进行静态检查,传递 nil 时会触发警告。 - - 改善 Swift 互操作性:Swift 能识别这些注解,将 nonnull 指针转换为非可选类型(如 String),将 nullable 指针转换为可选类型(如 String?),使接口更清晰。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.31.md b/Chapter1 - iOS/1.31.md deleted file mode 100644 index 936788f..0000000 --- a/Chapter1 - iOS/1.31.md +++ /dev/null @@ -1,30 +0,0 @@ -# 终端效率 - - -## tree - -如果要在终端查看当前目录的层级结构,不妨了解下**tree**。它可以以树状的形式展示当前的目录结构。 - -安装: -在终端输入**brew install tree** - -使用: - -在当前目录下,显示树状目录结构。**tree -L 2 -d**。其中 -L 表示遍历的深度,-d 表示只显示目录。 - - -## 合并写法 - -之前在终端操作的时候都是老老实实一行行的写代码,最近发现可以合并起来写。比如 - -``` -//写法一 -cd Desktop -mkdir awesome-project -cd awesome-project -//写法二 -cd Desktop && mkdir awesome-project && cd awesome-project -``` - - - diff --git a/Chapter1 - iOS/1.32.md b/Chapter1 - iOS/1.32.md deleted file mode 100644 index 33b77ec..0000000 --- a/Chapter1 - iOS/1.32.md +++ /dev/null @@ -1,303 +0,0 @@ -# 终极截屏 - -- **-(void)snapshotForView:(__kindof UIView *)view;** - - 今天新学到这种写法,__kindof 是苹果声明的一个特性,是 Xcode7 出现的新特性。 - - - 假如我们想声明一个方法,这个方法的参数必须是一个 UIView 类型的对象,那么我们应该可以写成下面这个样子 - - ``` - -(void)snapshotForView:(UIView *)view; - ``` - - - - - 那么我们想声明一个方法,这个方法的参数必须是 UIView 及其 UIView 的子类,那么前一种写法就满足不了我们的需求了,这时候引入了 __kindof 方法 - - ``` - -(void)snapshotForView:(__kindof UIView *)view; - ``` - - - - - -**UIWebView 截图** - -对 UIWebView 截图比较简单,renderInContext 这个方法相信大家都不会陌生,这个方法是 CALayer 的一个实例方法,可以用来对大部分 View 进行截图。我们知道,UIWebView 承载内容的其实是作为其子 View 的 UIScrollView,所以对 UIWebView 截图应该对其 scrollView 进行截图。具体的截图方法如下: - -``` -- (void)snapshotForScrollView:(UIScrollView *)scrollView -{ - // 1. 记录当前 scrollView 的偏移和位置 - CGPoint currentOffset = scrollView.contentOffset; - CGRect currentFrame = scrollView.frame; - - scrollView.contentOffset = CGPointZero; - // 2. 将 scrollView 展开为其实际内容的大小 - scrollView.frame = CGRectMake(0, 0, scrollView.contentSize.width, scrollView.contentSize.height); - - // 3. 第三个参数设置为 0 表示设置为屏幕的默认缩放因子 - UIGraphicsBeginImageContextWithOptions(scrollView.contentSize, YES, 0); - [scrollView.layer renderInContext:UIGraphicsGetCurrentContext()]; - UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - // 4. 重新设置 scrollView 的偏移和位置,还原现场 - scrollView.contentOffset = currentOffset; - scrollView.frame = currentFrame; -} -``` - -**WKWebView 截图** - -虽然 WKWebView 里也有 scrollView,但是直接对这个 scrollView 截图得到的是一片空白的,具体原因不明。一番 Google 之后可以看到好些人提到 drawViewHierarchyInRect 方法, 可以看到这个方法是 iOS 7.0 开始引入的。官方文档中描述为: - -> Renders a snapshot of the complete view hierarchy as visible onscreen into the current context. - -注意其中的 **visible onscreen**,也就是将屏幕中可见部分渲染到上下文中,这也解释了为什么对 WKWebView 中的 scrollView 展开为实际内容大小,再调用 drawViewHierarchyInRect 方法总是得到一张不完整的截图(只有屏幕可见区域被正确截到,其他区域为空白)。 - -不过,这样倒是给我们提供了一个思路,可以将 WKWebView 按屏幕高度裁成 n 页,然后将 WKWebView 一页一页的往上推,每推一页就调用一次 drawViewHierarchyInRect 将当前屏幕的截图渲染到上下文中,最后调用 UIGraphicsGetImageFromCurrentImageContext 从上下文中获取的图片即为完整截图。 - -核心代码如下: - -``` -- (void)snapshotForWKWebView:(WKWebView *)webView -{ - // 1 - UIView *snapshotView = [webView snapshotViewAfterScreenUpdates:YES]; - [webView.superview addSubview:snapshotView]; - - // 2 - CGPoint currentOffset = webView.scrollView.contentOffset; - ... - - // 3 - UIView *containerView = [[UIView alloc] initWithFrame:webView.bounds]; - [webView removeFromSuperview]; - [containerView addSubview:webView]; - - // 4 - CGSize totalSize = webView.scrollView.contentSize; - NSInteger page = ceil(totalSize.height / containerView.bounds.size.height); - - webView.scrollView.contentOffset = CGPointZero; - webView.frame = CGRectMake(0, 0, containerView.bounds.size.width, webView.scrollView.contentSize.height); - - UIGraphicsBeginImageContextWithOptions(totalSize, YES, UIScreen.mainScreen.scale); - [self drawContentPage:0 maxIndex:page completion:^{ - UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - // 8 - [webView removeFromSuperview]; - ... - }]; -} - -- (void)drawContentPage(NSInteger)index maxIndex:(NSInteger)maxIndex completion:(dispatch_block_t)completion -{ - // 5 - CGRect splitFrame = CGRectMake(0, index * CGRectGetHeight(containerView.bounds), containerView.bounds.size.width, containerView.frame.size.height); - CGRect myFrame = webView.frame; - myFrame.origin.y = -(index * containerView.frame.size.height); - webView.frame = myFrame; - - // 6 - [targetView drawViewHierarchyInRect:splitFrame afterScreenUpdates:YES]; - - // 7 - if (index < maxIndex) { - [self drawContentPage:index + 1 maxIndex:maxIndex completion:completion]; - } else { - completion(); - } -} -``` - -代码注意项如下(对应代码注释中的序号): - -1. 为了截图时对 frame 进行操作不会出现闪屏等现象,我们需要盖一个“假”的 webView 到现在的位置上,并将真正的 webView “摘下来”。调用 snapshotViewAfterScreenUpdates 即可得到这样一个“假”的 webView - - - -2. 保存真正的 webView 的偏移、位置等信息,以便截图完成之后“还原现场” - -3. 用一个新的视图承载“真正的” webView,这个视图也是绘图所用到的上下文 - -4. 将 webView 按照实际内容高度和屏幕高度分成 page 页 - -5. 得到每一页的实际位置,并将 webView 往上推到该位置 - -6. 调用 drawViewHierarchyInRect 将当前位置的 webView 渲染到上下文中 - -7. 如果还未到达最后一页,则递归调用 drawViewHierarchyInRect 方法进行渲染;如果已经渲染完了全部页,则回调通知截图完成 - -8. 调用 UIGraphicsGetImageFromCurrentImageContext 方法从当前上下文中获取到完整截图,将第 2 步中保存的信息重新赋予到 webView 上,“还原现场” - -注意:我们的截图方法中有对 webView 的 frame 进行操作,如果其他地方如果有对 frame 进行操作的话,是会影响我们截图的。所以在截图时应该禁用掉其他地方对 frame 的改变,就像这样: - -``` -- (void)layoutWebView -{ - if (!_isCapturing) { - self.wkWebView.frame = [self frameForWebView]; - } -} -``` - -``` -#import -@class PPSnapshotHandler; - -@protocol PPSnapshotHandlerDelegate - -@optional - -- (void)snapshotHandler:(PPSnapshotHandler *)snapshotHandler didFinish:(UIImage *)captureImage forView:(UIView *)view; - -@end - -@interface PPSnapshotHandler : NSObject - -+ (instancetype)defaultHandler; - -@property (nonatomic, weak) id delegate; - -- (void)snapshotForView:(__kindof UIView *)view; - -@end - - -#import "PPSnapshotHandler.h" -#import - -#define DELAY_TIME_DRAW 0.1 - -@interface PPSnapshotHandler () { - BOOL _isCapturing; - UIView *_captureView; -} -@end - -@implementation PPSnapshotHandler - -+ (instancetype)defaultHandler -{ - static dispatch_once_t onceToken; - static PPSnapshotHandler *defaultHandler = nil; - dispatch_once(&onceToken, ^{ - defaultHandler = [[PPSnapshotHandler alloc] init]; - }); - return defaultHandler; -} - -#pragma mark - public method - -- (void)snapshotForView:(__kindof UIView *)view -{ - if (!view || _isCapturing) { - return; - } - - _captureView = view; - - if ([view isKindOfClass:[UIScrollView class]]) { - [self snapshotForScrollView:(UIScrollView *)view]; - } else if ([view isKindOfClass:[UIWebView class]]) { - UIWebView *webView = (UIWebView *)view; - [self snapshotForScrollView:webView.scrollView]; - } else if ([view isKindOfClass:[WKWebView class]]) { - [self snapshotForWKWebView:(WKWebView *)view]; - } -} - -#pragma mark - WKWebView - -- (void)snapshotForWKWebView:(WKWebView *)webView -{ - UIView *snapshotView = [webView snapshotViewAfterScreenUpdates:YES]; - snapshotView.frame = webView.frame; - [webView.superview addSubview:snapshotView]; - - CGPoint currentOffset = webView.scrollView.contentOffset; - CGRect currentFrame = webView.frame; - UIView *currentSuperView = webView.superview; - NSUInteger currentIndex = [webView.superview.subviews indexOfObject:webView]; - - UIView *containerView = [[UIView alloc] initWithFrame:webView.bounds]; - [webView removeFromSuperview]; - [containerView addSubview:webView]; - - CGSize totalSize = webView.scrollView.contentSize; - NSInteger page = ceil(totalSize.height / containerView.bounds.size.height); - - webView.scrollView.contentOffset = CGPointZero; - webView.frame = CGRectMake(0, 0, containerView.bounds.size.width, webView.scrollView.contentSize.height); - - UIGraphicsBeginImageContextWithOptions(totalSize, YES, UIScreen.mainScreen.scale); - [self drawContentPage:containerView webView:webView index:0 maxIndex:page completion:^{ - UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - [webView removeFromSuperview]; - [currentSuperView insertSubview:webView atIndex:currentIndex]; - webView.frame = currentFrame; - webView.scrollView.contentOffset = currentOffset; - - [snapshotView removeFromSuperview]; - - self->_isCapturing = NO; - - if (self.delegate && [self.delegate respondsToSelector:@selector(snapshotHandler:didFinish:forView:)]) { - [self.delegate snapshotHandler:self didFinish:snapshotImage forView:self->_captureView]; - } - }]; -} - -- (void)drawContentPage:(UIView *)targetView webView:(WKWebView *)webView index:(NSInteger)index maxIndex:(NSInteger)maxIndex completion:(dispatch_block_t)completion -{ - CGRect splitFrame = CGRectMake(0, index * CGRectGetHeight(targetView.bounds), targetView.bounds.size.width, targetView.frame.size.height); - CGRect myFrame = webView.frame; - myFrame.origin.y = -(index * targetView.frame.size.height); - webView.frame = myFrame; - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(DELAY_TIME_DRAW * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [targetView drawViewHierarchyInRect:splitFrame afterScreenUpdates:YES]; - - if (index < maxIndex) { - [self drawContentPage:targetView webView:webView index:index + 1 maxIndex:maxIndex completion:completion]; - } else { - completion(); - } - }); -} - -#pragma mark - UIScrollView - -- (void)snapshotForScrollView:(UIScrollView *)scrollView -{ - CGPoint currentOffset = scrollView.contentOffset; - CGRect currentFrame = scrollView.frame; - - scrollView.contentOffset = CGPointZero; - scrollView.frame = CGRectMake(0, 0, scrollView.contentSize.width, scrollView.contentSize.height); - - UIGraphicsBeginImageContextWithOptions(scrollView.contentSize, YES, UIScreen.mainScreen.scale); - [scrollView.layer renderInContext:UIGraphicsGetCurrentContext()]; - UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - scrollView.contentOffset = currentOffset; - scrollView.frame = currentFrame; - - if (self.delegate && [self.delegate respondsToSelector:@selector(snapshotHandler:didFinish:forView:)]) { - [self.delegate snapshotHandler:self didFinish:snapshotImage forView:_captureView]; - } -} - -@end -``` - - - diff --git a/Chapter1 - iOS/1.33.md b/Chapter1 - iOS/1.33.md deleted file mode 100644 index a4140ef..0000000 --- a/Chapter1 - iOS/1.33.md +++ /dev/null @@ -1,28 +0,0 @@ -# 推送 - -> 1、现在 App 开发推送功能,一般都是接入极光推送,那么为什么极光推送就可以实现推送呢? -2、极光推送做了哪些事情?与 APNS 怎么交互的? -带着这2个问题来看看推送吧 - -## 一、推送原理 -![推送原理](/assets/4316713-49ef454cca917acd.jpg) - -(这张图转载于网络) - - - -说说推送的步骤: -1、你的 App 需要推送服务,要向苹果的 APNS 注册推送功能 -2、当苹果 APNS 推送服务器收到你的注册请求后会返回给你一串 device token -3、当应用收到 device token 后,需要将 device token 传给自己的应用服务器(自己公司的服务端) -4、当你需要为你的应用推送消息的时候,自己的应用服务器会将消息,以及 device token 打包发送给苹果的 APNS。 -5、APNS 再将消息推送给你的 手机 App - - -## 不接入极光推送的话,自己怎么做推送功能 - -参考这篇文章:[自己做推送](https://blog.csdn.net/shenjie12345678/article/details/41120637) - -## 所以极光推送逗帮我们做了什么? - -简化了获取 device token 的步骤,我们将申请号的证书上传到极光服务器,程序运行接入极光 SDK ,手机获取 device token, 然后将 device token 上传给极光推送服务器,。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.34.md b/Chapter1 - iOS/1.34.md deleted file mode 100644 index 833d5c7..0000000 --- a/Chapter1 - iOS/1.34.md +++ /dev/null @@ -1,55 +0,0 @@ -# App 评分 - -> 经常有这样的需求-引导用户在合适的时机对 App 做出好评。本文就尝试谈一谈这一块的一些知识 - -1. 评分的方式 -可以跳出应用对 App 进行评分,也可以在应用内进行评分(>= iOS 10.3)。 - -2. 跳出 App 评分 -利用系统方法打开 URL(跳到 App store 后跳转到自己 App 的评价页面) -``` -NSString *urlString = [NSString stringWithFormat:@"itms-apps://itunes.apple.com/app/id%@?action=write-review", @"你的App ID"]; -[[UIApplication sharedApplication] openURL:[NSURL URLWithString:urlString]]; - ``` - -3. 应用内评分 -iOS 10.3 之后系统为我们评分这个需求引入 **StoreKit**。利用它,我们可以很方便地在应用内对 App 进行快速评分,而不用跳出去。 - + 在 App 内部打开 App store并跳转到App 评价页面 - ``` - #import - SKStoreProductViewController *storeVC = [[SKStoreProductViewController alloc] init]; -storeVC.delegate = self; -[storeVC loadProductWithParameters:@{SKStoreProductParameterITunesItemIdentifier:@"1401834682"} completionBlock:^(BOOL result, NSError * _Nullable error) { - if (error) { - - }else{ - [self presentViewController:storeVC animated:YES completion:nil]; - } -}]; - ``` - + 在 App 内弹出评分对话框,用户星级评分后可以继续输入文字 - ``` - if (@available(iOS 10.3, *)) { - if([SKStoreReviewController respondsToSelector:@selector(requestReview)]){ - [SKStoreReviewController requestReview]; - else{ - NSString *urlString = [NSString stringWithFormat:@"itms-apps://itunes.apple.com/app/id%@?action=write-review", @"你的App ID"]; - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:urlString]]; - } -}else { - // Fallback on earlier versions - NSString *urlString = [NSString stringWithFormat:@"itms-apps://itunes.apple.com/app/id%@?action=write-review", @"你的App ID"]; - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:urlString]]; -} - ``` - -4. 注意时机哦 - 我们的目的是能得到用户的正反馈,如果在用户刚使用APP时就弹出评分框,可能会给某些用户带来反感,因此,选择一个合适的时机弹出评分很重要,不然适得其反。 - 今天在使用爱奇艺的时候发现他们的弹出场景是这样的。我因为要出门所以下载了一部电影。在会员模式下高速缓存成功后(我很满意)弹出评分按钮。 - ![爱奇艺评分](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/61530453779_.pic.jpg) - - - - - - \ No newline at end of file diff --git a/Chapter1 - iOS/1.35.md b/Chapter1 - iOS/1.35.md deleted file mode 100644 index 69cd577..0000000 --- a/Chapter1 - iOS/1.35.md +++ /dev/null @@ -1,163 +0,0 @@ -# 一些布局小知识 - -1. LaunchScreen 会根据设备大小设置屏幕的显示范围;LaunchImage 则根据提供的启动图片设置App的可见范围 -2. UITextView 可以设置显示范围 - textView.textContainerInset = UIEdgeInsetsMake(40, 0, 0, 0); -3. UITextView 可以设置像 Word 一样文字环绕在图片四周的效果。其中用到的属性就是exclusionPaths - // Default value : empty array An array of UIBezierPath representing the exclusion paths inside the receiver's bounding rect. - @property (copy, NS_NONATOMIC_IOSONLY) NSArray *exclusionPaths NS_AVAILABLE(10_11, 7_0); - NSString *str = @“xxx”;//xxx为文字内容 - textView = [[UITextView alloc] initWithFrame:CGRectMake(10, 20, self.view.frame.size.width-20, self.view.frame.size.height-30)]; - textView.text = str; - [self.view addSubview:textView]; - - imageView = [[UIImageView alloc] initWithFrame:CGRectMake(140, 280, 160, 100)]; imageView.backgroundColor = [UIColor orangeColor]; - imageView.image = [UIImage imageNamed:@"mao.jpg"]; - [self.view addSubview:imageView]; - textView.textContainer.exclusionPaths = @[[self translatedBezierPath]]; - - - (UIBezierPath *)translatedBezierPath{ - CGRect imageRect = [textView convertRect:imageView.frame fromView:self.view]; - UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRect:CGRectMake(imageRect.origin.x+5, imageRect.origin.y, imageRect.size.width-5, imageRect.size.height-5)]; - return bezierPath; - } - 1. 引申学习 CoreText - 2. 在读很多第方库的时候,经常会看到2个关键词:“IB_DESIGNABLE”和”IBInspectable“。如果你想让你纯代码写的 View 具有可以在 StoryBoard 和 xib 文件可预览,就要在自定义的 UIView 头文件加上 IB_DESIGNABLE - 3. 如果想让你自定义的 View 的参数可以在 xib 或者 storyboard 上 Attributes inspector 栏目中被看到且可以被修改,那么你需要在每个 property 前面加上 IBInspectable - #import - - IB_DESIGNABLE - @interface DashView : UIView - - @property (nonatomic, copy) void(^TimerBlock)(NSInteger); - - @property (nonatomic, strong) IBInspectable UIColor *color; - - //跃动数字刷新 - - (void)refreshJumpNOFromNO:(NSString *)startNO toNO:(NSString *)toNO andTime:(NSString *)time; - - @end - - -4.UITabBarController 设置图片不能过大,不然不能显示 - -5.设置导航控制器的 NavigationBar 的 BackgroundImage 且 使用了 UIBarMetericsDefault 会导航控制器的子控制器的 view 的高度会减小 64。只有设置为 UIBarMetricsDefault 的时候给 NavigationBar 设置背景图片才会显示。UIBarMetricsCompact 意味着导航条是透明的 - - [self.navigationBar setBackgroundImage:[UIImage imageNamed:@"Report_customreport"] forBarMetrics:UIBarMetricsDefault]; - -1. 在 iOS 6及之前的系统上默认都是 NO,在 iOS 7及其以后都是默认为 YES。效果表现为顶部的 NavigationBar 都是有透明度的效果 - @property(nonatomic,assign,getter=isTranslucent) BOOL translucent NS_AVAILABLE_IOS(3_0) UI_APPEARANCE_SELECTOR; // Default is NO on iOS 6 and earlier. Always YES if barStyle is set to UIBarStyleBlackTranslucent - translucent 设置为 YES ,则布局 view 从屏幕的左上角开始计算,如果设置为 NO,那么布局从 NavigationBar 的下面开始布局。 -2. 总结:需要让导航控制器里面的控制器的 view 从导航栏以下开始布局,有2种方法可以实现。 - - 设置导航控制器 setTranslucent = NO - - 给导航控制器的 NavigationBar 设置背景图片,且 BarMetrics 需要设置为 UIBarMetricsDefault -3. + (void)load 和 + (void)initialize 的使用分析 - - initialize:第一次使用这个类或者它的子类的时候调用 - - load :这个方法在类加载的时候调用一次。 - //window 下有一个导航控制器,导航控制器的根控制器是 ViewController ,点击屏幕跳转到 SubViewController(继承自 ViewController) - - //ViewController - + (void)initialize{ - NSLog(@"%s",__func__); - } - + (void)load{ - NSLog(@"%s",__func__); - } - - - (void)viewDidLoad { - [super viewDidLoad]; - self.title = @"test"; - self.view.backgroundColor = [UIColor whiteColor]; - } - - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - SubViewController *vc = [[SubViewController alloc] init]; - [self.navigationController pushViewController:vc animated:YES]; - } - //SubViewController - + (void)initialize{ - [super initialize]; - NSLog(@"%s",__func__); - } - - //这个方法在类加载的时候调用一次。 - + (void)load{ - NSLog(@"%s",__func__); - } - - 2018-07-19 11:26:04.621740+0800 Test[14617:1049502] +[ViewController load] - 2018-07-19 11:26:04.622463+0800 Test[14617:1049502] +[SubViewController load] - 2018-07-19 11:26:04.743541+0800 Test[14617:1049502] +[ViewController initialize] - 2018-07-19 11:26:07.648425+0800 Test[14617:1049502] +[ViewController initialize] - 2018-07-19 11:26:07.648610+0800 Test[14617:1049502] +[SubViewController initialize] - - - 结果分析来看,类都被加载了(调用了 load 方法,其中页面显示的是 ViewController 所以它的 initialize 被调用,点击屏幕跳转到 SubViewController,所以 SubViewController 的 initialize 方法会被调用,在调用的时候调用了 super 关键字,调用父类的 initialize 方法) -4. UIAppearance appearanceWhenContainedInInstancesOfClasses : 这个方法可以控制让自定义的导航控制器的 appearance 只修改自己需要修改的样式,不至于对于全部的导航控制器的 navigationBar 全部修改。 -5. UIImage 与 UIImageRenderingMode - 在 iOS 系统中经常会用到 UIImage 来渲染一些控件,比如 UITabBar 和 UIBarButtonItem - 在日常开发的时候我们可以为 UITabBar 设置 items 属性。其中可以指定 UITabBar 的 image 和 selectedImage。此时你可以提供2张图片,比如下面的代码 - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[UIImage imageNamed:@"Tab_home_selected"]]; - 你也可以按照下面的写法 - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[UIImage imageNamed:@"Tab_home"]]; - 这是因为系统会渲染。如果不为 UIImage 设置渲染模式,系统会在合适的地方根据上下文渲染,比如在这个地方的 UITabBar 就会根据上下文渲染出选中的效果。我们不必要必须设置选中的颜色。 - 当然你也可以指定 UIImage 的渲染模式。下面看看官方文档讲的 UIImage 的渲染模式 - // Create a version of this image with the specified rendering mode. By default, images have a rendering mode of UIImageRenderingModeAutomatic. - - (UIImage *)imageWithRenderingMode:(UIImageRenderingMode)renderingMode NS_AVAILABLE_IOS(7_0); - @property(nonatomic, readonly) UIImageRenderingMode renderingMode NS_AVAILABLE_IOS(7_0); - - - - /* Images are created with UIImageRenderingModeAutomatic by default. An image with this mode is interpreted as a template image or an original image based on the context in which it is rendered. For example, navigation bars, tab bars, toolbars, and segmented controls automatically treat their foreground images as templates, while image views and web views treat their images as originals. You can use UIImageRenderingModeAlwaysTemplate to force your image to always be rendered as a template or UIImageRenderingModeAlwaysOriginal to force your image to always be rendered as an original. - */ - typedef NS_ENUM(NSInteger, UIImageRenderingMode) { - UIImageRenderingModeAutomatic, // Use the default rendering mode for the context where the image is used - - UIImageRenderingModeAlwaysOriginal, // Always draw the original image, without treating it as a template - UIImageRenderingModeAlwaysTemplate, // Always draw the image as a template image, ignoring its color information - } NS_ENUM_AVAILABLE_IOS(7_0); - UIImage 的渲染模式共有3种值可以选择 - - UIImageRenderingModeAutomatic:根据所使用的环境和绘图上下文自动调整渲染模式 - - UIImageRenderingModeAlwaysOriginal:始终绘制图片原始状态,不使用 tintColor - - UIImageRenderingModeAlwaysTemplate:始终根据tintColor绘制图片,不管图片本身的颜色状态 - -6.下面举个例子。在 UITabBarController 设置 tabBar - - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[UIImage imageNamed:@"Tab_home_selected"]]; - - - 从 iconfont 网站上面随便选择1个彩色 icon 用来做对比实验 - - ![iconfont小图标](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180719-135721.png) - - - - 实验1 - - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[[UIImage imageNamed:@"Tab_home_selected"] imageWithRenderingMode:UIImageRenderingModeAutomatic]]; - - ![UIImageRenderingModeAutomatic模式](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180719-135617@2x.png) - - -- 实验2 - - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[[UIImage imageNamed:@"Tab_home_selected"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]]; - - ![UIImageRenderingModeAlwaysOriginal模式](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180719-135552@2x.png) - - -- 实验3 - [homeBar setImage:[UIImage imageNamed:@"Tab_home"]]; - [homeBar setSelectedImage:[[UIImage imageNamed:@"Tab_home_selected"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; - - ![UIImageRenderingModeAlwaysTemplate模式](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180719-135552@2x.png) - -结论:对于 UIImage 来说如果不指定渲染模式的话则默认使用**UIImageRenderingModeAutomatic**,则会根据渲染的环境和上下文进行渲染。如果指定了模式,则根据具体的模式开启渲染。**UIImageRenderingModeAlwaysOriginal:**则绘制图片的原始信息,不使用**tintColor**。**UIImageRenderingModeAlwaysTemplate:**则始终根据**tintColor**绘制图片,忽略图片本身的信息。 - - - -
-![引用自网络的图片](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/4673_140117110629_1.png) \ No newline at end of file diff --git a/Chapter1 - iOS/1.36.md b/Chapter1 - iOS/1.36.md deleted file mode 100644 index 73fecec..0000000 --- a/Chapter1 - iOS/1.36.md +++ /dev/null @@ -1,72 +0,0 @@ -# iOS 数值计算精度丢失问题 - -> 在 iOS 中经常会计算金额和价格,我们有时会定义数据类型为 double 或者 float,这样在做过一些运算后会发现精度丢失了,这显然不是我们想要的结果。今日偶然间看到一篇技术博文,为了记忆,顺道解决我的这个问题,所以记录了下来。 - - - -### 存在的问题(精度丢失) - -```objective-c -float a = 0.01; -int b = 99999999; -double c = 0.0; -c = a*b; -NSLog(@"c-%f",c); // c-1000000.000000 -NSLog(@"c-%.2f",c); // c-1000000.00 -``` - -通过上面的代码我们看到简单的数学运算后对于数据类型不合理的数值进行过运算后精度丢失了,这如果是在我们的 App 中,用户看到自己的金额不正确,那还不吓一跳?? - -接下来看看如何做简单的改进 - -```objective-c -NSString *aString = [NSString stringWithFormat:@"%f",a]; -NSString *bString = [NSString stringWithFormat:@"%.2f",(double)b]; -c = [aString doubleValue]*[bString doubleValue]; -NSLog(@"%f",c); //999999.990000 -NSLog(@"%.2f",c); //999999.99 -``` - -这样虽然可以达成目的,但是计算的过程比较麻烦,并不是我们想要的解决方案。通过查阅资料得知苹果推出了一个类,专门解决数据计算的精度问题NSDecimalNumber 。 - - - -### NSDecimalNumber 为数据精度应用而生 - - - -NSDecimalNumber 是 NSNumber 的子类,专门负责精度计算。提供了完善的初始化方案,对于头疼的精度计算问题(金额)它提供了便利的解决方案(加、减、乘、除、次方运算并且可以给计算出的结果设置明显的精度方案(四舍五入、取上、取下等等))。NSDecimalNumberHandler 可以对计算出的结果做一些策略,比如舍入的模式、数据溢出、除0等异常情况的处理规则。 - -我们来说说上面的问题吧,引入了 NSDecimalNumber,解决上面的问题就不费吹灰之力了。 - -```objective-c -NSString *decimalNumberMutiplyWithString(NSString *multiplierValue, NSString *multiplicandvalue){ - NSDecimalNumber *multiplierNumber = [NSDecimalNumber decimalNumberWithString:multiplierValue]; - - NSDecimalNumber *multiplicandNumber = [NSDecimalNumber decimalNumberWithString:multiplicandvalue]; - NSDecimalNumber *result = [multiplierNumber decimalNumberByMultiplyingBy:multiplicandNumber]; - return [result stringValue]; -} - -NSDecimalNumber *price = [NSDecimalNumber decimalNumberWithMantissa:18992 exponent:-3 isNegative:NO]; -NSDecimalNumber *price2 = [NSDecimalNumber decimalNumberWithString:@"18.992"]; -NSLog(@"price:%@",[price stringValue]); //999999.990000 -NSLog(@"price2:%@",[price2 stringValue]); //999999.99 - - -//设置计算的精度(小数点位数、舍入规则) -NSDecimalNumberHandler *roundPlain = [NSDecimalNumberHandler - decimalNumberHandlerWithRoundingMode:NSRoundPlain - scale:1 - raiseOnExactness:NO - raiseOnOverflow:NO - raiseOnUnderflow:NO - raiseOnDivideByZero:YES]; - -NSDecimalNumber *resultDecimal = [multiplierNumber decimalNumberByMultiplyingBy:multiplicandNumber withBehavior:roundPlain]; -``` - -### 参考文章 - -[文章1](https://www.jianshu.com/p/25d24a184016)、[文章2](https://www.jianshu.com/p/ea4da259a062) - diff --git a/Chapter1 - iOS/1.37.md b/Chapter1 - iOS/1.37.md deleted file mode 100644 index eb3035f..0000000 --- a/Chapter1 - iOS/1.37.md +++ /dev/null @@ -1,34 +0,0 @@ -# 数组、集合、字典与 hash、isEqual 方法的关联 - -1. NSArray 允许重复添加元素,添加元素的时候不查重,所以不会调用上面2个方法。在移出元素的时候会依次遍历数组内的元素,每个元素调用 **isEqual** 方法(remove 方法传入的元素作为参数),所有返回真值的元素都会被移除。在字典中不涉及 hash 方法。 - -2. NSSet 不允许重复添加元素,所以在添加新元素的时候,该元素的 **hash** 方法会被调用,若集合中不存在与此元素 hash 相同的元素,则它会被直接加入集合,不调用 **isEqual** 方法;若存在,则依次调用该集合每个元素的 **isEqual** 方法,返回真值则判等,不加入,处理结合,若返回 false, 则判定集合内不存在该元素,将其加入。 - -3. 集合中移除元素时,首先调用它的 **hash方法**,若集合中存在与其 **hash值** 相等的元素,则调用该元素的 **isEqual方法**,若返回真值则判断,进行移除;若不存在,则会依次调用集合中每个元素的 **isEqual方法**,只要找到一个返回真值的元素,就进行移除,并结束整个过程(所有这样会有其它满足 isEqual 方法但却漏掉未被删除的元素)。调用 contains 方法时类似 - -4. 因此如果自定义对象被加入到集合或作为字典的 key 时,需要同时重写 isEqual 方法和 hash 方法,这样,若集合存在某元素,则调用它的 contains 和 remove 方法时,可以在 O(1) 完成查询,否则需要 O(n) 完成。 -  -5. 需要注意的是,NSDictionary 的 key、value 都说对象类型即可,但是被设为 key 的对象需要遵循 NSCopying 协议。 - -6. hash 方法出现的目的是:当我们从数组中查找元素时,需要依次遍历数组中的元素,时间复杂度为 O(n),为了解决效率问题,引入了 Hash Table 方法。当添加元素的时候,为每个元素设置了 hash 值,这样当下次查找的时候就直接通过 hash 值找到对应的位置,时间复杂度为 O(1)。设计一个合理的 hash 算法的指标是对于每个参数,其返回的 hash 值唯一。 - -7. 查阅资料可知,一个推荐的自定义对象的 **hash** 算法是将关键属性的 hash 值,按照位或运算 - -``` -@interface Person : NSObject -@property (nonatomic, strong) NSString *name; -@property (nonatomic, strong) NSDate *birthday; -@end - - -- (NSUInteger)hash{ - return [self.name hash] ^ [self.birthday hash]; -} -``` - - - -8. NSObject 类中的 equal 方法的判断是包括内存地址的,也就是说,NSObject 若想判断2个对象相等,那么这2个对象的内存地址必须相等 - - - diff --git a/Chapter1 - iOS/1.38.md b/Chapter1 - iOS/1.38.md deleted file mode 100644 index 46fb552..0000000 --- a/Chapter1 - iOS/1.38.md +++ /dev/null @@ -1,2261 +0,0 @@ -# RunLoop 探究 - -> 为什么 main 函数可以保持一直运行而不退出? -> -> 卡顿如何监控 - - - -## RunLoop 是什么 - -- 运行循环 -- 在程序运行过程中循环做一些事情 - -作用:程序并不会马上退出,而是保持运行状态 - -- 保持程序的持续运行 - -- 处理App中的各种事件(比如触摸事件、定时器事件等) - -- 节省CPU资源,提高程序性能:该做事时做事,该休息时休息。休息和工作是从用户态切换到内核态,内核态切换到用户态的不断切换 - -- ...... - -场景 - -- 定时器(Timer)、PerformSelector - -- GCD Async Main Queue - -- 事件响应、手势识别、界面刷新 - -- 网络请求 - -- AutoreleasePool - -先附上一张总结的非常棒的RunLoop图 - - - - - -一言以蔽之,什么是 RunLoop?为什么 main 函数可以保持一直运行而不退出? - -iOS 侧 main 函数中,调用 UIApplicationMain 方法,内部启动主线程的 RunLoop,RunLoop 是一个事件循环的维护机制。有事情做的时候做事情(Source0、Source1),没有事做的时,从用户态到内核态的切换,去实现线程休眠。避免资源浪费。 - - - -## RunLoop 几个重要角色 - -### 获取 RunLoop - -iOS 中有2套 API 可以访问和使用 RunLoop。分别是 - -- Foundation:NSRunLoop - -- CoreFoundation:CFRunLoopRef - -``` -//Foundation -[NSRunLoop currentRunLoop]; // 获得当前线程的RunLoop对象 -[NSRunLoop mainRunLoop]; // 获得主线程的RunLoop对象 - -//Core Foundation -CFRunLoopGetCurrent(); // 获得当前线程的RunLoop对象 -CFRunLoopGetMain(); // 获得主线程的RunLoop对象 -``` - -NSRunLoop 是对 CFRunLoopRef 的一层 OC 包装,所以要了解 RunLoop 的内部结果,就需要了解 CFRunLoopRef - -- RunLoop 保存在一个全局的 Dictionary 里,线程作为 key,RunLoop 作为 value - -- 每条线程都有与之一一对应的 RunLoop 对象 - -- 主线程的 RunLoop 已经自动创建好了,子线程的 RunLoop 需要主动创建 - -- RunLoop 在第一次获取时创建,在线程结束时消失 - - - -### RunLoop 相关的5个类 - -- CFRunLoopRef -- CFRunLoopModeRef -- CFRunLoopSourceRef -- CFRunLoopTimerRef -- CFRunLoopObserverRef - -CFRunLoopRef 是什么? - -查看源码 CFRunLoop 发现是结构体对象别名,`typedef struct __CFRunLoop * CFRunLoopRef;`摘取主要信息如下 - -```c -struct __CFRunLoop { - pthread_t _pthread; - CFMutableSetRef _commonModes; - CFMutableSetRef _commonModeItems; - CFRunLoopModeRef _currentMode; - CFMutableSetRef _modes; -}; -``` - -其中 `_modes` 代表一个 RunLoop 有一个 set 存储运行模式,有多个 Mode。`_currentMode` 表示当前时刻只有一个 Mode。 - -`CFRunLoopModeRef` 是什么?查看发现 - -`typedef struct __CFRunLoopMode *CFRunLoopModeRef;` - -```c -struct __CFRunLoopMode { - CFStringRef _name; - CFMutableSetRef _sources0; - CFMutableSetRef _sources1; - CFMutableArrayRef _observers; - CFMutableArrayRef _timers; -}; -``` - - - -### CFRunLoopModeRef 代表 RunLoop 的运行模式 - -- 一个 RunLoop 包含若干个 Mode,每个 Mode 包含若干个 Source/Timer/Observer -- 每次 RunLoop 启动,只能指定一个 Mode,这个 Mode 被叫做 CurrentMode -- 如果需要切换 Mode,只能退出 RunLoop,则以一个 Mode 进入 -- 如果 Mode 里没有任何 Source0/Source1/Timer/Observer,RunLoop 会立马退出 - - - -系统默认注册了5个Mode - -- kCFRunLoopDefaultMode:App 的默认 Mode,通常主线程是在这个 Mode 下运行 -- UITrackingRunLoopMode:界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响 -- UIInitializationRunLoopMode: 在刚启动 App 时进入的第一个 Mode,启动完成后就不再使用 -- GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到 -- kCFRunLoopCommonModes: 这是一个占位用的Mode,不是一种真正的Mode - - - -Demo: - - - - - -结论:NSRunLoop 是对 CFRunLoop 的一层包装。 - - - -QA:为什么一个 RunLoop 需要创建这么多 Mode? - -这样做的目的是为了分隔开不同组的 Source/Timer/Observer 互不影响(性能、功能)。 - -在UITableView场景下,不同的 RunLoop Mode 主要是与 UITableView 的滑动优化和事件处理相关的。当 UITableView 滑动时,RunLoop的运行模式会从默认的 CFRunLoopDefaultMode 切换到 CFRunLoopTrackingMode,此 Mode 下 RunLoop 主要关注于处理与滑动相关的触摸事件和动画效果,而忽略其他类型的事件,如定时器事件。这是因为如果同时处理所有类型的事件,可能会导致滑动不流畅,影响用户体验。之前添加到 CFRunLoopDefaultMode 上的事件通知(如定时器事件)可能无法被及时处理,这就是为什么在UITableView 滑动时,添加到主线程的 NSTimer 可能会停止执行的原因。 - - - -### Source0、Source1、Timer、Observers 是什么 - -```c -struct __CFRunLoopMode { - CFStringRef _name; - CFMutableSetRef _sources0; - CFMutableSetRef _sources1; - CFMutableArrayRef _observers; - CFMutableArrayRef _timers; -}; -``` - -RunLoop 在各个 Mode 下做事情,其实就是在处理某个 Mode 中的 Source0、Source1、Timer、Observer 事件。 - -Source0: - -- 处理开发者主动提交的任务或应用内部逻辑。 - - `performSelector:onThread:` - - `dispatch_async`到主线程的任务(最终封装为Source0)。 - - 屏幕触摸事件处理(非基于 Port 的事件),对应需要手动触发的事件,对应官方文档 Input Source 中的 Custom 和 `performSelector:onThread` 事件源。UIKit控件的事件处理(如按钮点击后的回调)。 - - -Demo1:给屏幕点击事件加断点,查看堆栈可以看到是 Source0 触发的。 - - - -Demo2: `- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait` 该 API 的底层就是:向目标线程的 RunLoop 添加一个 Source0 事件,标记后唤醒线程执行对应的事件。 - - - -Source1: - -- 基于 Port 的线程间通信,可以主动唤醒 RunLoop -- 用户触摸屏幕时,系统通过 Mach Port 将事件传递到应用主线程的 RunLoop。RunLoop 被 Source1 唤醒后,将事件分发给 Source0处理具体的 UI 响应逻辑(如 `hitTest:withEvent:` 和响应链)。 -- 字典。`{machport : 1}` - -Timers: - -- NSTimer - -- `performSelector:withObject:afterDelay:`,底层也是 Timer - -Observers: - -- 用于监听 RunLoop 状态 - -- UI刷新(BeforeWaiting) - -- AutoReleasePool 实现(BeforeWaiting) - -CFRunLoopSourceRef 事件源(输入源) - -早期的分法: - -- Ported-Based Source -- Custom Input Source -- Cocoa Perform Selector Source - -现在的分法 - -- Source0:非基于 port 的,用户主动触发的事件 -- Source1: 基于 port的,通过内核在线程间相互发送消息 - - - -### 一对多的关系 - - - - - -#### RunLoopTimer 的封装 - -- `+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;` -- `+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;` -- `- (void)performSelector:(SEL)aSelector withObject: (id)argument afterDelay: (NSTimeInterval)seconds inModes: (NSArray*)modes;` -- `+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel;` -- `- (void)addToRunLoop:(NSRunLoop *)runloop forMode:(NSString *)mode;` - - - -#### CFRunLoopSource - -Source 是 RunLoop 的数据源抽象类(protocol) - -RunLoop 定义了2个 Version 的 Source: - -- Source0:处理 App 内部事件、App 自己负责管理(触发),如 UIEvent、CGSocket -- Source1:由 RunLoop 和内核管理,Mach port 驱动,如 CFMachPort、CFMessagePort。 - -定义如下: - -```c++ -struct __CFRunLoopSource { - CFRuntimeBase _base; - uint32_t _bits; - pthread_mutex_t _lock; - CFIndex _order; /* immutable */ - CFMutableBagRef _runLoops; - union { - CFRunLoopSourceContext version0; /* immutable, except invalidation */ - CFRunLoopSourceContext1 version1; /* immutable, except invalidation */ - } _context; -}; - -typedef struct { - CFIndex version; - void * info; - const void *(*retain)(const void *info); - void (*release)(const void *info); - CFStringRef (*copyDescription)(const void *info); - Boolean (*equal)(const void *info1, const void *info2); - CFHashCode (*hash)(const void *info); - void (*schedule)(void *info, CFRunLoopRef rl, CFStringRef mode); - void (*cancel)(void *info, CFRunLoopRef rl, CFStringRef mode); - void (*perform)(void *info); -} CFRunLoopSourceContext; - -typedef struct { - CFIndex version; - void * info; - const void *(*retain)(const void *info); - void (*release)(const void *info); - CFStringRef (*copyDescription)(const void *info); - Boolean (*equal)(const void *info1, const void *info2); - CFHashCode (*hash)(const void *info); -#if (TARGET_OS_MAC && !(TARGET_OS_EMBEDDED || TARGET_OS_IPHONE)) || (TARGET_OS_EMBEDDED || TARGET_OS_IPHONE) - mach_port_t (*getPort)(void *info); - void * (*perform)(void *msg, CFIndex size, CFAllocatorRef allocator, void *info); -#else - void * (*getPort)(void *info); - void (*perform)(void *info); -#endif -} CFRunLoopSourceContext1; -``` - - - -#### CFRunLoopObserver - -向外部报告 RunLoop 当前状态的更改。框架中很多机制都是由 RunLoopObserver 触发,比如 CAAnimation、AutoReleasePool。 - -系统或者开发者很多都是 RunLoop 的业务方。 - - - -#### CFRunLoopMode - -Mode 是 iOS App 滑动流畅的关键。 - -不同任务被添加到不同 Mode 中去。 - -UITrackingMode 模式下,核心关注滚动时 UI 流畅相关逻辑。 - - - - - -### CFRunLoopObserverRef 监听 RunLoop 状态变化 - -```objective-c -/* Run Loop Observer Activities */ -typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { - kCFRunLoopEntry = (1UL << 0), // 即将进入 RunLoop - kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 NSTimer - kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source - kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠 - kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒 - kCFRunLoopExit = (1UL << 7), // 退出 RunLoop - kCFRunLoopAllActivities = 0x0FFFFFFFU -}; -``` - - 添加 Observer - -```objective-c -//1、获得当前线程下的 RunLoop -CFRunLoopRef runloop = CFRunLoopGetCurrent(); -//2、为 RunLoop 创建观察者 -CFRunLoopObserverRef obersver = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { -}); -//3、为当前的 RunLoop 添加观察者 -CFRunLoopAddObserver(runloop, obersver, kCFRunLoopDefaultMode); -//4、在 CoreFoundation 框架中, create、copy、retain 过的对象都必须在最后 release -CFRelease(obersver); -``` - -注意:CoreFoundation 的内存管理:凡是带有 Create、Copy、Retain 等字眼的函数创建出来的对象都需要在最后调用 `release` - -```objective-c -//给 RunLoop 添加监听者 -- (void) { - - //创建监听者 -// CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopAllActivities, <#Boolean repeats#>, <#CFIndex order#>, <#CFRunLoopObserverCallBack callout#>, <#CFRunLoopObserverContext *context#>) - /* - 创建监听对象 - 参数1:分配内存空间 - 参数2:要监听的状态 kCFRunLoopAllActivities :所有状态 - 参数3:是否要持续监听 - 参数4:优先级 - 参数5:回调 - */ - - CFRunLoopObserverRef oberver = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - switch (activity) { - case kCFRunLoopEntry: - NSLog(@"RunLoop 闪亮登场"); - break; - case kCFRunLoopBeforeTimers: - NSLog(@"RunLoop 大哥要处理 Timer 了"); - break; - case kCF RunLoopBeforeSources: - //Source 有2种。Source0:非基于 port 的,用户主动触发的事件。Source1:基于 port,通过内核和其它线程互相发送消息 - NSLog(@"RunLoop 大哥要处理 Source 了"); - break; - case kCFRunLoopBeforeWaiting: - NSLog(@"RunLoop 大哥没事干要睡觉了"); - break; - case kCFRunLoopAfterWaiting: - NSLog(@""); - NSLog(@"RunLoop 大哥终于等到有缘人了,要醒来开始干活了"); - break; - case kCFRunLoopExit: - NSLog(@"RunLoop 大哥要退出离开了"); - break; - default: - break; - } - }); - /* - 参数1:要监听哪个RunLoop - 参数2:监听者 - 参数3:要监听 RunLoop 在哪种运行模式下的状态 - */ - CFRunLoopAddObserver(CFRunLoopGetCurrent(), oberver, kCFRunLoopDefaultMode); - CFRelease(oberver); - [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector(wakeupRunLoop) userInfo:nil repeats:YES]; -} - - -//等到 RunLoop 休眠后,5秒钟叫醒 RunLoop -- (void)wakeupRunLoop{ - NSLog(@"%s",__func__); -} -/* -2018-08-01 11:23:49.401626+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.401950+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.402326+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.402509+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.402721+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.402855+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.403080+0800 RunLoop[38148:1994974] RunLoop 大哥没事干要睡觉了 -2018-08-01 11:23:49.459238+0800 RunLoop[38148:1994974] -2018-08-01 11:23:49.459512+0800 RunLoop[38148:1994974] RunLoop 大哥终于等到有缘人了,要醒来开始干活了 -2018-08-01 11:23:49.459740+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.459932+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.460431+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.460607+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.460775+0800 RunLoop[38148:1994974] RunLoop 大哥没事干要睡觉了 -2018-08-01 11:23:49.880631+0800 RunLoop[38148:1994974] -2018-08-01 11:23:49.880867+0800 RunLoop[38148:1994974] RunLoop 大哥终于等到有缘人了,要醒来开始干活了 -2018-08-01 11:23:49.881530+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:49.881699+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:49.881870+0800 RunLoop[38148:1994974] RunLoop 大哥没事干要睡觉了 -2018-08-01 11:23:54.402263+0800 RunLoop[38148:1994974] -2018-08-01 11:23:54.402562+0800 RunLoop[38148:1994974] RunLoop 大哥终于等到有缘人了,要醒来开始干活了 -2018-08-01 11:23:54.402773+0800 RunLoop[38148:1994974] -[ViewController wakeupRunLoop] -2018-08-01 11:23:54.403081+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:54.403245+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:54.403476+0800 RunLoop[38148:1994974] RunLoop 大哥没事干要睡觉了 -2018-08-01 11:23:59.402151+0800 RunLoop[38148:1994974] -2018-08-01 11:23:59.402511+0800 RunLoop[38148:1994974] RunLoop 大哥终于等到有缘人了,要醒来开始干活了 -2018-08-01 11:23:59.402687+0800 RunLoop[38148:1994974] -[ViewController wakeupRunLoop] -2018-08-01 11:23:59.402913+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Timer 了 -2018-08-01 11:23:59.403037+0800 RunLoop[38148:1994974] RunLoop 大哥要处理 Source 了 -2018-08-01 11:23:59.403156+0800 RunLoop[38148:1994974] RunLoop 大哥没事干要睡觉了 -*/ -``` - -![触摸屏幕事件在 RunLoop 下的 source0](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180801-104553@2x.png) - -上个实验是在主线程对 RunLoop 进行的监听,但是由于是主线程是由系统创建的,所以系统也创建了对应的主 RunLoop,所以我们看不到 RunLoop 创建的状态,为了模拟完整的状态,我们开启子线程,在子线程中模拟 - -```objective-c -- (void)testRunLoopObserverOnSubThread{ - - //创建并发队列 - dispatch_queue_t queue = dispatch_queue_create("com.lbp.testRunLoopOnSubThread", DISPATCH_QUEUE_CONCURRENT); - //开启子线程 - dispatch_async(queue, ^{ - - //1、获得当前线程下的 RunLoop - CFRunLoopRef runloop = CFRunLoopGetCurrent(); - - - //2、为 RunLoop 创建观察者 - CFRunLoopObserverRef obersver = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - switch (activity) { - case kCFRunLoopEntry: - NSLog(@"RunLoop 闪亮登场"); - break; - case kCFRunLoopBeforeTimers: - NSLog(@"RunLoop 大哥要处理 Timer 了"); - break; - case kCFRunLoopBeforeSources: - //Source 有2种。Source0:非基于 port 的,用户主动触发的事件。Source1:基于 port,通过内核和其它线程互相发送消息 - NSLog(@"RunLoop 大哥要处理 Source 了"); - break; - case kCFRunLoopBeforeWaiting: - NSLog(@"RunLoop 大哥没事干要睡觉了"); - break; - case kCFRunLoopAfterWaiting: - NSLog(@""); - NSLog(@"RunLoop 大哥终于等到有缘人了,要醒来开始干活了"); - break; - case kCFRunLoopExit: - NSLog(@"RunLoop 大哥要退出离开了"); - break; - default: - break; - } - }); - //为了运行 RunLoop 必须触发事件 - [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(wakeUpRunLoopOnSubThread) userInfo:nil repeats:NO]; - //3、为当前的 RunLoop 添加观察者 - CFRunLoopAddObserver(runloop, obersver, kCFRunLoopDefaultMode); - //4、在 CoreFoundation 框架中, create、copy、retain 过的对象都必须在最后 release - CFRelease(obersver); - //5、在非主线程创建的 RunLoop 必须触发运行 - [[NSRunLoop currentRunLoop] run]; - }); -} - - -- (void)wakeUpRunLoopOnSubThread{ - NSLog(@"%s",__func__); -} -/* -2018-08-01 14:23:06.453282+0800 RunLoop[2376:115968] RunLoop 闪亮登场 -2018-08-01 14:23:06.453608+0800 RunLoop[2376:115968] RunLoop 大哥要处理 Timer 了 -2018-08-01 14:23:06.453781+0800 RunLoop[2376:115968] RunLoop 大哥要处理 Source 了 -2018-08-01 14:23:06.453982+0800 RunLoop[2376:115968] RunLoop 大哥没事干要睡觉了 -2018-08-01 14:23:08.458237+0800 RunLoop[2376:115968] -2018-08-01 14:23:08.458658+0800 RunLoop[2376:115968] RunLoop 大哥终于等到有缘人了,要醒来开始干活了 -2018-08-01 14:23:08.458894+0800 RunLoop[2376:115968] -[ViewController wakeUpRunLoopOnSubThread] -2018-08-01 14:23:08.459082+0800 RunLoop[2376:115968] RunLoop 大哥要退出离开了 -*/ -``` - - - -## RunLoop 运行原理 - -### 运行原概要 - -![RunLoop 运行原理图1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/image-20180801113342611.png) - -- 图上左上角的 Input source 是早期 RunLoop 的分法,现在分法为:Source0 和 Source1。 - - Source0:非基于 port 的,用户主动触发的事件。 - - Source1:基于 port,通过内核和其它线程互相发送消息 -- RunLoop 我们不能自己手动创建,而是可以通过 [NSRunLoop currentRunLoop] 方法获取,类似于懒加载。系统底层的做法是在全局维护了一个字典,字典的 key 和 value 分别是当前的线程和线程对应的 RunLoop,如果新开辟的线程没有对应的 RunLoop,系统则为其创建 RunLoop,并将其写入字典(线程、为其创建的 RunLoop) - - - -### 源码探究 - -内部就是 do-while 的循环,在这个循环内部不断处理各种任务(Timer、Source、Observer) - -我们来看看苹果官方开源的 [CFRunLoop.c 文件](https://legacy.gitbook.com/book/fantasticlbp/knowledge-kit/edit#)。看几个关键函数的实现猜测下 RunLoop 的内部原理 - -但是如何直到系统是运行 RunLoop 的哪个函数?给 viewDidLoad 设置断点,在 lldb 模式输入 `bt` 查看堆栈 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RunLoop-Specific.png) - -查看 CF 中 `CFRunLoop.c`源码。方法比较复杂,做了精简摘要 - -```c -SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */ - // ... - // 通知 Observers 进入 RunLoop - __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry); - // RunLoop 运行循环主逻辑 - result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode); - // 通知 Observers 退出 RunLoop - __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit); - return result; -} -``` - -`CFRunLoopRunSpecific` 方法就是系统启动 RunLoop 的入口。内部先通知 Runloop 的观察者进入 Runloop 了,然后 调用 `__CFRunLoopRun` 执行核心逻辑(处理 timers、source 事件、block),最后告诉观察者退出 Runloop。 - -我们继续看看 `__CFRunLoopRun` 。源码很多很乱,对无关代码进行裁剪,便于理解流程逻辑 - -```c -static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode) { - int32_t retVal = 0; - do { - // 通知 Obserers:即将处理 Timers - if (rlm->_observerMask & kCFRunLoopBeforeTimers) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); - // 通知 Obserers:即将处理 Sources - if (rlm->_observerMask & kCFRunLoopBeforeSources) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); - // 处理 blocks - __CFRunLoopDoBlocks(rl, rlm); - // 处理 Source0 - Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle); - if (sourceHandledThisLoop) { - // 处理 blocks - __CFRunLoopDoBlocks(rl, rlm); - } - - Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR); - // 判断有无 Source1 - if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { - msg = (mach_msg_header_t *)msg_buffer; - // 如果有 Source1 则跳转到 handle_msg - if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) { - goto handle_msg; - } - } - - didDispatchPortLastTime = false; - // 通知 Observers:即将休眠 - if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting); - - CFAbsoluteTime sleepStart = poll ? 0.0 : CFAbsoluteTimeGetCurrent(); - - - do { - if (kCFUseCollectableAllocator) { - // objc_clear_stack(0); - // - memset(msg_buffer, 0, sizeof(msg_buffer)); - } - msg = (mach_msg_header_t *)msg_buffer; - // 等待其他消息来唤醒 RunLoop - __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); - - if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { - // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer. - while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue)); - if (rlm->_timerFired) { - // Leave livePort as the queue port, and service timers below - rlm->_timerFired = false; - break; - } else { - if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); - } - } else { - // Go ahead and leave the inner loop. - break; - } - } while (1); - // 通知 Observers:结束休眠 - if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); - - handle_msg:; // 判断是怎么唤醒的 Runloop - __CFRunLoopSetIgnoreWakeUps(rl); - // - if (MACH_PORT_NULL == livePort) { - CFRUNLOOP_WAKEUP_FOR_NOTHING(); - // handle nothing - } else if (livePort == rl->_wakeUpPort) { - CFRUNLOOP_WAKEUP_FOR_WAKEUP(); - } - // 被 Timer 唤醒,执行代码。 - else if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { - CFRUNLOOP_WAKEUP_FOR_TIMER(); - if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { - // Re-arm the next timer, because we apparently fired early - __CFArmNextTimerInMode(rlm, rl); - } - } - // 被 Timer 唤醒,执行代码。 - else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) { - CFRUNLOOP_WAKEUP_FOR_TIMER(); - // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled. - // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754 - if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { - // Re-arm the next timer - __CFArmNextTimerInMode(rlm, rl); - } - } - // 被 GCD 唤醒 - else if (livePort == dispatchPort) { - // 处理 GCD - __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); - } else { - // 被 Source1 唤醒 - CFRUNLOOP_WAKEUP_FOR_SOURCE(); - // 处理 Source1 - __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop; - - } - if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); - // 处理 Blocks - __CFRunLoopDoBlocks(rl, rlm); - - // 设置返回值 - if (sourceHandledThisLoop && stopAfterHandle) { - retVal = kCFRunLoopRunHandledSource; - } else if (timeout_context->termTSR < mach_absolute_time()) { - retVal = kCFRunLoopRunTimedOut; - } else if (__CFRunLoopIsStopped(rl)) { - __CFRunLoopUnsetStopped(rl); - retVal = kCFRunLoopRunStopped; - } else if (rlm->_stopped) { - rlm->_stopped = false; - retVal = kCFRunLoopRunStopped; - } else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) { - retVal = kCFRunLoopRunFinished; - } - - voucher_mach_msg_revert(voucherState); - os_release(voucherCopy); - } while (0 == retVal); // 当 retVal == 0 的时候结束 Runloop - return retVal; -} -``` - -另一个版本 - -```objective-c - /* rl, rlm are locked on entrance and exit */ - static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode) { - - - uint64_t startTSR = mach_absolute_time(); - - - if (__CFRunLoopIsStopped(rl)) { - __CFRunLoopUnsetStopped(rl); - return kCFRunLoopRunStopped; - } else if (rlm->_stopped) { - rlm->_stopped = false; - return kCFRunLoopRunStopped; - } - - - mach_port_name_t dispatchPort = MACH_PORT_NULL; - Boolean libdispatchQSafe = pthread_main_np() && ((HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && NULL == previousMode) || (!HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && 0 == _CFGetTSD(__CFTSDKeyIsInGCDMainQ))); - if (libdispatchQSafe && (CFRunLoopGetMain() == rl) && CFSetContainsValue(rl->_commonModes, rlm->_name)) dispatchPort = _dispatch_get_main_queue_port_4CF(); - - - #if USE_DISPATCH_SOURCE_FOR_TIMERS - mach_port_name_t modeQueuePort = MACH_PORT_NULL; - if (rlm->_queue) { - modeQueuePort = _dispatch_runloop_root_queue_get_port_4CF(rlm->_queue); - if (!modeQueuePort) { - CRASH("Unable to get port for run loop mode queue (%d)", -1); - } - } - #endif - - - dispatch_source_t timeout_timer = NULL; - struct __timeout_context *timeout_context = (struct __timeout_context *)malloc(sizeof(*timeout_context)); - if (seconds <= 0.0) { // instant timeout - seconds = 0.0; - timeout_context->termTSR = 0ULL; - } else if (seconds <= TIMER_INTERVAL_LIMIT) { - dispatch_queue_t queue = pthread_main_np() ? __CFDispatchQueueGetGenericMatchingMain() : __CFDispatchQueueGetGenericBackground(); - timeout_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); - dispatch_retain(timeout_timer); - timeout_context->ds = timeout_timer; - timeout_context->rl = (CFRunLoopRef)CFRetain(rl); - timeout_context->termTSR = startTSR + __CFTimeIntervalToTSR(seconds); - dispatch_set_context(timeout_timer, timeout_context); // source gets ownership of context - dispatch_source_set_event_handler_f(timeout_timer, __CFRunLoopTimeout); - dispatch_source_set_cancel_handler_f(timeout_timer, __CFRunLoopTimeoutCancel); - uint64_t ns_at = (uint64_t)((__CFTSRToTimeInterval(startTSR) + seconds) * 1000000000ULL); - dispatch_source_set_timer(timeout_timer, dispatch_time(1, ns_at), DISPATCH_TIME_FOREVER, 1000ULL); - dispatch_resume(timeout_timer); - } else { - // 设置RunLoop超时时间 - seconds = 9999999999.0; - timeout_context->termTSR = UINT64_MAX; - } - - - Boolean didDispatchPortLastTime = true; - int32_t retVal = 0; - do { - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - voucher_mach_msg_state_t voucherState = VOUCHER_MACH_MSG_STATE_UNCHANGED; - voucher_t voucherCopy = NULL; - #endif - uint8_t msg_buffer[3 * 1024]; - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - mach_msg_header_t *msg = NULL; - mach_port_t livePort = MACH_PORT_NULL; - #elif DEPLOYMENT_TARGET_WINDOWS - HANDLE livePort = NULL; - Boolean windowsMessageReceived = false; - #endif - __CFPortSet waitSet = rlm->_portSet; - - - __CFRunLoopUnsetIgnoreWakeUps(rl); - - - if (rlm->_observerMask & kCFRunLoopBeforeTimers) - // 2. 通知 Observers: RunLoop 即将触发 Timer 回调 - __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); - if (rlm->_observerMask & kCFRunLoopBeforeSources) - // 3. 通知 Observers: RunLoop 即将触发 Source 回调 - __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); - // 执行被加入的block - __CFRunLoopDoBlocks(rl, rlm); - - - // 4. RunLoop 触发 Source0 (非port) 回调 - Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle); - if (sourceHandledThisLoop) { - // 执行被加入的block - __CFRunLoopDoBlocks(rl, rlm); - } - - - Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR); - - - // 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息 - if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - msg = (mach_msg_header_t *)msg_buffer; - - - if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) { - goto handle_msg; - } - #elif DEPLOYMENT_TARGET_WINDOWS - if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) { - goto handle_msg; - } - #endif - } - - - didDispatchPortLastTime = false; - - - // 通知 Observers: RunLoop 的线程即将进入休眠(sleep) - if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting); - __CFRunLoopSetSleeping(rl); - // do not do any user callouts after this point (after notifying of sleeping) - - - // Must push the local-to-this-activation ports in on every loop - // iteration, as this mode could be run re-entrantly and we don't - // want these ports to get serviced. - - - __CFPortSetInsert(dispatchPort, waitSet); - - - __CFRunLoopModeUnlock(rlm); - __CFRunLoopUnlock(rl); - - - CFAbsoluteTime sleepStart = poll ? 0.0 : CFAbsoluteTimeGetCurrent(); - - - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - #if USE_DISPATCH_SOURCE_FOR_TIMERS - do { - if (kCFUseCollectableAllocator) { - // objc_clear_stack(0); - // - memset(msg_buffer, 0, sizeof(msg_buffer)); - } - msg = (mach_msg_header_t *)msg_buffer; - - - __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); - - - if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { - // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer. - while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue)); - if (rlm->_timerFired) { - // Leave livePort as the queue port, and service timers below - rlm->_timerFired = false; - break; - } else { - if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); - } - } else { - // Go ahead and leave the inner loop. - break; - } - } while (1); - #else - if (kCFUseCollectableAllocator) { - // objc_clear_stack(0); - // - memset(msg_buffer, 0, sizeof(msg_buffer)); - } - msg = (mach_msg_header_t *)msg_buffer; - __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); - #endif - - - - - #elif DEPLOYMENT_TARGET_WINDOWS - // Here, use the app-supplied message queue mask. They will set this if they are interested in having this run loop receive windows messages. - __CFRunLoopWaitForMultipleObjects(waitSet, NULL, poll ? 0 : TIMEOUT_INFINITY, rlm->_msgQMask, &livePort, &windowsMessageReceived); - #endif - - - __CFRunLoopLock(rl); - __CFRunLoopModeLock(rlm); - - - rl->_sleepTime += (poll ? 0.0 : (CFAbsoluteTimeGetCurrent() - sleepStart)); - - - // Must remove the local-to-this-activation ports in on every loop - // iteration, as this mode could be run re-entrantly and we don't - // want these ports to get serviced. Also, we don't want them left - // in there if this function returns. - - - __CFPortSetRemove(dispatchPort, waitSet); - - - __CFRunLoopSetIgnoreWakeUps(rl); - - - // user callouts now OK again - __CFRunLoopUnsetSleeping(rl); - - - // 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了 - if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); - // 处理消息 - handle_msg:; - __CFRunLoopSetIgnoreWakeUps(rl); - - - #if DEPLOYMENT_TARGET_WINDOWS - if (windowsMessageReceived) { - // These Win32 APIs cause a callout, so make sure we're unlocked first and relocked after - __CFRunLoopModeUnlock(rlm); - __CFRunLoopUnlock(rl); - - - if (rlm->_msgPump) { - rlm->_msgPump(); - } else { - MSG msg; - if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE | PM_NOYIELD)) { - TranslateMessage(&msg); - DispatchMessage(&msg); - } - } - - - __CFRunLoopLock(rl); - __CFRunLoopModeLock(rlm); - sourceHandledThisLoop = true; - - - // To prevent starvation of sources other than the message queue, we check again to see if any other sources need to be serviced - // Use 0 for the mask so windows messages are ignored this time. Also use 0 for the timeout, because we're just checking to see if the things are signalled right now -- we will wait on them again later. - // NOTE: Ignore the dispatch source (it's not in the wait set anymore) and also don't run the observers here since we are polling. - __CFRunLoopSetSleeping(rl); - __CFRunLoopModeUnlock(rlm); - __CFRunLoopUnlock(rl); - - - __CFRunLoopWaitForMultipleObjects(waitSet, NULL, 0, 0, &livePort, NULL); - - - __CFRunLoopLock(rl); - __CFRunLoopModeLock(rlm); - __CFRunLoopUnsetSleeping(rl); - // If we have a new live port then it will be handled below as normal - } - - - - - #endif - if (MACH_PORT_NULL == livePort) { - CFRUNLOOP_WAKEUP_FOR_NOTHING(); - // handle nothing - } else if (livePort == rl->_wakeUpPort) { - CFRUNLOOP_WAKEUP_FOR_WAKEUP(); - // do nothing on Mac OS - #if DEPLOYMENT_TARGET_WINDOWS - // Always reset the wake up port, or risk spinning forever - ResetEvent(rl->_wakeUpPort); - #endif - } - #if USE_DISPATCH_SOURCE_FOR_TIMERS - else if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { - CFRUNLOOP_WAKEUP_FOR_TIMER(); - - - if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { - // Re-arm the next timer, because we apparently fired early - __CFArmNextTimerInMode(rlm, rl); - } - } - #endif - #if USE_MK_TIMER_TOO - // 9.1 如果一个 Timer 到时间了,触发这个Timer的回调 - else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) { - CFRUNLOOP_WAKEUP_FOR_TIMER(); - // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled. - // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754 - if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { - // Re-arm the next timer - __CFArmNextTimerInMode(rlm, rl); - } - } - #endif - // 9.2 如果有dispatch到main_queue的block,执行block - else if (livePort == dispatchPort) { - CFRUNLOOP_WAKEUP_FOR_DISPATCH(); - __CFRunLoopModeUnlock(rlm); - __CFRunLoopUnlock(rl); - _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL); - #if DEPLOYMENT_TARGET_WINDOWS - void *msg = 0; - #endif - /**/ - __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); - _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL); - __CFRunLoopLock(rl); - __CFRunLoopModeLock(rlm); - sourceHandledThisLoop = true; - didDispatchPortLastTime = true; - } - // 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件 - else { - CFRUNLOOP_WAKEUP_FOR_SOURCE(); - - - // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again. - voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release); - - - /**/ - CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort); - if (rls) { - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - mach_msg_header_t *reply = NULL; - sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop; - if (NULL != reply) { - (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL); - CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply); - } - #elif DEPLOYMENT_TARGET_WINDOWS - sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop; - #endif - } - - - // Restore the previous voucher - _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, previousVoucher, os_release); - - - } - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); - #endif - // 执行加入到Loop的block - __CFRunLoopDoBlocks(rl, rlm); - - - - - if (sourceHandledThisLoop && stopAfterHandle) { - // 进入loop时参数说处理完事件就返回 - retVal = kCFRunLoopRunHandledSource; - } else if (timeout_context->termTSR < mach_absolute_time()) { - // 超出传入参数标记的超时时间了 - retVal = kCFRunLoopRunTimedOut; - } else if (__CFRunLoopIsStopped(rl)) { - __CFRunLoopUnsetStopped(rl); - // 被外部调用者强制停止了 - retVal = kCFRunLoopRunStopped; - } else if (rlm->_stopped) { - rlm->_stopped = false; - retVal = kCFRunLoopRunStopped; - } else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) { - // source/timer一个都没有 - retVal = kCFRunLoopRunFinished; - } - - - #if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - voucher_mach_msg_revert(voucherState); - os_release(voucherCopy); - #endif - // 如果没超时,mode里没空,loop也没被停止,那继续loop - } while (0 == retVal); - - - if (timeout_timer) { - dispatch_source_cancel(timeout_timer); - dispatch_release(timeout_timer); - } else { - free(timeout_context); - } - - return retVal; - } -``` - -`__CFRunLoopModeIsEmpty`函数的作用就是判断这个 Mode 下面有没有 source0、source1、timer,只要存在就说明当前 Mode 不是空的,同时看看这个 Mode 是不是属于当前的 RunLoop - -```objective-c -// expects rl and rlm locked - static Boolean __CFRunLoopModeIsEmpty(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFRunLoopModeRef previousMode) { - CHECK_FOR_FORK(); - if (NULL == rlm) return true; - #if DEPLOYMENT_TARGET_WINDOWS - if (0 != rlm->_msgQMask) return false; - #endif - Boolean libdispatchQSafe = pthread_main_np() && ((HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && NULL == previousMode) || (!HANDLE_DISPATCH_ON_BASE_INVOCATION_ONLY && 0 == _CFGetTSD(__CFTSDKeyIsInGCDMainQ))); - if (libdispatchQSafe && (CFRunLoopGetMain() == rl) && CFSetContainsValue(rl->_commonModes, rlm->_name)) return false; // represents the libdispatch main queue - // 判断时候有没有_sources0 - if (NULL != rlm->_sources0 && 0 < CFSetGetCount(rlm->_sources0)) return false; - // 判断时候有没有_sources1 - if (NULL != rlm->_sources1 && 0 < CFSetGetCount(rlm->_sources1)) return false; - // 判断时候有没有_timers - if (NULL != rlm->_timers && 0 < CFArrayGetCount(rlm->_timers)) return false; - - - struct _block_item *item = rl->_blocks_head; - while (item) { - struct _block_item *curr = item; - item = item->_next; - Boolean doit = false; - if (CFStringGetTypeID() == CFGetTypeID(curr->_mode)) { - doit = CFEqual(curr->_mode, rlm->_name) || (CFEqual(curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(rl->_commonModes, rlm->_name)); - } else { - doit = CFSetContainsValue((CFSetRef)curr->_mode, rlm->_name) || (CFSetContainsValue((CFSetRef)curr->_mode, kCFRunLoopCommonModes) && CFSetContainsValue(rl->_commonModes, rlm->_name)); - } - if (doit) return false; - } - return true; - } -``` - -**CFRunLoopRun、CFRunLoopRunInMode** - -1、2个函数的作用分别是让 RunLoop 跑在 KCFRunLoopDefaultMode 下和特定的 Mode 下 - -2、2个函数本质上都是调用 CFRunLoopRunSpecific - -```objective-c - // 用DefaultMode启动 - void CFRunLoopRun(void) { /* DOES CALLOUT */ - int32_t result; - do { - result = CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false); - CHECK_FOR_FORK(); - } while (kCFRunLoopRunStopped != result && kCFRunLoopRunFinished != result); - } - - - // 用指定的Mode启动,允许设置RunLoop超时时间 - SInt32 CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */ - CHECK_FOR_FORK(); - return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled); - } -``` - - - -RunLoop 内部几个核心的动作:`__CFRunLoopDoObservers`、`__CFRunLoopServiceMachPort` 、`__CFRunLoopDoTimers` 方法内部实现调用的还是 `__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__`、`__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__` 等方法,均以 `__CFRUNLOOP_IS_CALLING_OUT_TO_` 方法名作为开头。可以在堆栈上得以体现。 - - - -### 运行流程 - -上面结合源码看了 Runloop 是怎么运行的。下面通过图片看看 RunLoop 各个状态运行切换的完整流程。 - - - -Demo: - -1. 上面第4步的 blocks 是指可以给 RunLoop 添加 Block 任务。 - - ```objective-c - CFRunLoopPerformBlock(CFRunLoopGetMain(), kCFRunLoopDefaultMode, ^{ - NSLog(@"runloop block task"); - }); - ``` - -2. 上面8>2,Runloop 处理 GCD Async To Main Quque - - - - - - - - - -### RunLoop 休眠原理 - -> Runloop 在处理完 timer、source、block 后会检查有没有 source1 事件,没有则休眠。这个休眠是 while 循环死等吗?怎么实现的? - -可以在 App 运行过程中,点击 Xcode 左下角的 debug 暂停按钮,可以看到 App 堆栈存在系统调用 - - - - - -本质上就是函数 `__CFRunLoopServiceMachPort`   来控制实现休眠。查看 CFRunLoop.c 源码可以发现,是由 `mach_msg` 实现的。不是平时的在应用层 API 上 sleep 休眠的。比如 while 循环。 - -**`mach_msg` 休眠是从用户态切换到内核态,内核 api 控制休眠,做到真正节省资源的作用,等到由新消息来到,继续切换到用户态**。能力更底层,效果更好,从而更加省电 - -```c -static Boolean __CFRunLoopServiceMachPort(mach_port_name_t port, mach_msg_header_t **buffer, size_t buffer_size, mach_port_t *livePort, mach_msg_timeout_t timeout, voucher_mach_msg_state_t *voucherState, voucher_t *voucherCopy) { - Boolean originalBuffer = true; - kern_return_t ret = KERN_SUCCESS; - for (;;) { /* In that sleep of death what nightmares may come ... */ - mach_msg_header_t *msg = (mach_msg_header_t *)*buffer; - msg->msgh_bits = 0; - msg->msgh_local_port = port; - msg->msgh_remote_port = MACH_PORT_NULL; - msg->msgh_size = buffer_size; - msg->msgh_id = 0; - if (TIMEOUT_INFINITY == timeout) { CFRUNLOOP_SLEEP(); } else { CFRUNLOOP_POLL(); } - ret = mach_msg(msg, MACH_RCV_MSG|(voucherState ? MACH_RCV_VOUCHER : 0)|MACH_RCV_LARGE|((TIMEOUT_INFINITY != timeout) ? MACH_RCV_TIMEOUT : 0)|MACH_RCV_TRAILER_TYPE(MACH_MSG_TRAILER_FORMAT_0)|MACH_RCV_TRAILER_ELEMENTS(MACH_RCV_TRAILER_AV), 0, msg->msgh_size, port, timeout, MACH_PORT_NULL); - - // Take care of all voucher-related work right after mach_msg. - // If we don't release the previous voucher we're going to leak it. - voucher_mach_msg_revert(*voucherState); - - // Someone will be responsible for calling voucher_mach_msg_revert. This call makes the received voucher the current one. - *voucherState = voucher_mach_msg_adopt(msg); - - if (voucherCopy) { - if (*voucherState != VOUCHER_MACH_MSG_STATE_UNCHANGED) { - // Caller requested a copy of the voucher at this point. By doing this right next to mach_msg we make sure that no voucher has been set in between the return of mach_msg and the use of the voucher copy. - // CFMachPortBoost uses the voucher to drop importance explicitly. However, we want to make sure we only drop importance for a new voucher (not unchanged), so we only set the TSD when the voucher is not state_unchanged. - *voucherCopy = voucher_copy(); - } else { - *voucherCopy = NULL; - } - } - - CFRUNLOOP_WAKEUP(ret); - if (MACH_MSG_SUCCESS == ret) { - *livePort = msg ? msg->msgh_local_port : MACH_PORT_NULL; - return true; - } - if (MACH_RCV_TIMED_OUT == ret) { - if (!originalBuffer) free(msg); - *buffer = NULL; - *livePort = MACH_PORT_NULL; - return false; - } - if (MACH_RCV_TOO_LARGE != ret) break; - buffer_size = round_msg(msg->msgh_size + MAX_TRAILER_SIZE); - if (originalBuffer) *buffer = NULL; - originalBuffer = false; - *buffer = realloc(*buffer, buffer_size); - } - HALT; - return false; -} -``` - - - -### RunLoop 是如何响应用户操作的? - -用户交互事件首先在 IOHID 层生成 HIDEvent,然后向事件处理线程的 Source1 的 mach port 发送 HIDEvent 消息,Source1 的回调函数将事件转化为 UIEvent 并筛选需要处理的事件推入待处理事件队列,向主线程的事件处理 Source0 发送信号,并唤醒主线程,主线程检查到事件处理 Source0 有待处理信号后,触发 Source0 的回调函数,从待处理事件队列中提取 UIEvent,最后进入 hit-test 等 UIEvent 事件响应流程 - -等待梳理完善。 - - - -## CFRunLoopTimerRef 是基于时间的触发器 - -- 基本上说就是 NSTimer,它会收到 RunLoopMode 的影响 -- GCD 的 timer 不受 RunLoopMode 的影响 - -## 源码解读 - -- performSelector 是在 source0 上实现的 -- RunLoopTimer 外部接口设置的精度,精度大于0,则使用 dispatch_source_set_timer,精度小于0,则使用 mk_timer_arm -- timer_source 使用 dispatch_timer_STRICT 创建,则系统会尽最大努力遵守设置的 leeway 值 -- NSTimer 不准的原因:底层 RunLoop Timer 底层使用的 timer 的精度不高(mk_timer);与 RunLoop 底层的调用机制有关系 -- 那么为什么存在 RunLoopTimer?意义是什么?应用场景 - -```c -// Data structure to hold TSD data, cleanup functions for each -typedef struct __CFTSDTable { - uint32_t destructorCount; - uintptr_t data[CF_TSD_MAX_SLOTS]; - tsdDestructor destructors[CF_TSD_MAX_SLOTS]; -} __CFTSDTable; -``` - -```c -// 主线程 RunLoop -CFRunLoopRef CFRunLoopGetMain(void) { - CHECK_FOR_FORK(); - // 局部静态变量 - static CFRunLoopRef __main = NULL; // no retain needed - // 创建主线程对应的 RunLoop - if (!__main) __main = _CFRunLoopGet0(pthread_main_thread_np()); // no CAS needed - return __main; -} - -// 子线程 RunLoop -// 先从 __CFTSDTable 获取 RunLoop,如果有则 return,没有则调用 _CFRunLoopGet0,_CFRunLoopGet0 内部调用 __CFRunLoopCreate 创建 CFRunLoop,然后写入 __CFTSDTable -CFRunLoopRef CFRunLoopGetCurrent(void) { - CHECK_FOR_FORK(); - // __CFTSDTable - CFRunLoopRef rl = (CFRunLoopRef)_CFGetTSD(__CFTSDKeyRunLoop); - if (rl) return rl; - // 没有则创建 - return _CFRunLoopGet0(pthread_self()); -} - -CF_EXPORT CFRunLoopRef _CFRunLoopGet0(_CFThreadRef t) { - if (pthread_equal(t, kNilPthreadT)) { - t = pthread_main_thread_np(); - } - __CFLock(&loopsLock); - if (!__CFRunLoops) { - CFMutableDictionaryRef dict = CFDictionaryCreateMutable(kCFAllocatorSystemDefault, 0, NULL, &kCFTypeDictionaryValueCallBacks); - CFRunLoopRef mainLoop = __CFRunLoopCreate(pthread_main_thread_np()); - CFDictionarySetValue(dict, pthreadPointer(pthread_main_thread_np()), mainLoop); - if (!OSAtomicCompareAndSwapPtrBarrier(NULL, dict, (void * volatile *)&__CFRunLoops)) { - CFRelease(dict); - } - CFRelease(mainLoop); - } - CFRunLoopRef newLoop = NULL; - CFRunLoopRef loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t)); - if (!loop) { - newLoop = __CFRunLoopCreate(t); - CFDictionarySetValue(__CFRunLoops, pthreadPointer(t), newLoop); - loop = newLoop; - } - __CFUnlock(&loopsLock); - // don't release run loops inside the loopsLock, because CFRunLoopDeallocate may end up taking it - if (newLoop) { CFRelease(newLoop); } - - if (pthread_equal(t, pthread_self())) { - _CFSetTSD(__CFTSDKeyRunLoop, (void *)loop, NULL); - if (0 == _CFGetTSD(__CFTSDKeyRunLoopCntr)) { -#if _POSIX_THREADS - _CFSetTSD(__CFTSDKeyRunLoopCntr, (void *)(PTHREAD_DESTRUCTOR_ITERATIONS-1), (void (*)(void *))__CFFinalizeRunLoop); -#else - _CFSetTSD(__CFTSDKeyRunLoopCntr, 0, &__CFFinalizeRunLoop); -#endif - } - } - return loop; -} -``` - -为什么只有当 RunLoop 中存在 Timer Sourcrs、Input Sources 时,才能保证 RunLoop 不退出? - -RunLoop 本质就是一个有条件的 do...while 循环。__CFRunLoopModeIsEmpty 里面去判断 source0、source1、timers 不存在则 while 循环条件不满足,RunLoop 退出 - -```c -void CFRunLoopRun(void) { /* DOES CALLOUT */ - int32_t result; - do { - result = CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false); - CHECK_FOR_FORK(); - } while (kCFRunLoopRunStopped != result && kCFRunLoopRunFinished != result); -} - -// expects rl and rlm locked -static Boolean __CFRunLoopModeIsEmpty(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFRunLoopModeRef previousMode) { - // ... - if (NULL != rlm->_sources0 && 0 < CFSetGetCount(rlm->_sources0)) return false; - if (NULL != rlm->_sources1 && 0 < CFSetGetCount(rlm->_sources1)) return false; - if (NULL != rlm->_timers && 0 < CFArrayGetCount(rlm->_timers)) return false; - // ... - return true; -} -``` - -```c -SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */ -// ... -CFRunLoopModeRef currentMode = __CFRunLoopFindMode(rl, modeName, false); -if (NULL == currentMode || __CFRunLoopModeIsEmpty(rl, currentMode, rl->_currentMode)) { - Boolean did = false; - if (currentMode) __CFRunLoopModeUnlock(currentMode); - __CFRunLoopUnlock(rl); - return did ? kCFRunLoopRunHandledSource : kCFRunLoopRunFinished; -} -// ... -``` - - - -## Mach Port 跨线程通信 - -1. Mach IPC 基于 Mach 内核实现进程间通讯。 - -2. Mach IPC 被抽象为3种操作:messages、ports、and port sets - -3. Mach port 跨线程通信 - - 线程 B 有个 port 在等待消息,具有消息接收权限,等待消息到来的时候会阻塞当前线程 - - 线程 A 有个 port 在发送消息,具有发送消息权限,要把发送的消息包装成消息,通过消息队列传递,message 包括:header(目的地 port、size)、data。 - - 线程 B 收到消息后,解除 block,线程继续向下运行 - -4. Mach port 如何进行跨线程通信? - - 线程开启一个 port,然后给 port 申请接收、发送的权限。mach_msg 是通信函数,在等待消息的时候不加 timeout 则会一直阻塞,等到消息到来 - -5. 在测试工作中 main.m 文件中打印当前的 RunLoop - - ```objectivec - int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { - appDelegateClassName = NSStringFromClass([AppDelegate class]); - } - NSLog(@"%@", NSRunLoop.currentRunLoop); - return UIApplicationMain(argc, argv, nil, appDelegateClassName); - } - - // - 2020-08-13 09:49:41.326621+0800 ***[52423:2383402] {wakeup port = 0x1103, stopped = false, ignoreWakeUps = true, - current mode = (none), - common modes = {type = mutable set, count = 1, - entries => - 2 : {contents = "kCFRunLoopDefaultMode"} - } - , - common mode items = (null), - modes = {type = mutable set, count = 1, - entries => - 2 : {name = kCFRunLoopDefaultMode, port set = 0x1003, queue = 0x6000007f0100, source = 0x6000007f0280 (not fired), timer port = 0xe03, - sources0 = (null), - sources1 = (null), - observers = (null), - timers = (null), - currently 618976181 (257342842892563) / soft deadline in: 1.84464867e+10 sec (@ -1) / hard deadline in: 1.84464867e+10 sec (@ -1) - }, - } - } - ``` - - 可以看到 main.m 中,还没有 return 的时候当前 RunLoop 的内部结构中存在一个 **wakeup port** 的端口。查看 RunLoop 源代码 **wakeup port** 就是 mach port 的一种。 - - ```c - typedef mach_port_t __CFPort; - - struct __CFRunLoop { - CFRuntimeBase _base; - _CFRecursiveMutex _lock; /* locked for accessing mode list */ - __CFPort _wakeUpPort; // used for CFRunLoopWakeUp - Boolean _unused; - volatile _per_run_data *_perRunData; // reset for runs of the run loop - _CFThreadRef _pthread; - uint32_t _winthread; - CFMutableSetRef _commonModes; - CFMutableSetRef _commonModeItems; - CFRunLoopModeRef _currentMode; - CFMutableSetRef _modes; - struct _block_item *_blocks_head; - struct _block_item *_blocks_tail; - CFAbsoluteTime _runTime; - CFAbsoluteTime _sleepTime; - CFTypeRef _counterpart; - _Atomic(uint8_t) _fromTSD; - CFLock_t _timerTSRLock; - }; - ``` - - ```c - void CFRunLoopWakeUp(CFRunLoopRef rl) { - // ... - kern_return_t ret; - /* We unconditionally try to send the message, since we don't want - * to lose a wakeup, but the send may fail if there is already a - * wakeup pending, since the queue length is 1. */ - ret = __CFSendTrivialMachMessage(rl->_wakeUpPort, 0, MACH_SEND_TIMEOUT, 0); - // ... - int ret; - do { - ret = eventfd_write(rl->_wakeUpPort, 1); - } while (ret == -1 && errno == EINTR); - // ... - SetEvent(rl->_wakeUpPort); - // ... - } - ``` - - `CFRunLoopWakeUp(CFRunLoopGetCurrent());` 可以唤醒 RunLoop,函数的底层实现如上。核心实现就是 `__CFSendTrivialMachMessage` 函数。在iOS 中,除了 source1 可以自己唤醒 RunLoop 之外,其他的事件都需要用户手动唤醒 RunLoop 才可以。RunLoop 提供了专门的方法来实现这个功能。其核心部分就是调用 mach_msg 来向指定的 **_wakeUpPort** 端口发送消息,从而唤醒线程继续工作。 - - 为什么 Source1 可以唤醒 RunLoop?因为 Source1 本质上就是针对 Mach Port 的封装 - -6. 做个实验检测 _wakeUpPort 端口 - - ```objective-c - - (void)viewDidLoad { - [super viewDidLoad]; - [self listenWakeUpPort]; - } - - - (void)listenWakeUpPort - { - NSArray *array = [NSRunLoop.currentRunLoop.description componentsSeparatedByString:@"wakeup port = "]; - NSString *wakeupPort = [array.lastObject substringToIndex:[array.lastObject rangeOfString:@","].location]; - - dispatch_queue_t queue = dispatch_queue_create("com.test.wake_up_port_queue", DISPATCH_QUEUE_CONCURRENT); - dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, [self numberWithHexString:wakeupPort], 0, queue); - dispatch_source_set_event_handler(source, ^{ - mach_port_t port = (mach_port_t)dispatch_source_get_handle(source); - NSLog(@"%u--wakeUp", port); - }); - dispatch_activate(source); - } - - - (NSInteger)numberWithHexString:(NSString *)hexString - { - const char *hexChar = [hexString cStringUsingEncoding:NSUTF8StringEncoding]; - int hexNumber; - sscanf(hexChar, "%x", &hexNumber); - return (NSInteger)hexNumber; - } - - - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event - { - CFRunLoopWakeUp(CFRunLoopGetCurrent()); - } - ``` - - 可以看到每次在点击屏幕时调用 `CFRunLoopWakeUp` 尝试唤醒 RunLoop,然后监听 RunLoop 的 _wakeUpPort,都可以在回调中获取到消息。 - - - -## RunLoop 应用场景 - -- 控制线程生命周期(线程保活) - -- 解决 NSTimer 在滑动时停止工作的问题 - -- APM 卡顿监控 - -- 性能优化 - - - -### NSTimer 经常会不准确,原因是什么? - -NSTimer 在创建的时候经常会指派到特定的 NSRunLoopMode 中去,举个例子,默认创建的NSTimer 是被添加到 NSRunLoopDefaultMode 中去,当你的页面上有 UIScrollView 或者子类的时如果被拖动了,当前 RunLoop 的 NSRunloopMode 会从 NSDefaultRunLoopMode 转变为 UITrackingRunLoopMode 。遇到这种情况你需要精确的 NSTimer 的话,在创建好 NSTimer 之后,设置 RunLoopMod 为 NSRunLoopCommonModes。 - -注意:NSRunLoopCommonModes 只是一个标识而已,而不是具体的模式。 - -`[NSRunLoop currentRunLoop] addTimer:forMode:` 的作用是告诉 RunLoop 当前 Timer 是可以在 NSRunLoopCommonModes 这个标识的 Mode 下运行。 - -UITrackingRunLoopMode、NSDefaultRunLoopMode 都是属于 NSRunLoopCommonModes 这个标识的。 - -```objective-c -NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(show) userInfo:nil repeats:YES]; -[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; -``` - -NSTimer 会受 NSRunLoopMode 影响,GCD 的 timer 则不会。 - -```objective-c -#import "ViewController.h" - -@interface ViewController () -@property (nonatomic, strong) dispatch_source_t timer; -@end - - -@implementation ViewController - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - /* - 只在默认状态下执行的 NSTimer - [NSTimer scheduledTimerWithTimeInterval:2 repeats:YES block:^(NSTimer * _Nonnull timer) { - NSLog(@"我在执行了"); - }]; - */ - - /* - 指定 NSRunLoopMode 的 NSTimer - NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(show) userInfo:nil repeats:YES]; - [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; - */ - - /* - GCD 的单位是 纳秒. - 使用 GCD 创建的 timer 正常创建后不会执行,因为创建后设置了指定的时间后触发,所以当代码运行到最后一行的时候,Timer 还没执行,就被销毁了。所以我们必须设置一个属性去保存它。 - */ - //1、创建队列 - dispatch_queue_t queue = dispatch_get_main_queue(); - //2、创建 timer - dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); - self.timer = timer; - //3、设置 timer 的参数:精准度、时间间隔 - //第三个参数为 GCD timer 的精准度 - dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 0 * NSEC_PER_SEC, 0 * NSEC_PER_SEC); - //4、为 Timer 设置任务 - dispatch_source_set_event_handler(timer, ^{ - NSLog(@"%@",[NSRunLoop currentRunLoop]); - }); - //5、执行任务 - dispatch_resume(timer); -} - -- (void)show{ - NSLog(@"shw-%@",[NSThread currentThread]); - NSLog(@"%@",[NSRunLoop currentRunLoop]); -} -@end -``` - - - -### ImageView显示(PerformSelector) - -UITableView 在滚动的时候一个优化点之一就是 UIImageView 的显示,通常需要根据网络去下载图片。所以如果用户快速滚动列表的时候,如果立马下载并显示图片的话,势必会对 UI 的刷新产生影响,直观的表现就是会卡顿,FPS 达不到60。 - -```objectivec -- (void)downloadAndShowImage{ - self.imageview.image = [UIImage imageNamed:@"test"]; -} - -- (IBAction)clickLoadIMage:(id)sender { - //[self.imageview performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"test"] afterDelay:2]; - [self performSelector:@selector(downloadAndShowImage) withObject:nil afterDelay:2 inModes:@[NSDefaultRunLoopMode,UITrackingRunLoopMode]]; -} -``` - -知道 RunLoop 的工作原理,就清楚 UITableView(任何 UIScrollView 子类)在滚动的时候,RunLoop 会处于 `UITrackingRunLoopMode`,那么可以将图片下载或者解码显示的逻辑放到 `NSDefaultRunLoopMode` 中 - -```objective-c -[self performSelector:@selector(downloadAndShowImage) withObject:nil afterDelay:0 inModes:@[NSDefaultRunLoopMode]]; -``` - - - - - -### 自动释放池 - -自动释放池什么时候创建和释放? - -App 启动后,主线程 RunLoop 里注册了2个 Observer,其回调都是 `_wrapRunLoopWithAutoReleasePoolHandler`。 - -第一个 Observer 监听 RunLoop 的 `kCFRunLoopEntry` 状态(即将进入 RunLoop),回调为 `_objc_autoreleasePoolPush()`,会创建自动释放池,其 order 为 `-2147483647`,优先级最高,保证创建自动释放池一定是发生在其他回调之前。 - -第二个 Observer 监听 RunLoop 2个事件: - -- 监听 `kCFRunLoopBeforeWaiting` (将要休眠),回调为 `_objc_autoreleasePoolPop()` 和 `_objc_autoreleasePoolPush()`,用来释放旧的自动释放池,创建新的自动释放池。 - -- 监听 `kCFRunLoopExit`(RunLoop 即将退出),回调为 `_objc_autoreleasePoolPop()`,Observer 优先级为 2147483647优先级最低,保证自动释放池的释放在其他所有的回调之后进行。 - -总结版:在主线程执行的代码,通常是写在事件回调、Timer 回调内的,这些回调都会被 RunLoop 自身状态相关的 AutoreleasePool 所包裹,所以会自动管理内存,开发者不需要手动创建 AutoreleasePool。 - - - -### 事件响应 - -系统注册了 Source1(基于 Mach port)用来接收系统事件,其回调函数为 `__IOHIDEventSystemClientQueueCallback` - -当一个硬件事件(触摸/锁屏/摇晃等)发生时,首先 `IOKit.Framework` 会生成一个 `IOHIDEvent` 事件并由 SpringBoard 接收。SpringBoard 只接收按键、触摸、传感器等几种 Event,然后通过 Mach Port 转发给需要处理的 App 进程。随后苹果注册的 Source1 就会触发回调,并调用 `_UIApplicationHandleEventQueue()` 进行应用内部的分发。 - -`_UIApplicationHandleEventQueue` 会把 `IOHIDEvent` 处理并包装成 UIEvent 进行处理和分发(其中包括 UIGesture、屏幕旋转等)。 - - - -### 手势识别 - -`_UIApplicationHandleEventQueue` 识别到一个手势时,首先会调用 cancel 将当前的 touchBegin/End/Move 系统回调打断,然后系统会将对应的 `UIGestureRecognizer ` 标记为待处理。 - -苹果注册了一个 Observer 监控 RunLoop 的 `kCFRunLoopBeforeWaiting`(将要休眠)状态,回调为 `_UIGestureRecognizerUpdateObserver`,其内部会获取所有刚被标记为待处理的 UIGestureRecognizer,并执行对应的回调。 - - - -### UI 刷新 - -当界面的 Frame 改变,或者更改 UIView、CALayer 的层次时,或者调用了 UIView、CALayer 的 setNeedsLayout、setNeedsDisplay 方法后,这个 UIView、CALayer 会被标记为待处理(类比前端的 Virtual Dom Diff,标记为 dirty),并被提交到一个全局容器中。 - -苹果设计 UI 更新也是 RunLoop 的业务方,所以会注册一个 Observer 监控 `kCFRunLoopBeforeWaiting`(将要休眠)和 `kCFRunLoopExit` (即将退出 RunLoop)状态,然后会执行 `_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()` 回调。内部会遍历所有待处理的 UIView、CALayer 以执行实际的绘制和渲染,更新 UI - - - -### RunLoop 空闲时做一些任务 - -```objectivec -- (void)print -{ - NSLog(@"test"); -} - -- (void)viewDidLoad -{ - CFRunLoopActivity flags = kCFRunLoopBeforeWaiting; - CFRunLoopObserverRef runloopObserver = CFRunLoopObserverCreateWithHandler( - kCFAllocatorDefault, flags, YES, 0, - ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { - [self performSelector:@selector(print) - onThread:[NSThread mainThread] - withObject:nil - waitUntilDone:NO - modes:@[ NSDefaultRunLoopMode ]]; - }); - CFRunLoopAddObserver(CFRunLoopGetCurrent(), runloopObserver, kCFRunLoopDefaultMode); -} -``` - - - -### Crash 防护 - -利用监控手段,比如 C/OC crash、Signal、Mach 异常,当监控到异常之后,正常来说会发生闪退等,体验较差。某些场景下希望 App 从异常中恢复,重新启动,这个可以利用 RunLoop 实现。 - -```objectivec -CFRunLoopRef runLoop = CFRunLoopGetCurrent(); -CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop); -while (1) { - for (NSString *mode in (__bridge NSArray *)allModes) { - if ([mode isEqualToString:(NSString *)kCFRunLoopCommonModes]) { - continue; - } - CFStringRef modeRef = (__bridge CFStringRef)mode; - CFRunLoopRunInMode(modeRef, 0.1, false); - } -} -CFRelease(allModes); -``` - -但该方案存在一些缺点,需要衡量: - -- 因为崩溃发生,将程序的控制权交给新的 RunLoop,所以不会返回原来的 RunLoop,函数调用堆栈将不会释放,造成泄漏 - -- 虽然可以保活,但是会增加业务异常风险,所以需要衡量 - - - -### 线程保活 - -为什么线程做完事情就会退出? - -NSThread 的一个工作流程如下: - -`start() -> 创建 pthread -> main() -> [target performSelector:selector] -> exit` - -NSThread 需要保活。为什么会死掉?看看 gnu 源码 - -```c++ -- (void) start -{ - pthread_attr_t attr; - - if (_active == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on active thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - if (_cancelled == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on cancelled thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - if (_finished == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on finished thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - - /* Make sure the notification is posted BEFORE the new thread starts. - */ - gnustep_base_thread_callback(); - - /* The thread must persist until it finishes executing. - */ - RETAIN(self); - - /* Mark the thread as active while it's running. - */ - _active = YES; - - errno = 0; - pthread_attr_init(&attr); - /* Create this thread detached, because we never use the return state from - * threads. - */ - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - /* Set the stack size when the thread is created. Unlike the old setrlimit - * code, this actually works. - */ - if (_stackSize > 0) - { - pthread_attr_setstacksize(&attr, _stackSize); - } - // 设置回调函数 - if (pthread_create(&pthreadID, &attr, nsthreadLauncher, self)) - { - DESTROY(self); - [NSException raise: NSInternalInconsistencyException - format: @"Unable to detach thread (last error %@)", - [NSError _last]]; - } -} -``` - -看看 pthread 创建后的回调函数 - -```c++ -static void * -nsthreadLauncher(void *thread) -{ - NSThread *t = (NSThread*)thread; - - setThreadForCurrentThread(t); - - /* - * Let observers know a new thread is starting. - */ - if (nc == nil) - { - nc = RETAIN([NSNotificationCenter defaultCenter]); - } - // 发送通知 - [nc postNotificationName: NSThreadDidStartNotification - object: t - userInfo: nil]; - // 设置线程名 - [t _setName: [t name]]; - // 调用 main 方法 - [t main]; - // 线程退出 - [NSThread exit]; - // Not reached - return NULL; -} -``` - -看了源码,会发现 NSThread 调用 start 内部就会调用 `[NSThread exit]` 所以会退出。要想常驻,就需要在 main 方法做 runloop 保活。 - -```c++ -- (void) main -{ - if (_active == NO) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on inactive thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - - [_target performSelector: _selector withObject: _arg]; -} -``` - -main 方法内其实就是在执行 _selector。也就是在 NSThread 的初始化方法中,传入的 selector 中进行 runloop 保活逻辑。 - - - -应用场景:经常在子线程中处理某些逻辑的场景。如果销毁再创建再销毁再创建效率很低,这个情况下就需要线程保活。 - -```objective-c -@interface LifeThread : NSThread -@end -@implementation LifeThread -- (void)dealloc{ - NSLog(@"%s", __func__); -} -@end - -@interface ViewController () -@property (nonatomic, strong) LifeThread *thread; -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - self.thread = [[LifeThread alloc] initWithTarget:self selector:@selector(run) object:nil]; - [self.thread start]; -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - [self performSelector:@selector(test) onThread:self.task withObject:nil waitUntilDone:NO]; -} -- (void)test{ - NSLog(@"沿用保活的线程,处理任务:%@", [NSThread currentThread]); -} -// 该方法仅用于线程保活 -- (void)run{ - NSLog(@"%s %@", __func__, [NSThread currentThread]); - // 给 RunLoop 的某个 Mode 里添加 Source/Timer/Observer。其中 addPort 就是 Source1 - [[NSRunLoop currentRunLoop] addPort:[[NSMachPort alloc] init] forMode:NSDefaultRunLoopMode]; - [[NSRunLoop currentRunLoop] run]; - NSLog(@"task finished"); -} -@end -``` - -默认创建的 NSThread 会在 NSDefaultRunLoopMode 模式下运行,当 UI 滑动则进入 UITrackingMode 模式,所以 NSThread 的方法会停止。 - -线程保活就是给方法内部添加 RunLoop,但是新创建的 RunLoop 运行肯定是基于某个 Mode,RunLoop 持续运行(保活)的前提是必须有 Timer、Sources、Observer。所以在 Demo 中我们添加了 `NSMachPort`。 - -上面的代码存在问题: - -1. ViewController 存在内存泄漏,`initWithTarget:self` 因为线程保活,所以 self 被持有,不会执行 dealloc - -2. LBPThread 线程不会死亡,假如我们需要在某个时机让保活线程销毁,现在是办不到的 - -3. RunLoop 不会停止 - -改进: - -1. Thread 换种 api `-(instancetype)initWithBlock:(void (^)(void))block`,线程不持有 self - -2. `[[NSRunLoop currentRunLoop] run]` api 换掉。查看系统说明,底层其实就是一个无限循环,循环内部不断调用 `runMode:beforeDate:`。下面也有建议,建议我们想销毁 RunLoop,可以替换 API,比如设置一个变量,标记是否需要结束 RunLoop - - - -改进代码如下 - -```objective-c -__weak ViewController *weakself = self; -self.thread = [[LifeThread alloc] initWithBlock:^{ - NSLog(@"RunLoop Start"); - [[NSRunLoop currentRunLoop] addPort:[[NSMachPort alloc] init] forMode:NSDefaultRunLoopMode]; - // tips2: - while (!weakself.needStopThread) { - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; - } - NSLog(@"RunLoop Stop"); -}]; -[self.thread start]; - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - if(!self.thread) return; - [self performSelector:@selector(threadTask) onThread:self.thread withObject:nil waitUntilDone:NO]; -} -#pragma mark - 线程相关 -- (void)threadTask { - NSLog(@"线程任务 %@", [NSThread currentThread]); -} - -- (void)stopThread { - if(!self.thread) return; - // tips1: - [self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:YES]; -} - -- (void)stop { - self.needStopThread = YES; - CFRunLoopStop(CFRunLoopGetCurrent()); - self.thread = nil; -} - -- (void)dealloc { - NSLog(@"%s", __func__); - [self stopThread]; -} - -@end -``` - -但上面的代码还是存在问题: - -- 如果 `stop` 方法内部的 **`waitUntilDone` 为 NO,则可能会出现 Crash**。因为该参数代表后续逻辑代表会不会等该 selector 执行完毕。因为为 NO,所以 ViewController 执行 dealloc 了,所以 `dealloc` 方法和 Thread 内部的 block 同时进行,不能确保在 block 内部执行的时候 dealloc 有没有执行完,访问 weakSelf.isStoped 可能会出现坏内存访问,则会 crash - - 解决方案:把 waitUntilDone 改为 YES - -- 但发现还存在问题:页面返回后,线程还是在执行打印任务。 - - 断点发现,在 NSThread 的 block 里,while 条件中 weakself 已经为 nil 了。但 self.thread 还存在,且 block 里的逻辑,while 条件取反后条件成立,则继续调用 `[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];` RunLoop 持续运行 - - 解决方案:不能在 block 里添加 `__strong typeof(weakself) strongSelf = weakself;` 否则造成循环引用(`VC -> thread -> block -> self(VC)`),页面消失,thread 无法释放。 - - 正确的做法是在 while 条件中增加 weakself 是否为 nil 的判断。 - - 可能有些人会产生疑问:为什么 waitUntilDone 改为 YES,dealloc 也就没执行完毕,为什么 weak 指针指向的 weakself 就为 nil 了? - - `weak` 指针的特性是:**当对象开始销毁时(即 `dealloc` 被调用时),所有指向它的 `weak` 指针会立即被置为 `nil`**。这一行为发生在 `dealloc` 方法执行之前,而不是之后。 - -继续优化版本: - -```objective-c -__weak ViewController *weakself = self; -self.thread = [[LifeThread alloc] initWithBlock:^{ - NSLog(@"RunLoop Start"); - [[NSRunLoop currentRunLoop] addPort:[[NSMachPort alloc] init] forMode:NSDefaultRunLoopMode]; - while (weakself && !weakself.needStopThread) { - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; - } - NSLog(@"RunLoop Stop"); -}]; -[self.thread start]; - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - if(!self.thread) return; - [self performSelector:@selector(threadTask) onThread:self.thread withObject:nil waitUntilDone:YES]; -} -#pragma mark - 线程相关 -- (void)threadTask { - NSLog(@"线程任务 %@", [NSThread currentThread]); -} - -- (void)stopThread { - if(!self.thread) return; - [self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:YES]; -} - -- (void)stop { - self.needStopThread = YES; - CFRunLoopStop(CFRunLoopGetCurrent()); - self.thread = nil; -} - -- (void)dealloc { - NSLog(@"%s", __func__); - [self stopThread]; -} - -@end -``` - -效果如下: - - - - - -注意:  - -- 线程的 RunLoop 结束了,线程也无法执行任务了,所以需要给线程对象设置为 nil。同时任务派发的地方也需要判断线程是否存在,否则会 crash - -- NSRunLoop 常驻线程可以运行在 NSRunLoopCommonModes 下吗? - - 不可以。因为在跑的时候如果 modeName 等于 kCFRunLoopCommonModes 则直接 kCFRunLoopRunFinished,则 RunLoop 的 while 循环条件失败 - - ```objective-c - SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */ - CHECK_FOR_FORK(); - if (modeName == NULL || modeName == kCFRunLoopCommonModes || CFEqual(modeName, kCFRunLoopCommonModes)) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - CFLog(kCFLogLevelError, CFSTR("invalid mode '%@' provided to CFRunLoopRunSpecific - break on _CFRunLoopError_RunCalledWithInvalidMode to debug. This message will only appear once per execution."), modeName); - _CFRunLoopError_RunCalledWithInvalidMode(); - }); - return kCFRunLoopRunFinished; - } - // ... - return result; - } - ``` - -线程保活的目的是保证线程处于激活状态,而不是使用强指针让线程不要释放。为让其处于激活状态就需要使用 RunLoop。 - - - -### 线程封装 - -思考:如何设计一个常驻线程工具类? - -继承自 NSThread 吗?不行,这样的话, api 不够收口,留的口子太多,不方便管控。 - -```objectivec -#import - -typedef void (^LBPPermenantThreadTask)(void); - -@interface LBPPermenantThread : NSObject - -/** - 开启线程 - */ -- (void)run; - -/** - 在当前子线程执行一个任务 - */ -- (void)executeTask:(LBPPermenantThreadTask)task; - -/** - 结束线程 - */ -- (void)stop; - -@end - - -#import "LBPPermenantThread.h" - -/** MJThread **/ -@interface LBPThread : NSThread -@end -@implementation LBPThread -- (void)dealloc -{ - NSLog(@"%s", __func__); -} -@end - -/** MJPermenantThread **/ -@interface LBPPermenantThread() -@property (strong, nonatomic) LBPThread *innerThread; -@property (assign, nonatomic, getter=isStopped) BOOL stopped; -@end - -@implementation LBPPermenantThread -#pragma mark - public methods -- (instancetype)init -{ - if (self = [super init]) { - self.stopped = NO; - - __weak typeof(self) weakSelf = self; - - self.innerThread = [[LBPThread alloc] initWithBlock:^{ - [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode]; - - while (weakSelf && !weakSelf.isStopped) { - [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; - } - }]; - - [self.innerThread start]; - } - return self; -} - -- (void)run -{ - if (!self.innerThread) return; - [self.innerThread start]; -} - -- (void)executeTask:(LBPPermenantThreadTask)task -{ - if (!self.innerThread || !task) return; - - [self performSelector:@selector(__executeTask:) onThread:self.innerThread withObject:task waitUntilDone:NO]; -} - -- (void)stop -{ - if (!self.innerThread) return; - - [self performSelector:@selector(__stop) onThread:self.innerThread withObject:nil waitUntilDone:YES]; -} - -- (void)dealloc -{ - NSLog(@"%s", __func__); - [self stop]; -} - -#pragma mark - private methods -- (void)__stop -{ - self.stopped = YES; - CFRunLoopStop(CFRunLoopGetCurrent()); - self.innerThread = nil; -} - -- (void)__executeTask:(LBPPermenantThreadTask)task -{ - task(); -} -@end -``` - - - -### 卡顿监控 - -RunLoop 监控卡顿,可以查看 [带你打造一套 APM 系统](./1.4.md) 文章 - - - -### AsyncDisplayKit - -卡顿主要原因是 CPU/GPU 高负荷工作(mask/cornerRadius/drawrect/opaque 带来的 offscreen rendering/blending 等),或者任务在时间分配下不均衡。 - -Autolayout 布局性能瓶颈。约束的计算会随着 View 数量和层级的增长呈指数级增长,且必须在主线程执行。 - -并行效率低。大多数情况下,主线程繁忙,其他子线程空余。所以思路是把主线程的任务转移一部分给其他线程进行异步处理,主线程带来性能提升 - -AsyncDisplayKit 主要针对: - -- 渲染:对于大量文字、图片混合在一起时,而文字区域的大小和布局,恰恰依赖着渲染结果。ASDK 尽可能走后台线程进行渲染,完成后再同步回到主线程相应的 UIView -- 布局。ASDK 抛弃了 Autolayout,实现了自己的布局和缓存 -- 系统对象的创建和销毁。UIKit 封装了 CALayer 以支持出没灯显示以外的操作。耗时也增加了,这些操作也需要在主线程进行。ASDK 基于 Node 的设计,突破了 UIKit 线程的限制。 - - - -ASDK 创建了 ASDisplayNode 对象,内部封装了 UIView/CALayer,具有和 UIView/CALayer 相似的属性,例如 frame、backgroundColor,这些属性都可以在子线程更改,这样可以实现将排版和绘制放到后台线程,但最终都需要将 View 的更改同步到主线程的 UIView/CALayer 中去。 - -这个同步时机,就是利用 RunLoop 实现的。系统的 UIKit/QuartzCore 也是 RunLoop 的业务方,同样,我们可以模仿系统行为,将针对 View 的改动,在主线程 RunLoop 添加一个 Observer,监听 `kCFRunLoopBeforeWaiting` 、`kCFRunLoopExit` 状态,当收到回调时,遍历所有之前加入到队列中待处理的事务,然后一一执行。 - -```objective-c -+ (void)registerTransactionGroupAsMainRunloopObserver:(_ASAsyncTransactionGroup *)transactionGroup -{ - ASDisplayNodeAssertMainThread(); - static CFRunLoopObserverRef observer; - ASDisplayNodeAssert(observer == NULL, @"A _ASAsyncTransactionGroup should not be registered on the main runloop twice"); - // defer the commit of the transaction so we can add more during the current runloop iteration - CFRunLoopRef runLoop = CFRunLoopGetCurrent(); - CFOptionFlags activities = (kCFRunLoopBeforeWaiting | // before the run loop starts sleeping - kCFRunLoopExit); // before exiting a runloop run - CFRunLoopObserverContext context = { - 0, // version - (__bridge void *)transactionGroup, // info - &CFRetain, // retain - &CFRelease, // release - NULL // copyDescription - }; - - observer = CFRunLoopObserverCreate(NULL, // allocator - activities, // activities - YES, // repeats - INT_MAX, // order after CA transaction commits - &_transactionGroupRunLoopObserverCallback, // callback - &context); // context - CFRunLoopAddObserver(runLoop, observer, kCFRunLoopCommonModes); - CFRelease(observer); -} - -static void _transactionGroupRunLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) -{ - ASDisplayNodeCAssertMainThread(); - _ASAsyncTransactionGroup *group = (__bridge _ASAsyncTransactionGroup *)info; - [group commit]; -} - -- (void)commit -{ - ASDisplayNodeAssertMainThread(); - - if ([_containers count]) { - NSHashTable *containersToCommit = _containers; - _containers = [NSHashTable hashTableWithOptions:NSHashTableObjectPointerPersonality]; - - for (id container in containersToCommit) { - // Note that the act of committing a transaction may open a new transaction, - // so we must nil out the transaction we're committing first. - _ASAsyncTransaction *transaction = container.asyncdisplaykit_currentAsyncTransaction; - container.asyncdisplaykit_currentAsyncTransaction = nil; - [transaction commit]; - } - } -} -``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.39.md b/Chapter1 - iOS/1.39.md deleted file mode 100644 index 17e3413..0000000 --- a/Chapter1 - iOS/1.39.md +++ /dev/null @@ -1,2713 +0,0 @@ -# 多线程探究 - -> 平时我们经常使用 GCD、锁、队列、block,那这些概念和本质到底是什么? -> -> 线程安全如何实现? -> -> 自旋锁、互斥锁区别是什么? -> -> 什么是死锁? -> -> 如果不清楚这些问题,带着问题,跟随本文来一探究竟 - - - -## 一、多线程方案 - -| 技术方案 | 简介 | 语言 | 线程生命周期 | 使用频率 | -| ----------- | -------------------------------------------------------------- | --- | ------- | ---------- | -| pthread | -一套通用的多线程API
适用于Unix\Linux\Windows等系统
跨平台\可移植
使用难度大 | C | 开发者手动管理 | 很少,底层监控会用到 | -| NSThread | 使用更加面向对象
简单易用,可直接操作线程对象 | OC | 开发者手动管理 | 偶尔 | -| GCD | 旨在替代NSThread等线程技术
充分利用设备的多核 | C | 系统自动管理 | 经常 | -| NSOperation | 基于GCD(底层是GCD)
比GCD多了一些更简单实用的功能
使用更加面向对象 | OC | 系统自动管理 | 经常 | - -| | 并发队列 | 自定义串行队列 | 主队列(串行) | -| --------- | ------------ | ------------ | ------------ | -| 同步(sync) | 不开新线程、串行执行任务 | 不开新线程、串行执行任务 | 不开新线程、串行执行任务 | -| 异步(async) | 开新线程、并发执行任务 | 开新线程、串行执行任务 | 不开新线程、串行执行任务 | - - - -## 二、多线程死锁 - -什么是死锁? - -**队列任务引起的循环等待。** - - - -看几个 Demo 观察下死锁情况 - -Demo0 - -```objective-c -// 死锁 -- (void)viewDidLoad { - [super viewDidLoad]; - dispatch_sync(dispatch_get_main_queue(), ^{ // Crash. Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0) - NSLog(@"task"); - }); -} - -// 不死锁 -- (void)viewDidLoad { - [super viewDidLoad]; - NSLog(@"1"); - dispatch_queue_t serialQueue = dispatch_queue_create("com.gcd.test", DISPATCH_QUEUE_SERIAL); - dispatch_sync(serialQueue, ^{ - NSLog(@"task"); - }); - NSLog(@"2"); -} -// console -1 -task -2 - -// 死锁 -- (void)viewDidLoad { - [super viewDidLoad]; - NSLog(@"1"); - dispatch_queue_t serialQueue = dispatch_queue_create("com.gcd.test", DISPATCH_QUEUE_SERIAL); - dispatch_sync(serialQueue, ^{ - NSLog(@"task"); - dispatch_sync(serialQueue, ^{ // Crash.Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0) - NSLog(@"3"); - }); - }); - NSLog(@"2"); -} -// console -1 -task -``` - -为什么一个死锁了,一个没有死锁? - -第一个写法:是因为 viewDidLoad 默认是主队列上跑的,主队列也只有一个主线程。所以 `viewDidLoad` 和 `NSLog(@"task")` 这2个任务都被放到主队列上等待被调度,然而主线程在执行的时候, `viewDidLoad` 的执行依赖 `NSLog(@"task")` ,`NSLog` 的执行依赖 `viewDidLoad`,所以主队列任务循环等待,引起了死锁 - -第二个写法:创建了一个新的串行队列,队列里只加了1个任务 `NSLog(@"task")`,主队列中有 `viewDidLoad`,没有与之相互等待的任务,故而不会产生死锁。 - -第三个写法:创建了一个新的串行队列。主队列里只有 `viewDidLoad` 任务。在主线程执行获取任务的时候,先从主队列获取了 `viewDidLoad` 任务,然后从创建的串行队列中获取任务,先获取了 `NSLog(@"task")` 任务,由于同步执行,后面的代码执行需要等待当前任务的执行结束,但当前任务的执行结束又依赖后面的任务 `NSLog(@"3")` - - - -Demo1 - -```objectivec -NSLog(@"执行任务1"); -dispatch_queue_t queue = dispatch_get_main_queue(); -dispatch_sync(queue, ^{ - NSLog(@"执行任务2"); -}); -NSLog(@"执行任务3"); -// 死锁 -``` - -分析:主队列是一个串行队列,任务3等待 `dispatch_sync` 内的任务执行完毕,可 `dispatch_sync` 内的任务等待任务3执行,互相等待,产生死锁 - -Demo2 - -```objectivec -NSLog(@"执行任务1"); -dispatch_queue_t queue = dispatch_get_main_queue(); -dispatch_async(queue, ^{ - NSLog(@"执行任务2"); -}); -NSLog(@"执行任务3"); -// 1 3 2 -``` - -Demo3 - -```objectivec -NSLog(@"执行任务1"); -dispatch_queue_t queue = dispatch_queue_create("myqueu", DISPATCH_QUEUE_SERIAL); -dispatch_async(queue, ^{ // 0 - NSLog(@"执行任务2") - dispatch_sync(queue, ^{ // 1 - NSLog(@"执行任务3"); - }); - NSLog(@"执行任务4"); -}); -NSLog(@"执行任务5"); -// 1 5 2 Crash -``` - -分析:任务4等待 `dispatch_sync` 内的任务3,`dispatch_sync` 内的任务3等待任务4执行,互相等待 - -Demo4 - -```objectivec -NSLog(@"执行任务1"); -dispatch_queue_t queue = dispatch_queue_create("myqueu", DISPATCH_QUEUE_SERIAL); -dispatch_queue_t queue2 = dispatch_queue_create("myqueu2", DISPATCH_QUEUE_SERIAL); -dispatch_async(queue, ^{ // 0 - NSLog(@"执行任务2"); - dispatch_sync(queue2, ^{ // 1 - NSLog(@"执行任务3"); - }); - NSLog(@"执行任务4"); -}); -NSLog(@"执行任务5"); -// 1 5 2 3 4 -``` - -分析:不会死锁。因为在存在2个任务队列。所以会按照顺序各自从队列上取任务执行。 - -Demo5 - -```objectivec -NSLog(@"执行任务1"); -dispatch_queue_t queue = dispatch_queue_create("myqueu", DISPATCH_QUEUE_CONCURRENT); -dispatch_async(queue, ^{ // 0 - NSLog(@"执行任务2"); - dispatch_sync(queue, ^{ // 1 - NSLog(@"执行任务3"); - }); - NSLog(@"执行任务4"); -}); -NSLog(@"执行任务5"); -// 1 5 2 3 4 -``` - -分析:为什么不会死锁? - -- 先打印1 -- 然后给并发队列派发了异步任务,所以不会阻塞,开启了子线程,在子线程中打印了5 -- 并发队列里存在任务2,然后先打印2 -- 然后用同步的方式给并发队列里添加了任务3,同时里面还存在任务4 -- 是不是产生了一种假设:任务4要执行必须等前面的任务3执行完毕,任务3的执行也必须等任务4执行完毕,造成互相等待死锁? -- 但别忘记这是并发队列,并发队列里的不同 task 可以同时执行,并不会互相等待。上一行的分析适用于串行队列。 - -总结: - -- **队列决定了任务执行完是否需要等待。任务决定是否可以产生新线程** -- 使用 `sync` 函数给当前串行队列派发同步任务,则会卡住当前串行队列,产生死锁 - -Demo6 - -```objectivec -- (void)test{ - NSLog(@"2"); -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - dispatch_queue_t queue = dispatch_get_global_queue(0, 0); - dispatch_async(queue, ^{ - NSLog(@"1"); - [self performSelector:@selector(test) withObject:nil afterDelay:3]; - NSLog(@"3"); - }); -} -// 1 3 -``` - -分析:为什么打印1、3,没有打印2。因为 `-(void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;` 底层是开启了定时器,定时器运行需要添加到 RunLoop。上述代码是在全局并发队列上开启子线程,子线程中没有 RunLoop,所以定时器没有运行。 - - - -Demo7 - -```objective-c -NSLog(@"1"); -dispatch_sync(dispatch_get_global_queue(0, 0), ^{ - NSLog(@"2"); - dispatch_sync(dispatch_get_global_queue(0, 0), ^{ - NSLog(@"3"); - dispatch_sync(dispatch_get_global_queue(0, 0), ^{ - NSLog(@"4"); - }); - }); -}); -NSLog(@"5"); -// console -1 2 3 4 5 -``` - -只要是同步提交任务 `dispatch_sync()` 不管是提交到串行队列还是并发队列,都是在当前线程执行。所以都是在主线程中执行。 - -因为是并发队列,所以可以同时执行,不需要互相等待,则先提交 `NSLog(@"2")` 任务到全局并发队列中,然后执行。由于不需要等待,可以执行后面的代码,继续提交 `NSLog(@"3")` 到全局并发队列,继续执行,因为不需要等待,继续执行后面的代码。继续提交 `NSLog(@"4")` 到到全局并发队列,继续执行。之后从主队列取出 `NSLog(@"5")` 任务 - - - -Demo8 - -```objective-c -dispatch_queue_t queue = dispatch_queue_create("myqueu", DISPATCH_QUEUE_SERIAL); - -dispatch_sync(queue, ^{ - NSLog(@"1"); -}); -dispatch_async(queue, ^{ - NSLog(@"2"); - dispatch_sync(queue, ^{ - // 这里有没有具体的逻辑都不影响,本质是一个任务 block,区别是空 block 和非空 block。 - }); - NSLog(@"4"); -}); -dispatch_sync(queue, ^{ - NSLog(@"5"); -}); -// console -1 -2 -死锁 -``` - -Demo9 - -```objective-c -dispatch_sync(dispathc_get_main_queue(), ^{ - NSLog(@"主队列同步"); -}); -// 死锁 -dispatch_sync(dispathc_get_main_queue(), ^{}); -// 死锁 -dispatch_sync(dispatch_get_main_queue(), nil); -// 死锁 -``` - - - -### 死锁总结 - -在当前串行队列上,使用 `sync` 函数给当前串行队列派发同步任务,则会卡住当前串行队列,产生死锁 - -( 当前队列是串行队列 A,且以同步的形势,派发了一个任务到同一个串行队列 A 上去)。 - - - - - -## 三、performSelector...withObject 底层原理剖析 - -### performSelector...withObject - -```objectivec -- (void)test{ - NSLog(@"2"); -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - dispatch_queue_t queue = dispatch_get_global_queue(0, 0); - dispatch_async(queue, ^{ - NSLog(@"1"); - [self performSelector:@selector(test) withObject:nil]; - NSLog(@"3"); - }); -} -// 1 3 2 -``` - -分析:为什么现在又执行打印2了?因为 `-(id)performSelector:(SEL)aSelector withObject:(id)object;` 是 Runtime API,本质上就是 `objc_msgSend`,所以不需要 RunLoop 便可运行 - -查看 objc4 `NSObject.m` 即可 - -```c -+ (id)performSelector:(SEL)sel withObject:(id)obj { - if (!sel) [self doesNotRecognizeSelector:sel]; - return ((id(*)(id, SEL, id))objc_msgSend)((id)self, sel, obj); -} -``` - - - -### performSelector...withObject...afterDelay 剖析以及经典问题 - -Demo1 - - - -QA:为什么先打印1、3再打印2? - -- 该方法会将 `showLog` 方法的调用封装成一个 NSTimer 定时器事件,并添加到当前线程的 RunLoop 中 -- 不会阻塞当前线程:调用后立即返回,继续执行后续代码,打印 2 -- NSTimer 的执行依赖 RunLoop 的运行:定时器事件需要 RunLoop 处于运行状态才能触发。由于主线程的 RunLoop 默认是开启的,因此无需手动启动 -- 即使延迟时间为 0,任务也不会立即执行,而是等待当前代码执行完毕,RunLoop 进入下一次循环时才触发 - -可以理解为本轮 RunLoop 在唤醒状态下优先处理屏幕点击事件(包括打印1、3,同时内部给 RunLoop 提交了一个 NSTimer),提交的 NSTimer 等本轮结束后下次 RunLoop 唤醒才执行。 - -所以先打印1、再打印3,最后打印2 - -Demo2: - - - - - - - -QA:为什么 test 里的2没有打印? - -查看源码分析,如何查看 `-(void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;` 源码。 - -- 未开源,但是可以设置断点查看汇编分析; - -- Apple 的 XNU 是参考 [GNUstep](http://www.gnustep.org/resources/downloads.php#core),它将 Cocoa 的 OC 库重新实现并开源。虽然不是官方源代码,但是具有研究参考价值 - -查看 GUNStep 源码 - -```c++ -- (void) performSelector: (SEL)aSelector - withObject: (id)argument - afterDelay: (NSTimeInterval)seconds -{ - NSRunLoop *loop = [NSRunLoop currentRunLoop]; - GSTimedPerformer *item; - - item = [[GSTimedPerformer alloc] initWithSelector: aSelector - target: self - argument: argument - delay: seconds]; - [[loop _timedPerformers] addObject: item]; - RELEASE(item); - [loop addTimer: item->timer forMode: NSDefaultRunLoopMode]; -} -``` - -通过源码分析可以看到: - -- `performSelector...withObject...afterDelay...` 本质是开启一个定时器,并添加到 RunLoop但没有启动 RunLoop -- 打印1、2是由于他们不需要 RunLoop 的配合 -- 点击事件里通过 GCD 开启一个子线程,子线程默认没有 RunLoop。所以定时器里的逻辑没办法执行 - -所以代码改下就可运行。 - - - - - -注意:可能有一部分人会这么在子线程中添加 RunLoop,会存在3无法打印的问题。为什么? - - - -`[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];` 本质上让 RunLoop 在指定模式下运行,直到发生以下情况之一: - -- 有事件到达并被处理 -- 到达指定的超时时间(`beforeDate`参数) - -- 如果 beforeDate 设置为 `[NSDate distantFuture]`,RunLoop会无限期等待事件,不会主动超时 - -所以改法也很简单,有3种: - -第一种:在子线程方法中,手动关闭子线程中的 RunLoop - - - -第二种:不要用 `[NSDate distantFuture]`,设置个0秒也可以,改为 `[NSDate dateWithTimeIntervalSinceNow:0]]` - - - -第三种:不要用 `[NSDate distantFuture]`,设置个0秒也可以,改为 `[NSDate dateWithTimeIntervalSinceNow:0]]`。另外不要加 Port,直接在子线程中先获取一次 RunLoop 就好,因为 ``performSelector...withObject...afterDelay...` ` 已经给当前的 RunLoop 添加了 NSTimer,只是没有开启。 分析 RunLoop 源码分析后会发现,在子线程中获取一次 RunLoop,会默认创建一个 RunLoop。 - - - -所以要研究 iOS 底层的同学,看看 **GUNStep 代码吧,这是宝藏** - - - -### pthread 线程原理 - -Demo1: - - - -同理,GCD 虽然开启了子线程,但是 Block 结束后,线程也就结束了。所以线程任务中的1秒后的任务肯定也结束了。 - -Demo2: - - - -可以看到 NSThread 里的 block 执行结束后,thread 结束了。后面的 performSelector 想在线程里执行任务,就会 crash。 - -分析: - -- 屏幕点击事件里创建了一个 NSThread,用 block 的形势添加了一个任务。 -- 调用 start,立马执行。执行完 block 里面的代码后,thread 内已经没有任务了,则 thread 立马销毁(注意:并不是在 131 行打断点发现 thread 内存还存在就没问题,因为此时 block 任务还没执行)。 -- 然后 performSelector 向已退出的线程提交任务发生 crash - -查看源码分析: - -GUN NSThread.m 文件 - -````objective-c -- (void) start -{ - // ... - pthread_attr_t attr; - // ... - pthread_attr_init(&attr); - /* Create this thread detached, because we never use the return state from - * threads. - */ - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - /* Set the stack size when the thread is created. Unlike the old setrlimit - * code, this actually works. - */ - if (_stackSize > 0) - { - pthread_attr_setstacksize(&attr, _stackSize); - } - if (pthread_create(&pthreadID, &attr, nsthreadLauncher, self)) - { - DESTROY(self); - [NSException raise: NSInternalInconsistencyException - format: @"Unable to detach thread (last error %@)", - [NSError _last]]; - } -} - - -/** - * Trampoline function called to launch the thread - */ -static void *nsthreadLauncher(void *thread) { - NSThread *t = (NSThread*)thread; - setThreadForCurrentThread(t); - - /* - * Let observers know a new thread is starting. - */ - if (nc == nil) { - nc = RETAIN([NSNotificationCenter defaultCenter]); - } - [nc postNotificationName: NSThreadDidStartNotification - object: t - userInfo: nil]; - - [t _setName: [t name]]; - [t main]; - // 执行完毕后退出,销毁线程 - [NSThread exit]; - // Not reached - return NULL; -} - - -- (void) main { - if (_active == NO) { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on inactive thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - // 执行线程中的方法 - [_target performSelector: _selector withObject: _arg]; -} - -+ (void) exit{ - NSThread *t; - - t = GSCurrentThread(); - if (t->_active == YES) { - unregisterActiveThread(t); - - if (t == defaultThread || defaultThread == nil) { - /* For the default thread, we exit the process. - */ - exit(0); - } else{ - pthread_exit(NULL); - } - } -} -```` - -分析: - -- 线程入口函数的执行流程 - - - 线程启动后,执行 `main` 函数 - - `performSelector:` 执行用户定义的任务 - - 任务执行完毕后,标记线程状态为 `finished` - - 线程入口函数返回,触发底层 `pthread_exit` 线程终止,操作系统回收线程资源 - -线程销毁的直接原因 - -- **POSIX 线程(`pthread`)的特性**:当线程的入口函数返回时,线程会立即终止,内核自动回收其资源(栈、寄存器状态等) -- `NSThread` 对象虽然可能未被立即释放,但底层线程已销毁,无法再执行任务 - -所以解决办法也是在线程的 block 里面加 RunLoop,让它保活 - - - - - - - -## 四、GCD API - 队列组 - -- 实现异步并发执行任务1、任务2 - -- 等任务1、2都执行完毕,再回到主线程执行任务3 - -```objectivec -dispatch_queue_t queue = dispatch_queue_create("concurrentQueue", DISPATCH_QUEUE_CONCURRENT); -dispatch_group_t group = dispatch_group_create(); -dispatch_group_async(group, queue, ^{ - for (NSInteger index = 0; index< 5; index++) { - NSLog(@"Task1: %@ - index:%zd", [NSThread currentThread], index); - } -}); -dispatch_group_async(group, queue, ^{ - for (NSInteger index = 0; index< 5; index++) { - NSLog(@"Task2: %@ - index:%zd", [NSThread currentThread], index); - } -}); - -dispatch_group_notify(group, queue, ^{ - dispatch_async(dispatch_get_main_queue(), ^{ - for (NSInteger index = 0; index< 5; index++) { - NSLog(@"Task3: %@ - index:%zd", [NSThread currentThread], index); - } - }); -}); -// 等价于 -dispatch_group_notify(group, dispatch_get_main_queue(), ^{ - for (NSInteger index = 0; index< 5; index++) { - NSLog(@"Task3: %@ - index:%zd", [NSThread currentThread], index); - } -}); -``` - - - - - -## 五、多线程安全问题(资源访问)- 加锁 - -### 经典 Demo - - - -会输出什么? - -打印结果,电脑速度快的话,会有很多次打印出5.慢的话,打印出大于5的几次。 - -分析:因为在循环内部,是全局并发队列。多线程的情况下,执行异步任务,任务的先后顺序没办法保证。可能线程1,拿到a=0,然后内部加了1.线程2一开始拿到a=0,但是代码还没执行到a++,在线程1里面,a就已经变为2,因为是 __block 修饰的。所以线程2里面拿到的a变成了a,然后内部a++后,a就是3.其他线程执行情况类似。 - -NSLog 属于 IO 流,比普通运算耗时。所以当能执行 NSLog 的时候,a 一定是大于等于5的。某条线程 a 大于等于5之后,就立马结束 while 循环,开始执行最后的 NSLog。 - -所以电脑越快,打印5的次数更多。电脑慢的情况下,可能会存在几次输出大于5的情况。 - - - -### 为什么需要锁? - -多线程存在资源共享问题。比如多个线程对同一块内存,同时读或者写,导致不一致,很容易引发数据错乱和数据安全问题。典型的生产者消费者问题。比如多个线程访问同一个对象、同一个变量、同一个文件。计算机中看上去一个很简单的操作,背后往往是多个指令的操作,所以很容易发生多线程资源访问的问题。 - -比如,`self.ticketCount++` 看似是原子操作,实际在底层会分解为多个步骤,涉及读取、计算和写入操作 - -拆解为: - -```objective-c -// 1. 读取当前值到寄存器 -int current = [self ticketCount]; - -// 2. 执行自增计算 -int newValue = current + 1; - -// 3. 将新值写回内存 -[self setTicketCount:newValue]; -``` - -X86 汇编为 - -```assembly -; 读取 ticketCount 到 eax 寄存器 -mov eax, [self.ticketCount] - -; 自增 eax 寄存器 -inc eax - -; 将 eax 写回 ticketCount -mov [self.ticketCount], eax -``` - - - -解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后次序进行) - -常见的线程同步技术是:加锁。 - -### iOS 锁种类 - -常见的锁有: - -- OSSpinLock -- os_unfair_lock -- pthread_mutex -- dispatch_semaphore -- dispatch_queue(DISPATCH_QUEUE_SERIAL) -- NSLock -- NSRecursiveLock -- NSCondition -- NSConditionLock -- @synchronized - - - -### OSSpinLock - -#### 使用 - -`OSSpinLock` 叫做”自旋锁”。缺点是:自旋类似于一个 while 循环,在死等。 - -使用的时候需要导入 `#import ` - -`OSSpinLock lock = OS_SPINLOCK_INIT` 初始化 - -`OSSpinLockLock(&lock);` 加锁 - -`OSSpinLockUnlock(&lock);` 解锁 - -`bool res = OSSpinLockTry(&lock)` 尝试加锁(如果需要等待就不加锁直接返回 false,如果不需等待则加锁,返回 true) - -Demo: - -```objectivec -@interface ViewController () -@property (assign, nonatomic) OSSpinLock bankLock; -@property (nonatomic, assign) NSInteger money; -@end - -@implementation ViewController -- (void)viewDidLoad{ - [super viewDidLoad]; - self.bankLock = OS_SPINLOCK_INIT; - self.money = 100; - [self moneyTest]; -} -- (void)moneyTest { - dispatch_queue_t queue = dispatch_queue_create("com.lbp.money.queue", DISPATCH_QUEUE_CONCURRENT); - dispatch_async(queue, ^{ - for (int i = 0; i < 10; i++) { - [self saveMoney]; - } - }); - dispatch_async(queue, ^{ - for (int i = 0; i < 10; i++) { - [self withdrawMoney]; - } - }); - // 100 + 10*50 - 10*10 = 500 -} -- (void)saveMoney { - OSSpinLockLock(&_bankLock); - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney += 50; - self.money = previousMoney; - NSLog(@"存50,还剩%zd元 - %@", self.money, [NSThread currentThread]); - OSSpinLockUnlock(&_bankLock); -} -- (void)withdrawMoney { - OSSpinLockLock(&_bankLock); - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney -= 10; - self.money = previousMoney; - NSLog(@"取20,还剩%zd元 - %@", self.money, [NSThread currentThread]); - OSSpinLockUnlock(&_bankLock); -} -@end -``` - -注意:多线程加锁必须是同一把锁,也就是第一次创建锁的时候,应该保存起来,后续其他线程访问的时候,继续使用同一把锁,否则每次访问都创建锁,则多线程锁对资源的保护效果就达不到。 - - - -#### 存在问题 - -- 等待锁的线程会处于忙等(busy-wait)状态,一直占用着 CPU 资源 - -- 不安全,可能会出现优先级反转问题 - -- 如果等待锁的线程优先级较高,它会一直占用着CPU资源,优先级低的线程就无法释放锁 - -#### 优先级反转问题 - -线程本质上就是 CPU 高速切换,系统分配很少的时间段分别给不同的线程,导致用户看上去是同时在做多个线程内的事情。操作系统会使用基于优先级抢占式调度算法。高优先级的线程始终在低优先级线程前执行。 - -高优先级任务被低优先级任务阻塞,导致高优先级任务迟迟得不到调度。但其他中等优先级的任务却能抢到CPU资源。从现象上来看,好像是中优先级的任务比高优先级任务具有更高的优先权。 - - - - - -操作系统通常采用**抢占式调度**策略,规则如下: - -- **高优先级任务优先**:只要高优先级任务处于就绪状态(未阻塞),它总能抢占低优先级任务的 CPU 时间。 -- **锁的阻塞行为**:操作系统调度器的核心逻辑是:**仅从就绪队列(Ready Queue)中选择任务执行**。所以当任务因等待锁而阻塞时,它的优先级对调度不再产生影响,直到锁被释放 - -举个例子:假设存在三个任务,优先级为 **H > M > L**,且 L 持有某个锁: - -1. 初始状态: - - L 持有锁,并在 CPU 上运行,因为此时没有更高优先级的任务需要执行 - - 过了一会儿,H 请求锁,但锁已被 L 持有,因此 H 被阻塞,忙等 - - 再过了一会儿,M 处于就绪状态,但不需要锁 -2. M 抢占 CPU: - - 出于时间片轮转算法,当 L 的时间片用完或被其他原因中断时,调度器会选择下一个最高优先级的就绪任务执行 - - 此时 H 因等待锁被阻塞(即使优先级最高,但出于等待锁的状态下,H 的状态变为 `Blocked`,会被移出就绪队列。调度器不再将 H 视为候选任务),M 的优先级高于 L,因此 M 抢占 CPU 并开始执行 -3. L 无法释放锁: - - M 的执行导致 L 无法继续运行,因此 L 无法完成工作并释放锁 - - H 继续被阻塞。所以产生高优先级的 H 一直在等待,中等优先级的 M 被执行的优先级反转现象 - -当高优先级任务正等待信号量(此信号量被一个低优先级任务拥有着)的时候,一个介于两个任务优先之间的中等优先级任务开始执行——这就会导致一个高优先级任务在等待一个低优先级任务,而低优先级任务却无法执行类似死锁]的情形发生 - -为了解决优先级反转问题,可以采取以下策略: - -1. 优先级天花板策略(Priority Ceiling):当任务使用共享资源时,将其优先级提高到访问该资源的所有任务的最高优先级或某个确定的优先级(即“优先级天花板”)。这样可以确保持有资源的任务不会被其他低优先级的任务抢占,从而避免了优先级反转。 -2. 优先级继承策略(Priority Inheritance):当一个任务被阻塞并等待一个低优先级任务释放资源时,将低优先级任务的优先级提升到等待它的最高优先级任务的优先级。这样可以确保低优先级任务能够尽快释放资源,从而使高优先级任务能够继续执行。 - -上面的代码改进下 - -```objectivec -- (void)saveMoney { - if (OSSpinLockLock(&_bankLock)) { - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney += 50; - self.money = previousMoney; - NSLog(@"存50,还剩%zd元 - %@", self.money, [NSThread currentThread]); - OSSpinLockUnlock(&_bankLock); - } -} -- (void)withdrawMoney { - if (OSSpinLockLock(&_bankLock)) { - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney -= 10; - self.money = previousMoney; - NSLog(@"取20,还剩%zd元 - %@", self.money, [NSThread currentThread]); - OSSpinLockUnlock(&_bankLock); - } -} -``` - -#### 汇编剖析实现原理 - -自旋锁是指在等锁的时候通过类似 while 循环的代码,让线程忙碌等到锁的到来。 - -自旋锁是一种特殊的锁机制,当线程试图获取锁但失败时,它会在一个循环中持续尝试(即“自旋”),而不是立即阻塞。这可以在某些情况下提高性能,尤其是当锁被持有的时间很短时。 - - - -为了调试方便,开启10个线程去执行 `saveMoney` 方法,为了查看自旋锁的等是什么实现。我们给里面休眠600s。同时 Xcode - Debug - DebugWorkflow - Always Show Disassembly - -lldb 模式下调试汇编有几个指令 - -c: 代表 continue, - -si:step instruction,简写为 stepi,si。当你在 Xcode 汇编面板看到某个认识或者可疑符号,断点在这一行的时候,在下方 lldb 面板,属于 si,即可进入内部实现。 - -第一步:当第二次调用 saveMoney 方法,开启汇编调试 - - - -看到可疑方法 `OSSpinLockLock`,给它加断点,看到第10行高亮了。lldb 模式输入 c,敲回车。次数输入 si 即可进入 `OSSpinLockLock`  方法内部调试 - -第二步:继续输入 si,敲回车 - - - -第三步:看到可疑方法 `_OSSpinLockLockSlow`,给它加断点,lldb 输入 C。此时断点到这一行了,继续输入 si。 - - - -第四步:在 `OSSpinLockLockSlow` 方法内部调试,不断输入 si。 - - - -发现不断 si 最终一直会在第6行到第19行之间执行。懂汇编的会发现这其实是一个 while 循环。便可以证明自旋锁 OSSpinLock 在等锁的时候,底层实现是执行 while 循环,忙等,“太浪费性能了”(如果使用锁资源的线程任务很简单,那自旋也是高效的,可以快速获取锁。) - -结论:OSSpinLock 底层就是一个自旋锁,内部不断循环,盲等。 - - - -#### 思考 - -OSSpinLock 效率这么低,那使用场景是什么? - -- 短临界区与多核优化 - - 自旋锁的核心优势在于 **避免线程上下文切换的开销**。在以下场景中,OSSpinLock 的性能可能优于传统互斥锁(如 `pthread_mutex`): - - - 锁持有时间极短(如几纳秒到微秒级):忙等的 CPU 消耗低于线程休眠与唤醒的开销 - - 多核 CPU 环境:当线程在另一个核心上即将释放锁时,忙等线程可以立即获取锁,无需等待调度器介入 - -- 实现简单且无系统调用 - - - 用户态实现:OSSpinLock 完全在用户空间运行,无需陷入内核态,减少了系统调用(syscall)的开销。 - - 低延迟:对于高频、轻量级的锁操作(如计数器自增),自旋锁的响应速度更快 - -虽然它有合适的使用场景,但 Apple 已经标记为废弃了,所以最好别用,否则某个版本出现什么不符合预期的行为,就有苦说不出了。 - - - -### os_unfair_lock - -#### 使用 - -`os_unfair_lock` 用于取代不安全的 `OSSpinLock` ,从iOS10开始才支持。使用的时候需要导入头文件 `#import ` - -从底层调用看,等待 `os_unfair_lock` 锁的线程会处于休眠状态,并非忙等(自旋锁会忙等) - -初始化 `os_unfair_lock moneylock = OS_UNFAIR_LOCK_INIT;` - -加锁 `os_unfair_lock_lock(&_moneylock);` - -解锁 `os_unfair_lock_unlock(&_moneylock);` - -尝试加锁 `os_unfair_lock_trylock(&_moneylock)` - -继续对存取钱 Demo 用 `os_unfair_lock` 实现 - -```objectivec -@interface ViewController () -@property (nonatomic, assign) NSInteger money; -@property (nonatomic, assign) os_unfair_lock moneylock; -@end - -@implementation ViewController -- (void)viewDidLoad{ - [super viewDidLoad]; - self.moneylock = OS_UNFAIR_LOCK_INIT; - self.money = 100; - [self moneyTest]; -} -- (void)moneyTest { - dispatch_queue_t queue = dispatch_queue_create("com.lbp.money.queue", DISPATCH_QUEUE_CONCURRENT); - dispatch_async(queue, ^{ - for (int i = 0; i < 10; i++) { - [self saveMoney]; - } - }); - dispatch_async(queue, ^{ - for (int i = 0; i < 10; i++) { - [self withdrawMoney]; - } - }); - // 100 + 10*50 - 10*10 = 500 -} -int cursorr = 1; -- (void)saveMoney { - NSLog(@"current cursor %d", cursorr); - cursorr++; - os_unfair_lock_lock(&_moneylock); - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney += 50; - self.money = previousMoney; - NSLog(@"存50,还剩%zd元 - %@", self.money, [NSThread currentThread]); - os_unfair_lock_unlock(&_moneylock); -} -- (void)withdrawMoney { - os_unfair_lock_lock(&_moneylock); - NSInteger previousMoney = self.money; - sleep(0.2); - previousMoney -= 10; - self.money = previousMoney; - NSLog(@"取20,还剩%zd元 - %@", self.money, [NSThread currentThread]); - os_unfair_lock_unlock(&_moneylock); -} -@end -``` - -假如对存钱过程,忘记解锁怎么办?产生死锁,如下 - - - -添加 cursor 标记死锁是发生在 `saveMoney` 方法执行的第几次。发现是第二次。因为第一次锁没有任何使用方,所以加锁成功,当第二次加锁的时候发现锁没有释放,所以产生死锁。 - -这时候使用尝试加锁 API `os_unfair_lock_trylock` 即可成功如下 - - - - - -#### 汇编剖析实现原理 - -同样方式看看 ,按照上述调试汇编代码的步骤,我将关键步骤截图如下 - - - - - - - - -结论:可以看到 `os_unfair_lock` 在锁等待的时候,底层调用的是 `sysCall`,当这一步执行后会发现后续代码都不执行了,也就是调用系统底层能力,线程真正休眠了,而不是一个循环忙等的实现,所以性能好。 - -系统对其描述是:`Low-level lock that allows waiters to block efficiently on contention.`,即低级锁,低级锁的特点是等不到锁就休眠。 - -在并发编程中,设计一种**低级别锁**,能够使等待线程在竞争时**高效阻塞**(而非忙等),通常需要从用户态切换到内核态这样的协作机制 - - - -### pthread_mutex - -#### 使用 - -`mutex` 叫做”互斥锁”,等待锁的线程会处于休眠状态。使用时需要引入 `#import ` - -使用: - -```objectivec -// 初始化属性 -pthread_mutexattr_t attr; -pthread_mutexattr_init(&attr); -pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT); -// 初始化锁 -pthread_mutex_init(&_moneyLock, &attr); -// 释放属性内存 -pthread_mutexattr_destroy(&attr); -// 加锁 -pthread_mutex_lock(&_moneyLock); -// 解锁 -pthread_mutex_unlock(&_moneyLock); -// 释放锁内存 -pthread_mutex_destroy(&_moneyLock); -``` - -其中 `pthread_mutexattr_settype(pthread_mutexattr_t *, int);` 第二个参数有4个枚举值 - -```objectivec -/* - * Mutex type attributes - */ -#define PTHREAD_MUTEX_NORMAL 0 -#define PTHREAD_MUTEX_ERRORCHECK 1 -#define PTHREAD_MUTEX_RECURSIVE 2 -#define PTHREAD_MUTEX_DEFAULT PTHREAD_MUTEX_NORMAL -``` - -如果类型选 `PTHREAD_MUTEX_DEFAULT` 或者 `PTHREAD_MUTEX_NORMAL` 则可以省略 `pthread_mutexattr_t` 的创建,直接传 NULL,即 `pthread_mutex_init(&_moneyLock, NULL)` - -使用如下 - - - -#### 化身递归锁 - -如果在某个方法内部递归调用自身怎么实现,好像挺简单的,直接内部调用即可。 - -```objective-c -- (void)otherTest { - pthread_mutex_lock(&_lock); - static int count = 0; - count++; - NSLog(@"%s", __func__); - if (count<10) { - [self otherTest]; - } - pthread_mutex_unlock(&_lock); -} - -- (void)sayHi { - NSLog(@"Hello"); -} -// console --[PThreadMutexRecursiveLockTester otherTest] -``` - -只打印了 1。为什么?因为第一次调用正常加锁,然后递归调用自身,第二次调用的时候尝试加锁,但是这时候第一次调用时候所占用的锁还没释放,会发生死锁。 - -我们的实际编程中,存在递归函数的情况。上面学完的锁,都不能满足该情况。执行函数 test,然后加锁,然后继续调用 test,要加锁,发现锁被占用了,则会死锁。所以引进了递归锁。 - -递归锁的工作流程:先加锁,然后递归调用,再继续加锁,再调用再加锁,最后一次函数执行完则解锁,出栈后继续解锁,再解锁。类似于 NodeJS 的洋葱模型,效果等价于 - -```shell -+ 代表加锁;- 代表解锁 -线程1: otherTest in: + - otherTest in: + - otherTest in: + ----------------------------------- - otherTest out:- - otherTest out:- - otherTest out:- -``` - -巧妙的是:互斥锁 pthread_mutex_lock 提供实现该功能的 API。只需要在互斥锁初始化地方将属性修改为 `PTHREAD_MUTEX_RECURSIVE`。即 `pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);` 就可以实现递归锁的效果了。 - -即:在**同一个线程中可以多次获取同一把锁。并且不会死锁**。 - -改进后的效果如下 - - - - - -QA:互斥递归锁,可以在不同线程中加锁吗? - -不可以,线程1加锁后,线程2尝试加锁的时候,发现锁已经被其他线程所使用,线程2则等待。 - - - -#### 汇编剖析实现原理 - - - -输入 si 继续跟进,可以看到还是在执行我们自己的代码,LockExplore image 的 `pthread_mutex_lock` 方法 - - - -继续输入 si 跟进 - - - -可以看到此时调用到系统 `libsystem_pthread.dylib` 库的 `pthread_mutex_lock` 方法了。 - -第41行看到关键函数,继续输入 si 进去看看 - - - -可以看到内部第62行关键函数调用了 `_pthread_mutex_firstfit_lock_wait` 方法。此时继续输入 si 跟踪看看 - - - -可以看到内部第25行调用了关键函数 `__psynch_mutexwait`,继续输入 si 看看 - - - -可以看到内部继续调用了系统 `libsystem_pthread.dylib` 库的 `__psynch_mutexwait` 方法。继续输入 si - - - -可以看到内部第4行发生了系统调用 `sysCall`,执行完第四句指令,线程立马就结束了。 - -结论:汇编逐句研究了 `pthread_mutex_t` 会发现最后也是调用 `syscall` 做到线程休眠,不像自旋锁一样,OSSpinLock 在底层实现是 while 循环一样忙等,浪费资源。 - - - -#### 使用注意事项 -##### 加解锁必须成对 -加解锁必须成对出现,否则容易出现多线程性能问题。提供一种思路,利用 `@try-finally` 来保证加解锁必须成对存在,这个写法也是 Weex 官方的实现。 -Weex 中的 WXThreadSafeMutableDictionary 提供了一个线程安全的字典,其本质是通过加 pthread_muext_t 锁来维护内部的一个字典的。 -比如下面的代码 - -初始化锁相关的配置 -```Objective-C -@interface WXThreadSafeMutableDictionary () -{ - NSMutableDictionary* _dict; - pthread_mutex_t _safeThreadDictionaryMutex; - pthread_mutexattr_t _safeThreadDictionaryMutexAttr; -} - -@end - -@implementation WXThreadSafeMutableDictionary - -- (instancetype)initCommon -{ - self = [super init]; - if (self) { - pthread_mutexattr_init(&(_safeThreadDictionaryMutexAttr)); - pthread_mutexattr_settype(&(_safeThreadDictionaryMutexAttr), PTHREAD_MUTEX_RECURSIVE); // must use recursive lock - pthread_mutex_init(&(_safeThreadDictionaryMutex), &(_safeThreadDictionaryMutexAttr)); - } - return self; -} - -- (instancetype)init -{ - self = [self initCommon]; - if (self) { - _dict = [NSMutableDictionary dictionary]; - } - return self; -} -``` -在字典操作的地方使用锁 -```Objective-C -- (void)setObject:(id)anObject forKey:(id)aKey -{ - id originalObject = nil; // make sure that object is not released in lock - @try { - pthread_mutex_lock(&_safeThreadDictionaryMutex); - originalObject = [_dict objectForKey:aKey]; - [_dict setObject:anObject forKey:aKey]; - } - @finally { - pthread_mutex_unlock(&_safeThreadDictionaryMutex); - } - originalObject = nil; -} -``` -这么写的价值:**解锁逻辑「绝对执行」,彻底避免死锁** -这是 `@try-finally` 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常),finally 块的解锁逻辑一定会执行 - -对比无 try-finally 的写法 -```Objective-C -// Bad: 若setObject抛异常,unlock不会执行→死锁 -pthread_mutex_lock(&_mutex); -[_dict setObject:anObject forKey:aKey]; -pthread_mutex_unlock(&_mutex); -``` -问题:`[_dict setObject:anObject forKey:aKey]` 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException),若没有 finally,锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。 - -设计优点: -- `@try-finallly`:即使 try 内逻辑出错,finally 也会执行 pthread_mutex_unlock,保证锁最终释放,这是**线程安全的「兜底保障」** -- 注意,不是 `try...catch...finally`: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 `try...catch...finally` 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了(这个问题不再赘述,是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题) - -##### `pthread_mutex_t` 的加解锁函数返回值处理 -「加解锁函数都有返回值,需要对返回值进行判断和处理」这个是意识也业务场景问题,先告诉你有返回值,看你的场景需要严格处理还是松散处理。类似 JS 的 `use strict` -iOS 系统开源组件(如 libdispatch/GCD)、Apple 官方开源代码,以及知名第三方开源库(AFNetworking、SDWebImage 等)都会严格检查 pthread 锁相关函数的返回值—— 因为系统 / 核心库需要保证鲁棒性,避免锁操作失败导致的死锁、崩溃或线程安全漏洞 - -1. GCD 的处理(libdispatch) -核心文件:dispatch/src/queue.c(队列的线程安全实现) -```c -// libdispatch 中 pthread_mutex_lock 返回值检查的典型写法 -static inline void _dispatch_mutex_lock(pthread_mutex_t *m) { - int ret = pthread_mutex_lock(m); - // 严格检查返回值,仅允许「成功(0)」或「递归锁重复加锁(EDEADLK)」(针对递归锁场景) - if (ret != 0 && ret != EDEADLK) { - // 系统级库会触发 crash 并打印错误,避免静默失败 - dispatch_fatal("pthread_mutex_lock failed: %d", ret); - } -} - -static inline void _dispatch_mutex_unlock(pthread_mutex_t *m) { - int ret = pthread_mutex_unlock(m); - if (ret != 0) { - dispatch_fatal("pthread_mutex_unlock failed: %d", ret); - } -} -``` -说明: -- 加锁 - - 对 pthread_mutex_lock,除了「成功(0)」,仅允许递归锁的「重复加锁(EDEADLK)」(递归锁场景下 EDEADLK 是预期行为); - - 非预期返回值直接触发 dispatch_fatal(系统级崩溃),避免锁异常导致的隐性问题; -- 解锁操作(pthread_mutex_unlock)仅接受「成功(0)」,失败则崩溃 - -思考:为什么 GCD 要这么做? -系统库是「基础设施」,锁操作失败(如 EINVAL/ENOMEM)意味着系统资源耗尽或参数错误,属于「致命错误」—— 与其静默运行导致更严重的线程安全问题,不如直接崩溃并暴露问题。 - -2. AFNetworking(网络库,线程安全的缓存 / 队列实现) -AFNetworking 的 AFURLSessionManager.m 中,对锁操作的返回值检查: -```Objective-C -// AFNetworking 中锁操作的返回值检查 -- (void)lock { - int lockResult = pthread_mutex_lock(&_lock); - NSAssert(lockResult == 0, @"Failed to lock mutex with error: %d", lockResult); -} - -- (void)unlock { - int unlockResult = pthread_mutex_unlock(&_lock); - NSAssert(unlockResult == 0, @"Failed to unlock mutex with error: %d", unlockResult); -} -``` -说明: -- 用 NSAssert 检查返回值(Debug 模式下断言失败会崩溃,Release 模式下跳过); -- 兼顾「调试阶段暴露问题」和「Release 阶段不影响运行」; -- 符合第三方库的「友好调试 + 线上稳定性」平衡原则。 - - -3. CocoaLumberjack(日志库,线程安全的日志队列) -CocoaLumberjack 的 DDLog.m 中,锁返回值检查的严谨写法: -```Objective-C -- (void)initLock { - pthread_mutexattr_t attr; - int ret = pthread_mutexattr_init(&attr); - if (ret != 0) { - DDLogError(@"pthread_mutexattr_init failed: %d", ret); - return; - } - - ret = pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); - if (ret != 0) { - DDLogError(@"pthread_mutexattr_settype failed: %d", ret); - pthread_mutexattr_destroy(&attr); - return; - } - - ret = pthread_mutex_init(&_lock, &attr); - if (ret != 0) { - DDLogError(@"pthread_mutex_init failed: %d", ret); - pthread_mutexattr_destroy(&attr); - return; - } - - pthread_mutexattr_destroy(&attr); -} -``` -说明: -- 「初始化→设置属性→创建锁」全链路检查返回值 -- 失败时「回滚操作」(如销毁已初始化的 attr),避免资源泄漏 -- 打错误日志,便于问题排查 - -总结: -核心原则:锁操作的返回值检查不是 “可选的”,而是 “必须的”,区别仅在于「失败后是崩溃、打日志还是抛异常」,需根据组件的核心程度选择。 -- 系统级开源库(如 libdispatch):严格检查返回值,非预期失败直接崩溃(保证系统稳定性); -- 第三方开源库(AFNetworking/SDWebImage):调试阶段断言 + Release 阶段日志(平衡调试和线上稳定性); -- 业务组件(如你的字典):推荐「断言 + 日志」模式 ——Debug 暴露问题,Release 不崩溃且留痕; - -#### 互斥锁的条件变量 pthread_cond_t - -多线程环境下,很多时候没办法确保先有数据再消费,比如生产者-消费者问题,这时候就有互斥锁的另一个 API 了,即条件变量`pthread_cond_t` - -#### 使用 - -初始化互斥锁条件 `pthread_cond_init(&_condition, NULL);` - -等待条件进入休眠,放开 mutex 锁,被唤醒后会再次对 mutex 加锁 `pthread_cond_wait(&_condition, &_moneyLock);` - -激活一个等待该条件的线程 `pthread_cond_signal(&_condition)` - -激活所有等待该条件的线程 `pthread_cond_broadcast(&_condition)` - - - -可以看到同时调用 remove、add 方法 - -- 执行 remove 方法先加锁,但是由于数组为空,这时候就不需要执行删除元素,然后执行 add 方法 -- add 方法要加锁,发现锁被 remove 方法占用了 -- remove 方法为了等有元素再去执行 remove 引入了互斥锁条件 `pthread_cond_t`,调用 `pthread_cond_wait` 。此时线程进入休眠,同时会释放锁。 -- add 方法内加完元素会调用 `pthread_cond_signal` 来激活等待该条件的线程,此时 remove 方法内的线程获得锁,此时再次加锁 -- remove 方法执行完线程任务后,再解锁。 - - - -### NSLock、NSRecursiveLock - -#### 使用 - -NSLock 是对 mutex 普通锁(pthread_mutex_t)的封装 - -NSRecursiveLock 是对 mutex 递归锁(pthread_mutex_t ,且 attr 为 `PTHREAD_MUTEX_RECURSIVE`)的封装,API 跟 NSLock 基本一致 - -区别在于一个是 c 语言版本的 API,一个是 OC 版本的包装。 - -查看 GUN 源码可以看看到底是如何实现的 - -```objectivec -+ (void) initialize{ - static BOOL beenHere = NO; - if (beenHere == NO){ - beenHere = YES; - /* Initialise attributes for the different types of mutex. - * We do it once, since attributes can be shared between multiple - * mutexes. - * If we had a pthread_mutexattr_t instance for each mutex, we would - * either have to store it as an ivar of our NSLock (or similar), or - * we would potentially leak instances as we couldn't destroy them - * when destroying the NSLock. I don't know if any implementation - * of pthreads actually allocates memory when you call the - * pthread_mutexattr_init function, but they are allowed to do so - * (and deallocate the memory in pthread_mutexattr_destroy). - */ - pthread_mutexattr_init(&attr_normal); - pthread_mutexattr_settype(&attr_normal, PTHREAD_MUTEX_NORMAL); - pthread_mutexattr_init(&attr_reporting); - pthread_mutexattr_settype(&attr_reporting, PTHREAD_MUTEX_ERRORCHECK); - pthread_mutexattr_init(&attr_recursive); - pthread_mutexattr_settype(&attr_recursive, PTHREAD_MUTEX_RECURSIVE); - - /* To emulate OSX behavior, we need to be able both to detect deadlocks - * (so we can log them), and also hang the thread when one occurs. - * the simple way to do that is to set up a locked mutex we can - * force a deadlock on. - */ - pthread_mutex_init(&deadlock, &attr_normal); - pthread_mutex_lock(&deadlock); - - baseConditionClass = [NSCondition class]; - baseConditionLockClass = [NSConditionLock class]; - baseLockClass = [NSLock class]; - baseRecursiveLockClass = [NSRecursiveLock class]; - - tracedConditionClass = [GSTracedCondition class]; - tracedConditionLockClass = [GSTracedConditionLock class]; - tracedLockClass = [GSTracedLock class]; - tracedRecursiveLockClass = [GSTracedRecursiveLock class]; - - untracedConditionClass = [GSUntracedCondition class]; - untracedConditionLockClass = [GSUntracedConditionLock class]; - untracedLockClass = [GSUntracedLock class]; - untracedRecursiveLockClass = [GSUntracedRecursiveLock class]; - } -} -``` - -可以看到 NSLock 底层就是 pthread_mutex_t。 - -再看看 NSRecursiveLock - -```objectivec -@implementation NSRecursiveLock -- (id) init{ - if (nil != (self = [super init])) { - if (0 != pthread_mutex_init(&_mutex, &attr_recursive)){ - DESTROY(self); - } - } - return self; -} -``` - -底层就是 pthread_mutex_init。参数 `attr_recursive` 其实就是一个递归锁的属性。 - -```objectivec -pthread_mutexattr_init(&attr_recursive); -pthread_mutexattr_settype(&attr_recursive, PTHREAD_MUTEX_RECURSIVE); -``` - -NSRecursiveLock 不能在多线程下递归调用。@synchronized 可以在多线程下递归调用。底层原因是 TLS 有关。 - - - -Demo - - - - - -NSLock 死锁 - - - -会发生死锁,后续代码无法执行,App 表现就是 ANR。重复对 NSLock 进行加锁可能导致死锁问题,同时也可能引发数据竞争和性能下降等并发相关隐患 - -针对上述的例子,可以用递归锁解决。可以重复加锁。 - - - -### NSCondition - -NSCondition 是对 `pthread_mutex_t` 和 `pthread_cond_t 的封装。 - -API - -```objective-c -@interface NSCondition : NSObject - -- (void)wait NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (BOOL)waitUntilDate:(NSDate *)limit NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (void)signal NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (void)broadcast; - -@property (nullable, copy) NSString *name API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)); - -@end -``` - -源码 - -```objectivec -- (id) init { - if (nil != (self = [super init])) { - if (0 != pthread_cond_init(&_condition, NULL)){ - DESTROY(self); - } else if (0 != pthread_mutex_init(&_mutex, &attr_reporting)) { - pthread_cond_destroy(&_condition); - DESTROY(self); - } - } - return self; -} -``` - -因为 NSCondtion 已经封装好锁和条件,所以直接使即可。pthread_mutex_t 需要搭配 pthread_cond_t 一起使用。 - -Demo: - - - - - -观察本次打印顺序,可以看到: - -- 程序先执行 `_remove` 方法,先加锁,然后遇到 if 条件满足了,则执行 `wait` 。wait 干的事情是先解锁,然后等待另一个地方发送 `signal` -- 然后 `_add` 方法得到了锁,加锁。开始执行 addObject 方法。然后立马调用 `signal` 方法 -- 可能看上去很快,感觉同一时刻在 `_remove` 方法中又得到了锁资源,然后删除了元素,最后释放了锁资源 - -疑问:调用 signal 方法后,另一个等待锁的地方会立马得到锁资源吗?可以做个实验,给 signal 后 sleep 2秒,再调用 unlock - - - -观察打印信息可以看到: - -- 程序先执行 `_remove` 方法,先加锁,然后遇到 if 条件满足了,则执行 `wait` 。wait 干的事情是先解锁,然后等待另一个地方发送 `signal` -- 然后 `_add` 方法得到了锁,加锁。开始执行 addObject 方法。然后立马调用 `signal` 方法 -- 但此时 `_remove` 方法内的逻辑还没执行,在2s后才执行。说明2s后等 `_add` 方法调用 unlock 方法后,`_remove` wait 才得到锁资源 - -结论:如果逻辑很简单,**NSCondition unlock 和 signal 的顺序没有要求。但要意识到只发送 signal,没有 unlock 的话,wait 是不能立马得到锁的,需要等 unlock 后才可以执行后续逻辑。具体顺序看业务场景** - - - - - - - - - -存在 `虚假唤醒` 的问题。则可以将后续的 if 判断换为 while。比如某一时刻发送了一次 signal,然后可能有多个线程收到唤醒的信号,则可能还是会存在问题。所以 if 换为 while。 - - - -### NSCondtionLock - -`NSConditionLock` 是对 NSCondition 的进一步封装,可以设置具体的条件值(感兴趣的可以查看 GUN 源码)。 - -API 如下: - -```objective-c -@interface NSConditionLock : NSObject - -- (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER; - -@property (readonly) NSInteger condition; -- (void)lockWhenCondition:(NSInteger)condition NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (BOOL)tryLock NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (BOOL)tryLockWhenCondition:(NSInteger)condition NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (void)unlockWithCondition:(NSInteger)condition NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (BOOL)lockBeforeDate:(NSDate *)limit NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit NS_SWIFT_UNAVAILABLE_FROM_ASYNC("Use async-safe scoped locking instead"); - -@property (nullable, copy) NSString *name API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)); - -@end -``` - -Demo - - - -分析:虽然通过3个线程,设置了线程的先后顺序,但是多线程任务执行的时候到底谁先执行,是没办法控制的。但是通过 `NSConditionLock lockWhenCondition:*` 的能力,可以控制线程的执行顺序。 - - - -另外如果初始化设置了 `[[NSConditionLock alloc] initWithCondition:1]` 但是使用的地方没有用 `lockWhenCondition` 而是直接用 `lock` 则会忽略 condition 的值,直接加锁成功。 - - - -### dispatch_queue(DISPATCH_QUEUE_SERIAL) - -使用 GCD 的串行队列,也是可以实现线程同步。 - -线程同步的本质就是多线程的任务是顺序执行 - - - - - -### dispatch_semaphore - -#### 使用 - -semaphore 叫做”信号量” - -信号量的初始值,可以用来控制线程并发访问的最大数量 - -信号量的初始值为1,代表同时只允许1条线程访问资源,保证线程同步 - - - -可以看到打印了20个线程,但是我们控制线程最大数量怎么办呢?可以用信号量实现。效果如下: - - - -#### dispatch_semaphore_wait 原理 - -执行 `dispatch_semaphore_wait` 方法时, - -- 如果信号量的值 > 0,则会让信号量的值 -1,然后继续向下执行代码 - -- 如果信号量的值 <= 0,则线程休眠等待(等待多久取决于第二个参数),直到信号量的值 > 0(直到其他的线程,任务执行完毕,利用 `dispatch_semaphore_signal`API 让信号量的值+1),此时继续会让信号量的值 -1,然后继续向下执行代码 - -`dispatch_semaphore_signal` 函数的作用:让信号量的值 + 1 - -所以如何让线程同步?设置信号量的值=1即可。保证同一时间只有一个线程任务在执行。代码如下 - - - - - -有趣的实验: - -```objectivec -self.semaphore = dispatch_semaphore_create(1); -dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER); -``` - -上面的代码会 crash。因为创建出来信号量为1,但是经过 dispatch_semaphore_wait 之后信号量变为0,底层会调用到 `_dispatch_semaphore_dispose`。内部会做判断,就是原始的信号量 - -```objectivec -void _dispatch_semaphore_dispose(dispatch_object_t dou, - DISPATCH_UNUSED bool *allow_free){ - dispatch_semaphore_t dsema = dou._dsema; - if (dsema->dsema_value < dsema->dsema_orig) { - DISPATCH_CLIENT_CRASH(dsema->dsema_orig - dsema->dsema_value, - "Semaphore object deallocated while in use"); - } - _dispatch_sema4_dispose(&dsema->dsema_sema, _DSEMA4_POLICY_FIFO); -} -``` - -#### 使用封装 - -有的时候我们需要在方法内部创建 semaphore ,则可以创建宏 - -```objectivec -#define SemaphoreBegin \ -static dispatch_semaphore_t semaphore; \ -static dispatch_once_t onceToken; \ -dispatch_once(&onceToken, ^{ \ - semaphore = dispatch_semaphore_create(1); \ -}); \ -dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); - -#define SemaphoreEnd \ -dispatch_semaphore_signal(semaphore); -``` - -使用 - -```objective-c -- (void)withdrawMoney { - SemaphoreBegin - [super withdrawMoney]; - SemaphoreEnd -} -``` - - - -### @synchronized - -`@synchronized` 可递归重入的原理分析/线程缓存空间 - -`@synchronized` 使用很方便,它是对 `pthread_mutex_t` 递归锁的封装。Demo 如下 - - - - - -#### 源码剖析 - -为了探究下实现,开启汇编调试 - - - - - -通过汇编可以看到 `@synchronized` 底层调用了 `objc_sync_enter` 方法,其中又调用了 `id2data` 和 `os_unfair_recursive_lock_lock_with_options` 方法。 可以查看 objc4 的源码(笔者的 objc 版本为 objc4-objc4-912.3),查找 `objc_sync_enter` - -```c++ -// Begin synchronizing on 'obj'. -// Allocates recursive mutex associated with 'obj' if needed. -// Returns OBJC_SYNC_SUCCESS once lock is acquired. -int objc_sync_enter(id obj) -{ - int result = _objc_sync_enter_kind(obj, SyncKind::atSynchronize); - if (result != OBJC_SYNC_SUCCESS) - OBJC_DEBUG_OPTION_REPORT_ERROR(DebugSyncErrors, - "objc_sync_enter(%p) returned error %d", obj, result); - return result; -} - -int _objc_sync_enter_kind(id obj, SyncKind kind) -{ - int result = OBJC_SYNC_SUCCESS; - - if (obj) { - SyncData* data = id2data(obj, kind, ACQUIRE); - ASSERT(data); - data->mutex.lock(); - } else { - // @synchronized(nil) does nothing - if (DebugNilSync) { - _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug"); - } - objc_sync_nil(); - if (DebugNilSync == Fatal) - _objc_fatal("@synchronized(nil) is fatal"); - } - - return result; -} - -typedef struct alignas(CacheLineSize) SyncData { - struct SyncData* nextData; - DisguisedPtr object; - SyncKind kind; - int32_t threadCount; // number of THREADS using this block - recursive_mutex_t mutex; - - bool matches(id matchObject, SyncKind matchKind) { - ASSERT(matchKind != SyncKind::invalid); - ASSERT(kind != SyncKind::invalid); - return object == matchObject && kind == matchKind; - } -} SyncData; - - -using recursive_mutex_t = objc_recursive_lock_t; -``` - -可以看到 `@synchronized` 的本质是一个包装了 `objc_recursive_lock_t`(不同版本的 OBJC ,其内部实现会不同) 的 `recursive_mutex_tt` C++ 类。 - -可以发现,如果 `@synchronized` 参数为`nil`,`@synchronized(nil) `调用 `objc_sync_nil()`,最终什么也不执行。 - -```objective-c -static SyncData* id2data(id object, SyncKind kind, enum usage why) -{ - ASSERT(kind != SyncKind::invalid); - spinlock_t *lockp = &LOCK_FOR_OBJ(object); - SyncData **listp = &LIST_FOR_OBJ(object); - SyncData* result = NULL; - -#if ENABLE_FAST_CACHE - // Check per-thread single-entry fast cache for matching object - bool fastCacheOccupied = NO; - SyncData *data = syncData; - if (data) { - fastCacheOccupied = YES; - - if (data->matches(object, kind)) { - // Found a match in fast cache. - result = data; - if (result->threadCount <= 0 || syncLockCount <= 0) { - _objc_fatal("id2data fastcache is buggy"); - } - - switch(why) { - case ACQUIRE: { - ++syncLockCount; - break; - } - case RELEASE: - if (--syncLockCount == 0) { - // remove from fast cache - syncData = nullptr; - // atomic because may collide with concurrent ACQUIRE - AtomicDecrement(&result->threadCount); - } - break; - case CHECK: - // do nothing - break; - } - - return result; - } - } -#endif // ENABLE_FAST_CACHE - - // Check per-thread cache of already-owned locks for matching object - SyncCache *cache = fetch_cache(NO); - if (cache) { - unsigned int i; - for (i = 0; i < cache->used; i++) { - SyncCacheItem *item = &cache->list[i]; - if (!item->data->matches(object, kind)) continue; - - // Found a match. - result = item->data; - if (result->threadCount <= 0 || item->lockCount <= 0) { - _objc_fatal("id2data cache is buggy"); - } - - switch(why) { - case ACQUIRE: - item->lockCount++; - break; - case RELEASE: - item->lockCount--; - if (item->lockCount == 0) { - // remove from per-thread cache - cache->list[i] = cache->list[--cache->used]; - // atomic because may collide with concurrent ACQUIRE - AtomicDecrement(&result->threadCount); - } - break; - case CHECK: - // do nothing - break; - } - - return result; - } - } - - // Thread cache didn't find anything. - // Walk in-use list looking for matching object - // Spinlock prevents multiple threads from creating multiple - // locks for the same new object. - // We could keep the nodes in some hash table if we find that there are - // more than 20 or so distinct locks active, but we don't do that now. - - lockp->lock(); - - { - SyncData* p; - SyncData* firstUnused = NULL; - for (p = *listp; p != NULL; p = p->nextData) { - if ( p->matches(object, kind) ) { - result = p; - // atomic because may collide with concurrent RELEASE - AtomicIncrement(&result->threadCount); - goto done; - } - if ( (firstUnused == NULL) && (p->threadCount == 0) ) - firstUnused = p; - } - - // no SyncData currently associated with object - if ( (why == RELEASE) || (why == CHECK) ) - goto done; - - // an unused one was found, use it - if ( firstUnused != NULL ) { - result = firstUnused; - result->object = (objc_object *)object; - result->kind = kind; - result->threadCount = 1; - goto done; - } - } - - // Allocate a new SyncData and add to list. - // XXX allocating memory with a global lock held is bad practice, - // might be worth releasing the lock, allocating, and searching again. - // But since we never free these guys we won't be stuck in allocation very often. - posix_memalign((void **)&result, alignof(SyncData), sizeof(SyncData)); - result->object = (objc_object *)object; - result->kind = kind; - result->threadCount = 1; - new (&result->mutex) recursive_mutex_t(fork_unsafe); - result->nextData = *listp; - *listp = result; - - done: - lockp->unlock(); - if (result) { - // Only new ACQUIRE should get here. - // All RELEASE and CHECK and recursive ACQUIRE are - // handled by the per-thread caches above. - if (why == RELEASE) { - // Probably some thread is incorrectly exiting - // while the object is held by another thread. - return nil; - } - if (why != ACQUIRE) _objc_fatal("id2data is buggy"); - if (!result->matches(object, kind)) _objc_fatal("id2data is buggy"); - -#if ENABLE_FAST_CACHE - if (!fastCacheOccupied) { - // Save in fast thread cache - syncData = result; - syncLockCount = 1; - } else -#endif // ENABLE_FAST_CACHE - { - // Save in thread cache - if (!cache) cache = fetch_cache(YES); - cache->list[cache->used].data = result; - cache->list[cache->used].lockCount = 1; - cache->used++; - } - } - - return result; -} -``` - -传递一个参数 obj,经过 `id2data` 方法得到一个结构体对象,访问结构体对象的成员变量 `mutex`,然后调用 `lock` 方法。 - -可以看到是一个哈希表 `StripedMap`,哈希表工作原理就是传递一个 key,经过哈希算法生成索引,然后获取对应的值。 - -内部维护了一个哈希表,一个对象对应一个锁(所以为了锁的使用正确,加解锁,需要用同一个对象) - -另外 `recursive_mutex_tt` 在初始化的时候传入 `OS_UNFAIR_RECURSIVE_LOCK_INIT`,看起来也支持递归。所以 `@synchronized` 的本质是一个**递归互斥锁**的封装。 - - - - - - - - - - - -### 各种锁性能对比 - -性能从高到低: - -````shell -os_unfair_lock > OSSpinLock > dispatch_semaphore > pthread_mutex > dispatch_queue(DISPATCH_QUEUE_SERIAL) > NSLock > NSCondition > pthread_mutex(recursive) > NSRecursiveLock > @synchronized -```` - - - -### 自旋锁、互斥锁对比 - -什么情况适合使用自旋锁? - -- 预计线程等待锁的时间很短(假设线程1的任务本来就很短,如果使用其他的锁,比如还需要互斥锁的话,底层实现会调用 sysCall,一个休眠一个唤醒,这个时间可能比如循环忙等更耗时。所以如果一个线程任务执行时间很短,则考虑使用自旋锁会更高效一些。) - -- 加锁的代码(临界区)经常被调用,但竞争情况很少发生 - -- CPU资源不紧张 - -- 多核处理器 - -什么情况使用互斥锁比较划算? - -- 预计线程等待锁的时间较长 - -- 单核处理器(一旦使用自旋锁,CPU 就很忙了,很少有资源去处理其他逻辑,会卡顿) - -- 临界区有IO操作(IO 操作一般占用 CPU 资源较多,互斥锁本身就占用 CPU,所以不适合) - -- 临界区代码复杂或者循环量大 - -- 临界区竞争非常激烈 - - - -### atomic - -#### 源码探究 - -`atomic` 用于保证属性 setter、getter 的原子性操作,相当于在 getter 和 setter 内部加了线程同步的锁。 - -与之相对的是 `nonatomic`,也就是非原子性的。假设多线程下,针对一个属性的 setter、getter,需要自己加锁来保证读写问题。 - -使用 `atomic` 则属性类似下面的伪代码 - -```objective-c -@property (atomic, strong) NSString *name; - -- (NSString *)name { - // 加锁 - // logic - // 解锁 - return _name; -} - -- (void)setName:(NSString *)name { - // 加锁 - // logic - _name = name; - // 解锁 -} -``` - -**用 atomic 修饰就是线程安全的吗?不是的** - -比如 atomic 修饰了数组,那么对数组指针的读取和赋值(数组的地址修改)是线程安全的,但是数组的操作不是线程安全的,比如增加元素、删除元素、读取元素。 - - -具体实现,可以参考源码 objc4 的 `objc-accessors.mm` - -属性取值逻辑: - -```c++ -id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) { - if (offset == 0) { - return object_getClass(self); - } - - // Retain release world - id *slot = (id*) ((char*)self + offset); - if (!atomic) return *slot; - - // Atomic retain release world - spinlock_t& slotlock = PropertyLocks.get()[slot]; - slotlock.lock(); - id value = objc_retain(*slot); - slotlock.unlock(); - - // for performance, we (safely) issue the autorelease OUTSIDE of the spinlock. - return objc_autoreleaseReturnValue(value); -} -``` - -可以看到在获取属性值的时候,判断是不是 atomic - -- 不是 atomic 则直接 return - -- 如果是 atomic,则调用自旋锁 `slotlock` 加锁,取值,解锁,return - -属性赋值逻辑: - -```c - -void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy) -{ - bool copy = (shouldCopy && shouldCopy != MUTABLE_COPY); - bool mutableCopy = (shouldCopy == MUTABLE_COPY); - reallySetProperty(self, _cmd, newValue, offset, atomic, copy, mutableCopy); -} - -static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) -{ - if (offset == 0) { - object_setClass(self, newValue); - return; - } - - id oldValue; - id *slot = (id*) ((char*)self + offset); - - if (copy) { - newValue = [newValue copyWithZone:nil]; - } else if (mutableCopy) { - newValue = [newValue mutableCopyWithZone:nil]; - } else { - if (*slot == newValue) return; - newValue = objc_retain(newValue); - } - - if (!atomic) { - // nonatomic,直接赋值 - oldValue = *slot; - *slot = newValue; - } else { - // atomic,加自旋锁 - spinlock_t& slotlock = PropertyLocks.get()[slot]; - slotlock.lock(); - oldValue = *slot; - *slot = newValue; - slotlock.unlock(); - } - - objc_release(oldValue); -} - -void lock() { -    lockdebug_mutex_lock(this); - // - uint32_t opts = OS_UNFAIR_LOCK_DATA_SYNCHRONIZATION | OS_UNFAIR_LOCK_ADAPTIVE_SPIN; - os_unfair_lock_lock_with_options_inline(&mLock, (os_unfair_lock_options_t)opts); -} -``` - -可以看到设置属性的时候会判断是不是 atomic - -- atomic 类型,则直接赋值 -- 非 atomic 类型,则先自旋锁加锁、赋值、解锁 - - - -它并不能保证使用属性的过程是线程安全的。 - -QA:为什么在 iOS 上几乎没有使用? - -因为属性 getter、setter 使用太高频了,另外 atomic 内部实现是自旋锁,自旋锁是忙等,针对移动设备上那寸土寸金的 CPU,太奢侈了,太耗费性能了。 - -#### atomic 并不能保证使用属性的过程是线程安全的 - -```objectivec -@property (atomic,copy) NSString *name; -dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - @synchronized(self){ - for (int i = 0; i<100; i++) { - self.name = @"杭城小刘"; - NSLog(@"线程1 : %@",self.name); - } - } - -}); -dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - @synchronized(self){ - for (int i = 0; i<100; i++) { - self.name = @"魅影"; - NSLog(@"线程2 : %@",self.name); - } - } -}); -``` - -预期:线程 A 打印出来一定是杭城小刘,线程 B 打印出来是魅影。但事实上可能存在乱序。 - -**`atomic` 仅保证单次读/写的原子性** - -```objective-c -// 即使属性是 atomic,以下代码仍然线程不安全 -if (self.atomicValue > 10) { // 步骤1:读取 - self.atomicValue = 0; // 步骤2:写入 -} -// 线程 A 可能在步骤1后,线程 B 修改了 atomicValue,导致逻辑错误 -``` - -**无法保护对象内部状态** - -**指针与内容的区别**:`atomic` 仅保证指针本身的原子性(如 `NSArray *` 的赋值),但对象内部的状态(如数组的元素)不受保护。即使属性声明为 `atomic`,对可变集合的操作仍可能崩溃。 - -```objective-c -// 线程A -NSMutableArray *array = self.atomicArray; // 原子性读取指针 -[array addObject:@"A"]; // 非原子操作,可能与其他线程冲突 - -// 线程B -NSMutableArray *array = self.atomicArray; // 原子性读取指针 -[array removeAllObjects]; // 导致线程A的 addObject: 崩溃 -``` - - - -总结:atomic 是原子属性,它内部实现是针对属性的 setter、getter 进行加锁(早期实现是自旋旋,因为存在问题,后续替换为了 os_unfair_lock)。但是事实上在进行多线程编程的时候,我们针对数据的操作并不是修改指针本身(思考 NSString 的 getter、setter),而是操作类似 NSMutableArray、NSDictionary 这样的 case。比如 `@property (atomic, strong) NSMutableArray *hobbies;` 如果在多线程情况下进行处理,一边生产者添加数据,一边消费者消费数据,则会产生多线程问题。 - -所以多线程并发编程来说,线程安全是一个系统性问题,无法仅靠声明 `atomic` 解决。推荐使用锁是一个合理的方案。此外自旋锁不推荐使用,互斥锁中 pthread_mutex 等性能高一些的锁推荐使用。 - - - -### 多线程读写锁 - -#### 读写的特点 - -- 同一时间,只能有1个线程进行写的操作(只能有1个写) -- 同一时间,允许有多个线程进行读的操作(可以同时读) -- 同一时间,不允许既有写的操作,又有读的操作(读写不能同时进行) - -**允许多个线程同时读,但仅允许一个线程写,读写分开的场景,提高读多写少场景的性能**。 - -“多读单写”问题,经常用于文件、数据的,**频繁读取但写入较少**的共享资源(如缓存数据) - -iOS 主流方案有: - -- pthread_rwlock:读写锁 - -- dispatch_barrier_async:异步栅栏调用 - - - -### pthread_rwlock - -初始化 : - -```objectivec -pthread_rwlock_t lock -pthread_rwlock_init(&_lock, NULL) -``` - -读操作-加锁: `pthread_rwlock_rdlock(&_lock)` - -读操作-尝试加锁: `pthread_rwlock_tryrdlock(&_lock);` - -写操作-加锁: `pthread_rwlock_wrlock(&_lock);` - -写操作-尝试加锁: `pthread_rwlock_trywrlock(&_lock);` - -解锁: `pthread_rwlock_unlock(&_lock);` - -销毁: `pthread_rwlock_destroy(&_lock);` - -Demo - - - - - -### dispatch_barrier_async - -多读单写 - -```objectivec -// 初始化队列 -self.queue = dispatch_queue_create("rwqueue", DISPATCH_QUEUE_CONCURRENT); -// 读 -dispatch_async(self.queue, ^{ - -}); -// 写 -dispatch_barrier_async(self.queue, ^{ - -}); -``` - -注意: - -- **`dispatch_barrier_async` 函数传入的并发队列必须是自己通过 `dispatch_queue_cretate` 创建的** -- **如果传入的是一个串行队列或全局并发队列,那 `dispatch_barrier_async` 函数便等同于 `dispatch_async` 函数的效果** - -上 Demo - - - - - - - -#### 栅栏函数拦不住全局队列 - -Demo - -```objective-c -- (void)testBarrierWithGlobalQueue { - NSLog(@"%s", __func__); - dispatch_queue_t queue = dispatch_get_global_queue(0, 0); - for (int i = 0; i < 100; i++) { - dispatch_async(queue, ^() { - NSLog(@"%d", i); - }); - } - dispatch_barrier_async(queue, ^() { - NSLog(@"100"); - }); - dispatch_async(queue, ^() { - NSLog(@"101"); - }); - NSLog(@"%s", __func__); -} -``` - - - - - -```objective-c -- (void)testBarrierWithCustomQueue { - NSLog(@"%s", __func__); - dispatch_queue_t queue = dispatch_queue_create(0, 0); - for (int i = 0; i < 100; i++) { - dispatch_async(queue, ^() { - NSLog(@"%d", i); - }); - } - dispatch_barrier_async(queue, ^() { - NSLog(@"100"); - }); - dispatch_async(queue, ^() { - NSLog(@"101"); - }); - NSLog(@"%s", __func__); -} -``` - - - -结论:可以发现 GCD `dispatch_barrier_async` 栅栏函数,拦不住全局队列,却可以拦住自己创建的普通队列。这是为什么? - -全局队列的业务方不只是当前 App 进程,还有一些系统任务(全局并发队列中不仅有开发者的任务,还有系统的任务),如果我们用我们的任务去栏住系统的任务,可能会导致一些未知的错误。栅栏函数对全局并发队列无效,所以我们在开发的时候一定要注意 - - - -#### 为什么 dispatch_barrier_async 拦不住全局并发队列 - -##### 官方文档证明 - -[Apple 官方文档](https://developer.apple.com/documentation/dispatch/dispatch_barrier_async?language=objc) `dispatch_barrier_async` 条目中也明确指出: - -> The queue you specify should be a concurrent queue that you create yourself using the [`dispatch_queue_create`](https://developer.apple.com/documentation/dispatch/dispatch_queue_create?language=objc) function. If the queue you pass to this function is a serial queue or one of the global concurrent queues, this function behaves like the [`dispatch_async`](https://developer.apple.com/documentation/dispatch/dispatch_async?language=objc) function. - -栅栏块仅对通过 `DISPATCH_QUEUE_CONCURRENT` 创建的并发队列有效。在串行队列或全局并发队列中,其行为与普通异步提交的任务相同 - - - -##### 系统设计角度 - -- **全局并发队列的共享性与系统任务** - - - 全局并发队列是系统提供的共享并发队列,被整个进程(甚至系统)的多个模块共同使用。这些队列不仅执行当前应用提交的任务,还可能运行系统级别的后台任务(如日志、框架内部操作等) - - 如果开发者用栅栏函数拦截全局队列,可能会**阻塞系统关键任务**,导致不可预见的后果(如死锁、性能下降或功能异常)。因此,苹果从设计上禁用了栅栏对全局队列的支持,避免干扰系统行为 - -- **自定义队列的私有性** - - 自定义并发队列由开发者显式创建,完全由应用控制。所有提交到该队列的任务都是开发者显式添加的,没有外部任务干扰 - - - -##### GCD 源码角度 - -`libdispatch` repo 中和 `dispatch_barrier_async` 有关的2个函数为:`_dispatch_lane_wakeup`、`_dispatch_lane_barrier_complete` - -- `_dispatch_lane_wakeup`:处理自定义并发队列的任务唤醒逻辑。当检测到 `DISPATCH_WAKEUP_BARRIER_COMPLETE` 标志时,会调用 `_dispatch_lane_barrier_complete`,确保栅栏前的任务全部执行完毕后再执行栅栏任务 -- `_dispatch_lane_barrier_complete`: 具体处理栅栏同步逻辑,括等待队列中现有任务完成、执行栅栏任务、释放后续任务 - -查看源码:`queue.c`(队列核心逻辑)、`queue_internal.h`(队列内部结构定义)、`source.c`(任务调度逻辑) - -下面的代码进行了简化 - -队列结构 - -```c++ -// 队列结构 -struct dispatch_queue_s { - const struct dispatch_queue_vtable_s *do_vtable; // 虚表指针(定义队列操作函数) - uint32_t dq_atomic_flags; // 队列状态标记(如是否并发、是否被栅栏阻塞) - // ... 其他字段(如任务链表、线程池引用等) -}; - -// 全局队列和自定义队列的虚表不同 -static const struct dispatch_queue_vtable_s _dispatch_queue_global_vtable = { /* 全局队列操作函数 */ }; -static const struct dispatch_queue_vtable_s _dispatch_queue_concurrent_vtable = { /* 自定义并发队列操作函数 */ }; -``` - -调用 `dispatch_barrier_async` 任务提交: - -- 将 `block` 封装为 `dispatch_continuation_t` 对象,并标记 `DC_FLAG_BARRIER` - - ```c++ - dispatch_barrier_async_f(dispatch_queue_t dq, void *ctxt, - dispatch_function_t func) { - dispatch_continuation_t dc = _dispatch_continuation_alloc_cacheonly(); - uintptr_t dc_flags = DC_FLAG_CONSUME | DC_FLAG_BARRIER; // 标记为栅栏函数 - dispatch_qos_t qos; - - if (likely(!dc)) { - return _dispatch_async_f_slow(dq, ctxt, func, 0, dc_flags); - } - - qos = _dispatch_continuation_init_f(dc, dq, ctxt, func, 0, dc_flags); - _dispatch_continuation_async(dq, dc, qos, dc_flags); - } - ``` - -- 将任务加入到队列。通过队列的 `dq_push` 方法将任务加入到队列任务链表里 - - ```c++ - static inline void - _dispatch_continuation_async(dispatch_queue_class_t dqu, - dispatch_continuation_t dc, dispatch_qos_t qos, uintptr_t dc_flags) - { - #if DISPATCH_INTROSPECTION - if (!(dc_flags & DC_FLAG_NO_INTROSPECTION)) { - _dispatch_trace_item_push(dqu, dc); - } - #else - (void)dc_flags; - #endif - return dx_push(dqu._dq, dc, qos); // 调用队列的入队函数 - } - - ``` - -- **自定义并发队列** 的 `dx_push` 指向 `_dispatch_lane_push`,将任务插入队列的任务链表尾部 - -- **全局队列** 的 `dx_push` 指向 `_dispatch_root_queue_push`,直接忽略 `DC_FLAG_BARRIER` 标记 - -队列唤醒与任务调度 - -当队列需要执行任务时,GCD 会调用队列的 `dx_wakeup` 方法。不同队列的唤醒逻辑存在差异: - -- 自定义并发队列的唤醒逻辑。自定义队列的 `dx_wakeup` 指向 `_dispatch_lane_wakeup`,其关键逻辑如下 - - ```c++ - // 自定义队列唤醒函数(queue.c) - static void _dispatch_lane_wakeup(dispatch_lane_class_t dq, ...) { - // 检查队列状态和栅栏标记 - if (dq->dq_atomic_flags & DC_FLAG_BARRIER) { - // 进入栅栏同步逻辑 - _dispatch_lane_barrier_complete(dq); - } else { - // 普通任务调度 - _dispatch_lane_drain(dq); - } - } - - static void _dispatch_lane_barrier_complete(dispatch_lane_class_t dq, ...) { - // 1. 等待前置任务完成 - while (存在未完成的非栅栏任务) { - _dispatch_lane_drain_non_barriers(dq); // 执行所有非栅栏任务 - } - - // 2. 执行栅栏任务 - dispatch_continuation_t barrier_dc = 从队列中取出栅栏任务; - _dispatch_client_callout(barrier_dc->dc_func); // 执行栅栏块 - - // 3. 释放后续任务 - _dispatch_lane_class_barrier_complete(dq); // 清除栅栏标记,唤醒后续任务 - _dispatch_lane_drain(dq); // 继续执行后续任务 - } - ``` - -- 全队队列的唤醒逻辑 - - 全局队列的 `dx_wakeup` 指向 `_dispatch_root_queue_wakeup`,其逻辑完全忽略栅栏标记 - - ```c++ - DISPATCH_OPTIONS(dispatch_wakeup_flags, uint32_t, - // The caller of dx_wakeup owns two internal refcounts on the object being - // woken up. Two are needed for WLH wakeups where two threads need - // the object to remain valid in a non-coordinated way - // - the thread doing the poke for the duration of the poke - // - drainers for the duration of their drain - DISPATCH_WAKEUP_CONSUME_2 = 0x00000001, - - // Some change to the object needs to be published to drainers. - // If the drainer isn't the same thread, some scheme such as the dispatch - // queue DIRTY bit must be used and a release barrier likely has to be - // involved before dx_wakeup returns - DISPATCH_WAKEUP_MAKE_DIRTY = 0x00000002, - - // This wakeup is made by a sync owner that still holds the drain lock - DISPATCH_WAKEUP_BARRIER_COMPLETE = 0x00000004, - - // This wakeup is caused by a dispatch_block_wait() - DISPATCH_WAKEUP_BLOCK_WAIT = 0x00000008, - - // This wakeup may cause the source to leave its DSF_NEEDS_EVENT state - DISPATCH_WAKEUP_EVENT = 0x00000010, - - // This wakeup is allowed to clear the ACTIVATING state of the object - DISPATCH_WAKEUP_CLEAR_ACTIVATING = 0x00000020, - ); - - - void _dispatch_root_queue_wakeup(dispatch_queue_global_t dq, - DISPATCH_UNUSED dispatch_qos_t qos, dispatch_wakeup_flags_t flags) - { - if (!(flags & DISPATCH_WAKEUP_BLOCK_WAIT)) { - DISPATCH_INTERNAL_CRASH(dq->dq_priority, - "Don't try to wake up or override a root queue"); - } - if (flags & DISPATCH_WAKEUP_CONSUME_2) { // 只处理 DISPATCH_WAKEUP_CONSUME_2 类型,忽略 DISPATCH_WAKEUP_BARRIER_COMPLETE - return _dispatch_release_2_tailcall(dq); - } - } - ``` - -可以看到:`_dispatch_root_queue_wakeup`:全局队列的唤醒函数。此函数未处理 `DC_FLAG_BARRIER` 标记,直接忽略栅栏逻辑,按默认并发方式执行任务。因此,全局队列无法支持栅栏功能 - -**总结**:`dispatch_barrier_async` 的设计初衷是为**开发者控制的私有并发队列**提供同步机制,避免全局队列的共享性引入风险。因此,务必仅将**栅栏用于自定义的并发队列**。 - - - -### dispatch_group_async - -如何实现 A、B、C 三个任务并发执行完,再去执行任务 D ?假设需求是根据省市区下载 json,然后根据 json 数据,选中地址 picker view。 - -```objective-c -dispatch_group_t group = dispatch_group_create(); -dispatch_queue_t queue = dispatch_queue_create("com.unix.concurrentQueue", DISPATCH_QUEUE_CONCURRENT); -for (NSURL *url in addressArray) { - dispatch_group_async(group, queue, ^{ - // 根据 url 请求 json 数据 - }); -} - -// 会等到上面加入到 group 的3个并发任务全部执行完,再执行下面的 block 任务 -dispatch_group_notify(group, dispatch_get_main_queue(), ^{ - // 根据下载好的省市区 json 去更新 picker view 。 -}); -``` - - - -## 六、NSOperation - -需要和 NSOperationQueue 配合使用。优点: - -- 可以添加任务依赖 - -- 任务执行状态控制 - - - isReady - - isExecuting - - isFinished - - isCancelled - - 如果只重写了 main 方法,底层控制变更任务执行完成状态,以及任务退出。 - - 如果重写了 start 方法,自行控制任务状态 - -- 最大并发量 - - - -系统是怎么样移除一个 isFinished = YES 的 NSOperation 的? - -看看 GNU 源码。 - -```c++ -- (void) start -{ - ENTER_POOL - // 获取线程优先级 - double prio = [NSThread threadPriority]; - - AUTORELEASE(RETAIN(self)); // Make sure we exist while running. - [internal->lock lock]; - NS_DURING - { - // 做一些状态判断 - if (YES == [self isExecuting]) - { - [NSException raise: NSInvalidArgumentException - format: @"[%@-%@] called on executing operation", - NSStringFromClass([self class]), NSStringFromSelector(_cmd)]; - } - if (YES == [self isFinished]) - { - [NSException raise: NSInvalidArgumentException - format: @"[%@-%@] called on finished operation", - NSStringFromClass([self class]), NSStringFromSelector(_cmd)]; - } - if (NO == [self isReady]) - { - [NSException raise: NSInvalidArgumentException - format: @"[%@-%@] called on operation which is not ready", - NSStringFromClass([self class]), NSStringFromSelector(_cmd)]; - } - if (NO == internal->executing) - { - // 如果调用 start 方法,通过 KVO 将 isExecuting 更改为 YES - [self willChangeValueForKey: @"isExecuting"]; - internal->executing = YES; - [self didChangeValueForKey: @"isExecuting"]; - } - } - NS_HANDLER - { - [internal->lock unlock]; - [localException raise]; - } - NS_ENDHANDLER - [internal->lock unlock]; - - NS_DURING - { - if (NO == [self isCancelled]) - { - [NSThread setThreadPriority: internal->threadPriority]; - // 内部调用 main 方法 - [self main]; - } - } - NS_HANDLER - { - [NSThread setThreadPriority: prio]; - [localException raise]; - } - NS_ENDHANDLER; - // 调用完 start,内部调用 finish 方法 - [self _finish]; - LEAVE_POOL -} -``` - -可以看到,内部会调用 `_finish` 方法 - -```c++ -- (void) _finish -{ - [internal->lock lock]; - if (NO == internal->finished) - { - if (YES == internal->executing) - { - [self willChangeValueForKey: @"isExecuting"]; - [self willChangeValueForKey: @"isFinished"]; - internal->executing = NO; - internal->finished = YES; - [self didChangeValueForKey: @"isFinished"]; - [self didChangeValueForKey: @"isExecuting"]; - } - else - { - [self willChangeValueForKey: @"isFinished"]; - internal->finished = YES; - [self didChangeValueForKey: @"isFinished"]; - } - if (NULL != internal->completionBlock) - { - CALL_BLOCK_NO_ARGS( - ((GSOperationCompletionBlock)internal->completionBlock)); - } - } - [internal->lock unlock]; -} -``` - -可以看到在 `_finish` 方法中,系统通过 KVO 移除 NSOperationQueue 中 NSOperation 的。 - - - -## 七、NSThread - -NSThread 的一个工作流程如下: - -`start() -> 创建 pthread -> main() -> [target performSelector:selector] -> exit` - -NSThread 需要保活。为什么会死掉?看看 gnu 源码 - -```c++ -- (void) start -{ - pthread_attr_t attr; - - if (_active == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on active thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - if (_cancelled == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on cancelled thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - if (_finished == YES) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on finished thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - - /* Make sure the notification is posted BEFORE the new thread starts. - */ - gnustep_base_thread_callback(); - - /* The thread must persist until it finishes executing. - */ - RETAIN(self); - - /* Mark the thread as active while it's running. - */ - _active = YES; - - errno = 0; - pthread_attr_init(&attr); - /* Create this thread detached, because we never use the return state from - * threads. - */ - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - /* Set the stack size when the thread is created. Unlike the old setrlimit - * code, this actually works. - */ - if (_stackSize > 0) - { - pthread_attr_setstacksize(&attr, _stackSize); - } - // 设置回调函数 - if (pthread_create(&pthreadID, &attr, nsthreadLauncher, self)) - { - DESTROY(self); - [NSException raise: NSInternalInconsistencyException - format: @"Unable to detach thread (last error %@)", - [NSError _last]]; - } -} -``` - -看看 pthread 创建后的回调函数 - -```c++ -static void * -nsthreadLauncher(void *thread) -{ - NSThread *t = (NSThread*)thread; - - setThreadForCurrentThread(t); - - /* - * Let observers know a new thread is starting. - */ - if (nc == nil) - { - nc = RETAIN([NSNotificationCenter defaultCenter]); - } - // 发送通知 - [nc postNotificationName: NSThreadDidStartNotification - object: t - userInfo: nil]; - // 设置线程名 - [t _setName: [t name]]; - // 调用 main 方法 - [t main]; - // 线程退出 - [NSThread exit]; - // Not reached - return NULL; -} -``` - -看了源码,会发现 NSThread 调用 start 内部就会调用 `[NSThread exit]` 所以会退出。要想常驻,就需要在 main 方法做 runloop 保活。 - -```c++ -- (void) main -{ - if (_active == NO) - { - [NSException raise: NSInternalInconsistencyException - format: @"[%@-%@] called on inactive thread", - NSStringFromClass([self class]), - NSStringFromSelector(_cmd)]; - } - - [_target performSelector: _selector withObject: _arg]; -} -``` - -main 方法内其实就是在执行 _selector。也就是在 NSThread 的初始化方法中,传入的 selector 中进行 runloop 保活逻辑。 - - - -## 八、其他常见的多线程编程模式 - -### Promise - -Promise 在多线程解决方案中比较常见,比如在前端中 Promise 就是一个标准解决方案。同样的,iOS 界也有三方开发者写的 PromiseKit。也有对应的 AFNetworking Promise 版本。 - -Promise 解决了什么问题? - -- 在需要多个操作的时候,我们可能会设置多个回调参数嵌套,导致代码很长,也就是传说中的“回调地狱”(Callback Hell) - -- 丧失了 return 特性 - -Promise 就是一个对象,用来传递异步操作的消息。代表了某个未来才会知道结果的事件(也就是异步操作),并且这个事件提供统一的 API,可以供进一步处理 - -对象的状态不受外界影响。Promise 对象代表一个异步操作,有三种状态:Pending(进行中,又称 Incomplete)、Resolved(已完成,又称 Fulfilled)和 Rejected (已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是 Promise 这个名字的由来,它的英语意思就是「承诺」,表示其他手段无法改变。 - -一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从 Pending 变为 Resolved 和从 Pending 变为 Rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。就算改变已经发生了,你再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。 - -```objectivec -APIClient.fetchData(...).then().onFailure(); -``` - -### Pipeline - -将一个任务分解为若干个阶段(Stage),前阶段的输出为下阶段的输入,各个阶段由不同的工 - -作者线程负责执行。 - -各个任务的各个阶段是并行(Parallel)处理的。 - -具体任务的处理是串行的,即完成一个任务要依次执行各个阶段,但从整体任务上看,不同任务的各个阶段的执行是并行的。 - -### Master-Slave - -将一个任务分解为若干个语义等同的子任务,并由专门的工作者线程来并行执行这些子任务,既 提高计算效率,又实现了信息隐藏。 - -比如 Jekins - -### Serial Thread Confinement - -如果并发任务的执行涉及某个非线程安全对象,而很多时候我们又不希望因此而引入锁。 - -通过将多个并发的任务存入队列实现任务的串行化,并为这些串行化任务创建唯一的工作者线程进行处理。 - -比如 FMDB 的设计,内部就是一个串行队列。 - - - - - -## 总结 - -- 怎么样实现多读单写?GCD dispatch_barrier_async -- iOS 提供了几种多线程技术 - - GCD:简单的任务处理,以及多读单写、读写锁、dispatch_group_async - - NSOperationQueue、NSOperation:AFNetworking、SDWebImage ,可以方便对 Operation 的状态管理和依赖管理 - - NSThread,主要用于实现常驻线程 -- NSOperation Finished 之后如何移除?KVO -- 锁操作的返回值检查不是 “可选的”,而是 “必须的”,区别仅在于「失败后是崩溃、打日志还是抛异常」,需根据组件的核心程度选择。 - - 系统级开源库(如 libdispatch):严格检查返回值,非预期失败直接崩溃(保证系统稳定性); - - 第三方开源库(AFNetworking/SDWebImage):调试阶段断言 + Release 阶段日志(平衡调试和线上稳定性); - - 业务组件(如你的字典):推荐「断言 + 日志」模式 ——Debug 暴露问题,Release 不崩溃且留痕; \ No newline at end of file diff --git a/Chapter1 - iOS/1.4.md b/Chapter1 - iOS/1.4.md deleted file mode 100644 index 328a276..0000000 --- a/Chapter1 - iOS/1.4.md +++ /dev/null @@ -1,17 +0,0 @@ -# 如何优雅地调试手机网页 -> 在web开发的过程中,抓包、调试页面样式、查看请求头是很常用的技巧。其实在iOS开发中,这些技巧也能用(无论是模拟器还是真机),不过我们需要用到mac自带的浏览器Safari。所以,本文将讲解如何使用Safari对iOS程序中的webview进行调试。 - -* 1、打开真机(模拟器)的开发者模式 -【设置】-> 【Safari】 -> 【高级】 -> 【Web检查器】打开 -![打开手机的调试模式](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-e937adb9c77a3768.png) - -* 2、打开MBP上的Safari的开发者模式: -【Safari】->【偏好设置】->【高级】-> 【在菜单栏中显示“开发”菜单】勾选。 - -* 3、调试你的WebView页面。 - -* 4、在MBP的Safari选项中的开发,看到手机,右击可以看到正在调试的WebView的url -![选择需要调试等页面](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-c12eb2da00e79f34.png) - -* 5、在弹出的这个框里面可以查看网页源代码以及可以调试样样式、查看localStorage、sessionStorage、Cookie的值等等,给原生端调试带来很大方便,不过这样前端调试更加方便啊,谷歌的模拟器不能完全模真实环境下的iphone使用效果啊。 -![调试手机页面](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2287777-4d55fd205fa81cc8.png) diff --git a/Chapter1 - iOS/1.40.md b/Chapter1 - iOS/1.40.md deleted file mode 100644 index 54b33d9..0000000 --- a/Chapter1 - iOS/1.40.md +++ /dev/null @@ -1,5186 +0,0 @@ -# iOS 内存原理探究 - -> 端上内存寸土寸金,对于内存知识你掌握了吗?掌握内存分配、释放的细节有助于我们写出内存使用有好的代码?同时为做 APM 内存监控打下坚实基础。接下来通过下面几个问题,研究下 iOS 侧内存分配、释放的相关知识: -> -> - 虚拟内存是什么、为什么需要分页? -> - NSTimer 存在什么问题、CADisplayLink 存在什么问题? -> - weak 指针的实现原理是什么? -> - ARC 帮我们做了什么处理? -> - 方法里有局部对象,出了方法会立马结束吗? -> - autorelease 修饰的对象,其内存在什么时机释放? -> - 类中的实例变量在哪释放? -> - 当对象 dealloc 方法中没有显示调用 `[super dealloc]` ,父类的析构如何触发? - - - -## 虚拟内存是什么、为什么需要分页 - -在早期的计算机中,程序是直接运行在物理内存上的,也就是说:程序在运行时访问的地址就是物理地址。这样也就是单运行的时候没有什么问题!可是,计算机会有多到程序、分时系统和多任务,当我们能够同时运行多个程序时,`CPU`的利用率将会比较高。那么有一个非常严重的问题:**如何将计算机的有限的物理内存分配给多个程序使用** - - - -假设我们计算有`128MB`内存,程序A需要`10MB`,程序B需要`100MB`,程序C需要`20MB`。如果我们需要同时运行程序A和B,那么比较直接的做法是将内存的`前10MB`分配给程序A,`10MB~110MB`分配给B。 - - - -但存在以下问题: - -- 当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小。 -- 安全性低。进程地址空间不隔离,由于程序是直接访问物理内存的,所以每一个进程都可以修改其他进程的内存数据,设置修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏 -- 内存使用效率低。内存空间不足,就需要将其他程序展示拷贝到硬盘当中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率会非常低 -- 程序运行的地址不确定。因为内存地址是随机分配的,所以程序运行的地址也是不正确的 - -计算机世界中的问题,大多可以用增加中间层的方式解决。即使用一种间接的地址访问方式。 - -把程序给出的地址看做是一种**虚拟地址**,然后通过某种映射,将这个虚拟地址转化到实际的物理地址。这样,只需要控制好映射过程,就能保证程序所能访问的物理内存区域跟别的程序不重叠,达到空间隔离的效果。 - -### 隔离 - -普通的程序它只需要一个简单的执行环境,一个单一的地址空间,有自己的CPU。 -地址空间比较抽象,如果把它想象成一个数组,每一个数组是一字节,数组大小就是地址空间的长度,那么32位的地址空间大小就是`2^32=4294967296`字节,即`4G`,地址空间有效位是 `0x00000000~0xFFFFFFFF`。 -地址空间分为两种: - -- 物理空间:就是物理内存。32 位的机器,地址线就有 32条,物理空间 4G,但如果只装有 512M 的内存,那么实际有效的空间地址就是 `0x00000000~0x1FFFFFFF`,其他部分都是无效的。 -- 虚拟空间:每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的空间地址,这样就有效的做到了进程隔离。 - - - -### 分段 - -**基本思路:** 把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。虚拟空间的每个字节对应物理空间的每个字节。这个映射过程由软件来完成。 - -比如A需要`10M`,就假设有`0x00000000` 到`0x00A00000`大小的虚拟空间,然后从物理内存分配一个相同大小的空间,比如是`0x00100000`到`0x00B00000`。操作系统来设置这个映射函数,实际的地址转换由硬件完成。如果越界,硬件就会判断这是一个非法访问,拒绝这个地址请求,并上报操作系统或监控程序。 - - - -这样一来利用**分段**的方式可以解决之前的**地址空间不隔离**和**程序运行地址不确定** - -首先做到了地址隔离,因为A和B被映射到了两块不同的物理空间,它们之间没有任何重叠,如果A访问虚拟空间的地址超过了`0x00A00000`这个范围,硬件就会判断这是一个非法的访问,并将这个请求报告给操作系统或者监控程序,由它决定如何处理。 - -再者,对于每个程序来说,无论它们被分配到地址空间的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变化,它们只要按照从地址`0x00000000`到`0x00A00000`来编写程序、放置变量,所以程序不需要重定位。 - -第二问题内存使用效率问题依旧没有解决。 - - - -但是分段的方法没有解决内存使用效率的问题。**分段对于内存区域的映射还是按照程序为单位,如果内存不足,被换入换出的磁盘的都是整个程序,这样势必会造成大量的磁盘访问操作,从而严重影响速度,这种方法还是显得粗糙,粒度比较大。**事实上根据程序的局部性原理,当一个程序正在运行时,在某个时间段内,它只是频繁用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内是不会被用到的。人们很自然地想到了更小粒度的内存分割和映射方法,使得程序的局部性原理得到充分利用,大大提高了内存的使用率。这种方法就是**分页。** - - - -### 分页 - -**分页的基本方法是把地址空间人为得等分成固定大小的页,每一个页的大小由硬件决定,或硬件支持多种页的大小,由操作系统选择决定页的大小。** 目前几乎所有PC的操作系统都是用`4KB`大小的页。我们使用的PC机是32位虚拟地址空间,也就是`4GB`,按`4KB`分页,总共有`1048576`个页。 - -那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时候再把它们从磁盘里取出即可。图中的线表示映射关系,我们可以看到虚拟空间有些页被映射到同一个**物理页**,这样就可以实现内存共享。 -**虚拟页,物理页,磁盘页**根据内存空间不一样而区分 - -我们可以看到Process 1 的VP2和VP3不在内存中,但是当进程需要用到这两个页的时候,硬件就会捕获到这个消息,就是所谓的**页错误(Page Fault)**,然后操作系统接管进程,负责将VP2和VP3从磁盘读取出来装入内存,然都将内存中的这两个页和VP2和VP3建立映射关系。以页为单位存取和交换数据非常方便,硬件本身就支持这种以页为单位的操作方式。 - - - - - -保护页也是页映射的目的之一,简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问,而且只有操作系统有权修改这些属性,那么操作系统就可以做到保护自己和保护进程。 - -虚拟存储的实现需要硬件支持,几乎所有CPU都采用称为**MMU的部件来进行页的映射** - - - -在页映射模式下,`CPU`发出的是`Virtual Address`,即我们程序看到的是`虚拟地址`。经过`MMU`转换以后就变成了`Physical Address`。一般`MMU`集成在`CPU`内部,不会以独立的部件存在。 - - - -## 定时器内存泄漏 - -NSTimer、CADisplayLink 的 基础 API `[NSTimer scheduledTimersWithTimeInterval:1 repeat:YES block:nil]` 对其 target 会产生强引用,如果 target 再对其产生强引用,则互相持有,会造成环,产生内存泄漏 - -定时器内存泄漏原因,解决方案以及高精度定时器,具体可以看这篇 [NSTimer 中的内存泄露](./1.45.md) 。 - - - -## iOS 内存布局 - -栈、堆、BSS、数据段、代码段 - - - - - -栈(stack):又称作堆栈,用来存储程序的局部变量(但不包括static声明的变量,static 修饰的数据存放于数据段中)。除此之外,在函数被调用时,栈用来传递参数和返回值。栈内存地址越来越少(函数内第一行变量、对象的地址最大、后续越来越小,最后一行代码的变量、对象越来越小) - -``` -func a { - 变量 1 地址最大 -    变量 2 地址第二大 -    // ... -    变量n  地址最小 -} -``` - -堆(heap):用于存储程序运行中被动态分配的内存段,它的大小并不固定,可动态的扩张和缩减。操作函数(malloc/free)。分配的内存空间地址越来越大。 - -BSS段(bss segment):通常用来存储程序中未被初始化的全局变量和静态变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段输入静态内存分配 - -数据段(data segment):通常用来存储程序中已被初始化的全局变量和静态变量和字符串的一块内存区域。数据段包含3部分: - -- 字符串常量。比如 `NSString *str = @"杭城小刘";` - -- 已初始化数据:已经初始化的全局变量、静态变量等(内存挨在一起的) - -- 未初始化数据:未初始化的全局变量、静态变量等 - -代码段(code segment):编译之后的代码。通常是指用来存储程序可执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。 - -![内存](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ram.png) - -上 Demo 验证 - -```objectivec -int a = 10; -static int b; -int main () { - NSString *name = @"杭城小刘"; - int age = 27; - int height = 177; - NSObject *obj = [[NSObject alloc] init]; - NSLog(@"\na: %p\nb: %p\n name: %p\nage: %p\n height: %p\nobj:%p", &a, &b, &name, &age, &height, obj); -} -a: 0x107b09b80 -b: 0x107b09c48 -name: 0x7ff7b83fdbc0 -age: 0x7ff7b83fdbbc -height: 0x7ff7b83fdbb8 -obj:0x6000012780e0 -``` - -我们按照内存地址由低到高排个序(如下),发现和我们总结的规律一致。 - -```shell -// 字符串常量 -name: 0x7ff7b83fdbc0 -// 已初始化的全局变量、静态变量 -a: 0x107b09b80 -// 未初始化的全局变量、静态变量 -b: 0x107b09c48 -// 堆 -obj: 0x6000012780e0 -// 栈 -height: 0x7ff7b83fdbb8 -age: 0x7ff7b83fdbbc -``` - -``` -NSObject *obj = [[NSObject alloc] init]; -NSLog(@"%p %p %@", obj, &obj, obj); -``` - -分别打印 obj指针指向的堆上的内存地址、obj 指针在栈上的地址、obj 内容 - - - -## Tagged Pointer - -### 为什么有 Tagged Pointer - -现状:一般,存放 NSNumber、NSDate 这类变量的时候,本身占用的内存大小常常不需要8个字节。4字节带符号的整数可以达到2^31= 2147483648,99% 的情况都能满足了。因此为了更高效、更节省空间,用一个看似是指针的计数,来存储数据,且在 Runtime 侧判断了,节省了消息机制那一套冗长的流程,Tagged Pointer cover 一些小数据的场景,cover 不了则申请堆内存。 - - - -创建对象需要动态分配内存、维护引用计数等,对象指针存储的是堆中对象的地址值。 - -创建一个对象的流程:先在堆上申请一块内存,然后再在栈上增加一个指针类型,指针指向堆上这块内存。举个例子,假设用 NSNumber 指针,存储一个数值, `NSNumber *value = [NSNumber numberWithInt:2]` ,分析下,需要耗费多少内存? - -栈:在栈上,value 是一个指针类型,占用8字节 - -堆:在堆上,1个 isa 指针占8字节,1个 int 类型,占4字节,但由于存在内存对齐机制,所以堆上共需要16字节大小。 - -加起来 24 字节,耗费24字节就为了存储一个值为2的 int 数据。 - -效率上:此外还需要维护引用计数,沿用 OC 中指针,isa、类对象、元类对象的结构和消息发送流程,是不是太大材小用了?? - - - - - -### 什么是 Tagged Pointer - -iOS 从 64bit 开始引入了Tagged Pointer 技术,用于优化 NSNumber、NSDate、NSString 等小对象的存储。 - -Tagged Pointer 格式下,指针值不再是有效抵制,而是表示值。对象指针里面存储的数据变成了:`Tag + Data`,将数据直接存储在了指针中。当指针不够存储数据时,才会使用动态分配内存的方式来存储数据。 - -当对 TaggedPointer 数据调用方法的时候,objc_msgSend 能识别出如果是 Tagged Pointer,比如 NSNumber 的 intValue 方法,直接从指针提取数据,节省了调用开销。所以使用了 TaggedPointer 技术不仅节约内存空间,又能提高方法查找速度。 - - - - - -Tagged Pointer 也就是一个伪指针,对象的指针中存储的数据变成了:Tag + Data 的形式。 - -- Tag 为特殊标记,用于区分是否是 Tagged Pointer 指针以及具体是 NSNumber、NSDate、NSString 等对象类型 -- Data:为对象对应存储的值。 - - - -根据官方的说明,使用tagged pointer进行小数据存储的优势非常明显: - -- 可以见上一半的内存占用; -- 可以将访问速度提升3倍以上; -- 提升100倍的创建销毁速度 - -从 objc 源码,objc-runtime-new.mm 中可以看到: - -> /*********************************************************************** -> -> \* Tagged pointer objects. -> -> * -> -> \* Tagged pointer objects store the class and the object value in the -> -> \* object pointer; the "pointer" does not actually point to anything. -> -> * -> -> \* Tagged pointer objects currently use this representation: -> -> \* (LSB) -> -> \* 1 bit set if tagged, clear if ordinary object pointer -> -> \* 3 bits tag index -> -> \* 60 bits payload -> -> \* (MSB) -> -> \* The tag index defines the object's class. -> -> \* The payload format is defined by the object's class. -> -> * -> -> \* If the tag index is 0b111, the tagged pointer object uses an -> -> \* "extended" representation, allowing more classes but with smaller payloads: -> -> \* (LSB) -> -> \* 1 bit set if tagged, clear if ordinary object pointer -> -> \* 3 bits 0b111 -> -> \* 8 bits extended tag index -> -> \* 52 bits payload -> -> \* (MSB) -> -> * -> -> \* Some architectures reverse the MSB and LSB in these representations. -> -> * -> -> \* This representation is subject to change. Representation-agnostic SPI is: -> -> \* objc-internal.h for class implementers. -> -> \* objc-gdb.h for debuggers. -> -> ***\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\**\*******/** - -从 apple 给出的声明中,可以得到: - -- 标签指针对象存储了类信息和对象实际的值,此时的指针不指向任何东西; - -- 使用最低位作为标记位,如果是标签指针对象就标记为1,如果是普通对象类型就标记为0; - -- 紧接着三位是标签索引位; - -- 剩余的60位位有效的负载位,标签索引位定义了标签对象代表的对象的真实类型,负载的格式由实际的类定义; - -- 如果标签位是0b111,表示该对象使用了是被扩展的标签对象,这种扩展的方式可以运训更多的类使用标签对象来表示,同时负载的有效位数变小。这时: - - 最低位是标记位 - - 紧接着三位位0b111 - - 紧接着八位位扩展的标记位 - - 剩余的52位才是真正的有效的负载位 - -- 并不是所有的架构中都使用低位做标记位.在指令集框架中,除了64-bit的Mac操作系统之外,其余全是使用 MSB。比如 iOS 就是使用高位作为标志位。 - - - -Demo - - - -在 64 位的 cpu 下,如果使用 Tagged Pointer 技术的话,则 NSNumber 对象的值直接存储在了指针中,系统不会为其在堆上分配内存,可以节省很多内存开销。此时,NSNumber 对象的指针中存储的数据变成了 Tag + Data 的形式(Tag 为特殊标记,用于区分NSNumber、NSDate、NSString 等小内存对象的类型;Data 为具体的值)。这样使用一个 NSNumber 对象只需要在栈中开辟 8 个字节的指针内存。当栈中 8 个字节的指针内存不够存储数据时,才会再将 NSNumber 对象存储到堆中 - -系统会自动判断是分配 TaggedPointer 还是普通指针(也就是真正在堆上开辟内存) - - - -### 实践探索 Tagged Pointer - -为了保证数据安全,对 Tagged Pointer 类型的指针做了数据混淆,无法通过打印指针的内容来判断一个指针是否为 Tagged Pointer 类型,更无法读取存储在 Tagged Pointer 类型的指针中的数据 - -为了方便我们在分析 Tagged Pointer 的原理时调试程序,需要先解除系统对 Tagged Pointer 的数据混淆,2个办法: - -第一种:Xcode 环境变量。 - -环境变量 `OBJC_DISABLE_TAG_OBFUSCATION` 来控制 Tagged Pointer 数据混淆的禁用和启用。默认情况下,Tagged Pointer 的数据混淆处于启用状态。 - -路径:Xcode - Edit Scheme - Run - Arguments - Environment Variables - 添加环境变量 `OBJC_DISABLE_TAG_OBFUSCATION` 设置为 YES 即可。 - - - - - -第二种:还原 Runtime 对 tagged Pointer 的混淆函数。objc 源码中 objc-runtime-new.mm 文件 - -```c++ -/*********************************************************************** -* initializeTaggedPointerObfuscator -* Initialize objc_debug_taggedpointer_obfuscator with randomness. -* -* The tagged pointer obfuscator is intended to make it more difficult -* for an attacker to construct a particular object as a tagged pointer, -* in the presence of a buffer overflow or other write control over some -* memory. The obfuscator is XORed with the tagged pointers when setting -* or retrieving payload values. They are filled with randomness on first -* use. -**********************************************************************/ -static void -initializeTaggedPointerObfuscator(void) -{ - if (!DisableTaggedPointerObfuscation -#if !TARGET_OS_EXCLAVEKIT - && dyld_program_sdk_at_least(dyld_fall_2018_os_versions) -#endif - ) { - // Pull random data into the variable, then shift away all non-payload bits. - arc4random_buf(&objc_debug_taggedpointer_obfuscator, - sizeof(objc_debug_taggedpointer_obfuscator)); - objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK; - -#if OBJC_SPLIT_TAGGED_POINTERS - // The obfuscator doesn't apply to any of the extended tag mask or the no-obfuscation bit. - objc_debug_taggedpointer_obfuscator &= ~(_OBJC_TAG_EXT_MASK | _OBJC_TAG_NO_OBFUSCATION_MASK); - - // Shuffle the first seven entries of the tag permutator. - int max = 7; - for (int i = max - 1; i >= 0; i--) { - int target = uniformRandom(i + 1); - swap(objc_debug_tag60_permutations[i], - objc_debug_tag60_permutations[target]); - } -#endif - } else { - // Set the obfuscator to zero for apps linked against older SDKs, - // in case they're relying on the tagged pointer representation. - objc_debug_taggedpointer_obfuscator = 0; - } -} -``` - -`initializeTaggedPointerObfuscator` 函数用一个随机数,来初始化 Tagged Pointer 的混淆因子 `objc_debug_taggedpointer_obfuscator`。 - -主要作用是使攻击者在发现(缓冲区溢出漏洞)或者(内存写入控制漏洞)时,更难将特定对象构造成 Tagged Pointer 类型的指针,为的是更安全。 - -同时,也提供了 Tagged Pointer 的编码、解码方法 - -```c++ -// objc-internal.h -static inline void * _Nonnull -_objc_encodeTaggedPointer(uintptr_t ptr) -{ - return _objc_encodeTaggedPointer_withObfuscator(ptr, objc_debug_taggedpointer_obfuscator); -} - -static inline void * _Nonnull -_objc_encodeTaggedPointer_withObfuscator(uintptr_t ptr, uintptr_t obfuscator) -{ - uintptr_t value = (obfuscator ^ ptr); -#if OBJC_SPLIT_TAGGED_POINTERS - if ((value & _OBJC_TAG_NO_OBFUSCATION_MASK) == _OBJC_TAG_NO_OBFUSCATION_MASK) - return (void *)ptr; - uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK; - uintptr_t permutedTag = _objc_basicTagToObfuscatedTag(basicTag); - value &= ~(_OBJC_TAG_INDEX_MASK << _OBJC_TAG_INDEX_SHIFT); - value |= permutedTag << _OBJC_TAG_INDEX_SHIFT; -#endif - return (void *)value; -} - -static inline uintptr_t -_objc_decodeTaggedPointer(const void * _Nullable ptr) -{ - return _objc_decodeTaggedPointer_withObfuscator(ptr, objc_debug_taggedpointer_obfuscator); -} - -static inline uintptr_t -_objc_decodeTaggedPointer_withObfuscator(const void * _Nullable ptr, - uintptr_t obfuscator) -{ - uintptr_t value - = _objc_decodeTaggedPointer_noPermute_withObfuscator(ptr, obfuscator); -#if OBJC_SPLIT_TAGGED_POINTERS - uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK; - - value &= ~(_OBJC_TAG_INDEX_MASK << _OBJC_TAG_INDEX_SHIFT); - value |= _objc_obfuscatedTagToBasicTag(basicTag) << _OBJC_TAG_INDEX_SHIFT; -#endif - return value; -} - -static inline uintptr_t -_objc_decodeTaggedPointer_noPermute_withObfuscator(const void * _Nullable ptr, - uintptr_t obfuscator) -{ - uintptr_t value = (uintptr_t)ptr; -#if OBJC_SPLIT_TAGGED_POINTERS - if ((value & _OBJC_TAG_NO_OBFUSCATION_MASK) == _OBJC_TAG_NO_OBFUSCATION_MASK) - return value; -#endif - return value ^ obfuscator; -} -``` - -虽然无法使用 objc 里的函数,但是 `objc_debug_taggedpointer_obfuscator` 是一个导出的全局变量。 - -```c++ -OBJC_EXPORT uintptr_t objc_debug_taggedpointer_obfuscator - OBJC_AVAILABLE(10.14, 12.0, 12.0, 5.0, 3.0); -``` - -所以可以参考源码,对其进行改造,打造自己的 Tagged Pointer 编码、解码方法 -```objective-c -OBJC_EXPORT uintptr_t objc_debug_taggedpointer_obfuscator; - -static inline void * _Nonnull lbp_objc_encodeTaggedPointer(uintptr_t ptr) { - return lbp_objc_encodeTaggedPointer_withObfuscator(ptr, objc_debug_taggedpointer_obfuscator); -} - -static inline void * _Nonnull lbp_objc_encodeTaggedPointer_withObfuscator(uintptr_t ptr, uintptr_t obfuscator) { - uintptr_t value = (obfuscator ^ ptr); -#if OBJC_SPLIT_TAGGED_POINTERS - if ((value & _OBJC_TAG_NO_OBFUSCATION_MASK) == _OBJC_TAG_NO_OBFUSCATION_MASK) - return (void *)ptr; - uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK; - uintptr_t permutedTag = _objc_basicTagToObfuscatedTag(basicTag); - value &= ~(_OBJC_TAG_INDEX_MASK << _OBJC_TAG_INDEX_SHIFT); - value |= permutedTag << _OBJC_TAG_INDEX_SHIFT; -#endif - return (void *)value; -} - -static inline uintptr_t lbp_objc_decodeTaggedPointer(const void * _Nullable ptr) { - return lbp_objc_decodeTaggedPointer_withObfuscator(ptr, objc_debug_taggedpointer_obfuscator); -} - -static inline uintptr_t lbp_objc_decodeTaggedPointer_withObfuscator(const void * _Nullable ptr, uintptr_t obfuscator) { - uintptr_t value - = _objc_decodeTaggedPointer_noPermute_withObfuscator(ptr, obfuscator); -#if OBJC_SPLIT_TAGGED_POINTERS - uintptr_t basicTag = (value >> _OBJC_TAG_INDEX_SHIFT) & _OBJC_TAG_INDEX_MASK; - - value &= ~(_OBJC_TAG_INDEX_MASK << _OBJC_TAG_INDEX_SHIFT); - value |= _objc_obfuscatedTagToBasicTag(basicTag) << _OBJC_TAG_INDEX_SHIFT; -#endif - return value; -} - -static inline uintptr_t -_objc_decodeTaggedPointer_noPermute_withObfuscator(const void * _Nullable ptr, uintptr_t obfuscator) { - uintptr_t value = (uintptr_t)ptr; -#if OBJC_SPLIT_TAGGED_POINTERS - if ((value & _OBJC_TAG_NO_OBFUSCATION_MASK) == _OBJC_TAG_NO_OBFUSCATION_MASK) - return value; -#endif - return value ^ obfuscator; -} -``` - - - - - -### Tagged Pointer 结构 - -#### Tagged Pointer 与 isa - - - -通过参考 objc 源码,针对对象指针进行解密后发现: - -| 原始指针地址 | decode 后指针地址 | 数值 | isa | -| ------------------ | ------------------ | ----------------- | ------------ | -| 0xa6560a805d53eb09 | 0xb000000000000013 | 1 | 0x0 | -| 0xa6560a805d53eb39 | 0xb000000000000023 | 2 | 0x0 | -| 0xa6560a805d53eb29 | 0xb000000000000033 | 3 | 0x0 | -| 0x600000ca8020 | 0x16566a805d996b3a | 0xFFFFFFFFFFFFFFF | __NSCFNumber | - -num1、num2、num3 对象的值1、2、3分别存储在指针 `0xb000000000000013` 、`0xb000000000000023`、`0xb000000000000033` 的倒数第二位中。而 `0xFFFFFFFFFFFFFFF` 数据太大,无法存储在1个指针长度可以表示的数据范围内,所以申请了堆内存。 - -num1、num2、num3 都是 Tagged Pointer,是伪指针,所以 isa 都是 nil。num4 1个指针长度存储不下数据,所以分配了堆内存,是真正的对象,有 isa。 - - - -#### Tagged Pointer 数据类型 - -0xb000000000000013 种的 b 和 3是什么? - -b 也就是11,二进制为 `1011`,Tagged Pointer 中,iOS 侧第一位是 Tagged Pointer 标识位,1代表是 Tagged Pointer; - -`011` 是类标识位,对应10进制的3,表示 NSNumber 类( 源码中 `OBJC_TAG_NSNumber = 3` )。 - - - -指针中的 3 代表什么? - -3 区分数据类型。具体是什么数据类型,继续做个实验看看 - - - - - -分析: - -- 针对 NSNumber:按照指针顺序依次打印发现,不同的基本数据类型,但都用 NSNumber 类包装,内存地址中,倒数第二位都是字面量的值,指针地址除了最后一位不同之外,都相同。 - - | 数据类型 | 内存地址二进制最后1位 | - | -------- | --------------------- | - | char | 0 | - | short | 1 | - | int | 2 | - | long | 3 | - | float | 4 | - | double | 5 | - - 所以这个3代表是数据类型(NSNumber 中 char、short、int、long、float、double,NSString 为 string 长度) - - Objc 源码中,NSInteger、NSUInteger 都是别名。初始化 NSNumber 的时候用的是 `NSNumber numberWithInteger:<#(NSInteger)#>` - - - - - -- 通过对 str1、str2、str3 的分析可以看出,指针最后1位代表字符串的长度,长度分别为1、2、3。后面分别是字符串的 ASCII 值 - - | 原始指针地址 | decode 后指针地址 | 数值 | ASCII 值 | - | ------------------ | ------------------ | ---- | -------- | - | 0x82afd51af4bd2853 | 0xa000000000000611 | a | 61 | - | 0x82afd51af4bb0850 | 0xa000000000062612 | ab | 61 62 | - | 0x82afd51af28b0851 | 0xa000000006362613 | abc | 61 62 63 | - -结论:对于 NSNumber 来说,最后一位代表包装前原始数据的类型;对于 NSString 来说,最后一位代表字符串的长度。 - - - -#### 类标识 - -Tagged Pointer 如何区分是较小的对象,比如 NSString、NSDate、NSNumber? - -源码 `objc_internal.h` 如下 - -```c++ -{ - // 60-bit payloads - OBJC_TAG_NSAtom = 0, - OBJC_TAG_1 = 1, - OBJC_TAG_NSString = 2, - OBJC_TAG_NSNumber = 3, - OBJC_TAG_NSIndexPath = 4, - OBJC_TAG_NSManagedObjectID = 5, - OBJC_TAG_NSDate = 6, - - // 60-bit reserved - OBJC_TAG_RESERVED_7 = 7, - - // 52-bit payloads - OBJC_TAG_Photos_1 = 8, - OBJC_TAG_Photos_2 = 9, - OBJC_TAG_Photos_3 = 10, - OBJC_TAG_Photos_4 = 11, - OBJC_TAG_XPC_1 = 12, - OBJC_TAG_XPC_2 = 13, - OBJC_TAG_XPC_3 = 14, - OBJC_TAG_XPC_4 = 15, - OBJC_TAG_NSColor = 16, - OBJC_TAG_UIColor = 17, - OBJC_TAG_CGColor = 18, - OBJC_TAG_NSIndexSet = 19, - OBJC_TAG_NSMethodSignature = 20, - OBJC_TAG_UTTypeRecord = 21, - OBJC_TAG_Foundation_1 = 22, - OBJC_TAG_Foundation_2 = 23, - OBJC_TAG_Foundation_3 = 24, - OBJC_TAG_Foundation_4 = 25, - OBJC_TAG_CGRegion = 26, - - // When using the split tagged pointer representation - // (OBJC_SPLIT_TAGGED_POINTERS), this is the first tag where - // the tag and payload are unobfuscated. All tags from here to - // OBJC_TAG_Last52BitPayload are unobfuscated. The shared cache - // builder is able to construct these as long as the low bit is - // not set (i.e. even-numbered tags). - OBJC_TAG_FirstUnobfuscatedSplitTag = 136, // 128 + 8, first ext tag with high bit set - - OBJC_TAG_Constant_CFString = 136, - - OBJC_TAG_First60BitPayload = 0, - OBJC_TAG_Last60BitPayload = 6, - OBJC_TAG_First52BitPayload = 8, - OBJC_TAG_Last52BitPayload = 263, - - OBJC_TAG_RESERVED_264 = 264 -}; -``` - -验证下 - - - -可以看到: - -- num1 地址 `0xb000000000000013`,其中 b 为11,二进制为 `1011`,其中 iOS 侧采用 LSB,则第一位标记判断是不是 Tagged Pointer,当前为1,则说明是 Tagged Pointer。剩下的 `011` 是类标识位,对应十进制为3,表示 NSNumber 类型。 -- str1 地址 `0xa000000000062612`,其中 a 为10,二进制为 `1010`,第一位是 1,表示是 Tagged Pointer。其余 `010`,也就是2,表示 NSString 类 - - - -#### 结构 - -下面是针对 double 包装成 NSNumber 的 Tagged Pointer 指针结构拆分: - - - - - - - - - - - - - - - -### 如何判断一个指针是否为Tagged Pointer - -查看 objc4 源码(目前新版的 objc4-objc4-912.3) - -```c++ -// objc-internal.h -#if __arm64__ -// ARM64 uses a new tagged pointer scheme where normal tags are in -// the low bits, extended tags are in the high bits, and half of the -// extended tag space is reserved for unobfuscated payloads. -# define OBJC_SPLIT_TAGGED_POINTERS 1 -#else -# define OBJC_SPLIT_TAGGED_POINTERS 0 -#endif - -#if OBJC_SPLIT_TAGGED_POINTERS -# define _OBJC_TAG_MASK (1UL<<63) -# define _OBJC_TAG_INDEX_SHIFT 0 -# define _OBJC_TAG_SLOT_SHIFT 0 -# define _OBJC_TAG_PAYLOAD_LSHIFT 1 -# define _OBJC_TAG_PAYLOAD_RSHIFT 4 -# define _OBJC_TAG_EXT_MASK (_OBJC_TAG_MASK | 0x7UL) -# define _OBJC_TAG_NO_OBFUSCATION_MASK ((1UL<<62) | _OBJC_TAG_EXT_MASK) -# define _OBJC_TAG_CONSTANT_POINTER_MASK \ - ~(_OBJC_TAG_EXT_MASK | ((uintptr_t)_OBJC_TAG_EXT_SLOT_MASK << _OBJC_TAG_EXT_SLOT_SHIFT)) -# define _OBJC_TAG_EXT_INDEX_SHIFT 55 -# define _OBJC_TAG_EXT_SLOT_SHIFT 55 -# define _OBJC_TAG_EXT_PAYLOAD_LSHIFT 9 -# define _OBJC_TAG_EXT_PAYLOAD_RSHIFT 12 -#elif OBJC_MSB_TAGGED_POINTERS -# define _OBJC_TAG_MASK (1UL<<63) -# define _OBJC_TAG_INDEX_SHIFT 60 -# define _OBJC_TAG_SLOT_SHIFT 60 -# define _OBJC_TAG_PAYLOAD_LSHIFT 4 -# define _OBJC_TAG_PAYLOAD_RSHIFT 4 -# define _OBJC_TAG_EXT_MASK (0xfUL<<60) -# define _OBJC_TAG_EXT_INDEX_SHIFT 52 -# define _OBJC_TAG_EXT_SLOT_SHIFT 52 -# define _OBJC_TAG_EXT_PAYLOAD_LSHIFT 12 -# define _OBJC_TAG_EXT_PAYLOAD_RSHIFT 12 -#else -# define _OBJC_TAG_MASK 1UL -# define _OBJC_TAG_INDEX_SHIFT 1 -# define _OBJC_TAG_SLOT_SHIFT 0 -# define _OBJC_TAG_PAYLOAD_LSHIFT 0 -# define _OBJC_TAG_PAYLOAD_RSHIFT 4 -# define _OBJC_TAG_EXT_MASK 0xfUL -# define _OBJC_TAG_EXT_INDEX_SHIFT 4 -# define _OBJC_TAG_EXT_SLOT_SHIFT 4 -# define _OBJC_TAG_EXT_PAYLOAD_LSHIFT 0 -# define _OBJC_TAG_EXT_PAYLOAD_RSHIFT 12 -#endif - -static inline bool -_objc_isTaggedPointer(const void * _Nullable ptr) -{ - return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK; -} -``` - -分析: - -- MacOS 采用 **LSB**(Least Significant Bit,即最低有效位)为 Tagged Pointer 标识位; -- iOS 采用 **MSB**(Most Significant Bit,即最高有效位)为 Tagged Pointer 标识位 -- 宏定义 `OBJC_SPLIT_TAGGED_POINTERS` 在 arm64 架构下为真,其他情况为假。 - -可以看到源码通过 `_objc_isTaggedPointer` 方法判断是否是 Tagged Pointer 类型。传入对象地址,内部通过 `_OBJC_TAG_MASK` 按位与运算。 - -其中 `_OBJC_TAG_MASK` 是一个宏,宏定义外部有个 if 判读,判断是 `OBJC_SPLIT_TAGGED_POINTERS` 或 `OBJC_MSB_TAGGED_POINTERS`,都为 `(1UL<<63)`,其余则为 `1UL` - -综合来看,iOS 侧不管是不是 arm64,对于 `_OBJC_TAG_MASK` 的值都是 ` (1UL<<63)` ,其他 MacOS 下则为 `1UL` - -- iOSOS: 最高有效位是1(第64bit)`1UL<<63`,也就是 `10000000...0`,第一位是1,后面63个0。 - -- 非 iOS: 最低有效位是1`1UL`。也就是 `0000...1`,共63个0,最后一位是1。 - -所以,判断是不是 TaggedPointer 可以用下面代码判断 - -```c++ -#if OBJC_SPLIT_TAGGED_POINTERS -# define _OBJC_TAG_MASK (1UL<<63) -#elif OBJC_MSB_TAGGED_POINTERS -# define _OBJC_TAG_MASK (1UL<<63) -#else -# define _OBJC_TAG_MASK 1UL -#endif - -static inline bool _objc_isTaggedPointer(const void * _Nullable ptr) { - return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK; -} -``` - - - -#### 为什么 ARM64 使用 `(1ULL << 63)` 判断 - -ARM64 地址空间设计: - -- 用户空间地址范围: - ARM64 的用户态程序地址通常为 `0x0000000000000000 ~ 0x0000FFFFFFFFFFFF`,最高位(第 63 位)始终为 `0`。 -- 内核空间地址: - 内核空间地址最高位为 `1`(如 `0xFFFF000000000000`),但用户态程序无法访问。 - -因此,苹果**将最高位用作 Tagged Pointer 标志**,天然避免与普通指针冲突。 - -其他几种情况就不举例子了。 - - - -### NONPOINTER_ISA - -在64位架构下,ISA 占64位空间,但实际上用不了那么多,实际上有32位或者40位就够用了,剩余的比较浪费。iOS 为了提高利用率,在剩余的位上存储了一些内存管理相关的信息。所以是不纯粹的指针。叫 NONPOINTER_ISA。 - -isa 中64位的首位为1,即 NONPOINTER_ISA。 - - - -### Tagged Pointer 与内存管理 - -因为 Tagged Pointer 是伪指针,如果设计 objc 指针的一些逻辑,比如对象 retain、release,都是优先判断是不是 Tagged Pointer 的。没必要执行一个真正对象指针的后续流程。 - -```c++ -// objc-object.h -ALWAYS_INLINE id -objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant) -{ - if (slowpath(isTaggedPointer())) return (id)this; - // ... -} - -ALWAYS_INLINE bool -objc_object::rootRelease(bool performDealloc, objc_object::RRVariant variant) -{ - if (slowpath(isTaggedPointer())) return false; - // ... -} - -inline bool -objc_object::isTaggedPointer() const -{ - return _objc_isTaggedPointer(this); -} -``` - - - -### Tagged Pointer 与消息发送 - -消息机制 objc_msgSend 也会优先判断 Tagged Pointer 相关逻辑 - -```assembly - MSG_ENTRY _objc_msgSend // 入口 - UNWIND _objc_msgSend, NoFrame - - cmp p0, #0 // nil check and tagged pointer check 判断 nil 和 Tagged Pointer 逻辑 -#if SUPPORT_TAGGED_POINTERS // 如果支持 Tagged Pointer,则执行下面逻辑 - b.le LNilOrTagged // (MSB tagged pointer looks negative) // 跳转到 LNilOrTagged 部分 -#else - b.eq LReturnZero -#endif - ldr p14, [x0] // p14 = raw isa - GetClassFromIsa_p16 p14, 1, x0 // p16 = class -LGetIsaDone: - // calls imp or objc_msgSend_uncached - CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached - -#if SUPPORT_TAGGED_POINTERS -LNilOrTagged: - b.eq LReturnZero // nil check 先做 nil 检查 - GetTaggedClass // 获取 Tagged Pointer 真实的 class - b LGetIsaDone // 跳转到 LGetIsaDone -// SUPPORT_TAGGED_POINTERS -#endif - -LReturnZero: - // x0 is already zero - mov x1, #0 - movi d0, #0 - movi d1, #0 - movi d2, #0 - movi d3, #0 - ret - - END_ENTRY _objc_msgSend -``` - -可以看到 objc_msgSend 的汇编实现里,也优先判断了 Tagged Pointer。 - - - -### Tagged Pointer 经典问题 - -Demo1 - -运行该代码会 Crash,报错信息如下 - - - - - -说明:一开始的报错信息只说坏内存访问,但是并没有显示具体的方法调用堆。想知道具体 Crash 原因还是需要看看堆栈比较方便。输入 bt 查看最后是由于 `objc_release` 方法造成 crash。 - -小窍门:利用 LLDB 模式下输入 `bt`,可以查看堆栈。也就是 backtrace 的缩写。 - -不仔细想可能发现不了问题,看到 `objc_release` 就会想到是在多线程情况下 NSString 的 setter 方法内,ARC 代码经过编译器最后会按照 MRC 去运行。所以 Setter 类似下面代码。 - -```objectivec --(void)setName:(NSString *)name { - if (_name!=name) { // 避免对同一个对象多次复制 - [_name release]; // 释放旧值 - _name = [name copy]; // 拷贝并持有新值 - } -} -``` - -怎么改? - -改法1:将 property 改为 **atomic** 修饰的。 - - - -改法2:对 name 加锁 - - - - - - - -Demo2 - - - - - -同样的代码字符串变短居然不 crash 了?因为命中 Tagged Pointer 逻辑了,查看类型是 `NSTaggedPointerString` - -本问题本质是: - -- ARC 代码在编译后真正运行阶段是走 MRC 的,strong、copy 修饰的属性,内部的 setter 实现都会 release 旧的值,copy/retain 新的 - - ```objective-c - - (void)setName:(name) { - if (!_name != name) { - [_name release]; - _name = [name copy]; - } - } - ``` - -- 多线程情况下访问 setter 需要加锁 - -- 字符串在 NSTaggedPointerString 情况下,不存在像 OC 对象的 setter 方法内的 release、copy 操作。所以多线程下不会 crash - - - -### TaggedPointer 与类簇 -Cocoa 里有很多类簇。比如 NSArray、NSString。 -- 类簇模式可以把实现细节隐藏在一套简单的公共接口后面 -- 系统框架中经常使用类簇 -- 从类簇的公共抽象基类中继承子类时要小心,应该覆写基类中需要覆写的方法。 -- 工厂方法是实现类簇的常见方案 - -NSString 是一个抽象工厂模式设计的类簇。NSString、NSMutableString 在外部提供了接口,这些方法的实现由具体的内部类完成。当使用 NSString、NSMutableString 的外部接口生成一个实例对象的时候,初始化方法会判断哪个内部类最适合完成,最后根据此内部类生成具体实例返回给调用者。不同的创建方式、不同的字符串长度,决定生成不同的内部类类型。 - -NSString、NSMutableString 的内部类如下: - -- NSTaggedPointerString:数据存储在指针中,不需要维护引用计数和方法调用的开销 -- NSCFConstantString:用于表示字符串常量,存储在字符串常量区,不需要维护引用计数。相同内容的 NSCFConstantString 对象的地址相同,也就是说字符串常量是一种单例对象,NSCFConstantString 对象一般通过字面量 `@"**"` 创建 -- NSCFString 存储在堆区,需要维护引用计数。通过 `stringWithFormat:` 等方法创建的 NSString 对象(且字符串长度过长,无法使用 Tagged Pointer 存储)一般都是这种类型 - -NSString、NSMutableString 继承关系如下: - -- NSTaggedPointerString 继承自 NSString -- NSCFConstantString 继承自 NSSimpleCString,NSSimpleCString 继承 NSString -- NSMutableString 继承自 NSString - -通过 `@"**"` 创建的 NSString: - -- 无论字面量长度多长或多短,都是 `__NSCFConstantString` 类型 - - 无论字面量是中文还是英文,都是 `__NSCFConstantString` 类型 - - 在创建相同内容的字符串时,得到的内存地址相同 - - `__NSCFConstantString` 类型的字符串引用计数为-1,对其进行 retain、release 等操作,不会改变其引用计数 - - `[[NSString alloc] initWithString:@""]` 、`[NSString stringWithString:@"xx"]` 与直接用字面量赋值的结果是一样的,创建的都是 `__NSCFConstantString` 类型的字符串。 - - - - - -通过 `[[NSString alloc] initWithString:@"**"]` 方式创建的 NSString 字符串 - -- 当 `@"**"` 长度为0,是 `__NSCFConstantString` 类型 -- 当 `@"**"` 长度在1~10之间,是 `NSTaggedPointerString` 类型 -- 当 `@"**"` 长度为大于10,是 `__NSCFString` 类型 -- 当字符串长度超过7 Byte 时,NSTaggedPointerString 并没有立即转换为 `__NSCFString`,而是采用了一种压缩算法进行编码,把字符串的长度进行压缩 -- 当压缩算法产生的字符串长度还是大于7 Byte 时,才会将 `NSTaggedPointerString` 转换为 `__NSCFString` -- 字符的编码格式有很多,其他语言字符不能用标准的 ASCII 码来表示,所以对于中文、日文等非 ASCII 字符,即使只有1个字符,也用 `__NSCFString` 来存储 - -通过 `[[NSMutableString alloc] initWithFormat:@"xx"]` 格式创建的 NSMutableString - -- 无论 `@"**"` 为什么值,都是 `__NSCFString` 类型 - - - -## OC 对象内存管理 - -### 内存管理方法 - - - -#### alloc 实现 - -经过一系列调用,最终调用了 c 函数的 calloc。此时并没有设置引用计数为1。 - -#### retain 实现 - -```c++ -SideTable& table = SideTables()[this]; -size_t& refcntStorage = table.refcnts[this]; -refcntStorage += SIDE_TABLE_RC_ONE; -``` - -SIDE_TABLE_RC_ONE 不是1,为什么? - -size_t 是64位,其中前2位不是存储引用计数信息的,所以+1,其实就是加偏移量 - -#### release 实现 - -```c++ -SideTable& table = SideTables()[this]; -RefcountMap::iterator it = table.refcnts.find[this]; -it->second -= SIDE_TABLE_RC_ONE; -``` - - - -#### retainCount 实现 - -```c++ -SideTable& table = SideTables()[this]; -size_t refcnt_result = 1; -RefcountMap::iterator it = table.refcnts.find(this); -refcnt_result += it->second >> SIDE_TABLE_RC_SHIFT; -``` - -alloc 后的对象,在引用计数表中是没有对应的 key、value 信息的。由于局部变量 refcnt_result 是1,所以计算完结果就是1。这也是调用 retainCount 返回为1的原因。 - - - -### 引用计数和 getter、setter - -为了研究内存管理,使用 MRC 环境。 - -iOS 中使用引用计数来管理 OC 对象的内存。一个新创建的 OC 对象引用计数默认是1,当引用计数减为 0,OC 对象就会销毁,释放其占用的内存空间 - -调用 retain/copy 会让 OC 对象的引用计数 +1,调用 release 会让 OC 对象的引用计数 -1。 - - - -可以看到,如果我们提前将 cat 释放了,那后续赋值给 person 的 _cat 成员变量就没法使用了,因为已经释放了,否则就会造成 `EXC_BAD_ACCESS`。这样子太不灵活了。需要改进下: - -调用 setCat 的时候,对传入的 cat 进行 retain,引用计数 +1,谁用谁管理,同样的最后在 Person 对象释放的时候对 cat 进行 release,引用计数 -1. - - - -但上面的代码不完美,还是存在问题。假设 cat1、cat2 2个对象,当作参数调用2次 setCat 方法,如果 setCat 方法内部不做处理,会导致第2次调用 setCat 后,之前调用时传入的 cat1 会无法释放。 - - - -修改下。调用 setCat 方法时,对之前的 _cat 调用 release,对旧的引用计数-1,再对新传入的对象调用 retain,让引用计数+1,然后赋值 - - - - - -上面的代码还是存在问题,会造成僵尸对象问题 - - - -分析下 cat 的引用计数情况: - -- 创建后引用计数为1 -- 第一次调用 setCat,由于 _cat 为nil,对 cat 进行 retain 后,引用计数为2 -- 然后调用1次 relase,引用计数为1 -- 再调用 setCat 时,由于 _cat 就是外部的 cat,所以对其调用 `[_cat relase]` 会让引用计数-1,变为0 -- 当引用计数为0的时候,调用 `_cat = [cat retain]` ,Xcode 开启僵尸对象检测,则会 crash - -改进 - - - - - - - -### 内存管理的经验总结 - -- 当调用 alloc、new、copy、mutableCopy 方法返回了一个对象,在不需要这个对象时,要调用 release 或者 autorelease 来释放它 - -- 想拥有某个对象,就让它的引用计数 +1;不想再拥有某个对象,就让它的引用计数 -1(谁用谁就 +1,最后要在合适的时机 -1) - -- 可以通过以下私有函数来查看自动释放池的情况 `extern void _objc_autoreleasePoolPrint(void);` - -僵尸对象:重复释放内存造成的。一个典型场景是多次 setter。setter 内部实现不合理,比如下面 setter。 - -```objectivec -//Person.h -@interface Person: NSObject { - Cat *_cat; -} -- (void)setCat:(Cat *)cat; -@end - -// Person.m -@implementation Person -- (void)setCat:(Cat *)cat -{ - [_cat release]; - _cat = [cat retain]; // 谁用谁+1,随后在合适的时间 -1 -} - -- (void)dealloc { - [_cat release]; // -1 - _cat = nil; - [super dealloc]; -} -@end - -Person *p = [[Person aloc] init]; // 1 -Cat *cat = [[Cat alloc] init]; // 1 -[p setCat:cat]; // 2 -[cat release]; // 1 -[p setCat:cat]; // 0 -[p setCat:cat]; // badAccess -``` - -改进 - -```objectivec -- (void)setCat:(Cat *)cat { - if (_cat != cat) { - [_cat release]; - _cat = [cat retain]; - } -} -``` - -早期在 MRC 时代,在 .h 文件中 `@property` 只会属性的 getter、setter 声明,`@synthesize` 会自动生成成员变量和属性的 setter、getter 的实现。随着编译器进步,现在 `@property` 会做完全部的事情。 - -早期 VC 中使用属性 - -```objectivec -@property (nonatomic, strong) NSMutableDictionary *dict; - -NSMutableDictionary *dict = [[NSMutableDictionary alloc] init]; -self.dict = dict; -[dict release]; -``` - -通过 Foundation 框架中类方法创建出来的对象,会自动调用 autorelease 方法。 - -简写为 `self.dict = [NSMutableDictionary dictionary];` - -上述可以查看 GUNStep 源码  `NSDictionary.m` - -```objectivec -#define AUTORELEASE(object) [(id)(object) autorelease] -+ (id) dictionary { - return AUTORELEASE([[self allocWithZone: NSDefaultMallocZone()] init]); -} -``` - - - -QA:ARC 做了什么 - -ARC 其实是 LLVM + Runtime 共同作用的结果。LLVM 编译器自动插入 retain、release 内存管理代码。Runtime 运行时帮我们处理类似 `__weak` 程序运行过程中弱引用清除掉。 - - - -## copy/mutableCopy - -OC 有2个拷贝方法 - -- copy 不可变拷贝,产生新不可变对象 - - **针对不可变类型,调用 copy 方法,效果是产生一个新的引用。因为本身不可变,所以一个引用就好,可以实现“产生不可变对象”的目的**。 - - - **针对可变类型,调用 copy 方法,效果是产生一个新的对象,并且将内容拷贝到新对象里面。产生1个新的不可变对象** - -- mutableCopy 可变拷贝,产生新可变对象 - - 针对不可变类型,调用 mutablecopy 方法,需要产生一个可变对象,但是需要互不影响的新的可变对象 - - **针对可变类型,调用 mutablecopy 方法,需要产生一个新的可变对象**。 - - -上个 Demo1 - -```objectivec -NSArray *array1 = [[NSArray alloc] initWithObjects:@"1", @"2", nil]; -NSLog(@"array1 --- %zd", array1.retainCount); -NSArray *array2 = [array1 copy]; -NSLog(@"array1 --- %zd", array1.retainCount); -NSLog(@"array2 --- %zd", array2.retainCount); -NSMutableArray *array3 = [array1 mutableCopy]; -NSLog(@"array1 --- %zd", array1.retainCount); -NSLog(@"array2 --- %zd", array2.retainCount); -NSLog(@"array3 --- %zd" array3.retainCount); - -[array3 release]; -NSLog(@"array3 --- %zd", array3.retainCount); -[array2 release]; -NSLog(@"array2 --- %zd", array2.retainCount); -NSLog(@"array1 --- %zd", array1.retainCount); -[array1 release]; -NSLog(@"array1 --- %zd", array1.retainCount); -2022-04-12 20:50:43.639296+0800 Main[4408:60897] array1 --- 1 -2022-04-12 20:50:43.639715+0800 Main[4408:60897] array1 --- 2 -2022-04-12 20:50:43.639772+0800 Main[4408:60897] array2 --- 2 -2022-04-12 20:50:43.639846+0800 Main[4408:60897] array1 --- 2 -2022-04-12 20:50:43.639899+0800 Main[4408:60897] array2 --- 2 -2022-04-12 20:50:43.639957+0800 Main[4408:60897] array3 --- 1 -2022-04-12 20:50:43.640013+0800 Main[4408:60897] array3 --- 0 -2022-04-12 20:50:43.640059+0800 Main[4408:60897] array2 --- 1 -2022-04-12 20:50:43.640105+0800 Main[4408:60897] array1 --- 1 -2022-04-12 20:50:43.640159+0800 Main[4408:60897] array1 --- 0 -``` - -疑问1: 为什么在 array2 创建之后 array2、array1 的引用技术都是2. - -因为 array1 指针指向堆上一块内存(NSArray 类型),创建好后 array1 引用计数为1。在创建 array2 的时候发现是对 array1 的浅拷贝,系统为了内存的节省优化,array2 的指针也指向堆上的这一块内存,copy 本身会对 array1 引用技术 +1,变为2。所以这时候 array2 指针指向的内存,引用计数也是2. - -基于此,我们稍微修改下,看看 Demo2 - -```objectivec -NSArray *array1 = [[NSArray alloc] initWithObjects:@"1", @"2", nil]; -NSLog(@"array1 --- %zd", array1.retainCount); -NSArray *array2 = [array1 mutableCopy]; -NSLog(@"array1 --- %zd", array1.retainCount); -NSLog(@"array2 --- %zd", array2.retainCount); - -2022-04-12 20:55:36.539060+0800 Main[4576:65031] array1 --- 1 -2022-04-12 20:55:36.539514+0800 Main[4576:65031] array1 --- 1 -2022-04-12 20:55:36.539631+0800 Main[4576:65031] array2 --- 1 -``` - -因为 array1 指针指向堆上一块内存(NSArray 类型),创建好后 array1 引用计数为1。在创建 array2 的时候发现是对 array1 的深拷贝,要产生不可变对象,所以堆上申请内存空间,array2 指针指向这块内存,引用技术为1。 - -此外 mutableCopy 是 Foundation 针对集合类提供的。如果自定义对象需要支持 copy 方法,需遵循对应的`NSCopyint` 协议,实现协议方法 `-(id)copyWithZone:(NSZone *)zone` - - - -Demo3 - - - -会发现发生了 crash。问题是因为 - -- `@property (nonatomic, copy) NSMutableArray *data;` 对 NSMutableArray 用了 copy 修饰词。在 setter 方法里的实现就是 ARC 编译器会做的事情。对 NSMutableArray 调用 copy 方法,得到一个不可变对象 NSArray -- NSArray 不存在 `addObject` 方法。调用不存在方法自然会报 `unrecognized selector sent to instance ...` 的 - -总结: - -- **不可变对象,调用 copy 方法,得到一个不可变的副本,就是浅拷贝**,其余都是深拷贝 -- 若可变类型(NSMutableArray、NSMutableString、NSMutableDictionary)属性需要 **可变性**,应使用 `strong` 配合 - -| 数据类型 | - 调用 copy 方法得到
- 深 or 浅拷贝 | - 调用 mutablecopy 方法得到
- 深 or 浅拷贝 | -| ------------------- | -------------------------------------- | --------------------------------------------- | -| NSString | NSString
浅拷贝 | NSMutableString
深拷贝 | -| NSMutableString | NSArray
深拷贝 | NSMutableString
深拷贝 | -| NSArray | NSArray
浅拷贝 | NSMutableArray
深拷贝 | -| NSMutableArray | NSArray
深拷贝 | NSMutableArray
深拷贝 | -| NSDictionary | NSDictionary
浅拷贝 | NSMutableDictionary
深拷贝 | -| NSMutableDictionary | NSDictionary
深拷贝 | NSMutableDictionary
深拷贝 | - -深拷贝和浅拷贝的区别? -- 深拷贝不会影响对的引用计数 -- 深拷贝开辟了新的内存空间 - - - -## 引用计数及weak指针 - -### weak 指针 - -Case1 - -```objective-c -__strong Person *p1; -__weak Person *p2; -__unsafe_unretained Person *p3; -{ - Person *p = [[Person alloc] init]; -} -``` - -大括号结束,则立马调用了 Person 的 dealloc 方法 - -Case2 - -```objective-c -{ - Person *p = [[Person alloc] init]; - p1 = p -} -``` - -有强指针指向,大括号结束,引用计数位1,则不会执行 dealloc 方法 - -Case3 - -```objective-c -{ - Person *p = [[Person alloc] init]; - p2 = p -} -NSLog(@"p2:%@", p2); -``` - -弱指针指向则不改变引用计数,大括号结束,则不执行 dealloc 方法 - -Case4 - -```objective-c -{ - Person *p = [[Person alloc] init]; - p3 = p -} -NSLog(@"p3:%@", p3); -``` - -用 `__unsafe_unretained` 指向的指针,当对象释放后,则会 crash `Thread 1: EXC_BAD_ACCESS (code=1, address=0x3eadde6d8408)` - -原因在于: - -1. **对象释放后的行为**: - - `__weak`:当对象被释放时,指针会自动设置为 nil(空指针) - - `__unsafe_unretained`:当对象被释放时,指针保持不变,成为"悬垂指针"(dangling pointer) -2. **安全性**: - - `__weak` 是安全的,因为访问 nil 指针不会导致崩溃 - - `__unsafe_unretained` 是不安全的,因为访问已释放对象会导致崩溃 - -为什么会有这种差异? - -- **`__weak` 的实现**: - - 运行时系统维护了一个弱引用表 - - 当对象被释放时,运行时系统会遍历所有指向该对象的弱引用,并将它们置为 nil - - 这个过程是自动的,由 ARC 管理 -- **`__unsafe_unretained` 的实现**: - - 完全不参与引用计数 - - 运行时系统不会跟踪这些指针 - - 当对象被释放时,指针仍然指向原来的内存地址 - - 如果访问这个指针,实际上是在访问已释放的内存,导致崩溃 - - - -### 引用计数信息 - -```objectivec -union isa_t { - Class cls; - uintptr_t bits; - struct { - uintptr_t nonpointer : 1; - uintptr_t has_assoc : 1; - uintptr_t has_cxx_dtor : 1; - uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000 - uintptr_t magic : 6; - uintptr_t weakly_referenced : 1; - uintptr_t deallocating : 1; - uintptr_t has_sidetable_rc : 1; - uintptr_t extra_rc : 19; - }; -} -``` - -iOS 从 64 位开始开始,对 isa 进行了优化,信息存放于 union 结构中 - -- `extra_rc` 存储着引用计数值 -1 后的值。可以看到是 19位 - -- `has_sidetable_rc` 引用计数是否过大无法存储在 isa。当过大无法存储与 isa 中时,`has_sidetable_rc` 这位会变为1,引用计数存储在 SideTable 的类的属性中 - -也就是说,iOS 从64位开始,引用计数存放于 isa 结构体的一个 union 中,字段为 `extra_rc`,值为对象引用计数值 。当引用计数过大无法存放的时候, union 中 `has_sidetable_rc `为 1,则引用计数存放于 SideTable 结构体中。 - -QA:不知道你是否会有这样的疑问:extra_rc 字段都19位了,还担心不够,居然设计了一个 has_sidetable_rc字段?按理说19位可以存储非常大的数字了,什么对象会有那么大的引用计数? - -- **循环引用或泄漏**:若代码存在循环引用或内存泄漏,引用计数可能无限增长。虽然 19 位理论上能存储 50 万+的引用,但设计上需要防止溢出导致未定义行为。 - -- **框架与系统级对象**:某些系统级对象(如单例、缓存池)可能被大量持有。例如: - - ```objective-c - // 假设某个缓存池错误地持有了大量对象 - for (int i = 0; i < 1000000; i++) { - [cache addObject:someObject]; // someObject 的引用计数暴增 - } - ``` - -所以: - -- 19 位 `extra_rc` :覆盖了绝大多数场景,同时避免了频繁访问 Side Table 的性能损失。 -- **`has_sidetable_rc`** 作为安全网,处理极端情况(如泄漏、系统级对象),同时支持 Side Table 的多功能用途(弱引用、关联对象等)。 -- 这种设计体现了 **“优化常态,防御极端”** 的工程哲学,在内存效率、性能和健壮性之间取得平衡 - - - -### 散列表 - -SideTable 结构如下 - -```c -struct SideTable { - spinlock_t slock; - RefcountMap refcnts; - weak_table_t weak_table; -}; -``` - -其中 refcnts 是一个存放着对象引用计数的散列表。其实 `RefcountMap` 是一个 `objc::DenseMap` ,是高性能哈希表,专为密集内存布局优化 - -```c++ -// RefcountMap disguises its pointers because we -// don't want the table to act as a root for `leaks`. -typedef objc::DenseMap,size_t,RefcountMapValuePurgeable> RefcountMap; -``` - -其中: - -- 键类型:`DisguisedPtr`:用于伪装指针,对原始对象指针进行编码,使其不直接暴露内存地址。同时**避免内存泄漏误报**:防止 `leaks` 等工具将哈希表中的指针误判为活动根节点(Root)。若存储原始指针,工具可能认为这些是有效引用,导致泄漏检测失效。 -- 值类型:`size_t`:对象的引用计数值,可能包含额外标志位 -- 策略类:`RefcountMapValuePurgeable` :管理哈希表值的生命周期 -- - -查看 objc4 源码,看看如何获取引用计数 - -```c++ -uintptr_t -_objc_rootRetainCount(id obj) -{ - assert(obj); - - return obj->rootRetainCount(); -} - -inline uintptr_t -objc_object::rootRetainCount() -{ - if (isTaggedPointer()) return (uintptr_t)this; //如果是采用 isTaggedPointer 直接返回 this 本身 - - sidetable_lock(); - isa_t bits = LoadExclusive(&isa.bits); // 取出isa_t - ClearExclusive(&isa.bits); - if (bits.nonpointer) { // 如果是优化的指针 - uintptr_t rc = 1 + bits.extra_rc; // 引用计数值 - if (bits.has_sidetable_rc) { // 如果 has_sidetable_rc 为1,则说明引用计数过大无法存贮在 isa 中,需要去 SideTable 中获取 - rc += sidetable_getExtraRC_nolock(); // 去 sidetable 中去拿取计数 - } - sidetable_unlock(); - return rc; - } - sidetable_unlock(); - return sidetable_retainCount(); -} - -size_t -objc_object::sidetable_getExtraRC_nolock() -{ - assert(isa.nonpointer); - SideTable& table = SideTables()[this]; // SideTables 重载 [] 运算符,本质上就是调用 indexForPointer 方法 - RefcountMap::iterator it = table.refcnts.find(this); // 从 refcnts 哈希表中根据 this 指针地址,经过哈希计算,得到结果 - if (it == table.refcnts.end()) return 0; - else return it->second >> SIDE_TABLE_RC_SHIFT; -} - -// 没有优化过的 isa,则去 sidetable 中拿计数 -uintptr_t -objc_object::sidetable_retainCount() -{ - SideTable& table = SideTables()[this]; // 根据地址拿到 SideTable - - size_t refcnt_result = 1; - - table.lock(); - RefcountMap::iterator it = table.refcnts.find(this); // 从 SideTable 中根据地址拿取 RefcountMap - if (it != table.refcnts.end()) { - // this is valid for SIDE_TABLE_RC_PINNED too - refcnt_result += it->second >> SIDE_TABLE_RC_SHIFT; - } - table.unlock(); - return refcnt_result; // 返回 RefcountMap 中的计数 -} -``` - - - -### 分离锁 - -为什么不是一个 SideTable?而是 SideTables - -| Ptr(1) | 1 | -| ------ | ---- | -| Ptr(2) | 3 | -| ... | ... | -| Ptr(N) | 2 | - -假设所有的对象和其引用计数信息存在一个 SideTable 中,不同的对象可能在不同的线程中操作,那不同的线程操作一张表需要进行加锁处理,才可以保证数据访问安全。App 运行过程中可能有成千上万的对象,都去访问这个表,下一个对象则需要前一个对象把锁使用完释放后才可以使用,则会存在效率问题。 - -为了解决这个问题,系统引入了“分离锁”方案。 - -系统将内存对象对应的引用计数表分拆成多个,在 iOS 真机模式下,SideTable的最大数量是8张(StripeCount=8)。 - -- 需要对多个这样的表分别加锁。例如,当对象 A 在表 1 中,对象 B 在表 2 中时,A 和 B 的引用计数操作可以并发进行。 -- 这种方式避免了单一锁模型下的顺序操作,提高了多线程环境下的访问效率。 - - - -### 如何实现快速分流? - -SideTables 的本质是一张 **Hash 表**。 - -快速分流的目的,就是根据对象地址,如何快速计算出属于哪一张 SideTable?这个就是哈希函数要做的事情。 - -输入:ptr -> 经过:f(ptr) -> 计算出 index。即 `f(ptr) = (uintptr_t)ptr % array.count` - -使用哈希查找就是为了提高查找效率。 - -```c++ -template -class StripedMap { - // ... -#if TARGET_OS_EMBEDDED - enum { StripeCount = 8 }; -#else - enum { StripeCount = 64 }; -#endif - static unsigned int indexForPointer(const void *p) { - uintptr_t addr = reinterpret_cast(p); - return ((addr >> 4) ^ (addr >> 9)) % StripeCount; - } -} -``` - - - -SideTable 源码 - -```c++ -template -class StripedMap { - - enum { CacheLineSize = 64 }; - -#if TARGET_OS_EMBEDDED - enum { StripeCount = 8 }; // iOS 侧 SideTables 包含8个 SideTable -#else - enum { StripeCount = 64 }; -#endif - - struct PaddedT { - T value alignas(CacheLineSize); - }; - - PaddedT array[StripeCount]; - - static unsigned int indexForPointer(const void *p) { - uintptr_t addr = reinterpret_cast(p); - return ((addr >> 4) ^ (addr >> 9)) % StripeCount; - } - - public: - T& operator[] (const void *p) { // 重写运算符 [],调用起来更像一个数组。底层调用 indexForPointer 方法。 - return array[indexForPointer(p)].value; - } - const T& operator[] (const void *p) const { - return const_cast>(this)[p]; - } - - // Shortcuts for StripedMaps of locks. - void lockAll() { - for (unsigned int i = 0; i < StripeCount; i++) { - array[i].value.lock(); - } - } - - void unlockAll() { - for (unsigned int i = 0; i < StripeCount; i++) { - array[i].value.unlock(); - } - } - - void forceResetAll() { - for (unsigned int i = 0; i < StripeCount; i++) { - array[i].value.forceReset(); - } - } - - void defineLockOrder() { - for (unsigned int i = 1; i < StripeCount; i++) { - lockdebug_lock_precedes_lock(&array[i-1].value, &array[i].value); - } - } - - void precedeLock(const void *newlock) { - // assumes defineLockOrder is also called - lockdebug_lock_precedes_lock(&array[StripeCount-1].value, newlock); - } - - void succeedLock(const void *oldlock) { - // assumes defineLockOrder is also called - lockdebug_lock_precedes_lock(oldlock, &array[0].value); - } - - const void *getLock(int i) { - if (i < StripeCount) return &array[i].value; - else return nil; - } - -#if DEBUG - StripedMap() { - // Verify alignment expectations. - uintptr_t base = (uintptr_t)&array[0].value; - uintptr_t delta = (uintptr_t)&array[1].value - base; - assert(delta % CacheLineSize == 0); - assert(base % CacheLineSize == 0); - } -#endif -}; -``` - -- iOS 侧 StripeCount 为8 - -- `indexForPointer` 方法根据传入的指针,将指针地址转换为 uintptr_t 类型,然后将地址右移4位和右移9位的结果进行异或运算,然后将结果取模 StripeCount(iOS 侧为8),用于确定索引的范围(范围在:[0, stripeCount -1] ) - -- Operator 重写了运算符 [],底层调用 `indexForPointer` 方法,使之使用起来更像一个数组 - -- 位移和异或操作在 CPU 指令级别极快,适合高频调用场景(如对象内存管理)。 - - - - - -### 引用计数表 - - - - - -### weak 指针原理 - -weak_table_t 结构如下: - - - -```c++ -#define WEAK_INLINE_COUNT 4 -#define REFERRERS_OUT_OF_LINE 2 - -struct weak_entry_t { - DisguisedPtr referent; // 被弱引用的对象 - - // 引用该对象的对象列表,联合。 引用个数小于4,用 inline_referrers 数组。 个数大于4,用动态数组 weak_referrer_t *referrers - union { - struct { - weak_referrer_t *referrers; // 弱引用该对象的对象指针地址的hash数组 - uintptr_t out_of_line_ness : 2; // 是否使用动态hash数组标记位 - uintptr_t num_refs : PTR_MINUS_2; // hash数组中的元素个数 - uintptr_t mask; // hash数组长度-1,会参与hash计算。(注意,这里是hash数组的长度,而不是元素个数。比如,数组长度可能是64,而元素个数仅存了2个) - uintptr_t max_hash_displacement; // 可能会发生的hash冲突的最大次数,用于判断是否出现了逻辑错误(hash表中的冲突次数绝不会超过改值) - }; - struct { - // out_of_line_ness field is low bits of inline_referrers[1] - weak_referrer_t inline_referrers[WEAK_INLINE_COUNT]; - }; - }; - // 判断当前是否使用动态哈希表模式 - bool out_of_line() { - return (out_of_line_ness == REFERRERS_OUT_OF_LINE); - } - - weak_entry_t& operator=(const weak_entry_t& other) { - memcpy(this, &other, sizeof(other)); - return *this; - } - - weak_entry_t(objc_object *newReferent, objc_object **newReferrer) - : referent(newReferent) // 构造方法,里面初始化了静态数组 - { - inline_referrers[0] = newReferrer; - for (int i = 1; i < WEAK_INLINE_COUNT; i++) { - inline_referrers[i] = nil; - } - } -}; -``` - -可以看到 - -- 在 `weak_entry_t ` 的结构中有联合体,联合体用于高效存储弱引用指针的地址,分为两种模式:定长数组 `inline_referrers[WEAK_INLINE_COUNT]` 和动态数组`weak_referrer_t *referrers `两种方式来存储弱引用对象的指针地址 - - - 内联数组模式(`inline_referrers`):直接存储少量弱引用指针地址 - - 动态哈希表模式(`referrers`):存储大量弱引用指针地址,通过哈希表管理 - -- 通过 `out_of_line()` 这样一个函数方法来判断采用哪种存储方式: - - - 内联数组(`inline_referrers`) - - 当弱引用该对象的指针数目小于等于 `WEAK_INLINE_COUNT`(即4)时,使用定长数组直接存储前4个弱引用指针地址 - - 优势:避免动态内存分配,提升对小规模弱引用的处理效率 - - - - 动态哈希表(`referrers`) - - 当超过`WEAK_INLINE_COUNT`时,会将定长数组中的元素转移到动态数组中,并之后都是用动态数组存储 - - `weak_referrer_t *referrers`:指向动态分配的哈希表数组。 - - `out_of_line_ness`(2位):标记当前是否为动态模式(值`REFERRERS_OUT_OF_LINE`)。 - - `num_refs`:当前存储的弱引用数量。 - - `mask`:哈希表容量(总槽位数,值为`2^n - 1`)。 - - `max_hash_displacement`:最大哈希冲突步长,用于优化查找 - - 优势:支持大规模弱引用的高效插入、查找和删除。 - - - -#### 存 weak 对象 - -声明一个 `__weak` 对象 - -```objective-c -{ - id __weak obj = strongObj; -} -``` - -LLVM转换成对应的代码 - -```objective-c -id __attribute__((objc_ownership(none))) obj1 = strongObj; -``` - -相应的会调用 - -```objective-c -id obj ; -objc_initWeak(&obj,strongObj); -objc_destoryWeak(&obj); -``` - -上 Demo - - - -可以看到当一个 weak 指针被赋值的时候,底层调用了 `objc_initWeak`,跟踪查看 objc 源码 - -```c++ -id objc_initWeak(id *location, id newObj) // location: __weak 指针的地址。 newObj:指向对象的地址,即 person -{ - if (!newObj) { - *location = nil; - return nil; - } - - return storeWeak - (location, (objc_object*)newObj); -} -``` - -继续跟进 - -```c++ -static id storeWeak(id *location, objc_object *newObj) -{ - assert(haveOld || haveNew); - if (!haveNew) assert(newObj == nil); - - Class previouslyInitializedClass = nil; - id oldObj; - SideTable *oldTable; - SideTable *newTable; - - // Acquire locks for old and new values. - // Order by lock address to prevent lock ordering problems. - // Retry if the old value changes underneath us. - retry: - if (haveOld) { // 如果 weak 指针之前弱引用过一个对象,则将这个对象对应的 SideTable 取出,赋值给 oldTable - oldObj = *location; - oldTable = &SideTables()[oldObj]; - } else { - oldTable = nil; - } - if (haveNew) { // 如果 weak 指针,要修饰一个新的对象,则将该对象对应的 SideTable 取出(SideTables 中根据对象地址,进行哈希算法,取出对应的 SideTable),赋值给 newTable - newTable = &SideTables()[newObj]; - } else { - newTable = nil; - } - // 加锁,多线程保护 - SideTable::lockTwo(oldTable, newTable); - - if (haveOld && *location != oldObj) { - SideTable::unlockTwo(oldTable, newTable); - goto retry; - } - - // Prevent a deadlock between the weak reference machinery - // and the +initialize machinery by ensuring that no - // weakly-referenced object has an un-+initialized isa. - if (haveNew && newObj) { - Class cls = newObj->getIsa(); - if (cls != previouslyInitializedClass && - !((objc_class *)cls)->isInitialized()) // 如果 cls 还没有初始化,则先初始化,再尝试设置 weak - { - SideTable::unlockTwo(oldTable, newTable); - _class_initialize(_class_getNonMetaClass(cls, (id)newObj)); - - // If this class is finished with +initialize then we're good. - // If this class is still running +initialize on this thread - // (i.e. +initialize called storeWeak on an instance of itself) - // then we may proceed but it will appear initializing and - // not yet initialized to the check above. - // Instead set previouslyInitializedClass to recognize it on retry. - previouslyInitializedClass = cls; // 记录 previouslyInitializedClass,防止再次进入 - - goto retry; // 重新获取一遍 newObj,因为此时已经确保 newObj 初始化过了 - } - } - - // Clean up old value, if any. 如果当前的 weak 指针,修饰过旧的对象,则调用 weak_unregister_no_lock 方法 - if (haveOld) { - weak_unregister_no_lock(&oldTable->weak_table, oldObj, location); - } - - // Assign new value, if any. - if (haveNew) { // 如果 weak 指针,修饰新的对象 - // 调用 weak_register_no_lock 方法,将 weak 指针地址(location),记录到 newObj 对应的 weak_entry_t 中(weak_referrer_t的结构) - newObj = (objc_object *) - weak_register_no_lock(&newTable->weak_table, (id)newObj, location, - crashIfDeallocating); - // weak_register_no_lock returns nil if weak store should be rejected - - // 更新 newObj 的 isa 中的 weakly_referenced bit 标记位 - // Set is-weakly-referenced bit in refcount table. - if (newObj && !newObj->isTaggedPointer()) { - newObj->setWeaklyReferenced_nolock(); - } - - // Do not set *location anywhere else. That would introduce a race. - *location = (id)newObj; - } - else { - // No new value. The storage is not changed. - } - // 多线程解锁 - SideTable::unlockTwo(oldTable, newTable); - - return (id)newObj; // 返回 newObj,此时 newObj 的 isa union 中,weakly_referenced bit 为1 -} -``` - -说明: - -- storeWeak 方法实际上接受5个参数,分别是:haveOld、haveNew、crashIfDeallocating、location,、newObj,前3个是以模版的方式传入,是 BOOL 类型。分别表示 weak 指针之前是否修饰过一个弱引用,weak 指针是否需要指向一个新的引用,如果被弱引用的对象正在析构,此时再弱引用是否需要 crash -- 如果 weak 指针,之前指向过一个弱引用,则调用 `weak_unregister_no_lock` 逻辑,会将旧的 weak 指针地址移除 -- 如果 weak 指针,指向一个新的引用,则调用 `weak_register_no_lock` 将新的 weak 指针地址添加到 weak_table_t 中 -- 最后调用 `setWeaklyReferenced_nolock` 方法,修改对象的 isa union 中的 weak 标记位 - -其中,看看 `weak_register_no_lock` - -```c++ -id -weak_register_no_lock(weak_table_t *weak_table, id referent_id, - id *referrer_id, bool crashIfDeallocating) -{ - objc_object *referent = (objc_object *)referent_id; - objc_object **referrer = (objc_object **)referrer_id; - // 前置判断,如果是 nil 或者是 TaggedPointer 则直接返回(TaggedPointer 仅仅是一个虚假指针,没有在堆上面分配对象,所以也不存在 weak 修饰的问题) - if (!referent || referent->isTaggedPointer()) return referent_id; - // 确保对象可用(没有在析构,且支持 weak) - // ensure that the referenced object is viable - bool deallocating; - if (!referent->ISA()->hasCustomRR()) { - deallocating = referent->rootIsDeallocating(); - } - else { - BOOL (*allowsWeakReference)(objc_object *, SEL) = - (BOOL(*)(objc_object *, SEL)) - object_getMethodImplementation((id)referent, - SEL_allowsWeakReference); - if ((IMP)allowsWeakReference == _objc_msgForward) { - return nil; - } - deallocating = - ! (*allowsWeakReference)(referent, SEL_allowsWeakReference); - } - // 如果在析构函,则报错 - if (deallocating) { - if (crashIfDeallocating) { - _objc_fatal("Cannot form weak reference to instance (%p) of " - "class %s. It is possible that this object was " - "over-released, or is in the process of deallocation.", - (void*)referent, object_getClassName((id)referent)); - } else { - return nil; - } - } - - // now remember it and where it is being stored - weak_entry_t *entry; - // 根据对象,从 weak_table_t 中找到 weak_entry_t - if ((entry = weak_entry_for_referent(weak_table, referent))) { - append_referrer(entry, referrer); // 将 referrer 插入到 weak_entry_t 的引用数组中 - } - else { - // 如果找不到,则对当前的对象,创建一个 weak_entry_t - weak_entry_t new_entry(referent, referrer); - // 创建后,判断要不要增长空间 - weak_grow_maybe(weak_table); - // 插入 weak_table_t 中 - weak_entry_insert(weak_table, &new_entry); - } - - // Do not set *referrer. objc_storeWeak() requires that the - // value not change. - - return referent_id; -} -``` - -`referent_id ` 是 weak 指针,`*referrer_id` 是 weak 指针地址。 - -```c++ -static weak_entry_t * -weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent) -{ - assert(referent); - - weak_entry_t *weak_entries = weak_table->weak_entries; - - if (!weak_entries) return nil; - - size_t begin = hash_pointer(referent) & weak_table->mask; // 这里通过和 mask 按位与的位操作,来确保 index 不会越界 - size_t index = begin; - size_t hash_displacement = 0; - while (weak_table->weak_entries[index].referent != referent) { - index = (index+1) & weak_table->mask; - if (index == begin) bad_weak_table(weak_table->weak_entries); // 触发 bad weak table crash - hash_displacement++; - if (hash_displacement > weak_table->max_hash_displacement) { // 当 hash 冲突超过了 max hash 冲突时,说明元素不在 hash 表中,返回 nil - return nil; - } - } - - return &weak_table->weak_entries[index]; -} -``` - -继续看看 `append_referrer` 方法 - -```objective-c -static void append_referrer(weak_entry_t *entry, objc_object **new_referrer) -{ - if (! entry->out_of_line()) { // weak_entry_t 没有走动态数组,走静态数组 - // Try to insert inline. - for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { - if (entry->inline_referrers[i] == nil) { - entry->inline_referrers[i] = new_referrer; - return; - } - } - // 走到这里,说明 inline_referrers 满了,此时创建动态数组 referrers - // Couldn't insert inline. Allocate out of line. - weak_referrer_t *new_referrers = (weak_referrer_t *) - calloc(WEAK_INLINE_COUNT, sizeof(weak_referrer_t)); - // This constructed table is invalid, but grow_refs_and_insert - // will fix it and rehash it. - // for 循环,填充创建的动态数组 - for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { - new_referrers[i] = entry->inline_referrers[i]; - } - entry->referrers = new_referrers; - entry->num_refs = WEAK_INLINE_COUNT; - entry->out_of_line_ness = REFERRERS_OUT_OF_LINE; - entry->mask = WEAK_INLINE_COUNT-1; - entry->max_hash_displacement = 0; - } - // 断言,保护逻辑,走到这里一定是使用了动态数组 - assert(entry->out_of_line()); - - if (entry->num_refs >= TABLE_SIZE(entry) * 3/4) {// 拓容。如果数组元素个数大于或等于数组位置空间的3/4,则拓展为当前长度的一倍 - return grow_refs_and_insert(entry, new_referrer); // 填充,并插入 - } - // 如果没有执行拓容逻辑,则说明空间足够,直接插入到 weak_entry_t 中。weak_entry是一个哈希表,key:w_hash_pointer(new_referrer) value: new_referrer - - size_t begin = w_hash_pointer(new_referrer) & (entry->mask); // 哈希算法,确保 begin 只能小于等于数组的长度 - size_t index = begin; - size_t hash_displacement = 0; // 用于记录 hash 冲突的次数,也就是 hash 再位移的次数 - while (entry->referrers[index] != nil) { - hash_displacement++; - index = (index+1) & entry->mask; // // index + 1, 移到下一个位置,再试一次能否插入。(这里要考虑到entry->mask取值,一定是:0x111, 0x1111, 0x11111, ... ,因为数组每次都是*2增长,即8, 16, 32,对应动态数组空间长度-1的mask,也就是前面的取值。 - if (index == begin) bad_weak_table(entry); // // index == begin 意味着数组绕了一圈都没有找到合适位置,这时候一定是出了什么问题。 - } - // 记录最大的hash冲突次数, max_hash_displacement意味着: 我们尝试至多max_hash_displacement次,肯定能够找到object对应的hash位置 - if (hash_displacement > entry->max_hash_displacement) { - entry->max_hash_displacement = hash_displacement; - } - // 找到要插入的位置 index,设置引用,然后引用 = 新要加入的 new_referrer ,完成插入 - weak_referrer_t &ref = entry->referrers[index]; - ref = new_referrer; - // 更新元素个数 - entry->num_refs++; -} -``` - -逻辑内先判断能否使用定长数组,然后将 weak 指针地址添加到合适的位置。不能则创建动态数组,然后找到要插入的位置进行插入 - - - -如果 weak 指针之前就指向一个弱引用,则会调用 weak_unregister_no_lock 方法,将旧的 weak 指针地址移除。 - -```c++ -void -weak_unregister_no_lock(weak_table_t *weak_table, id referent_id, - id *referrer_id) -{ - // referent_id 对象 - // referrer_id weak 指针 - objc_object *referent = (objc_object *)referent_id; // 对象 - objc_object **referrer = (objc_object **)referrer_id; // weak 指针 - - weak_entry_t *entry; - - if (!referent) return; - - // 从 weak_table_t 中找到对象对应的 weak_entry_t - if ((entry = weak_entry_for_referent(weak_table, referent))) { - // 从 weak_entry_t 中移除 weak 指针 - remove_referrer(entry, referrer); - bool empty = true; - if (entry->out_of_line() && entry->num_refs != 0) { - empty = false; - } - else { - for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) { - if (entry->inline_referrers[i]) { - empty = false; - break; - } - } - } - // 如果 entry 已经空了,则从 weak_table_t中 移除 weak_entry_t - if (empty) { - weak_entry_remove(weak_table, entry); - } - } - - // Do not set *referrer = nil. objc_storeWeak() requires that the - // value not change. -} -``` - -总结: - -weak 指针修饰步骤: - -1. 通过 SideTable 找到 weak_table_t -2. weak_table_t 根据 referent 找到或创建 weak_entry_t -3. 然后 append_referrer(entry, referrer) 将新的弱引用的对象加入到 entry 中 -4. 最后调用 weak_entry_insert 把 entry 加入到 weak_table_t 中 - - - -#### 释放 weak 对象 - -释放就是 dealloc 环节做的事情。 - -``` -- (void)dealloc { - _objc_rootDealloc(self); -} - -void -_objc_rootDealloc(id obj) -{ - assert(obj); - - obj->rootDealloc(); -} - - -inline void -objc_object::rootDealloc() -{ - if (isTaggedPointer()) return; // fixme necessary? - - if (fastpath(isa.nonpointer && - !isa.weakly_referenced && - !isa.has_assoc && - !isa.has_cxx_dtor && - !isa.has_sidetable_rc)) - { - assert(!sidetable_present()); - free(this); - } - else { - object_dispose((id)this); - } -} - -void *objc_destructInstance(id obj) -{ - if (obj) { - // Read all of the flags at once for performance. - bool cxx = obj->hasCxxDtor(); - bool assoc = obj->hasAssociatedObjects(); - - // This order is important. - if (cxx) object_cxxDestruct(obj); - if (assoc) _object_remove_assocations(obj); - obj->clearDeallocating(); - } - - return obj; -} -``` - -着重看看 `objc_object::clearDeallocating` - -```c++ -inline void -objc_object::clearDeallocating() -{ - if (slowpath(!isa.nonpointer)) { - // Slow path for raw pointer isa. - // 针对 isa 是 union 结构的转型下面逻辑 - sidetable_clearDeallocating(); - } - else if (slowpath(isa.weakly_referenced || isa.has_sidetable_rc)) { - // Slow path for non-pointer isa with weak refs and/or side table data. - clearDeallocating_slow(); - } - - assert(!sidetable_present()); -} - -void -objc_object::sidetable_clearDeallocating() -{ - // StripeMap 重写 [] 运算符,传入对象地址,哈希计算,找到对应的 SideTable - SideTable& table = SideTables()[this]; - - // clear any weak table items - // clear extra retain count and deallocating bit - // (fixme warn or abort if extra retain count == 0 ?) - table.lock(); - // 从 SideTable 中找到 refcnts 引用计数信息 - RefcountMap::iterator it = table.refcnts.find(this); - if (it != table.refcnts.end()) { - // 找到对象的引用计数信息后,同时清理 weak_table_t - if (it->second & SIDE_TABLE_WEAKLY_REFERENCED) { - weak_clear_no_lock(&table.weak_table, (id)this); - } - // 清理引用计数信息 - table.refcnts.erase(it); - } - table.unlock(); -} - -void -weak_clear_no_lock(weak_table_t *weak_table, id referent_id) -{ - objc_object *referent = (objc_object *)referent_id; - // 通过对象找到 weak_table_t 中的 weak_entry_t - weak_entry_t *entry = weak_entry_for_referent(weak_table, referent); - if (entry == nil) { - /// XXX shouldn't happen, but does with mismatched CF/objc - //printf("XXX no entry for clear deallocating %p\n", referent); - return; - } - - // zero out references - weak_referrer_t *referrers; - size_t count; - // 判断使用动态数组还是定长数组,来找出 referrers 的数组长度和数组地址 - if (entry->out_of_line()) { - // 进入这个 if 则说明是动态哈希表模式 - referrers = entry->referrers; - count = TABLE_SIZE(entry); - } - else { - // 使用内联数组模式 - referrers = entry->inline_referrers; - count = WEAK_INLINE_COUNT; - } - - // 抹平差异,无差别处理是内联数组还是动态哈希表,根据 count 遍历 referrers,依次设置为 nil - for (size_t i = 0; i < count; ++i) { - objc_object **referrer = referrers[i]; // 取出每个 weak 指针地址 - if (referrer) { - if (*referrer == referent) { // 如果 weak 指针,确实引用了 referent 对象,则将 weak 指针设置为 nil - *referrer = nil; - } - else if (*referrer) { // 如果所存储的weak ptr没有weak 引用referent,这可能是由于runtime代码的逻辑错误引起的,报错 - _objc_inform("__weak variable at %p holds %p instead of %p. " - "This is probably incorrect use of " - "objc_storeWeak() and objc_loadWeak(). " - "Break on objc_weak_error to debug.\n", - referrer, (void*)*referrer, (void*)referent); - objc_weak_error(); - } - } - } - // 由于指向该对象的 weak 指针都释放了,所以 weak_table_t 也要移除 weak_entry_t - weak_entry_remove(weak_table, entry); -} -``` - -总结:当对象的引用计数为0的时候,会先调用 dealloc 方法,然后内部调用 `clearDeallocating` 方法,该方法清理与对象相关的弱引用。 - -- 会根据对象地址,经过哈希计算算,从全局的 SideTables 中找到对应的 SideTable -- 然后访问成员变量 `weak_table_t weak_table`,其中存储着当前对象所有的弱引用。 -- 再次根据哈希计算,从 weak_entries 中找到 `weak_entry_t` -- `weak_entry_t` 根据 `out_of_line` 方法,判断采用的是动态哈希表还是内联数组。用2个变量(referrers、count)抹平差异,记录个数和数据源,然后依次遍历,设置为 nil -- 最后从 weak_table_t 中移除 weak_entry_t,完成了对象释放后,所有指向该对象的 weak 指针都被设置为 nil 这个效果 - - - -### `__unsafe_unretained` 不安全 - -如何体现?上 Demo - -```objectivec -__weak Person *p2; -__unsafe_unretained Person *p3; -{ - Person *p = [[Person alloc] init]; - p2 = p; -} -NSLog(@"%@", p2); -2022-04-12 21:39:30.308917+0800 Main[5307:98296] -[Person dealloc] -2022-04-12 21:39:30.309413+0800 Main[5307:98296] (null) -``` - -可以看到出了代码块,之后 p2 虽然指向 p,但是 p 没有强指针指向,所以回收了,此时打印 p2,是 null。 - -```objectivec -__unsafe_unretained Person *p3; -{ - Person *p = [[Person alloc] init]; - p3 = p; -} -NSLog(@"%@", p3); -2022-04-12 21:40:47.558581+0800 Main[5342:99598] -[Person dealloc] -2022-04-12 21:40:47.559330+0800 Main[5342:99598] -``` - -当对象用 `__unsafe_unretained` 修饰后,对象虽然被释放了,但是内存还没回收,这时候去使用,很容易出错,报 `EXC_BAD_ACCESS` - -### 总结 - -在 OC 中,每个对象对应一个 SideTable,而一个 SideTable 对应多个对象。StrippedMap 是一种数据结构,用于实现高效的并发访问和锁分离。在 StrippedMap 中,有多个 SideTable 实例(iOS 真机,是8个),每个 SideTable 包含一个 `weak_table_t` 和一个 `spinlock_t` ,以实现对弱引用表和引用计数的线程安全访问。这种设计通过锁分离和分区的方式,提高了系统的并发性能,避免了全局锁带来的性能瓶颈,从而实现了高效的对象管理和引用计数处理。(但也有缺点,哈希表越满,哈希冲突会多,性能越差.) - - - -## dealloc 是如何工作的? - -在 MRC 时代,写完代码都需要显示在 dealloc 方法中做一些内存回收之类的工作。对象析构时将内部对象先 release 掉,非 OC 对象(比如定时器、c 对象、CF 对象等) 也需要回收内存,最后调用 `[super dealloc]` 继续将父类对象做析构。 - -```objectivec -- (void)dealloc { - CFRelease(XX); - self.timer = nil; - [super dealloc]; -} -``` - -但在 ARC 时代,dealloc 中一般只需要写一些非 OC 对象的内存释放工作,比如 `CFRelease()` - -带来2个问题: - -- 类中的实例变量在哪释放? - -- 当前类中没有显示调用 `[super dealloc]` ,父类的析构如何触发? - - - -### LLVM 文档对 dealloc 的描述 - -[LLVM ARC 文档对 dealloc 描述](https://clang.llvm.org/docs/AutomaticReferenceCounting.html#dealloc) 如下 - -> A class may provide a method definition for an instance method named `dealloc`. This method will be called after the final `release` of the object but before it is deallocated or any of its instance variables are destroyed. The superclass’s implementation of `dealloc` will be called automatically when the method returns. -> -> The instance variables for an ARC-compiled class will be destroyed at some point after control enters the `dealloc` method for the root class of the class. The ordering of the destruction of instance variables is unspecified, both within a single class and between subclasses and superclasses. - -根据描述可以看到 dealloc 方法在最后一次 release 方法调用后触发,但实例变量(ivars) 还未释放,父类的 dealloc 方法将会在子类 dealloc 方法返回后自动调用。 - -ARC 模式下,对象的实例变量会在基类 `[NSObject dealloc]` 中释放,但是释放的顺序是不一定的。 - -也就是说会自动调用 `[super dealloc]`,那到底如何实现的,探究下。 - - - -### 查看 objc4 源码 - -```c -- (void)dealloc { - _objc_rootDealloc(self); -} - -void -_objc_rootDealloc(id obj) -{ - ASSERT(obj); - - obj->rootDealloc(); -} - -inline void -objc_object::rootDealloc() -{ - // 如果是 Tagged Pointer 指针,也就是一个伪对象,不需要执行堆上内存回收流程,直接 return - if (isTaggedPointer()) return; // fixme necessary? - -#if !ISA_HAS_INLINE_RC - object_dispose((id)this); -#else - // fastpath 判断当前对象是否满足条件。 - if (fastpath(isa().nonpointer && // nonpointer:0,普通的 isa 指针,1,代表优化过的 isa 指针,是一个联合体结构 - !isa().weakly_referenced && // 是否有弱引用 - !isa().has_assoc && // 是否有关联对象 -#if ISA_HAS_CXX_DTOR_BIT - !isa().has_cxx_dtor && // 是否有 c++ 析构函数 -#else - !isa().getClass(false)->hasCxxDtor() && // 析构函数 -#endif - !isa().has_sidetable_rc)) // 引用计数信息是否存的下,存不下则用 sideTable 存储 - { - assert(!sidetable_present()); - free(this); // 一个普通的对象,会执行快速释放逻辑 free - } - else { - object_dispose((id)this); // 执行完整的对象释放流程 - } -#endif // ISA_HAS_INLINE_RC -} - -id -object_dispose(id obj) -{ - if (!obj) return nil; - - objc_destructInstance(obj); - free(obj); - - return nil; -} - -void *objc_destructInstance(id obj) -{ - if (obj) { - // Read all of the flags at once for performance. - bool cxx = obj->hasCxxDtor(); // 判断有c++析构函数 - bool assoc = obj->hasAssociatedObjects(); // 判断有关联对象 - - // This order is important. - if (cxx) object_cxxDestruct(obj); // 清除成员变量 - if (assoc) _object_remove_associations(obj, /*deallocating*/true); // 移除关联对象 - obj->clearDeallocating(); // 将指向当前对象的弱指针置为 nil - } - - return obj; -} - -inline void -objc_object::clearDeallocating() -{ - if (slowpath(!isa().nonpointer)) { // nonpointer 为0,则代表是普通的 isa 指针 - // Slow path for raw pointer isa. - sidetable_clearDeallocating(); // 普通的 isa 指针执行 sidetable_clearDeallocating 方法 -#if ISA_HAS_INLINE_RC // 编译器定义了 ISA_HAS_INLINE_RC,则会执行慢路径操作,调用 clearDeallocating_slow 方法 - } else if (slowpath(isa().weakly_referenced || isa().has_sidetable_rc)) { -#else - } else { // 对象具有弱引用或引用计数表数据,也会执行 clearDeallocating_slow 方法 -#endif - // Slow path for non-pointer isa with weak refs and/or side table data. - clearDeallocating_slow(); - } - - /* -等价于 - - if (slowpath(!isa().nonpointer)) { - // Slow path for raw pointer isa. - sidetable_clearDeallocating(); - } else if (slowpath(isa().weakly_referenced || isa().has_sidetable_rc)) { - // Slow path for non-pointer isa with weak refs and/or side table data. - clearDeallocating_slow(); - } - - if (slowpath(!isa().nonpointer)) { - // Slow path for raw pointer isa. - sidetable_clearDeallocating(); - } else { - // Slow path for non-pointer isa with weak refs and/or side table data. - clearDeallocating_slow(); - } -*/ - - assert(!sidetable_present()); // 因为走完了指向对象的弱指针置为 nil 的逻辑,所以断言判断不存在引用计数表 -} - - -void -objc_object::sidetable_clearDeallocating() -{ - SideTable& table = SideTables()[this]; // 根据对象的地址获取 SideTab(refcnts、weak_table)。用于管理对象的引用计数和若引用信息 - - // clear any weak table items - // clear extra retain count and deallocating bit - // (fixme warn or abort if extra retain count == 0 ?) - table.lock(); // 多线程环境下加锁 - RefcountMap::iterator it = table.refcnts.find(this); // 根据对象的地址,查找对象的引用计数信息 - if (it != table.refcnts.end()) { // 如果找到了对象的引用计数信息 - if (it->second & SIDE_TABLE_WEAKLY_REFERENCED) { // 如果对象被弱引用指向 - weak_clear_no_lock(&table.weak_table, (id)this); // 则执行 weak_clear_no_lock 方法清除与该对象相关的弱引用 - } - table.refcnts.erase(it); // 然后从 refcnts 中移除当前对象的引用计数信息,表示该对象即将被释放 - } - table.unlock(); // 解锁 -} - -NEVER_INLINE void -objc_object::clearDeallocating_slow() -{ - // 断言,判断进该方法的,符合前面的 if 条件。nonpointer 普通 isa 指针,且存在弱引用计数信息,才执行下面流程 - ASSERT(isa().nonpointer && (isa().weakly_referenced -#if ISA_HAS_INLINE_RC - || isa().has_sidetable_rc -#endif - )); - // 根据对象地址获取 SideTable - SideTable& table = SideTables()[this]; - // 加锁 - table.lock(); - // isa 中 weakly_referenced 为真,则执行 weak_clear_no_lock 清除与当前对象有关的引用计数信息。 - if (isa().weakly_referenced) { - weak_clear_no_lock(&table.weak_table, (id)this); - } -#if ISA_HAS_INLINE_RC - if (isa().has_sidetable_rc) { -#endif - table.refcnts.erase(this); // 如果对象有引用计数表数据,则从 refcnts 中移除当前对象的引用计数信息 -#if ISA_HAS_INLINE_RC - } -#endif - table.unlock(); // 解锁 -} - - -void -weak_clear_no_lock(weak_table_t *weak_table, id referent_id) -{ - // 根据传入对象的指针 referent_id 转换为 objc_object 类型的指针 referent - objc_object *referent = (objc_object *)referent_id; - // 调用 weak_entry_for_referent 方法查找指向该对象的弱引用条目 - weak_entry_t *entry = weak_entry_for_referent(weak_table, referent); - // 如果未找到对应的条目,则可能出现异常,则打印一条警告信息后 return - if (entry == nil) { - /// XXX shouldn't happen, but does with mismatched CF/objc - //printf("XXX no entry for clear deallocating %p\n", referent); - return; - } - - // zero out references - weak_referrer_t *referrers; - size_t count; - // 根据弱引用条目的类型(是否超出内联存储)判断需要处理的弱引用数组和数量 - if (entry->out_of_line()) { - referrers = entry->referrers; - count = TABLE_SIZE(entry); - } - else { - referrers = entry->inline_referrers; - count = WEAK_INLINE_COUNT; - } - // 遍历数组 - for (size_t i = 0; i < count; ++i) { - objc_object **referrer = referrers[i]; - if (referrer) { - // 如果弱引用指针指向的对象和当前对象 referent 相同,则将该弱引用指针设为 nil,表示对象已经释放 - if (*referrer == referent) { - *referrer = nil; - } - // 如果不同则可能存在错误,报告错误信息 - else if (*referrer) { - REPORT_WEAK_ERROR("__weak variable at %p holds %p instead of %p. " - "This is probably incorrect use of " - "objc_storeWeak() and objc_loadWeak().", - referrer, (void*)*referrer, (void*)referent); - } - } - } - // 调用该方法从弱引用表中移除该弱引用,完成对象的弱引用清除 - weak_entry_remove(weak_table, entry); -} -``` - -可以清楚看到在 `objc_destructInstance` 方法中调用了3个核心方法 - -- object_cxxDestruct(obj): 清除成员变量 - -- object_remove_assocations(obj):去除该对象相关的关联属性(Category 添加的) - -- obj->clearDeallocating():清空引用计数表和弱引用表,将 weak 引用设置为 nil - -继续看看 object_cxxDestruct 方法内部细节。 - - - -### 神秘的 cxx_destruct - -`object_cxxDestruct` 方法最终会调用到 `object_cxxDestructFromClass` - -```c++ -void object_cxxDestruct(id obj) { - if (_objc_isTaggedPointerOrNil(obj)) return; - object_cxxDestructFromClass(obj, obj->ISA()); -} - -static void object_cxxDestructFromClass(id obj, Class cls) { - void (*dtor)(id); - // Call cls's dtor first, then superclasses's dtors. - for ( ; cls; cls = cls->getSuperclass()) { - if (!cls->hasCxxDtor()) return; - dtor = (void(*)(id)) - lookupMethodInClassAndLoadCache(cls, SEL_cxx_destruct); - // 调用 - if (dtor != (void(*)(id))_objc_msgForward_impcache) { - if (PrintCxxCtors) { - _objc_inform("CXX: calling C++ destructors for class %s", - cls->nameForLogging()); - } - (*dtor)(obj); - } - } -} -``` - -做的事情就是遍历,不断寻找父类中 `SEL_cxx_destruct`这个 selector,找到函数实现并调用。 - -```c -void sel_init(size_t selrefCount){ -#if SUPPORT_PREOPT - if (PrintPreopt) { - _objc_inform("PREOPTIMIZATION: using dyld selector opt"); - } -#endif - namedSelectors.init((unsigned)selrefCount); - // Register selectors used by libobjc - mutex_locker_t lock(selLock) - SEL_cxx_construct = sel_registerNameNoLock(".cxx_construct", NO); - SEL_cxx_destruct = sel_registerNameNoLock(".cxx_destruct", NO); -} -``` - -继续翻阅源码发现 `SEL_cxx_destruct` 其实就是 `.cxx_destruct`。在 《Effective Objective-C 2.0》中说明: - -> When the compiler saw that an object contained C++ objects, it would generate a method called .cxx_destruct. ARC piggybacks on this method and emits the required cleanup code within it. - -也就是说,当编译器看到 C++ 对象的时候,它将会生成 `.cxx_destruct` 析构方法,但是 ARC 借用这个方法,并在其中插入了代码以实现自动内存释放的功能。 - - - -### 探究啥时候生成 .cxx_destruct 方法 - -```objectivec -@interface Person : NSObject -@property (nonatomic, strong) NSString *name; -@end -// -- (void)viewDidLoad { - [super viewDidLoad]; - { - NSLog(@"comes"); - Person *p = [[Person alloc] init]; - p.name = @"杭城小刘"; - NSLog(@"gone"); - } -} -``` - -在 gone 处加断点,利用 runtime 查看类中的方法信息 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/cxx_destructDemo1.png) - -发现存在 `.cxx_destruct` 方法。 - -我们一开始要研究的是 ivars 啥时候释放,所以控制变量,将属性改为成员对象 - -```objectivec -@interface Person : NSObject -{ - @public - NSString *name; -} -@end - -{ - NSLog(@"comes"); - Person *p = [[Person alloc] init]; - p->name = @"杭城小刘"; - NSLog(@"gone"); -} -``` - -也有 `.cxx_destruct` 方法 - -将成员变量换为基本数据类型 - -```objectivec -@interface Person : NSObject -{ - @public - int age; -} -@end -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/cxx_destructdemo3.png) - -Tips:@property 会自动生成成员变量,另外类后面加 `{}` 在内部也可以加成员变量,假如成员变量是对象类型,比如 NSString,则叫实例变量。 - -得出结论: - -- 只有 ARC 模式下才有 `.cxx_destruct` 方法 - -- 类拥有实例变量的时候(`{}` 或者 `@property`) 才有 `.cxx_destruct`,父类成员对象的实例变量不会让子类拥有该方法 - -使用 watchpoint 观察内存释放时机 - -在 gone 的地方加断点,输入 `watchpoint set variable p->_name`,则会将 `_name` 实例变量加入 watchpoint,当变量被修改时会触发断点,可以看出从某个值变为 0x0,也就是 nil。此时边上调用堆栈显示在 `objc_storestrong` 方法中,被设置为 nil. - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/cxx_destructDemo2.png) - - - -### 深入 .cxx_destruct - -简单梳理下,在 ARC 模式下,类拥有实例变量的时候会在 `.cxx_destruct` 方法内调用 `objc_storeStrong` 去释放的内存。 - -我们也知道 `.cxx_destruct` 是编译器生成的代码。去查询资料 `.cxx_destruct site:clang.llvm.org` - -在 clang 的 doxygen 文档中 [CodeGenModule 模块源码](https://clang.llvm.org/doxygen/CodeGenModule_8cpp_source.html)发现了相关逻辑。在 5907 行代码 - -```c -void CodeGenModule::EmitObjCIvarInitializations(ObjCImplementationDecl *D) { - // We might need a .cxx_destruct even if we don't have any ivar initializers. - if (needsDestructMethod(D)) { - IdentifierInfo *II = &getContext().Idents.get(".cxx_destruct"); - Selector cxxSelector = getContext().Selectors.getSelector(0, &II); - ObjCMethodDecl *DTORMethod = ObjCMethodDecl::Create( - getContext(), D->getLocation(), D->getLocation(), cxxSelector, - getContext().VoidTy, nullptr, D, - /*isInstance=*/true, /*isVariadic=*/false, - /*isPropertyAccessor=*/true, /*isSynthesizedAccessorStub=*/false, - /*isImplicitlyDeclared=*/true, - /*isDefined=*/false, ObjCMethodDecl::Required); - D->addInstanceMethod(DTORMethod); - CodeGenFunction(*this).GenerateObjCCtorDtorMethod(D, DTORMethod, false); - D->setHasDestructors(true); - } - - // If the implementation doesn't have any ivar initializers, we don't need - // a .cxx_construct. - if (D->getNumIvarInitializers() == 0 || - AllTrivialInitializers(*this, D)) - return; - - IdentifierInfo *II = &getContext().Idents.get(".cxx_construct"); - Selector cxxSelector = getContext().Selectors.getSelector(0, &II); - // The constructor returns 'self'. - ObjCMethodDecl *CTORMethod = ObjCMethodDecl::Create( - getContext(), D->getLocation(), D->getLocation(), cxxSelector, - getContext().getObjCIdType(), nullptr, D, /*isInstance=*/true, - /*isVariadic=*/false, - /*isPropertyAccessor=*/true, /*isSynthesizedAccessorStub=*/false, - /*isImplicitlyDeclared=*/true, - /*isDefined=*/false, ObjCMethodDecl::Required); - D->addInstanceMethod(CTORMethod); - CodeGenFunction(*this).GenerateObjCCtorDtorMethod(D, CTORMethod, true); - D->setHasNonZeroConstructors(true); -} -``` - -源码大概做的事情就是:获取 `.cxx_destructor` 的 selector,创建 Method,然后将新创建的 Method 插入到 class 方法列表中。调用 `GenerateObjCCtorDtorMethod` 方法,才创建这个方法的实现。查看 GenerateObjCCtorDtorMethod 的实现。在 https://clang.llvm.org/doxygen/CGObjC_8cpp_source.html 的1626行处。 - -```c -static void emitCXXDestructMethod(CodeGenFunction &CGF, - ObjCImplementationDecl *impl) { - CodeGenFunction::RunCleanupsScope scope(CGF); - - llvm::Value *self = CGF.LoadObjCSelf(); - - const ObjCInterfaceDecl *iface = impl->getClassInterface(); - for (const ObjCIvarDecl *ivar = iface->all_declared_ivar_begin(); - ivar; ivar = ivar->getNextIvar()) { - QualType type = ivar->getType(); - - // Check whether the ivar is a destructible type. - QualType::DestructionKind dtorKind = type.isDestructedType(); - if (!dtorKind) continue; - - CodeGenFunction::Destroyer *destroyer = nullptr; - - // Use a call to objc_storeStrong to destroy strong ivars, for the - // general benefit of the tools. - if (dtorKind == QualType::DK_objc_strong_lifetime) { - destroyer = destroyARCStrongWithStore; - - // Otherwise use the default for the destruction kind. - } else { - destroyer = CGF.getDestroyer(dtorKind); - } - - CleanupKind cleanupKind = CGF.getCleanupKind(dtorKind); - - CGF.EHStack.pushCleanup(cleanupKind, self, ivar, destroyer, - cleanupKind & EHCleanup); - } - - assert(scope.requiresCleanups() && "nothing to do in .cxx_destruct?"); - } -``` - -可以看到:遍历了当前对象的所有实例变量,调用 `objc_storeStrong`,从 clang 文档上可以看出 - -```c -id objc_storeStrong(id *object, id value) { - value = [value retain]; - id oldValue = *object; - *object = value; - [oldValue release]; - return value; -} -``` - -在 `.cxx_destruct` 方法内部会对所有的实例变量调用 `objc_storeStrong(&ivar, null)` ,实例变量就会 release 。 - -类的 `isa` 指针指向的类结构体中包含 `CLS_HAS_CXX_STRUCTORS` 标志位时,`object_cxxDestruct`才会被调用。该标志在编译时由 Clang 自动生成,若类包含 C++ 成员变量或显式定义析构函数58。 - - - -### 自动调用 [super dealloc] 的原理 - -同理,CodeGen 也会做自动调用 `[super dealloc]` 的事情。https://clang.llvm.org/doxygen/CGObjC_8cpp_source.html,第751行 `StartObjCMethod` 方法。 - -```c - 751 void CodeGenFunction::StartObjCMethod(const ObjCMethodDecl *OMD, - 752 const ObjCContainerDecl *CD) { - // ... - 789 // In ARC, certain methods get an extra cleanup. - 790 if (CGM.getLangOpts().ObjCAutoRefCount && - 791 OMD->isInstanceMethod() && - 792 OMD->getSelector().isUnarySelector()) { - 793 const IdentifierInfo *ident = - 794 OMD->getSelector().getIdentifierInfoForSlot(0); - 795 if (ident->isStr("dealloc")) - 796 EHStack.pushCleanup(getARCCleanupKind()); - 797 } - 798 } -``` - -可以看到在调用到 dealloc 方法时,插入了代码,实现如下 - -```c -struct FinishARCDealloc : EHScopeStack::Cleanup { - void Emit(CodeGenFunction &CGF, Flags flags) override { - const ObjCMethodDecl *method = cast(CGF.CurCodeDecl); - - const ObjCImplDecl *impl = cast(method->getDeclContext()); - const ObjCInterfaceDecl *iface = impl->getClassInterface(); - if (!iface->getSuperClass()) return; - - bool isCategory = isa(impl); - - // Call [super dealloc] if we have a superclass. - llvm::Value *self = CGF.LoadObjCSelf(); - - CallArgList args; - CGF.CGM.getObjCRuntime().GenerateMessageSendSuper(CGF, ReturnValueSlot(), - CGF.getContext().VoidTy, - method->getSelector(), - iface, - isCategory, - self, - /*is class msg*/ false, - args, - method); - } -}; -``` - -代码大概就是向父类转发 dealloc 的调用实现,内部自动调用 [super dealloc] 方法。 - -总结下: - -- ARC 模式下,实例变量由编译器插入 `.cxx_destruct` 方法自动释放 - -- ARC 模式下 `[super dealloc]` 由 llvm 编译器自动插入(CodeGen) - - - -### ARC 帮我们做了什么? - -#### LLVM + Runtime 共同协作的结果 - -LLVM 编译器前端 clang 在编译阶段,自动帮我们给对象加了 release、retain、autorelease 的代码(比如在一个大括号内的代码,声明的对象,在大括号将要结束的时候会自动加 `[person release] 之类的代码`)。 - -ARC 中禁止手动调用 retain/release/retainCount/dealloc 方法。 - -ARC 中新增 weak、strong 属性关键字。 - -弱引用这样的情况,需要借助于 Runtime 实现。在对象将要销毁的时候,执行 dealloc 方法,判断对象存在 c++ 析构函数、关联对象,则执行进一步的处理,清除成员变量、关联对象。内部借助于 Runtime 能力,根据 isa 找到对象的 SideTable(weak_table、refcnts),清除所有指向对象的弱应引用指针。 - - - -#### 编译器对 Method Family 的处理 - -一个方法生成的对象,没有任何附加标识,ARC 如何知道生成的对象是不是 `autorelease` ? - -```objective-c -@interface Person: NSObject -- (instancetype)initWithName:(NSString *)name; -+ (instancetype)personWithName:(NSString *)name; -@end - -Person *person1 = [[Person alloc] initWithName:@"FantasticLBP"]; -Person *person2 = [Person personWithName:@"FantasticLBP"]; -``` - -使用约定。NS 定义了下面3个编译属性: - -```objective-c -#define NS_RETURNS_RETAINED __attribute__((ns_returns_retained)) -#define NS_RETURNS_NOT_RETAINED __attribute__((ns_returns_not_retained)) -#define NS_RETURNS_INNER_POINTER __attribute__((objc_returns_inner_pointer)) -``` - -这3个属性是 Clang 自己使用的标识,除非特殊情况,不要自己使用。 - -- `NS_RETURNS_RETAINED`:init 和 initWithName 都属于 init 家族方法。对于以 alloc、init、copy、mutableCopy、new 开头的家族方法,后面默认加 `NS_RETURNS_RETAINED`,ARC 会在调用方法外围加上内存管理代码,retain + release -- `NS_RETURNS_NOT_RETAINED`: `personWithName` 方法,则是不带 alloc、init、copy、mutableCopy、new 开头的方法,默认添加 `NS_RETURNS_NOT_RETAINED` 标识,表明返回的对象已经在方法内加过 autorelease 了 -- `NS_RETURNS_INNER_POINTER`:这个只用做返回 c 语言指针变量,ARC 外围不做内存管理的操作。如 `- (__strong const char *)UTF8String NS_RETURNS_INNER_POINTER;` - - - -上面提到了[Method families](https://clang.llvm.org/docs/AutomaticReferenceCounting.html#id37) - -> An Objective-C method may fall into a **method family**, which is a conventional set of behaviors ascribed to it by the Cocoa conventions. - -在 OC 中,方法可以被归类到方法族(method family)中,这是由 Cocoa 约定赋予方法的一组传统行为。方法族是一种命名约定,用于指示方法的特定行为和语义。举例来说,alloc、init、copy、mutableCopy、new 等方法族在 OC 中具有特殊的内存管理行为和语义,这些方法族在 Cocoa 框架中有着重要的作用,帮助开发者遵循内存管理规则和约定,确保代码的可靠性和性能。 - -这些方法族的存在使得 OC 代码更易于理解和遵循,同时也有助于保持代码的一致性和可维护性。通过遵循 Cocoa 的方法族约定,开发者可以更好地利用 OC 的动态、面向对象的特性,编写出清晰、高效的代码。 - - - -上面针对 Person 的代码,编译器看起来是这样的 - -```objective-c -@interface Person: NSObject -- (instancetype)initWithName:(NSString *)name NS_RETURNS_RETAINED; -+ (instancetype)personWithName:(NSString *)name NS_RETURNS_NOT_RETAINED; -@end -``` - -这也是为什么不能在 ARC 下,将属性命名以 new 开头的原因,`@property (nonatomic, copy) NSString *newString;` 编译器会报错。`newString` 的 getter 方法会被编译器看成是 new 家族方法,会在外围加入内存管理代码 retain + release,从而导致内存管理错误。 - - - -### ARC 中显式或隐式调用对于引用计数的影响 - -约定 `[target selector]` 的方式为显示,其他都是隐式。 - - - -隐式调用工厂方法 - - - - - -隐式调用的时候没有对 person 进行显式的赋值,而是传入 `getReturnValue: `方法中去获取返回值,这样的赋值后 ARC 没有自动给这个变量插入 retain 语句,但退出作用域时还是自动插入了release 语句,导致这个变量多释放了一次,导致 crash - - - -如何修改?加一个 bridge 即可。 - - - -由于 ARC 没有加 retain。所以 `person = (__bridge id)result;` 这里完成了对象的 retain。ARC 在退出方法的作用域时给对象加上release。前后对应,内存正确。 - - - - - - - - - -## AutoreleasePool 底层原理探索 - -### AutoreleasePool 结构探究 - -```objectivec -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p = [[[Person alloc] init] autorelease]; - } - return 0; -} -``` - -clang 转为 c++ `xcrun -sdk iphonesimulator clang -rewrite-objc main.m` - -```c++ -int main(int argc, const char * argv[]) { - /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; - Person *p = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)((Person *(*)(id, SEL))(void *)objc_msgSend)((id)((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("alloc")), sel_registerName("init")), sel_registerName("autorelease")); - } - return 0; -} -``` - -下面的代码其实就是 objc_msgSend,有效代码是 `__AtAutoreleasePool __autoreleasepool;` - -继续查找 - -```c++ -struct __AtAutoreleasePool { - __AtAutoreleasePool() { -     atautoreleasepoolobj = objc_autoreleasePoolPush(); - } - ~__AtAutoreleasePool() { -     objc_autoreleasePoolPop(atautoreleasepoolobj); - } - void * atautoreleasepoolobj; -}; -``` - -OC 对象本质就是结构体 - -- `__AtAutoreleasePool` 结构体中 `__AtAutoreleasePool` 是构造方法,在创建结构体的时候调用 - -- `~__AtAutoreleasePool` 是析构函数,在结构体销毁的时候调用 - -main 内的代码作用域,离开代表销毁。所以上面代码等价于 - -```objective-c -atautoreleasepoolobj = objc_autoreleasePoolPush(); -Person *p = [[[Person alloc] init] autorelease]; -objc_autoreleasePoolPop(atautoreleasepoolobj); -``` - -利用关键函数继续查看 objc4 源码 - -```c++ -void *_objc_autoreleasePoolPush(void) -{ - return objc_autoreleasePoolPush(); -} - -void _objc_autoreleasePoolPop(void *ctxt) -{ - objc_autoreleasePoolPop(ctxt); -} - -void *objc_autoreleasePoolPush(void) -{ - return AutoreleasePoolPage::push(); -} - -NEVER_INLINEvoid objc_autoreleasePoolPop(void *ctxt) -{ - AutoreleasePoolPage::pop(ctxt); -} -``` - -**自动释放池的主要实现依靠2个对象:`__AtAutoreleasePool`、`AutoreleasePoolPage`** - -**objc_autoreleasePoolPush、objc_autoreleasePoolPop 底层都是调用了 AutoreleasePoolPage 对象来管理的。** - -查看源码 `NSObject-internal.h` 后对 `AutoreleasePoolPageData` 剔出无用成员后,关键信息如下 - -```c++ -class AutoreleasePoolPage { - magic_t const magic; - id *next; - pthread_t const thread; - AutoreleasePoolPage * const parent; - AutoreleasePoolPage *child; - uint32_t const depth; - uint32_t hiwat; -} -``` - -- 每个 AutoreleasePoolPage 对象占用 4096 (16的3次方,0x2000)字节内存,除了用来存放它内部的成员变量(内部成员固定有7个,56个字节,即 `0x18`, `0x1000 + 0x38 = 0x1038` ),剩下的空间用来存放 autorelease 对象的地址 -- 所有的 AutoreleasePoolPage 对象通过**双向链表**的形式连接在一起。child 指向下一个 AutoreleasePoolPage 对象,parent 指向上一个 AutoreleasePoolPage 对象 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/autoreleasepool.png) - -```objectivec -id * begin() { - return (id *) ((uint8_t *)this+sizeof(*this)); -} - -id * end() { - return (id *) ((uint8_t *)this+SIZE); -} - - static size_t const SIZE = -#if PROTECT_AUTORELEASEPOOL - PAGE_MAX_SIZE; // must be multiple of vm page size -#else - PAGE_MIN_SIZE; // size and alignment, power of 2 -#endif -``` - -分析: - -- begin 方法返回 autoreleasePoolPage 对象中开始存储 autorelease 对象的开始地址,也就是4049个字节中,扣除最开始存放 AutoreleasePoolPage 对象固定空间外,可以存 autorelease 对象的位置。该怎么算?`可以存放 autorelease 对象的开始地址 = 自己对象的地址 + 偏移量 = 自己对象的地址 + 自己对象的所占空间 = (uint8_t *)this + sizeof(*this)` - -- end 方法返回 autoreleasePoolPage 对象中结束存储 autorelease 对象的开始地址。该怎么算?`end 地址 = 自己对象的开始地址 + 4096字节`。其中 `SIZE` 是一个宏计算结果,也就是 4096。 - -### 源码分析 - -1.源码分析 push 方法 - -```c++ -static inline void *push() { - id *dest; - if (DebugPoolAllocation) { - // Each autorelease pool starts on a new pool page. - dest = autoreleaseNewPage(POOL_BOUNDARY); // 没有 autoreleasepool 则执行 autoreleaseNewPage 方法,并且传入一个 POOL_BOUNDARY - } else { - dest = autoreleaseFast(POOL_BOUNDARY); // 如果有 autoreleasepool,执行 push 也会放入一个 POOL_BOUNDARY - } - assert(dest == EMPTY_POOL_PLACEHOLDER || *dest == POOL_BOUNDARY); - return dest; -} - -static __attribute__((noinline)) -id *autoreleaseNewPage(id obj) -{ - AutoreleasePoolPage *page = hotPage(); // 通过 hotPage 方法创建一个 AutoreleasePoolPage 对象 - if (page) return autoreleaseFullPage(obj, page); // 调用 autoreleaseFullPage 方法,传入新创建的 page 和外部传入的 POOL_BOUNDARY - else return autoreleaseNoPage(obj); -} - -static __attribute__((noinline)) -id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page) -{ - // The hot page is full. - // Step to the next non-full page, adding a new page if necessary. - // Then add the object to that page. - ASSERT(page == hotPage()); - ASSERT(page->full() || DebugPoolAllocation); - - do { - if (page->child) page = page->child; - else page = new AutoreleasePoolPage(page); - } while (page->full()); - - setHotPage(page); - - // dtrace probe - OBJC_RUNTIME_AUTORELEASE_POOL_GROW(page->depth); - - return page->add(obj); // 将首次创建的 AutoreleasePoolPage 对象中,添加一个外部传入的 POOL_BOUNDARY -} - -``` - -结论:**调用 `AutoreleasePoolPage::push` 方法会将一个 `POOL_BOUNDARY `入栈,并且返回其存放的内存地址** - - - -2.源码分析 pop 方法 - -```c++ -class AutoreleasePoolPage: private AutoreleasePoolPageData { - // ... - static inline void - pop(void *token) - { - // dtrace probe - OBJC_RUNTIME_AUTORELEASE_POOL_POP(token); - - // We may have an object in the ReturnAutorelease TLS when the pool is - // otherwise empty. Release that first before checking for an empty pool - // so we don't return prematurely. Loop in case the release placed a new - // object in the TLS. - while (releaseReturnAutoreleaseTLS()) - ; - - AutoreleasePoolPage *page; - id *stop; - if (token == (void*)EMPTY_POOL_PLACEHOLDER) { - // Popping the top-level placeholder pool. - page = hotPage(); - if (!page) { - // Pool was never used. Clear the placeholder. - return setHotPage(nil); - } - // Pool was used. Pop its contents normally. - // Pool pages remain allocated for re-use as usual. - page = coldPage(); - token = page->begin(); - } else { - page = pageForPointer(token); - } - - stop = (id *)token; - if (*stop != POOL_BOUNDARY) { - if (stop == page->begin() && !page->parent) { - // Start of coldest page may correctly not be POOL_BOUNDARY: - // 1. top-level pool is popped, leaving the cold page in place - // 2. an object is autoreleased with no pool - } else { - // Error. For bincompat purposes this is not - // fatal in executables built with old SDKs. - return badPop(token); - } - } - - if (slowpath(PrintPoolHiwat || DebugPoolAllocation || DebugMissingPools)) { - return popPageDebug(token, page, stop); - } - - return popPage(token, page, stop); - } -} - -template -static void -popPage(void *token, AutoreleasePoolPage *page, id *stop) -{ - if (allowDebug && PrintPoolHiwat) printHiwat(); - - page->releaseUntil(stop); - - // memory: delete empty children - if (allowDebug && DebugPoolAllocation && page->empty()) { - // special case: delete everything during page-per-pool debugging - AutoreleasePoolPage *parent = page->parent; - page->kill(); - setHotPage(parent); - } else if (allowDebug && DebugMissingPools && page->empty() && !page->parent) { - // special case: delete everything for pop(top) - // when debugging missing autorelease pools - page->kill(); - setHotPage(nil); - } else if (page->child) { - // hysteresis: keep one empty child if page is more than half full - if (page->lessThanHalfFull()) { - page->child->kill(); - } - else if (page->child->child) { - page->child->child->kill(); - } - } -} - -void releaseUntil(id *stop) - { - // Not recursive: we don't want to blow out the stack - // if a thread accumulates a stupendous amount of garbage - - do { - while (this->next != stop) { - // Restart from hotPage() every time, in case -release - // autoreleased more objects - AutoreleasePoolPage *page = hotPage(); - - // fixme I think this `while` can be `if`, but I can't prove it - while (page->empty()) { - page = page->parent; - setHotPage(page); - } - - page->unprotect(); -#if SUPPORT_AUTORELEASEPOOL_DEDUP_PTRS - AutoreleasePoolEntry* entry = (AutoreleasePoolEntry*) --page->next; - - // create an obj with the zeroed out top byte and release that - id obj = (id)entry->getPointer(); - int count = (int)entry->getCount(); // grab these before memset -#else - id obj = *--page->next; // 跨 AutoreleasePoolPage release 对象 -#endif - memset((void*)page->next, SCRIBBLE, sizeof(*page->next)); - page->protect(); - - if (obj != POOL_BOUNDARY) { -#if SUPPORT_AUTORELEASEPOOL_DEDUP_PTRS - // release count+1 times since it is count of the additional - // autoreleases beyond the first one - for (int i = 0; i < count + 1; i++) { - objc_release(obj); - } -#else - objc_release(obj); // for 循环,对每个对象调用 objc_release 方法 -#endif - } - } - - // Stale return autorelease info is conceptually autoreleased. If - // there is any, release the object in the info. If stale info is - // present, we have to loop in case it autoreleased more objects - // when it was released. - } while (releaseReturnAutoreleaseTLS()); - - setHotPage(this); - -#if DEBUG - // we expect any children to be completely empty - for (AutoreleasePoolPage *page = child; page; page = page->child) { - ASSERT(page->empty()); - } -#endif - } -``` - -系统在 autoreleasepool 执行析构函数的时候,会调用 pop 方法,pop 方法传入一个创建时候的地址,也就是 `POOL_BOUNDARY` 的地址,... 最后调用 `releaseUntil` 方法,for 循环不断遍历(当前对象到 `POOL_BOUNDARY` 地址之间)对象,调用 `objc_release` 方法释放对象。并向回移动`next`指针到正确位置。 - -在移除的过程中,从最新加入的对象一直向前清理,期间可以向前跨越若干个page,直到哨兵所在的page - -结论:**调用 `AutoreleasePoolPage::pop` 方法时传入一个 `POOL_BOUNDARY` 的内存地址,系统会从最后一个入栈的对象开始发送 `release` 消 息,不断调用,直到遇到这个 `POOL_BOUNDARY` 地址为止**。 - - - -- `next` 方法指向了下一个能存放 autorelease 对象地址的区域(感兴趣的可以查看源码) - - - -看看系统源码,OC 对象调用 autorelease 方法做了什么? - -```c++ -// NSObject.mm -// Replaced by ObjectAlloc -- (id)autorelease { - return _objc_rootAutorelease(self); -} - -NEVER_INLINE id -_objc_rootAutorelease(id obj) -{ - ASSERT(obj); - return obj->rootAutorelease(); -} - -// Base autorelease implementation, ignoring overrides. -ALWAYS_INLINE id -objc_object::rootAutorelease() -{ - if (isTaggedPointer()) return (id)this; - bool nonpointerIsa = false; -#if ISA_HAS_INLINE_RC - nonpointerIsa = isa().nonpointer; - - // When we can cheaply determine if the object is deallocating, avoid - // putting it in the pool. Refcounting doesn't work on a deallocating object - // so it's pointless to put it in the pool, and potentially dangerous. - if (nonpointerIsa && isa().isDeallocating()) return (id)this; -#endif - - // If the class has custom dealloc initiation, we also want to avoid putting - // deallocating instances in the pool even if it's expensive to check. (UIView - // and UIViewController need this. rdar://97186669) - if (!nonpointerIsa && ISA()->hasCustomDeallocInitiation() && rootIsDeallocating()) - return (id)this; - - if (prepareOptimizedReturn((id)this, true, ReturnAtPlus1)) return (id)this; - if (slowpath(isClass())) return (id)this; - - return rootAutorelease2(); -} - - -__attribute__((noinline,used)) -id -objc_object::rootAutorelease2() -{ - ASSERT(!isTaggedPointer()); - return AutoreleasePoolPage::autorelease((id)this); -} - -class AutoreleasePoolPage : private AutoreleasePoolPageData { - //... - static inline id *autoreleaseFast(id obj){ - AutoreleasePoolPage *page = hotPage(); - if (page && !page->full()) { - return page->add(obj); // 有 autoreleasepage 且没有满 - } else if (page) { // 有 page 但满了 - return autoreleaseFullPage(obj, page); - } else { // 没有 page,则去创建 page - return autoreleaseNoPage(obj); - } - } - - static inline id autorelease(id obj) { - ASSERT(!_objc_isTaggedPointerOrNil(obj)); - id *dest __unused = autoreleaseFast(obj); - #if SUPPORT_AUTORELEASEPOOL_DEDUP_PTRS - ASSERT(!dest || dest == (id *)EMPTY_POOL_PLACEHOLDER || (id)((AutoreleasePoolEntry *)dest)->getPointer() == obj); - #else - ASSERT(!dest || dest == (id *)EMPTY_POOL_PLACEHOLDER || *dest == obj); - #endif - return obj; - } -} -``` - -查看源码发现,对象调用 `autorelease ` 方法,会调用 `_objc_rootAutorelease` 方法。内部会继续调用 `rootAutorelease` 方法,底层实现会调用 `rootAutorelease2` 方法,其会调用 `AutoreleasePoolPage::autorelease` 方法,其会调用 `autoreleaseFast` 方法,其会调用 `page->add` 方法,将对象添加到自动释放池中。 - -结论:**对象调用 `autorelease()` 方法的本质是将该对象加入到自动释放池中** - - - -### 单 AutoreleasePool 的 case - -举个例子,for 循环创建1000个 Person 对象,用 autorelease 修饰,如何工作? - - - -分析: - -1. 首先创建第一个 AutoreleasePoolPage 对象,假设地址为 `0x1000`,由于一个 Auto releasePoolPage 内有固定成员变量,占用56个字节。AutoreleasePoolPage 对象的 `begin` 方法,获取可以存储被 autorelease 修饰的对象地址。 -2. 一个 person 指针占用8个字节,循环 1000 次,创建了1000个 person 对象,所以需要 8000 字节空间存储 autorelease 对象 -3. 最开始,先插入一个 `POOL_BOUNDARY` 对象 -4. 然后开始插入第一个 person 对象,插入到 `POOL_BOUNDARY` 紧挨着的位置,也就是 `0x1040` 处,占用8个字节 -5. 第三个 person 对象被插入到 `0x1048` 处,依次类推... -6. 但第一个 AutoreleasePoolPage 存不下1000个 person 指针,所以创建了第二个 AutoreleasePoolPage 对象 -7. 同样按照上面的逻辑,一个 person 对象占8个字节,将剩余的 person 对象插满 -8. 最后 for 循环结束,也就是 `atautoreleasepoolobj` 对象将要释放了,本质上就是执行 ` objc_autoreleasePoolPop(atautoreleasepoolobj)` -9. 释放的过程和插入的过程刚好相反,从最后一个 person 对象开始,执行 `release` 方法 -10. 直到遇到传入的 `atautoreleasepoolobj` 为止(`atautoreleasepoolobj` 地址,其实就是传入的 `POOL_BOUNDARY` 内存地址) - - - -### 多 AutoreleasePool 的 case - -来个骚一些的例子 - -```objectivec -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p1 = [[[Person alloc] init] autorelease]; - Person *p2 = [[[Person alloc] init] autorelease]; - @autoreleasepool { - Person *p3 = [[[Person alloc] init] autorelease]; - @autoreleasepool { - Person *p4 = [[[Person alloc] init] autorelease]; - } - } - } - return 0; -} -``` - -main 方法内部3个 autoreleasepool 底层怎么样工作的? - - - -分析: - -上面代码等价于 - -```c++ -atautoreleasepoolobj1 = objc_autoreleasePoolPush(); -Person *p1 = [[[Person alloc] init] autorelease]; -Person *p2 = [[[Person alloc] init] autorelease]; -objc_autoreleasePoolPop(atautoreleasepoolobj1); - -atautoreleasepoolobj2 = objc_autoreleasePoolPush(); -Person *p3 = [[[Person alloc] init] autorelease]; -objc_autoreleasePoolPop(atautoreleasepoolobj2); - -atautoreleasepoolobj3 = objc_autoreleasePoolPush(); -Person *p4 = [[[Person alloc] init] autorelease]; -objc_autoreleasePoolPop(atautoreleasepoolobj3); -``` - -- 共 3 个@auto releasepool,系统遇到一个 `@autoreleasepool{}` 的时候,底层就是初始化一个结构体 `__AtAutoreleasePool`,结构体构造方法内部调用 `AutoreleasePoolPage::push` 方法、析构函数调用 `AutoreleasePoolPage::pop` 方法 -- 插入阶段: - - 遇到第一个 `@autoreleasepool{}` ,则创建第一个 AutoreleasePoolPage 对象 atautoreleasepoolobj1,构造方法调用 push 方法的时候,首先插入一个 `POOL_BOUNDARY` 对象,一个对象调用 `autorelease` 方法,则会被加入到 AutoreleasePoolPage 对象的,自动释放区。然后插入 P1、P2 对象地址 - - 遇到第二个 `@autoreleasepool{}` ,则创建第一个 AutoreleasePoolPage 对象 atautoreleasepoolobj2,构造方法调用 push 方法的时候,首先插入一个 `POOL_BOUNDARY` 对象,一个对象调用 `autorelease` 方法,则会被加入到 AutoreleasePoolPage 对象的,自动释放区。然后插入 P3 对象地址 - - 遇到第三个 `@autoreleasepool{}` ,则创建第一个 AutoreleasePoolPage 对象 atautoreleasepoolobj3,构造方法调用 push 方法的时候,首先插入一个 `POOL_BOUNDARY` 对象,一个对象调用 `autorelease` 方法,则会被加入到 AutoreleasePoolPage 对象的,自动释放区。然后插入 P4 对象地址 -- 释放阶段:(从里到外释放) - - 遇到第三个 `@autoreleasepool{}` 大括号结束,则其内部的 atautoreleasepoolobj3 执行析构函数,调用 `AutoreleasePoolPage::pop` 方法 - - 首先从 p4(最后一个入栈的对象) 开始,不断执行 `release` 消息,直到遇到调用 `objc_autoreleasePoolPop(atautoreleasepoolobj3)` 传入的 atautoreleasepoolobj3 - - 继续从 p3 开始,不断执行 `release` 消息,直到遇到调用 `objc_autoreleasePoolPop(atautoreleasepoolobj2)` 传入的 atautoreleasepoolobj2 - - 继续从 p2 开始,不断执行 `release` 消息,直到遇到调用 `objc_autoreleasePoolPop(atautoreleasepoolobj1)` 传入的 atautoreleasepoolobj1 -- 至此,完成了 autorelease 对象的管理和释放流程。 - -所以,嵌套的AutoreleasePool就非常简单了,pop的时候总会释放到上次 push 的位置为止,多层的 pool 就是多个**哨兵对象(POOL_BOUNDARY)**而已,就像剥洋葱一样,每次一层,互不影响 - - - -小窍门,对于上述原理的分析可以用源码中看到的 `AutoreleasePoolPage` 对象的 `printAll` 方法。 - -```c -static void printAll() { - _objc_inform("##############"); - _objc_inform("AUTORELEASE POOLS for thread %p", pthread_self()); - - AutoreleasePoolPage *page; - ptrdiff_t objects = 0; - for (page = coldPage(); page; page = page->child) { - objects += page->next - page->begin(); - } - _objc_inform("%llu releases pending.", (unsigned long long)objects); - - if (haveEmptyPoolPlaceholder()) { - _objc_inform("[%p] ................ PAGE (placeholder)", - EMPTY_POOL_PLACEHOLDER); - _objc_inform("[%p] ################ POOL (placeholder)", - EMPTY_POOL_PLACEHOLDER); - } - else { - for (page = coldPage(); page; page = page->child) { - page->print(); - } - } - _objc_inform("##############"); -} - - -void _objc_autoreleasePoolPrint(void) { - AutoreleasePoolPage::printAll(); -} -``` - -查了下 `printAll` 函数的使用方,就只有 `_objc_autoreleasePoolPrint` 函数。且可以看到在 objc4 `objc-internal.h` 头文件中有将该函数 export 出去,也就是可以在外部链接该符号。 - -```c -OBJC_EXPORT void _objc_autoreleasePoolPrint(void) OBJC_AVAILABLE(10.7, 5.0, 9.0, 1.0, 2.0); -``` - -所以我们在测试 Demo 中将 `_objc_autoreleasePoolPrint` 函数声明下。在打印下 - -```c -extern void _objc_autoreleasePoolPrint(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p1 = [[[Person alloc] init] autorelease]; - Person *p2 = [[[Person alloc] init] autorelease]; - @autoreleasepool { - Person *p3 = [[[Person alloc] init] autorelease]; - @autoreleasepool { - Person *p4 = [[[Person alloc] init] autorelease]; - _objc_autoreleasePoolPrint(); - } - } - } - return 0; -} -objc[23132]: ############## -objc[23132]: AUTORELEASE POOLS for thread 0x100094600 -objc[23132]: 7 releases pending. -objc[23132]: [0x10080a000] ................ PAGE (hot) (cold) -objc[23132]: [0x10080a038] ################ POOL 0x10080a038 -objc[23132]: [0x10080a040] 0x10075f060 Person -objc[23132]: [0x10080a048] 0x10075f0c0 Person -objc[23132]: [0x10080a050] ################ POOL 0x10080a050 -objc[23132]: [0x10080a058] 0x10075f0e0 Person -objc[23132]: [0x10080a060] ################ POOL 0x10080a060 -objc[23132]: [0x10080a068] 0x10075f100 Person -objc[23132]: ############## -``` - -可以看到打印结果和上面的分析是一致的(和上面的图片对比看看) - -再来个 Demo,验证下 AutoreleasePoolPage 一页满情况 - -```c -extern void _objc_autoreleasePoolPrint(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p1 = [[[Person alloc] init] autorelease]; - Person *p2 = [[[Person alloc] init] autorelease]; - @autoreleasepool { - for (NSInteger index = 0; index<600; index++) { - Person *p3 = [[[Person alloc] init] autorelease]; - } - @autoreleasepool { - Person *p4 = [[[Person alloc] init] autorelease]; - _objc_autoreleasePoolPrint(); - } - } - } - return 0; -} - -objc[23504]: ############## -objc[23504]: AUTORELEASE POOLS for thread 0x100094600 -objc[23504]: 606 releases pending. -objc[23504]: [0x10080d000] ................ PAGE (full) (cold) -objc[23504]: [0x10080d038] ################ POOL 0x10080d038 -objc[23504]: [0x10080d040] 0x1007092f0 Person -objc[23504]: [0x10080d048] 0x100709350 Person -objc[23504]: [0x10080d050] ################ POOL 0x10080d050 -objc[23504]: [0x10080d058] 0x100753250 Person -objc[23504]: [0x10080d060] 0x100753270 Person -objc[23504]: [0x10080d068] 0x100753290 Person -objc[23504]: [0x10080d070] 0x1007532b0 Person -objc[23504]: [0x10080d078] 0x1007532d0 Person -objc[23504]: [0x10080d080] 0x1007532f0 Person -objc[23504]: [0x10080d088] 0x100753310 Person -objc[23504]: [0x10080d090] 0x100753330 Person -objc[23504]: [0x10080d098] 0x100753680 Person -objc[23504]: [0x10080d0a0] 0x1007536a0 Person -objc[23504]: [0x10080d0a8] 0x1007536c0 Person -objc[23504]: [0x10080d0b0] 0x1007536e0 Person -objc[23504]: [0x10080d0b8] 0x100753700 Person -objc[23504]: [0x10080d0c0] 0x100753720 Person -objc[23504]: [0x10080d0c8] 0x100753740 Person -objc[23504]: [0x10080d0d0] 0x100753760 Person -objc[23504]: [0x10080d0d8] 0x100753780 Person -objc[23504]: [0x10080d0e0] 0x1007537a0 Person -objc[23504]: [0x10080d0e8] 0x1007537c0 Person -objc[23504]: [0x10080d0f0] 0x1007537e0 Person -objc[23504]: [0x10080d0f8] 0x100753800 Person -objc[23504]: [0x10080d100] 0x100753820 Person -objc[23504]: [0x10080d108] 0x100753840 Person -objc[23504]: [0x10080d110] 0x100753860 Person -objc[23504]: [0x10080d118] 0x100753880 Person -objc[23504]: [0x10080d120] 0x1007538a0 Person -objc[23504]: [0x10080d128] 0x1007538c0 Person -objc[23504]: [0x10080d130] 0x1007538e0 Person -objc[23504]: [0x10080d138] 0x100753900 Person -objc[23504]: [0x10080d140] 0x100753920 Person -objc[23504]: [0x10080d148] 0x100753940 Person -objc[23504]: [0x10080d150] 0x100753960 Person -objc[23504]: [0x10080d158] 0x100753980 Person -objc[23504]: [0x10080d160] 0x1007539a0 Person -objc[23504]: [0x10080d168] 0x1007539c0 Person -objc[23504]: [0x10080d170] 0x1007539e0 Person -objc[23504]: [0x10080d178] 0x100753a00 Person -objc[23504]: [0x10080d180] 0x100753a20 Person -objc[23504]: [0x10080d188] 0x100753a40 Person -objc[23504]: [0x10080d190] 0x100753a60 Person -objc[23504]: [0x10080d198] 0x100753a80 Person -objc[23504]: [0x10080d1a0] 0x100753aa0 Person -objc[23504]: [0x10080d1a8] 0x100753ac0 Person -objc[23504]: [0x10080d1b0] 0x100753ae0 Person -objc[23504]: [0x10080d1b8] 0x100753b00 Person -objc[23504]: [0x10080d1c0] 0x100753b20 Person -objc[23504]: [0x10080d1c8] 0x100753b40 Person -objc[23504]: [0x10080d1d0] 0x100753b60 Person -objc[23504]: [0x10080d1d8] 0x100753b80 Person -objc[23504]: [0x10080d1e0] 0x100753ba0 Person -objc[23504]: [0x10080d1e8] 0x100753bc0 Person -objc[23504]: [0x10080d1f0] 0x100753be0 Person -objc[23504]: [0x10080d1f8] 0x100753c00 Person -objc[23504]: [0x10080d200] 0x100753c20 Person -objc[23504]: [0x10080d208] 0x100753c40 Person -objc[23504]: [0x10080d210] 0x100753c60 Person -objc[23504]: [0x10080d218] 0x100753c80 Person -objc[23504]: [0x10080d220] 0x100753ca0 Person -objc[23504]: [0x10080d228] 0x100753cc0 Person -objc[23504]: [0x10080d230] 0x100753ce0 Person -objc[23504]: [0x10080d238] 0x100753d00 Person -objc[23504]: [0x10080d240] 0x100753d20 Person -objc[23504]: [0x10080d248] 0x100753d40 Person -objc[23504]: [0x10080d250] 0x100753d60 Person -objc[23504]: [0x10080d258] 0x100753d80 Person -objc[23504]: [0x10080d260] 0x100753da0 Person -objc[23504]: [0x10080d268] 0x100753dc0 Person -objc[23504]: [0x10080d270] 0x100753de0 Person -objc[23504]: [0x10080d278] 0x100753e00 Person -objc[23504]: [0x10080d280] 0x100753e20 Person -objc[23504]: [0x10080d288] 0x100753e40 Person -objc[23504]: [0x10080d290] 0x100753e60 Person -objc[23504]: [0x10080d298] 0x100753e80 Person -objc[23504]: [0x10080d2a0] 0x100753ea0 Person -objc[23504]: [0x10080d2a8] 0x100753ec0 Person -objc[23504]: [0x10080d2b0] 0x100753ee0 Person -objc[23504]: [0x10080d2b8] 0x100753f00 Person -objc[23504]: [0x10080d2c0] 0x100753f20 Person -objc[23504]: [0x10080d2c8] 0x100753f40 Person -objc[23504]: [0x10080d2d0] 0x100753f60 Person -objc[23504]: [0x10080d2d8] 0x100753f80 Person -objc[23504]: [0x10080d2e0] 0x100753fa0 Person -objc[23504]: [0x10080d2e8] 0x100753fc0 Person -objc[23504]: [0x10080d2f0] 0x100753fe0 Person -objc[23504]: [0x10080d2f8] 0x100754000 Person -objc[23504]: [0x10080d300] 0x100754020 Person -objc[23504]: [0x10080d308] 0x100754040 Person -objc[23504]: [0x10080d310] 0x100754060 Person -objc[23504]: [0x10080d318] 0x100754080 Person -objc[23504]: [0x10080d320] 0x1007540a0 Person -objc[23504]: [0x10080d328] 0x1007540c0 Person -objc[23504]: [0x10080d330] 0x1007540e0 Person -objc[23504]: [0x10080d338] 0x100754100 Person -objc[23504]: [0x10080d340] 0x100754120 Person -objc[23504]: [0x10080d348] 0x100754140 Person -objc[23504]: [0x10080d350] 0x100754160 Person -objc[23504]: [0x10080d358] 0x100754180 Person -objc[23504]: [0x10080d360] 0x1007541a0 Person -objc[23504]: [0x10080d368] 0x1007541c0 Person -objc[23504]: [0x10080d370] 0x1007541e0 Person -objc[23504]: [0x10080d378] 0x100754200 Person -objc[23504]: [0x10080d380] 0x100754220 Person -objc[23504]: [0x10080d388] 0x100754240 Person -objc[23504]: [0x10080d390] 0x100754260 Person -objc[23504]: [0x10080d398] 0x100754280 Person -objc[23504]: [0x10080d3a0] 0x1007542a0 Person -objc[23504]: [0x10080d3a8] 0x1007542c0 Person -objc[23504]: [0x10080d3b0] 0x1007542e0 Person -objc[23504]: [0x10080d3b8] 0x100754300 Person -objc[23504]: [0x10080d3c0] 0x100754320 Person -objc[23504]: [0x10080d3c8] 0x100754340 Person -objc[23504]: [0x10080d3d0] 0x100754360 Person -objc[23504]: [0x10080d3d8] 0x100754380 Person -objc[23504]: [0x10080d3e0] 0x1007543a0 Person -objc[23504]: [0x10080d3e8] 0x1007543c0 Person -objc[23504]: [0x10080d3f0] 0x1007543e0 Person -objc[23504]: [0x10080d3f8] 0x100754400 Person -objc[23504]: [0x10080d400] 0x100754420 Person -objc[23504]: [0x10080d408] 0x100754440 Person -objc[23504]: [0x10080d410] 0x100754460 Person -objc[23504]: [0x10080d418] 0x100754480 Person -objc[23504]: [0x10080d420] 0x1007544a0 Person -objc[23504]: [0x10080d428] 0x1007544c0 Person -objc[23504]: [0x10080d430] 0x1007544e0 Person -objc[23504]: [0x10080d438] 0x100754500 Person -objc[23504]: [0x10080d440] 0x100754520 Person -objc[23504]: [0x10080d448] 0x100754540 Person -objc[23504]: [0x10080d450] 0x100754560 Person -objc[23504]: [0x10080d458] 0x100754580 Person -objc[23504]: [0x10080d460] 0x1007545a0 Person -objc[23504]: [0x10080d468] 0x1007545c0 Person -objc[23504]: [0x10080d470] 0x1007545e0 Person -objc[23504]: [0x10080d478] 0x100754600 Person -objc[23504]: [0x10080d480] 0x100754620 Person -objc[23504]: [0x10080d488] 0x100754640 Person -objc[23504]: [0x10080d490] 0x100754660 Person -objc[23504]: [0x10080d498] 0x100754680 Person -objc[23504]: [0x10080d4a0] 0x1007546a0 Person -objc[23504]: [0x10080d4a8] 0x1007546c0 Person -objc[23504]: [0x10080d4b0] 0x1007546e0 Person -objc[23504]: [0x10080d4b8] 0x100754700 Person -objc[23504]: [0x10080d4c0] 0x100754720 Person -objc[23504]: [0x10080d4c8] 0x100754740 Person -objc[23504]: [0x10080d4d0] 0x100754760 Person -objc[23504]: [0x10080d4d8] 0x100754780 Person -objc[23504]: [0x10080d4e0] 0x1007547a0 Person -objc[23504]: [0x10080d4e8] 0x1007547c0 Person -objc[23504]: [0x10080d4f0] 0x1007547e0 Person -objc[23504]: [0x10080d4f8] 0x100754800 Person -objc[23504]: [0x10080d500] 0x100754820 Person -objc[23504]: [0x10080d508] 0x100754840 Person -objc[23504]: [0x10080d510] 0x100754860 Person -objc[23504]: [0x10080d518] 0x100754880 Person -objc[23504]: [0x10080d520] 0x1007548a0 Person -objc[23504]: [0x10080d528] 0x1007548c0 Person -objc[23504]: [0x10080d530] 0x1007548e0 Person -objc[23504]: [0x10080d538] 0x100754900 Person -objc[23504]: [0x10080d540] 0x100754920 Person -objc[23504]: [0x10080d548] 0x100754940 Person -objc[23504]: [0x10080d550] 0x100754960 Person -objc[23504]: [0x10080d558] 0x100754980 Person -objc[23504]: [0x10080d560] 0x1007549a0 Person -objc[23504]: [0x10080d568] 0x1007549c0 Person -objc[23504]: [0x10080d570] 0x1007549e0 Person -objc[23504]: [0x10080d578] 0x100754a00 Person -objc[23504]: [0x10080d580] 0x100754a20 Person -objc[23504]: [0x10080d588] 0x100754a40 Person -objc[23504]: [0x10080d590] 0x100754a60 Person -objc[23504]: [0x10080d598] 0x100754a80 Person -objc[23504]: [0x10080d5a0] 0x100754aa0 Person -objc[23504]: [0x10080d5a8] 0x100754ac0 Person -objc[23504]: [0x10080d5b0] 0x100754ae0 Person -objc[23504]: [0x10080d5b8] 0x100754b00 Person -objc[23504]: [0x10080d5c0] 0x100754b20 Person -objc[23504]: [0x10080d5c8] 0x100754b40 Person -objc[23504]: [0x10080d5d0] 0x100754b60 Person -objc[23504]: [0x10080d5d8] 0x100754b80 Person -objc[23504]: [0x10080d5e0] 0x100754ba0 Person -objc[23504]: [0x10080d5e8] 0x100754bc0 Person -objc[23504]: [0x10080d5f0] 0x100754be0 Person -objc[23504]: [0x10080d5f8] 0x100754c00 Person -objc[23504]: [0x10080d600] 0x100754c20 Person -objc[23504]: [0x10080d608] 0x100754c40 Person -objc[23504]: [0x10080d610] 0x100754c60 Person -objc[23504]: [0x10080d618] 0x100754c80 Person -objc[23504]: [0x10080d620] 0x100754ca0 Person -objc[23504]: [0x10080d628] 0x100754cc0 Person -objc[23504]: [0x10080d630] 0x100754ce0 Person -objc[23504]: [0x10080d638] 0x100754d00 Person -objc[23504]: [0x10080d640] 0x100754d20 Person -objc[23504]: [0x10080d648] 0x100754d40 Person -objc[23504]: [0x10080d650] 0x100754d60 Person -objc[23504]: [0x10080d658] 0x100754d80 Person -objc[23504]: [0x10080d660] 0x100754da0 Person -objc[23504]: [0x10080d668] 0x100754dc0 Person -objc[23504]: [0x10080d670] 0x100754de0 Person -objc[23504]: [0x10080d678] 0x100754e00 Person -objc[23504]: [0x10080d680] 0x10074fa70 Person -objc[23504]: [0x10080d688] 0x10074fa90 Person -objc[23504]: [0x10080d690] 0x10074fab0 Person -objc[23504]: [0x10080d698] 0x10074fad0 Person -objc[23504]: [0x10080d6a0] 0x10074faf0 Person -objc[23504]: [0x10080d6a8] 0x10074fb10 Person -objc[23504]: [0x10080d6b0] 0x10074fb30 Person -objc[23504]: [0x10080d6b8] 0x10074fb50 Person -objc[23504]: [0x10080d6c0] 0x10074fb70 Person -objc[23504]: [0x10080d6c8] 0x10074fb90 Person -objc[23504]: [0x10080d6d0] 0x10074fbb0 Person -objc[23504]: [0x10080d6d8] 0x10074fbd0 Person -objc[23504]: [0x10080d6e0] 0x10074fbf0 Person -objc[23504]: [0x10080d6e8] 0x10074fc10 Person -objc[23504]: [0x10080d6f0] 0x10074fc30 Person -objc[23504]: [0x10080d6f8] 0x10074fc50 Person -objc[23504]: [0x10080d700] 0x10074fc70 Person -objc[23504]: [0x10080d708] 0x10074fc90 Person -objc[23504]: [0x10080d710] 0x10074fcb0 Person -objc[23504]: [0x10080d718] 0x10074fcd0 Person -objc[23504]: [0x10080d720] 0x10074fcf0 Person -objc[23504]: [0x10080d728] 0x10074fd10 Person -objc[23504]: [0x10080d730] 0x10074fd30 Person -objc[23504]: [0x10080d738] 0x10074fd50 Person -objc[23504]: [0x10080d740] 0x10074fd70 Person -objc[23504]: [0x10080d748] 0x10074fd90 Person -objc[23504]: [0x10080d750] 0x10074fdb0 Person -objc[23504]: [0x10080d758] 0x10074fdd0 Person -objc[23504]: [0x10080d760] 0x10074fdf0 Person -objc[23504]: [0x10080d768] 0x10074fe10 Person -objc[23504]: [0x10080d770] 0x10074fe30 Person -objc[23504]: [0x10080d778] 0x10074fe50 Person -objc[23504]: [0x10080d780] 0x10074fe70 Person -objc[23504]: [0x10080d788] 0x10074fe90 Person -objc[23504]: [0x10080d790] 0x10074feb0 Person -objc[23504]: [0x10080d798] 0x10074fed0 Person -objc[23504]: [0x10080d7a0] 0x10074fef0 Person -objc[23504]: [0x10080d7a8] 0x10074ff10 Person -objc[23504]: [0x10080d7b0] 0x10074ff30 Person -objc[23504]: [0x10080d7b8] 0x10074ff50 Person -objc[23504]: [0x10080d7c0] 0x10074ff70 Person -objc[23504]: [0x10080d7c8] 0x10074ff90 Person -objc[23504]: [0x10080d7d0] 0x10074ffb0 Person -objc[23504]: [0x10080d7d8] 0x10074ffd0 Person -objc[23504]: [0x10080d7e0] 0x10074fff0 Person -objc[23504]: [0x10080d7e8] 0x100750010 Person -objc[23504]: [0x10080d7f0] 0x100750030 Person -objc[23504]: [0x10080d7f8] 0x100750050 Person -objc[23504]: [0x10080d800] 0x100750070 Person -objc[23504]: [0x10080d808] 0x100750090 Person -objc[23504]: [0x10080d810] 0x1007500b0 Person -objc[23504]: [0x10080d818] 0x1007500d0 Person -objc[23504]: [0x10080d820] 0x1007500f0 Person -objc[23504]: [0x10080d828] 0x100750110 Person -objc[23504]: [0x10080d830] 0x100750130 Person -objc[23504]: [0x10080d838] 0x100750150 Person -objc[23504]: [0x10080d840] 0x100750170 Person -objc[23504]: [0x10080d848] 0x100750190 Person -objc[23504]: [0x10080d850] 0x1007501b0 Person -objc[23504]: [0x10080d858] 0x1007501d0 Person -objc[23504]: [0x10080d860] 0x1007501f0 Person -objc[23504]: [0x10080d868] 0x100750210 Person -objc[23504]: [0x10080d870] 0x100750230 Person -objc[23504]: [0x10080d878] 0x100750250 Person -objc[23504]: [0x10080d880] 0x100750270 Person -objc[23504]: [0x10080d888] 0x100750290 Person -objc[23504]: [0x10080d890] 0x1007502b0 Person -objc[23504]: [0x10080d898] 0x1007502d0 Person -objc[23504]: [0x10080d8a0] 0x1007502f0 Person -objc[23504]: [0x10080d8a8] 0x100750310 Person -objc[23504]: [0x10080d8b0] 0x100750330 Person -objc[23504]: [0x10080d8b8] 0x100750350 Person -objc[23504]: [0x10080d8c0] 0x100750370 Person -objc[23504]: [0x10080d8c8] 0x100750390 Person -objc[23504]: [0x10080d8d0] 0x1007503b0 Person -objc[23504]: [0x10080d8d8] 0x1007503d0 Person -objc[23504]: [0x10080d8e0] 0x1007503f0 Person -objc[23504]: [0x10080d8e8] 0x100750410 Person -objc[23504]: [0x10080d8f0] 0x100750430 Person -objc[23504]: [0x10080d8f8] 0x100750450 Person -objc[23504]: [0x10080d900] 0x100750470 Person -objc[23504]: [0x10080d908] 0x100750490 Person -objc[23504]: [0x10080d910] 0x1007504b0 Person -objc[23504]: [0x10080d918] 0x1007504d0 Person -objc[23504]: [0x10080d920] 0x1007504f0 Person -objc[23504]: [0x10080d928] 0x100750510 Person -objc[23504]: [0x10080d930] 0x100750530 Person -objc[23504]: [0x10080d938] 0x100750550 Person -objc[23504]: [0x10080d940] 0x100750570 Person -objc[23504]: [0x10080d948] 0x100750590 Person -objc[23504]: [0x10080d950] 0x1007505b0 Person -objc[23504]: [0x10080d958] 0x1007505d0 Person -objc[23504]: [0x10080d960] 0x1007505f0 Person -objc[23504]: [0x10080d968] 0x100750610 Person -objc[23504]: [0x10080d970] 0x100750630 Person -objc[23504]: [0x10080d978] 0x100750650 Person -objc[23504]: [0x10080d980] 0x100750670 Person -objc[23504]: [0x10080d988] 0x100750690 Person -objc[23504]: [0x10080d990] 0x1007506b0 Person -objc[23504]: [0x10080d998] 0x1007506d0 Person -objc[23504]: [0x10080d9a0] 0x1007506f0 Person -objc[23504]: [0x10080d9a8] 0x100750710 Person -objc[23504]: [0x10080d9b0] 0x100750730 Person -objc[23504]: [0x10080d9b8] 0x100750750 Person -objc[23504]: [0x10080d9c0] 0x100750770 Person -objc[23504]: [0x10080d9c8] 0x100750790 Person -objc[23504]: [0x10080d9d0] 0x1007507b0 Person -objc[23504]: [0x10080d9d8] 0x1007507d0 Person -objc[23504]: [0x10080d9e0] 0x1007507f0 Person -objc[23504]: [0x10080d9e8] 0x100750810 Person -objc[23504]: [0x10080d9f0] 0x100750830 Person -objc[23504]: [0x10080d9f8] 0x100750850 Person -objc[23504]: [0x10080da00] 0x100750870 Person -objc[23504]: [0x10080da08] 0x100750890 Person -objc[23504]: [0x10080da10] 0x1007508b0 Person -objc[23504]: [0x10080da18] 0x1007508d0 Person -objc[23504]: [0x10080da20] 0x1007508f0 Person -objc[23504]: [0x10080da28] 0x100750910 Person -objc[23504]: [0x10080da30] 0x100750930 Person -objc[23504]: [0x10080da38] 0x100750950 Person -objc[23504]: [0x10080da40] 0x100750970 Person -objc[23504]: [0x10080da48] 0x100750990 Person -objc[23504]: [0x10080da50] 0x1007509b0 Person -objc[23504]: [0x10080da58] 0x1007509d0 Person -objc[23504]: [0x10080da60] 0x1007509f0 Person -objc[23504]: [0x10080da68] 0x100750a10 Person -objc[23504]: [0x10080da70] 0x100750a30 Person -objc[23504]: [0x10080da78] 0x100750a50 Person -objc[23504]: [0x10080da80] 0x100750a70 Person -objc[23504]: [0x10080da88] 0x100750a90 Person -objc[23504]: [0x10080da90] 0x100750ab0 Person -objc[23504]: [0x10080da98] 0x100750ad0 Person -objc[23504]: [0x10080daa0] 0x100750af0 Person -objc[23504]: [0x10080daa8] 0x100750b10 Person -objc[23504]: [0x10080dab0] 0x100750b30 Person -objc[23504]: [0x10080dab8] 0x100750b50 Person -objc[23504]: [0x10080dac0] 0x100750b70 Person -objc[23504]: [0x10080dac8] 0x100750b90 Person -objc[23504]: [0x10080dad0] 0x100750bb0 Person -objc[23504]: [0x10080dad8] 0x100750bd0 Person -objc[23504]: [0x10080dae0] 0x100750bf0 Person -objc[23504]: [0x10080dae8] 0x100750c10 Person -objc[23504]: [0x10080daf0] 0x100750c30 Person -objc[23504]: [0x10080daf8] 0x100750c50 Person -objc[23504]: [0x10080db00] 0x100750c70 Person -objc[23504]: [0x10080db08] 0x100750c90 Person -objc[23504]: [0x10080db10] 0x100750cb0 Person -objc[23504]: [0x10080db18] 0x100750cd0 Person -objc[23504]: [0x10080db20] 0x100750cf0 Person -objc[23504]: [0x10080db28] 0x100750d10 Person -objc[23504]: [0x10080db30] 0x100750d30 Person -objc[23504]: [0x10080db38] 0x100750d50 Person -objc[23504]: [0x10080db40] 0x100750d70 Person -objc[23504]: [0x10080db48] 0x100750d90 Person -objc[23504]: [0x10080db50] 0x100750db0 Person -objc[23504]: [0x10080db58] 0x100750dd0 Person -objc[23504]: [0x10080db60] 0x100750df0 Person -objc[23504]: [0x10080db68] 0x100750e10 Person -objc[23504]: [0x10080db70] 0x100750e30 Person -objc[23504]: [0x10080db78] 0x100750e50 Person -objc[23504]: [0x10080db80] 0x100750e70 Person -objc[23504]: [0x10080db88] 0x100750e90 Person -objc[23504]: [0x10080db90] 0x100750eb0 Person -objc[23504]: [0x10080db98] 0x100750ed0 Person -objc[23504]: [0x10080dba0] 0x100750ef0 Person -objc[23504]: [0x10080dba8] 0x100750f10 Person -objc[23504]: [0x10080dbb0] 0x100750f30 Person -objc[23504]: [0x10080dbb8] 0x100750f50 Person -objc[23504]: [0x10080dbc0] 0x100750f70 Person -objc[23504]: [0x10080dbc8] 0x100750f90 Person -objc[23504]: [0x10080dbd0] 0x100750fb0 Person -objc[23504]: [0x10080dbd8] 0x100750fd0 Person -objc[23504]: [0x10080dbe0] 0x100750ff0 Person -objc[23504]: [0x10080dbe8] 0x100751010 Person -objc[23504]: [0x10080dbf0] 0x100751030 Person -objc[23504]: [0x10080dbf8] 0x100751050 Person -objc[23504]: [0x10080dc00] 0x100751070 Person -objc[23504]: [0x10080dc08] 0x100751090 Person -objc[23504]: [0x10080dc10] 0x1007510b0 Person -objc[23504]: [0x10080dc18] 0x1007510d0 Person -objc[23504]: [0x10080dc20] 0x1007510f0 Person -objc[23504]: [0x10080dc28] 0x100751110 Person -objc[23504]: [0x10080dc30] 0x100751130 Person -objc[23504]: [0x10080dc38] 0x100751150 Person -objc[23504]: [0x10080dc40] 0x100751170 Person -objc[23504]: [0x10080dc48] 0x100751190 Person -objc[23504]: [0x10080dc50] 0x1007511b0 Person -objc[23504]: [0x10080dc58] 0x1007511d0 Person -objc[23504]: [0x10080dc60] 0x1007511f0 Person -objc[23504]: [0x10080dc68] 0x100751210 Person -objc[23504]: [0x10080dc70] 0x100751230 Person -objc[23504]: [0x10080dc78] 0x100751250 Person -objc[23504]: [0x10080dc80] 0x100751270 Person -objc[23504]: [0x10080dc88] 0x100751290 Person -objc[23504]: [0x10080dc90] 0x1007512b0 Person -objc[23504]: [0x10080dc98] 0x1007512d0 Person -objc[23504]: [0x10080dca0] 0x1007512f0 Person -objc[23504]: [0x10080dca8] 0x100751310 Person -objc[23504]: [0x10080dcb0] 0x100751330 Person -objc[23504]: [0x10080dcb8] 0x100751350 Person -objc[23504]: [0x10080dcc0] 0x100751370 Person -objc[23504]: [0x10080dcc8] 0x100751390 Person -objc[23504]: [0x10080dcd0] 0x1007513b0 Person -objc[23504]: [0x10080dcd8] 0x1007513d0 Person -objc[23504]: [0x10080dce0] 0x1007513f0 Person -objc[23504]: [0x10080dce8] 0x100751410 Person -objc[23504]: [0x10080dcf0] 0x100751430 Person -objc[23504]: [0x10080dcf8] 0x100751450 Person -objc[23504]: [0x10080dd00] 0x100751470 Person -objc[23504]: [0x10080dd08] 0x100751490 Person -objc[23504]: [0x10080dd10] 0x1007514b0 Person -objc[23504]: [0x10080dd18] 0x1007514d0 Person -objc[23504]: [0x10080dd20] 0x1007514f0 Person -objc[23504]: [0x10080dd28] 0x100751510 Person -objc[23504]: [0x10080dd30] 0x100751530 Person -objc[23504]: [0x10080dd38] 0x100751550 Person -objc[23504]: [0x10080dd40] 0x100751570 Person -objc[23504]: [0x10080dd48] 0x100751590 Person -objc[23504]: [0x10080dd50] 0x1007515b0 Person -objc[23504]: [0x10080dd58] 0x1007515d0 Person -objc[23504]: [0x10080dd60] 0x1007515f0 Person -objc[23504]: [0x10080dd68] 0x100751610 Person -objc[23504]: [0x10080dd70] 0x100751630 Person -objc[23504]: [0x10080dd78] 0x100751650 Person -objc[23504]: [0x10080dd80] 0x100751670 Person -objc[23504]: [0x10080dd88] 0x100751690 Person -objc[23504]: [0x10080dd90] 0x1007516b0 Person -objc[23504]: [0x10080dd98] 0x1007516d0 Person -objc[23504]: [0x10080dda0] 0x1007516f0 Person -objc[23504]: [0x10080dda8] 0x100751710 Person -objc[23504]: [0x10080ddb0] 0x100751730 Person -objc[23504]: [0x10080ddb8] 0x100751750 Person -objc[23504]: [0x10080ddc0] 0x100751770 Person -objc[23504]: [0x10080ddc8] 0x100751790 Person -objc[23504]: [0x10080ddd0] 0x1007517b0 Person -objc[23504]: [0x10080ddd8] 0x1007517d0 Person -objc[23504]: [0x10080dde0] 0x1007517f0 Person -objc[23504]: [0x10080dde8] 0x100751810 Person -objc[23504]: [0x10080ddf0] 0x100751830 Person -objc[23504]: [0x10080ddf8] 0x100751850 Person -objc[23504]: [0x10080de00] 0x100751870 Person -objc[23504]: [0x10080de08] 0x100751890 Person -objc[23504]: [0x10080de10] 0x1007518b0 Person -objc[23504]: [0x10080de18] 0x1007518d0 Person -objc[23504]: [0x10080de20] 0x1007518f0 Person -objc[23504]: [0x10080de28] 0x100751910 Person -objc[23504]: [0x10080de30] 0x100751930 Person -objc[23504]: [0x10080de38] 0x100751950 Person -objc[23504]: [0x10080de40] 0x100751970 Person -objc[23504]: [0x10080de48] 0x100751990 Person -objc[23504]: [0x10080de50] 0x1007519b0 Person -objc[23504]: [0x10080de58] 0x1007519d0 Person -objc[23504]: [0x10080de60] 0x1007519f0 Person -objc[23504]: [0x10080de68] 0x100751a10 Person -objc[23504]: [0x10080de70] 0x100751a30 Person -objc[23504]: [0x10080de78] 0x100751a50 Person -objc[23504]: [0x10080de80] 0x100751a70 Person -objc[23504]: [0x10080de88] 0x100751a90 Person -objc[23504]: [0x10080de90] 0x100751ab0 Person -objc[23504]: [0x10080de98] 0x100751ad0 Person -objc[23504]: [0x10080dea0] 0x100751af0 Person -objc[23504]: [0x10080dea8] 0x100751b10 Person -objc[23504]: [0x10080deb0] 0x100751b30 Person -objc[23504]: [0x10080deb8] 0x100751b50 Person -objc[23504]: [0x10080dec0] 0x100751b70 Person -objc[23504]: [0x10080dec8] 0x100751b90 Person -objc[23504]: [0x10080ded0] 0x100751bb0 Person -objc[23504]: [0x10080ded8] 0x100751bd0 Person -objc[23504]: [0x10080dee0] 0x100751bf0 Person -objc[23504]: [0x10080dee8] 0x100751c10 Person -objc[23504]: [0x10080def0] 0x100751c30 Person -objc[23504]: [0x10080def8] 0x100751c50 Person -objc[23504]: [0x10080df00] 0x100751c70 Person -objc[23504]: [0x10080df08] 0x100751c90 Person -objc[23504]: [0x10080df10] 0x100751cb0 Person -objc[23504]: [0x10080df18] 0x100751cd0 Person -objc[23504]: [0x10080df20] 0x100751cf0 Person -objc[23504]: [0x10080df28] 0x100751d10 Person -objc[23504]: [0x10080df30] 0x100751d30 Person -objc[23504]: [0x10080df38] 0x100751d50 Person -objc[23504]: [0x10080df40] 0x100751d70 Person -objc[23504]: [0x10080df48] 0x100751d90 Person -objc[23504]: [0x10080df50] 0x100751db0 Person -objc[23504]: [0x10080df58] 0x100751dd0 Person -objc[23504]: [0x10080df60] 0x100751df0 Person -objc[23504]: [0x10080df68] 0x100751e10 Person -objc[23504]: [0x10080df70] 0x100751e30 Person -objc[23504]: [0x10080df78] 0x100751e50 Person -objc[23504]: [0x10080df80] 0x100751e70 Person -objc[23504]: [0x10080df88] 0x100751e90 Person -objc[23504]: [0x10080df90] 0x100751eb0 Person -objc[23504]: [0x10080df98] 0x100751ed0 Person -objc[23504]: [0x10080dfa0] 0x100751ef0 Person -objc[23504]: [0x10080dfa8] 0x100751f10 Person -objc[23504]: [0x10080dfb0] 0x100751f30 Person -objc[23504]: [0x10080dfb8] 0x100751f50 Person -objc[23504]: [0x10080dfc0] 0x100751f70 Person -objc[23504]: [0x10080dfc8] 0x100751f90 Person -objc[23504]: [0x10080dfd0] 0x100751fb0 Person -objc[23504]: [0x10080dfd8] 0x100751fd0 Person -objc[23504]: [0x10080dfe0] 0x100751ff0 Person -objc[23504]: [0x10080dfe8] 0x100752010 Person -objc[23504]: [0x10080dff0] 0x100752030 Person -objc[23504]: [0x10080dff8] 0x100752050 Person -objc[23504]: [0x100817000] ................ PAGE (hot) -objc[23504]: [0x100817038] 0x100752070 Person -objc[23504]: [0x100817040] 0x100752090 Person -objc[23504]: [0x100817048] 0x1007520b0 Person -objc[23504]: [0x100817050] 0x1007520d0 Person -objc[23504]: [0x100817058] 0x1007520f0 Person -objc[23504]: [0x100817060] 0x100752110 Person -objc[23504]: [0x100817068] 0x100752130 Person -objc[23504]: [0x100817070] 0x100752150 Person -objc[23504]: [0x100817078] 0x100752170 Person -objc[23504]: [0x100817080] 0x100752190 Person -objc[23504]: [0x100817088] 0x1007521b0 Person -objc[23504]: [0x100817090] 0x1007521d0 Person -objc[23504]: [0x100817098] 0x1007521f0 Person -objc[23504]: [0x1008170a0] 0x100752210 Person -objc[23504]: [0x1008170a8] 0x100752230 Person -objc[23504]: [0x1008170b0] 0x100752250 Person -objc[23504]: [0x1008170b8] 0x100752270 Person -objc[23504]: [0x1008170c0] 0x100752290 Person -objc[23504]: [0x1008170c8] 0x1007522b0 Person -objc[23504]: [0x1008170d0] 0x1007522d0 Person -objc[23504]: [0x1008170d8] 0x1007522f0 Person -objc[23504]: [0x1008170e0] 0x100752310 Person -objc[23504]: [0x1008170e8] 0x100752330 Person -objc[23504]: [0x1008170f0] 0x100752350 Person -objc[23504]: [0x1008170f8] 0x100752370 Person -objc[23504]: [0x100817100] 0x100752390 Person -objc[23504]: [0x100817108] 0x1007523b0 Person -objc[23504]: [0x100817110] 0x1007523d0 Person -objc[23504]: [0x100817118] 0x1007523f0 Person -objc[23504]: [0x100817120] 0x100752410 Person -objc[23504]: [0x100817128] 0x100752430 Person -objc[23504]: [0x100817130] 0x100752450 Person -objc[23504]: [0x100817138] 0x100752470 Person -objc[23504]: [0x100817140] 0x100752490 Person -objc[23504]: [0x100817148] 0x1007524b0 Person -objc[23504]: [0x100817150] 0x1007524d0 Person -objc[23504]: [0x100817158] 0x1007524f0 Person -objc[23504]: [0x100817160] 0x100752510 Person -objc[23504]: [0x100817168] 0x100752530 Person -objc[23504]: [0x100817170] 0x100752550 Person -objc[23504]: [0x100817178] 0x1007556d0 Person -objc[23504]: [0x100817180] 0x1007556f0 Person -objc[23504]: [0x100817188] 0x100755710 Person -objc[23504]: [0x100817190] 0x100755730 Person -objc[23504]: [0x100817198] 0x100755750 Person -objc[23504]: [0x1008171a0] 0x100755770 Person -objc[23504]: [0x1008171a8] 0x100755790 Person -objc[23504]: [0x1008171b0] 0x1007557b0 Person -objc[23504]: [0x1008171b8] 0x1007557d0 Person -objc[23504]: [0x1008171c0] 0x1007557f0 Person -objc[23504]: [0x1008171c8] 0x100755810 Person -objc[23504]: [0x1008171d0] 0x100755830 Person -objc[23504]: [0x1008171d8] 0x100755850 Person -objc[23504]: [0x1008171e0] 0x100755870 Person -objc[23504]: [0x1008171e8] 0x100755890 Person -objc[23504]: [0x1008171f0] 0x1007558b0 Person -objc[23504]: [0x1008171f8] 0x1007558d0 Person -objc[23504]: [0x100817200] 0x1007558f0 Person -objc[23504]: [0x100817208] 0x100755910 Person -objc[23504]: [0x100817210] 0x100755930 Person -objc[23504]: [0x100817218] 0x100755950 Person -objc[23504]: [0x100817220] 0x100755970 Person -objc[23504]: [0x100817228] 0x100755990 Person -objc[23504]: [0x100817230] 0x1007559b0 Person -objc[23504]: [0x100817238] 0x1007559d0 Person -objc[23504]: [0x100817240] 0x1007559f0 Person -objc[23504]: [0x100817248] 0x100755a10 Person -objc[23504]: [0x100817250] 0x100755a30 Person -objc[23504]: [0x100817258] 0x100755a50 Person -objc[23504]: [0x100817260] 0x100755a70 Person -objc[23504]: [0x100817268] 0x100755a90 Person -objc[23504]: [0x100817270] 0x100755ab0 Person -objc[23504]: [0x100817278] 0x100755ad0 Person -objc[23504]: [0x100817280] 0x100755af0 Person -objc[23504]: [0x100817288] 0x100755b10 Person -objc[23504]: [0x100817290] 0x100755b30 Person -objc[23504]: [0x100817298] 0x100755b50 Person -objc[23504]: [0x1008172a0] 0x100755b70 Person -objc[23504]: [0x1008172a8] 0x100755b90 Person -objc[23504]: [0x1008172b0] 0x100755bb0 Person -objc[23504]: [0x1008172b8] 0x100755bd0 Person -objc[23504]: [0x1008172c0] 0x100755bf0 Person -objc[23504]: [0x1008172c8] 0x100755c10 Person -objc[23504]: [0x1008172d0] 0x100755c30 Person -objc[23504]: [0x1008172d8] 0x100755c50 Person -objc[23504]: [0x1008172e0] 0x100755c70 Person -objc[23504]: [0x1008172e8] 0x100755c90 Person -objc[23504]: [0x1008172f0] 0x100755cb0 Person -objc[23504]: [0x1008172f8] 0x100755cd0 Person -objc[23504]: [0x100817300] 0x100755cf0 Person -objc[23504]: [0x100817308] 0x100755d10 Person -objc[23504]: [0x100817310] 0x100755d30 Person -objc[23504]: [0x100817318] 0x100755d50 Person -objc[23504]: [0x100817320] 0x100755d70 Person -objc[23504]: [0x100817328] 0x100755d90 Person -objc[23504]: [0x100817330] 0x100755db0 Person -objc[23504]: [0x100817338] 0x100755dd0 Person -objc[23504]: [0x100817340] 0x100755df0 Person -objc[23504]: [0x100817348] 0x100755e10 Person -objc[23504]: [0x100817350] ################ POOL 0x100817350 -objc[23504]: [0x100817358] 0x100755e30 Person -objc[23504]: ############## -``` - -可以看到当600*8=4800字节,所以一页肯定存不下,可以看到 - -`................ PAGE (full) (cold)` page 右边有个 cold、hot。cold 代表不是当前页,hot 代表当前页。 - -继续看看对象调用 `autorelease` 方法做了什么事情? - -```c -- (id)autorelease { - return ((id)self)->rootAutorelease(); -} - -inline id objc_object::rootAutorelease() { - if (isTaggedPointer()) return (id)this; - if (prepareOptimizedReturn(ReturnAtPlus1)) return (id)this; - return rootAutorelease2(); -} -_attribute__((noinline,used)) id objc_object::rootAutorelease2() { - assert(!isTaggedPointer()); - return AutoreleasePoolPage::autorelease((id)this); -} - -static inline id autorelease(id obj) { - assert(obj); - assert(!obj->isTaggedPointer()); - id *dest __unused = autoreleaseFast(obj); - assert(!dest || dest == EMPTY_POOL_PLACEHOLDER || *dest == obj); - return obj; -} - -static inline id *autoreleaseFast(id obj) { - AutoreleasePoolPage *page = hotPage(); - if (page && !page->full()) { - return page->add(obj); - } else if (page) { - return autoreleaseFullPage(obj, page); - } else { - return autoreleaseNoPage(obj); - } -} -``` - -查看 NSObject autorelease 方法调用链路可以看到最后还是调用 AutoreleasePoolPage 的 add 方法(会判断有没有页、有没有满) - - - -### autorelease 实现原理 - -`[obj autoreleaae]` 底层调用 NSObject 的 autorelease 实例方法。 - -```objective-c -// NSObject.m -static id autorelease_class = nil; -static SEL autorelease_sel; -static IMP autorelease_imp; - -+ (void) initialize -{ - // ... - autorelease_class = [NSAutoreleasePool class]; - autorelease_sel = @selector(addObject:); - autorelease_imp = [autorelease_class methodForSelector: autorelease_sel]; - // ... -} - -- (id) autorelease -{ - if (double_release_check_enabled) - { - NSUInteger release_count; - NSUInteger retain_count = [self retainCount]; - release_count = [autorelease_class autoreleaseCountForObject:self]; - if (release_count > retain_count) - [NSException - raise: NSGenericException - format: @"Autorelease would release object too many times.\n" - @"%"PRIuPTR" release(s) versus %"PRIuPTR" retain(s)", - release_count, retain_count]; - } - - (*autorelease_imp)(autorelease_class, autorelease_sel, self); - return self; -} -``` - -对象的 autorelease 方法,本质就是调用 NSAutoreleasePool 的 addObject 类方法。 - -另外,通过 GUN 源码可以看到, autorelease 方法是用一种特殊的方法来实现的。这方法能够高效的运行 OSX、iOS 应用程序中频繁调用的 autorelease 方法,它被称为 “**IMP Caching**”。在进行方法调用时,为了解决类名/方法名以及取得方法运行时的函数指针,在 NSObject 类的 initialize 方法中,对其结果进行缓存。 - -IMP Caching 比其他方法快2倍。 - - - -### AutoreleasePoolPage::pop - -- 根据传入的哨兵对象找到对应位置 -- 给上次 push 之后添加的对象依次发送 release 消息 -- 回退 next 指针到正确的位置 - - - -### autorelease 对象释放时机 - -> 也就是在聊:autorelease 对象 什么时候调用 release 方法? - -Demo1: iOS 项目中,MRC 环境,`viewDidLoad` 方法内的创建的对象什么时候释放? - -```objective-c -@implementation ViewController -- (void)viewDidLoad { - [super viewDidLoad]; - Person *p = [[Person alloc] init]; - NSLog(@"%s", __func__); -} -- (void)viewWillAppear:(BOOL)animated { - [super viewWillAppear:animated]; - NSLog(@"%s", __func__); -} -- (void)viewDidAppear:(BOOL)animated { - [super viewDidAppear:animated]; - NSLog(@"%s", __func__); -} -@end -// console -- [ViewController viewDidLoad] -- [ViewController viewWillAppear] -- [Person dealloc] -- [ViewController viewDidAppear] -``` - -看上去这个释放时机不确定? - -在 `viewDidLoad` 方法中,打印 runloop。可以发现: - -iOS 在主线程的 Runloop **通用模式(Common Modes)** 中注册 **1 个观察者**,**该观察者的优先级为最高(`order = -2147483647`),确保 AutoreleasePool 操作先于其他回调执行。**其监听以下三个阶段: - -- 监听了 `kCFRunLoopEntry`(进入 RunLoop) 事件,会调用`objc_autoreleasePoolPush()` -- `kCFRunLoopBeforeWaiting`(即将休眠),会调用`objc_autoreleasePoolPop()` 同时也会调用 `objc_autoreleasePoolPush()` -- `kCFRunLoopExit`(退出 RunLoop),会调用 `objc_autoreleasePoolPop()` - -另外,在 `viewDidLoad` 中打印 RunLoop 会发现系统给 RunLoop 昨天添加了2个 Observer,用来在 AutoreleasePool 的场景下使用。 - -```shell -//activities=0x1,kCFRunLoopEntry -{valid=Yes,activities=0x1,repeats=Yes,order=-2147483647,callout=_wrapRunLoopWithAutoreleasePoolHandler(***)} -//activities=0xa0,kCFRunLoopBeforeWaiting|kCFRunLoopExit -{valid=Yes,activities=0xa0,repeats=Yes,order=2147483647,callout=_wrapRunLoopWithAutoreleasePoolHandler(***)} - -``` - -看上去这个释放时机不确定? - -结合 RunLoop 运行图 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RunLoop-SourceCode.png) - -- 01 通知 Observer 进入 Loop 会调用 `objc_autoreleasePoolPush` - -- 做一堆其他事情 - -- 07 在将要休眠的时候先调用 `objc_autoreleasePoolPop`,再调用 `objc_autoreleasePoolPush` - -- 等待唤醒做一堆其他事情,回到第二步 - -- 07 又开始休眠,先调用 `objc_autoreleasePoolPop`,再调用 `objc_autoreleasePoolPush` - -- 11 没任务将要休眠,调用 `objc_autoreleasePoolPop` - -可以看到 `objc_autoreleasePoolPush`、`objc_autoreleasePoolPop` 成对调用,贯穿 RunLoop - -AutoreleasePool 也是 RunLoop 的业务方。iOS GUI 系统下,很多动画、视图渲染、对象的销毁都依赖 RunLoop。 - -可以回答上面的问题了:系统对 Runloop 添加了观察者,监听 RunLoop 的状态,当将要休眠的时候会触发回调,系统会执行 AutoreleasePool 的 pop 方法,来释放当前一轮 RunLoop 中需要释放的对象。 - -RunLoop 和 AutoreleasePool 存在几种可能: - -- RunLoop 进入的时候(`kCFRunLoopEntry`),执行 `objc_autoreleasePoolPush` 方法,然后不断运行,等待没任务的时候,RunLoop 在进入休息的时候(`kCFRunLoopBeforeWaiting`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。同时执行一次 `objc_autoreleasePoolPush` 方法,然后去休眠。休眠唤醒后继续执行 RunLoop 各个阶段的事情。等没事(Timer、Source)处理的时候,继续 RunLoop 在进入休息的时候(`kCFRunLoopBeforeWaiting`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。不断循环,依次类推,push 和 pop 成对存在 -- RunLoop 进入的时候(`kCFRunLoopEntry`),执行 `objc_autoreleasePoolPush` 方法,然后不断运行,等待需要退出的时候(`kCFRunLoopBeforeExit`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。push 和 pop 成对存在 -- RunLoop 进入的时候(`kCFRunLoopEntry`),执行 `objc_autoreleasePoolPush` 方法,然后不断运行,等待没任务的时候,RunLoop 在进入休息的时候(`kCFRunLoopBeforeWaiting`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。同时执行一次 `objc_autoreleasePoolPush` 方法,然后去休眠。休眠唤醒后继续执行 RunLoop 各个阶段的事情。等没事(Timer、Source)处理的时候,继续 RunLoop 在进入休息的时候(`kCFRunLoopBeforeWaiting`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。不断循环,最后没事情处理了,等待需要退出的时候(`kCFRunLoopBeforeExit`),执行一次 `objc_autoreleasePoolPop` 方法,释放当前需要释放的对象。push 和 pop 成对存在在 - - - -Demo2: iOS 项目中,ARC 环境,`viewDidLoad` 方法内的创建的对象什么时候释放? - -```objective-c -@implementation ViewController -- (void)viewDidLoad { - [super viewDidLoad]; - Person *p = [[Person alloc] init]; - NSLog(@"%s", __func__); -} -- (void)viewWillAppear:(BOOL)animated { - [super viewWillAppear:animated]; - NSLog(@"%s", __func__); -} -- (void)viewDidAppear:(BOOL)animated { - [super viewDidAppear:animated]; - NSLog(@"%s", __func__); -} -@end -// console -- [ViewController viewDidLoad] -- [Person dealloc] -- [ViewController viewWillAppear] -- [ViewController viewDidAppear] -``` - -可以看到 ARC 下 p 对象被立马释放了。也就是说方法内部, ARC 下 LLVM 会自动给生成的对象做了: - -- **所有权规则**:通过 `alloc`/`new`/`copy`/`mutableCopy` 创建的对象,调用者默认持有其 **强引用**(即引用计数为 1)。 -- **作用域结束时释放**:若对象未被其他强引用持有,编译器会在作用域结束时插入 `release`,触发引用计数归零,对象被销毁。 - - - -系统回答下: - -区分3种情况: - -- 含全局 AutoreleasePool 和 RunLoop - - iOS 项目(Mac 项目):`main` 函数中的 `@autoreleasepool` 是全局池,包裹了应用的启动代码(如 `UIApplicationMain`) - - - iOS/Mac 的 **主 RunLoop** 添加了1个观察者用于监听 RunLoop 的状态 - - 从 `kCFRunLoopEntry` 就会调用 `objc_autoreleasePoolPush` 方法 - - 在 `kCFRunLoopBeforeWaiting` 事件,会调用`objc_autoreleasePoolPop()` 同时也会调用 `objc_autoreleasePoolPush()` - - `kCFRunLoopExit `事件,会调用 `objc_autoreleasePoolPop()` - - ```objc - int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { - // Setup code that might create autoreleased objects goes here. - appDelegateClassName = NSStringFromClass([AppDelegate class]); - } - return UIApplicationMain(argc, argv, nil, appDelegateClassName); - } - ``` - -- (Mac 终端项目)没有全局 autorelasepool 对象和 RunLoop 处理的,也就是局部 `@autoreleasepool` - - - `@autoreleasepool` 会被 LLVM 编译器转换为 `__AtAutoreleasePool` 结构体 - - 每个线程维护一个 `AutoreleasePoolPage` 链表,存储 `autorelease` 对象指针。 - - 开始会自动调用结构体构造方法,调用 `autoreleasepoolPush` 方法,会插入一个 哨兵对象 `POOL_BOUNDARY`, 返回哨兵对象的地址,用 obj 承接 - - 大括号结束要自动调用 `autoreleasepoolPop(obj)` 方法。其内部会从当前 AutoreleasePoolPage 存储 autorelease 对象的顶部,不断调用 release 方法,直到遇到 obj 地址 - -- ARC 下 - - - 在 ARC 下,编译器会尽可能避免使用 `autorelease`。对于通过 `alloc`/`new`/`copy`/`mutableCopy` 创建的对象,若未被返回或赋值给强引用,编译器会直接插入 `release` 而非 `autorelease`。 - - - -### ARC 时代会自动加 autorelease - -> 方法里面的对象,出了方法会立马释放吗? - -系统容器类,在使用 block 枚举器的时候,内部会自动创建 AutoreleasePool - -```objectivec -[array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { - @autoreleasepool { - <#statements#> - } -}]; -``` - -所以,我们老老实实写的 for、while 循环中需要手加局部 AutoreleasePool。推荐使用系统提供的容器类的 block 枚举器。 - - - -Cocoa 框架中,很多类方法用于返回 autorelease 对象。 - -```objective-c -// NSArray.m -+ (id) arrayWithCapacity: (NSUInteger)numItems -{ - return AUTORELEASE([[self allocWithZone: NSDefaultMallocZone()] - initWithCapacity: numItems]); -} - -// NSDictionary.m -+ (id) dictionary -{ - return AUTORELEASE([[self allocWithZone: NSDefaultMallocZone()] init]); -} -``` - - - -### 思考 - -- 在当次 runloop 将要结束的时候调用 AutoreleasePoolPage::pop() -- AutoreleasePool 多层嵌套的本质就是多次插入哨兵对象,AutoreleasePoolPage 以栈为节点的双向链表结构。 -- 在循环中比如 alloc 图片数据等内存消耗较大的场景,手动插入 autoreleasePool,每次 for 循环都会进行一次内存的释放 @autoreleasepool{} 本质编译器会转换为 AutoreleasePoolPage 的构造函数和析构函数,内部会调用 `AutoreleasePoolPage::push` 和 `AutoreleasePoolPage::pop` 来及时释放内存。否则释放时机就是 RunLoop 将要休眠的时候,对象没有及时释放,会导致内存压力陡增 - - - -## 典型的内存问题 - -### NSTimer、CSDisplayLink 中的内存泄露 -#### CADisplayLink 内存泄漏 - - - -可以看到 CADisplayLink 和 VC,VC 和 CADisplayLink 互相持有,造成内存泄漏,没有释放。即使页面离开,定时器还在继续运行,不断打印。 - - - -#### NSTimer 内存泄漏 - -对比实验 - -NSTimer 的基础 API `[NSTimer scheduledTimersWithTimeInterval:1 repeat:YES block:nil]` 和当前的 VC 都会互相持有,造成环,会存在内存泄漏问题 - -Demo 如下: - - - -但是当使用 `[NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerTask) userInfo:nil repeats:NO];` repeats 为 NO 的时候,好像不会内存泄漏。这是为什么? - - - - - -#### 源码分析 - -查看 gnu 源码发现 - -```objective-c -// NSTimer.m -+ (NSTimer*) timerWithTimeInterval: (NSTimeInterval)ti - target: (id)object - selector: (SEL)selector - userInfo: (id)info - repeats: (BOOL)f -{ - return AUTORELEASE([[self alloc] initWithFireDate: nil - interval: ti - target: object - selector: selector - userInfo: info - repeats: f]); -} -``` - -内部调用下面的函数 - -```objective-c -- (id) initWithFireDate: (NSDate*)fd - interval: (NSTimeInterval)ti - target: (id)object - selector: (SEL)selector - userInfo: (id)info - repeats: (BOOL)f -{ - if (ti <= 0.0) - { - ti = 0.0001; - } - if (fd == nil) - { - _date = [[NSDate_class allocWithZone: NSDefaultMallocZone()] - initWithTimeIntervalSinceNow: ti]; - } - else - { - _date = [fd copyWithZone: NSDefaultMallocZone()]; - } - _target = RETAIN(object); - _selector = selector; - _info = RETAIN(info); - if (f == YES) - { - _repeats = YES; - _interval = ti; - } - else - { - _repeats = NO; - _interval = 0.0; - } - return self; -} -``` - -外面的 repeat 根据传递的布尔值,内部赋值给 _repeats 参数。 - -内部会自动调用 fire - -```objective-c -- (void) fire -{ - /* We check that we have not been invalidated before we fire. - */ - if (NO == _invalidated) { - if ((id)_block != nil) { - CALL_BLOCK(_block, self); - } else { - id target; - - /* We retain the target so it won't be deallocated while we are using - * it (if this timer gets invalidated while we are firing). - */ - target = RETAIN(_target); - - if (_selector == 0) { - NS_DURING { - [(NSInvocation*)target invoke]; - } - NS_HANDLER { - NSLog(@"*** NSTimer ignoring exception '%@' (reason '%@') " - @"raised during posting of timer with target %s(%s) " - @"and selector '%@'", - [localException name], [localException reason], - GSClassNameFromObject(target), - GSObjCIsInstance(target) ? "instance" : "class", - NSStringFromSelector([target selector])); - } - NS_ENDHANDLER - } else { - NS_DURING { - [target performSelector: _selector withObject: self]; - } - NS_HANDLER { - NSLog(@"*** NSTimer ignoring exception '%@' (reason '%@') " - @"raised during posting of timer with target %p and " - @"selector '%@'", - [localException name], [localException reason], target, - NSStringFromSelector(_selector)); - } - NS_ENDHANDLER - } - RELEASE(target); - } - - if (_repeats == NO) { - [self invalidate]; - } - } -} -``` - -可以看到如果 repeat 为 NO ,则会执行 `[target performSelector: _selector withObject: self];` 调用1次方法,然后会执行 `invalidate` 函数,`invalidate` 实现如下 - -```objective-c -- (void) invalidate -{ - /* OPENSTEP allows this method to be called multiple times. */ - _invalidated = YES; - if (_target != nil) - { - DESTROY(_target); - } - if (_info != nil) - { - DESTROY(_info); - } -} -``` - -可以看到当 target 和 info 存在的时候,都会在 `invalidate` 方法中被 destory,也就是释放。 - -``` -#define DESTROY(object) ({ \ - void *__o = (void*)object; \ - object = nil; \ - [(id)__o release]; \ -}) -#endif -``` - -结论:通过 gnu 可以看到,NSTimer 会对传入的 target、info 对象进行持有强引用,当 repeat 参数为 NO 的时候,则会立马通过 performSelector 的方式执行定时器任务,然后执行 invalidate 方法,对其内部引用的 object、info 进行释放。 - - - -上面的代码主要是利用定时器重复执行 p_doSomeThing 方法,在合适的时候调用 p_stopDoSomeThing 方法使定时器失效。 - -能看出问题吗?在开始讨论上面代码问题之前,需要对 NSTimer 做一点说明。NSTimer 的 `scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:` 方法的最后一个参数为 YES 时,NSTimer 会保留目标对象,等到自身失效才释放目标对象。执行完任务后,一次性的定时器会自动失效;重复性的定时器,需要主动调用 invalidate 方法才会失效。 - -当前的 VC 和 定时器互相引用,造成循环引用。所以思路如下: - -如果能在合适的时机打破循环引用就不会有问题了 - -1. 控制器不再强引用定时器 -2. 定时器不再保留当前的控制器 - - - -#### 解决方案 - -##### 改用 block 的方式替换 API,不再持有 target - - - -该种方式,控制器 (self)强引用 timer,timer 强引用 block,block 弱引用 self,3者没有形成环。 - - - -##### 采用系统 NSProxy 代替自定义的中间类 - -发生循环引用的原因是: - -```shell -VC.timer -> NSTimer -NSTimer.target -> VC -``` - -形成循环引用。所以引入第三者,weak 指针指向 target。效果为: - -```shell -VC.timer -> NSTimer -NSTimer.target -> TimerTarget -TimerTarget.target(weak) -> VC -``` - -但如果自定义继承自 NSObject 的对象,则不优雅。 - -```objective-c -@interface TimerTarget : NSObject -+ (instancetype)proxyWithTarget:(id)target; -- (void)timerTask; -@end - - -@interface TimerTarget() -@property (nonatomic, weak) id target; -@end - -@implementation TimerTarget - -+ (instancetype)proxyWithTarget:(id)target { - TimerTarget *proxy = [[TimerTarget alloc] init]; - proxy.target = target; - return proxy; -} - -- (void)timerTask { - [self.target performSelector:@selector(timerTask)]; -} -@end -``` - -存在问题:虽然解决了循环引用的问题,但每次都要创建类,并且实现和定时器方法签名一样的对象方法。 - -解决方案1:打破循环引用,NSTimer target 自定义。不去实现对象方法,因为最终会走到消息转发流程,调用 `forwardingTargetForSelector` 方法。统一解决未实现的方法。 - -```objective-c -- (id)forwardingTargetForSelector:(SEL)aSelector { - return self.target; -} -``` - - - -解决方案2:使用专门处理消息转发的 NSProxy 类 - - - -##### NSProxy 闪亮登场 - - - -可以看到使用 NSProxy 也可以解决 NSTimer 和 VC 循环引用的问题。但注意:继承自 NSProxy 的类,不能 init。 - -QA:自己写的继承自 NSObject 的代理对象和继承自 NSProxy 的代理有何区别?看上去反而是自定义的 NSObject 使用更简单呀? - -答:**NSProxy 效率更高**。NSProxy 的主要作用是为消息转发提供一个通用的接口,是一个继承自 NSObject 的对象,虽然看上去 API 更简单,写法简单,但内部运行的时候还是基于 isa 去查找类对象、元类对象的 cache 中查找,找不到再去 class_rw_t 中查找,找不到再从 superclass 找父类的类对象、元类对象...流程,最后还是找不到,则走 runtime 的动态方法解析、消息转发阶段。 - - - - - -看一段神奇的代码 - - - -为什么打印出 `0 1`? - -分析: - -- p1 是 `TimerProxy` 类,继承于 NSObject 所以就不是 UIViewController 类型。 - -- p2 是 `MethodProxy` 类,继承自 NSProxy,当调用 isKindOfClass 这个方法的时候,也会进行消息转发,即调用 `forwardInvocation` 方法,其内部实现 `[invocation invokeWithTarget:self.target];` 则触发 self.target 的逻辑。此时 self.target 就是 VC 对象,所以上面的 `[p2 isKindOfClass:[self class]]` 等价于 `[self isKindOfClass:[self.class]]`,所以为 1。 - -也就是说继承自 NSProxy 类的对象,调用方法的时候,会自动走消息转发的流程。 - -这一点可以查看 GUN 查看下源码印证,代码位于 `NSProxy.m` 中 - -```objectivec -- (BOOL) isKindOfClass: (Class)aClass { - NSMethodSignature *sig; - NSInvocation *inv; - BOOL ret; - sig = [self methodSignatureForSelector: _cmd]; - inv = [NSInvocation invocationWithMethodSignature: sig]; - [inv setSelector: _cmd]; - [inv setArgument: &aClass atIndex: 2]; - [self forwardInvocation: inv]; - [inv getReturnValue: &ret]; - return ret; -} -``` - -可以看到内部直接调用了消息转发。 - - - -#### GCD Timer - -**CADisplayLink、NSTimer 都是依靠 RunLoop 实现的,所以当 RunLoop 任务繁重的时候,定时器可能不准。** - - - - - -假设一个 NSTimer 被加到 RunLoop 开头,NSTimer 执行周期为1s,RunLoop 前面任务繁重,第一次走完一个完整的 RunLoop 需要0.4s,然后从头检测 NSTimer 有没有到时间,发现还没到继续执行 RunLoop 后续逻辑。后面遇到卡顿任务了,第二次 RunLoop 用了0.5s,然后从头检测 NSTimer 有没有到时间,0.4+0.5还不到时间,继续跑,第三次 RunLoop 比较轻松,耗时0.2s,再判断定时器时间有没有到,则此次已经0.4+0.5+0.2=1.1s了,此时 NSTimer 的事件被执行,此时精确度已经不够了(每次 RunLoop 的执行时间不固定) - -如果 NSTimer 被添加到了一个特定的模式,当滚动视图时, RunLoop 会切换到 `UITrackingRunLoopMode`,如果 NSTimer 没有被添加到这个模式,它就不会触发。 - -当 RunLoop 没有事件可处理时,它会进入休眠状态。这意味着即使定时器的时间间隔到了,但 `RunLoop` 可能还在休眠中,因此定时器不会立即触发。 - - - -网上有些针对 FPS 帧率的检测是基于 CADisplayLink 计算的,所以这种方案不准确。具体可以查看文章:[带你打造一套 APM 监控系统](./1.74.md) - - - -GCD 的 timer 会更加准时,底层依赖系统内核,不依赖 RunLoop。 - -```objectivec -@property (nonatomic, strong) dispatch_source_t timer; -// 创建队列 -dispatch_queue_t queue = dispatch_get_main_queue(); -// 创建 GCD 定时器 -dispatch_source_t timerSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); -uint64_t start = 2.0; -uint64_t interval = 1.0; -// 设置定时器周期 -dispatch_source_set_timer(timerSource, dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC), interval * NSEC_PER_SEC, 0); -// 设置定时器任务 -dispatch_source_set_event_handler(timerSource, ^{ - NSLog(@"tick tock"); -}); -// 启动定时器 -dispatch_resume(timerSource); -self.timer = timerSource; -``` - -为什么 GCD timer 会更准确?因为普通定时器运行依赖 RunLoop,RunLoop 一个运行周期内的任务繁忙程度是不确定的。当某次任务繁重,那么定时器调度就不准时。 - -GCD timer 不依赖 RunLoop,系统底层驱动,所以会更加准确。因为和 RunLoop 无关,所以和 UI 滚动,RunLoop mode 切换到 UITrackingMode 也不影响 GCD timer。 - - - -#### 高精度定时器封装 - -项目中经常使用定时器,普通定时器存在精度丢失的问题、循环引用的问题,为了使用方法我们封装一个定时器 - -```objectivec -#import - -@interface PreciousTimer : NSObject - -+ (NSString *)execTask:(void(^)(void))task - start:(NSTimeInterval)start - interval:(NSTimeInterval)interval - repeats:(BOOL)repeats - async:(BOOL)async; - -+ (NSString *)execTask:(id)target - selector:(SEL)selector - start:(NSTimeInterval)start - interval:(NSTimeInterval)interval - repeats:(BOOL)repeats - async:(BOOL)async; - -+ (void)cancelTask:(NSString *)name; - -@end - -#import "PreciousTimer.h" - -@implementation PreciousTimer - -static NSMutableDictionary *timers_; -dispatch_semaphore_t semaphore_; -+ (void)initialize -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - timers_ = [NSMutableDictionary dictionary]; - semaphore_ = dispatch_semaphore_create(1); - }); -} - -+ (NSString *)execTask:(void (^)(void))task start:(NSTimeInterval)start interval:(NSTimeInterval)interval repeats:(BOOL)repeats async:(BOOL)async -{ - if (!task || start < 0 || (interval <= 0 && repeats)) return nil; - - // 队列 - dispatch_queue_t queue = async ? dispatch_get_global_queue(0, 0) : dispatch_get_main_queue(); - - // 创建定时器 - dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); - - // 设置时间 - dispatch_source_set_timer(timer, - dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC), - interval * NSEC_PER_SEC, 0); - - - dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER); - // 定时器的唯一标识 - NSString *name = [NSString stringWithFormat:@"%zd", timers_.count]; - // 存放到字典中 - timers_[name] = timer; - dispatch_semaphore_signal(semaphore_); - - // 设置回调 - dispatch_source_set_event_handler(timer, ^{ - task(); - - if (!repeats) { // 不重复的任务 - [self cancelTask:name]; - } - }); - - // 启动定时器 - dispatch_resume(timer); - - return name; -} - -+ (NSString *)execTask:(id)target selector:(SEL)selector start:(NSTimeInterval)start interval:(NSTimeInterval)interval repeats:(BOOL)repeats async:(BOOL)async -{ - if (!target || !selector) return nil; - - return [self execTask:^{ - if ([target respondsToSelector:selector]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [target performSelector:selector]; -#pragma clang diagnostic pop - } - } start:start interval:interval repeats:repeats async:async]; -} - -+ (void)cancelTask:(NSString *)name -{ - if (name.length == 0) return; - - dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER); - - dispatch_source_t timer = timers_[name]; - if (timer) { - dispatch_source_cancel(timer); - [timers_ removeObjectForKey:name]; - } - - dispatch_semaphore_signal(semaphore_); -} - -@end -``` - -使用 Demo - -```objectivec -- (void)viewDidLoad{ - [super viewDidLoad]; - NSLog(@"now"); - self.timerId = [PreciousTimer execTask:^{ - NSLog(@"tick tock %@", [NSThread currentThread]); - } start:2 interval:1 repeats:YES async:YES]; -} -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event -{ - [PreciousTimer cancelTask:self.timerId]; -} -``` - -说明:直接 `performSelector` 存在警告,可以告诉编译器忽略警告。可以在 Xcode 点开警告,查看详情,复制 `[]` 里面的字符串去忽略警告 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ignoreXcodewarning.png) - - - -#### 采用 Block 的形式为 NSTimer 增加分类 - -```objectivec -//.h文件 -#import - -@interface NSTimer (UnRetain) -+ (NSTimer *)lbp_scheduledTimerWithTimeInterval:(NSTimeInterval)inerval - repeats:(BOOL)repeats - block:(void(^)(NSTimer *timer))block; -@end - -//.m文件 -#import "NSTimer+SGLUnRetain.h" - -@implementation NSTimer (SGLUnRetain) - -+ (NSTimer *)lbp_scheduledTimerWithTimeInterval:(NSTimeInterval)inerval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block{ - - return [NSTimer scheduledTimerWithTimeInterval:inerval target:self selector:@selector(lbp_blcokInvoke:) userInfo:[block copy] repeats:repeats]; -} - -+ (void)lbp_blcokInvoke:(NSTimer *)timer { - - void (^block)(NSTimer *timer) = timer.userInfo; - - if (block) { - block(timer); - } -} -@end - -//控制器.m - -#import "ViewController.h" -#import "NSTimer+UnRetain.h" - -//定义了一个__weak的self_weak_变量 -#define weakifySelf \ -__weak __typeof(&*self)weakSelf = self; - -//局域定义了一个__strong的self指针指向self_weak -#define strongifySelf \ -__strong __typeof(&*weakSelf)self = weakSelf; - -@interface ViewController () - -@property(nonatomic, strong) NSTimer *timer; - -@end - -@implementation ViewController -- (void)viewDidLoad { - [super viewDidLoad]; - - __block NSInteger i = 0; - weakifySelf - self.timer = [NSTimer lbp_scheduledTimerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *timer) { - strongifySelf - [self p_doSomething]; - NSLog(@"----------------"); - if (i++ > 10) { - [timer invalidate]; - } - }]; -} - -- (void)p_doSomething { - -} - -- (void)dealloc { - // 务必在当前线程调用invalidate方法,使得Runloop释放对timer的强引用(具体请参阅官方文档) - [self.timer invalidate]; -} -@end -``` - -上面的方法之所以能解决内存泄漏的问题,关键在于把保留转移到了定时器的类对象身上,这样就避免了实例对象被保留。 - -当我们谈到循环引用时,其实是指实例对象间的引用关系。类对象在 App 杀死时才会释放,在实际开发中几乎不用关注类对象的内存管理。下面的代码摘自苹果开源的 NSObject.mm 文件,从中可以看出,对于类对象,并不需要像实例对象那样进行内存管理。 - -```objective-c -+ (id)retain { - return (id)self; -} - -// Replaced by ObjectAlloc -- (id)retain { - return ((id)self)->rootRetain(); -} - -+ (oneway void)release { -} - -// Replaced by ObjectAlloc -- (oneway void)release { - ((id)self)->rootRelease(); -} - -+ (id)autorelease { - return (id)self; -} - -// Replaced by ObjectAlloc -- (id)autorelease { - return ((id)self)->rootAutorelease(); -} - -+ (NSUInteger)retainCount { - return ULONG_MAX; -} - -- (NSUInteger)retainCount { - return ((id)self)->rootRetainCount(); -} -``` - -iOS 10 中,定时器 api 增加了 block 方法,实现原理与此类似,这里采用分类为 NSTimer 增加 block 参数的方法,最终的行为一致 - -## 检测 - -根据 Instrucments 提供的工具的工作原理,写一个野指针探针工具去发现并定位问题。具体见[野指针监控工具](./1.74.md#zombieSniffer) - -### 使用NSArray 保存weak对象,会有什么问题? -Foundation 中数组在元素被添加的时候(这里的数组 指平常使用的 NSArray 和 NSMutableArray )会强引用持有,就算使用 `__weak` 修饰也没有用,导致一些奇特的内存泄漏和循环引用问题。 - -`-(NSValue *)valueWithNonretainedObject:(nullable id)anObject;` - -`NSPointerArray` 提供 `strongObjectsPointerArray` 和 `weakObjectsPointerArray`工厂,weakObjectsPointerArray 就是我们需要的弱引用数组方法。 - -NSHashTable 和 NSMapTable - -``` -// 弱应用对象 -NSMapTable *map = [NSMapTable weakToWeakObjectsMapTable]; -[map setObject:dog forKey:@"first"]; - -// 弱应用对象 -NSHashTable *hashTable = [NSHashTable weakObjectsHashTable]; -[hashTable addObject:dog]; -``` - - - -### OC 中有没有不对内存进行强持有的集合类型? - -`NSHashMap`、`NSMapTable` 都可以描述 key、value 的内存修饰。 - -数组有 NSPointerArray 内部持有的是对象的指针,并非直接保存对象。不过 oc 转指针需要加 `(__bridge void*)` 进行修饰。NSPointerArray 的构造方法中可以通过 NSPointerFunctionsOptions 来声明内存的控制。 - -```objectivec -- (void)viewDidLoad { - [super viewDidLoad]; - Person *p1 = [[Person alloc] init]; - Person *p2 = [[Person alloc] init]; - Person *p3 = [[Person alloc] init]; - NSPointerArray *arrays = [[NSPointerArray alloc] initWithOptions:NSPointerFunctionsWeakMemory]; -// NSMutableArray *array = [NSMutableArray array]; -// [array addObject:p1]; -// [array addObject:p2]; -// [array addObject:p3]; - [arrays addPointer:(__bridge void *)p1]; - [arrays addPointer:(__bridge void *)p2]; - [arrays addPointer:(__bridge void *)p3]; - p1 = nil; - p2 = nil; - // 断点设置到 NSLog,可以看到 Person 马上释放了 - NSLog(@"%@", arrays); -} -2022-05-24 21:57:27.071793+0800 TTTTW[63427:2087468] -[Person dealloc] -2022-05-24 21:57:27.071916+0800 TTTTW[63427:2087468] -[Person dealloc] -(lldb) -``` - -再来2个实验: - - - -分析:可以看到 p1 的地址,在刚初始化后,和当作 key 加入到 NSDictionary 后,地址发生了变化。对 p1 执行了 copy 操作。 - - - -分析: - -- table1 只有1个元素是因为对 p1 执行的是 `NSPointerFunctionsWeakMemory` 所以不会产生2个对象。同一个对象的 hash 值一样。所以仅存在1个元素 -- table2 的 key 是 `NSPointerFunctionsCopyIn`, copy 产生2个不同的 p,且 hash 值不一样,所以存在2个元素 -- 2个 NSMapTable 对 key 的内存操作不一样,其结果也不一样。如果 NSMapTable key 用 `NSPointerFunctionsCopyIn` 修饰,其效果等价于 NSMutableDictionary。 - - - -### NSError 内存泄漏的 case - -同事问了一个问题,下面的代码存在什么问题? - -据说是 Zoom 这个公司的面试题,看了下其实就是考察 NSError 有没有踩过坑。怎么理解呢 - -```objectivec -- (BOOL)isZoomUserWithUserID:(NSInteger)userID error:(NSError **)error -{ - @autoreleasepool { - NSString *errorMessage = [[NSString alloc] initWithFormat:@"the user is not zoom user"]; - if (userID == 100) { - *error = [NSError errorWithDomain:@"com.test" code:userID userInfo:@{NSLocalizedDescriptionKey: errorMessage}]; - return NO; - } - } - return YES; -} - -- (void)viewDidLoad { - [super viewDidLoad]; - [self test]; -} - -- (void)test { - for (NSInteger index = 0; index <= 100; index++) { - NSString *str; - str = [NSString stringWithFormat:@"welcome to zoom:%ld", index]; - str = [str stringByAppendingString:@" user"]; - NSError *error = NULL; - if ([self isZoomUserWithUserID:index error:&error]) { - NSLog(@"%@", str); - } else { - NSLog(@"%@", error); - } - } -} -``` - -这段代码运行会 crash,信息如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/NSErrorZombieCrash.png) - -原因是 NSError 构造方法内部会加 autorelease。源码如下 - -```objectivec -#define AUTORELEASE(object) [(id)(object) autorelease] -+ (id) errorWithDomain: (NSErrorDomain)aDomain - code: (NSInteger)aCode - userInfo: (NSDictionary*)aDictionary -{ - NSError *e = [self allocWithZone: NSDefaultMallocZone()]; - - e = [e initWithDomain: aDomain code: aCode userInfo: aDictionary]; - return AUTORELEASE(e); -} -``` - -MRC 下的 `[(id)(object) autorelease]` 等价于 ARC 下的 `id __autoreleasing obj` - -所以这个问题的本质就是 `autoreleasepool` 和 `__autoreleasing` 的问题 - -> `__autoreleasing` is used to denote arguments that are passed by reference (`id *`) and are autoreleased on return. - -用 `__autoreleasing` 修饰的变量会被添加到当前的 autoreleasepool 中。 - -方法的 Out Parameters 参数会自动添加 __autoreleasing 属性。当方法参数里面有 Out Parameters 参数时,就是有指针的指针类型时,编译器会自动为参数加上`__autoreleasing` 属性。改如下 - -```objectivec -- (BOOL)isZoomUserWithUserID:(NSInteger)userID error:(NSError **)error -{ - NSError *temp; - @autoreleasepool { - NSString *errorMessage = [[NSString alloc] initWithFormat:@"the user is not zoom user"]; - if (userID == 100) { - temp = [NSError errorWithDomain:@"com.test" code:userID userInfo:@{NSLocalizedDescriptionKey: errorMessage}]; - } - } - *error = temp; - return YES; -} - -- (void)viewDidLoad { - [super viewDidLoad]; - [self test]; -} - -- (void)test { - for (NSInteger index = 0; index <= 100; index++) { - NSString *str; - str = [NSString stringWithFormat:@"welcome to zoom:%ld", index]; - str = [str stringByAppendingString:@" user"]; - NSError * __autoreleasing error = NULL; - if ([self isZoomUserWithUserID:index error:&error]) { - NSLog(@"%@", str); - } else { - NSLog(@"%@", error); - } - } -} -``` - -我写了个僵尸对象检测工具,效果如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ZombieSniffer.png) - -可以定位僵尸对象,并且打印出具体堆栈,并模拟系统行为调用 `abort` 。对监控原理和工具实现感兴趣的可以查看这里[带你打造一套 APM 监控系统-内存监控之野指针/内存泄漏监控](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md#zombieSniffer) - -Demo [👇这里](https://github.com/FantasticLBP/BlogDemos/tree/master/僵尸对象探针) - -### 内存是连续的吗? - -应用启动后,Mach-O 文件是分段载入内存的。我们使用的内存都是虚拟内存,通过内存映射表来做。 - -每个进程在创建加载时,会被分配一个大小大概为1~2倍真实地内存的连续虚拟地址空间,让当前软件认为自己拥有一块很大内存空间。实际上是把磁盘的一小部分作为假想内存来使用。 - -CPU 不直接和物理内存打交道,而是通过 MMU(Memory Manage Unit,内存管理单元),MMU 是一种硬件电路,速度很快,主要工作是内存管理,地址转换是功能之一。 - -每个进程都会有自己的页表 `Page Table` ,页表存储了进程中虚拟地址到物理地址的映射关系,所以就相当于地图。MMU 收到 CPU 的虚拟地址之后就开始查询页表,确定是否存在映射以及读写权限是否正常。 - -iOS 程序在进行加载时,会根据一 page 大小16kb 将程序分割为多页,启动时部分的页加载进真实内存,部分页还在磁盘中,中间的调度记录在一张内存映射表(Page Table),这个表用来调度磁盘和内存两者之间的数据交换。 - -如上图,App 运行时执行某个任务时,会先访问虚拟页表,如果页表的标记为1,则说明该页面数据已经存在于内存中,可以直接访问。如果页表为0,则说明数据未在物理内存中,这时候系统会阻塞进程,叫做缺页中断(page fault),进程会从用户态切换到内核态,并将缺页中断交给内核的 page Fault Handler 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。 - -因为磁盘访问速度较慢,所以 page in 比较耗时,而且 iOS 不仅仅是将数据加载到内存中,还要对这页做 Code Sign 签名认证,所以 iOS 耗时更长 - -Tips:Code Sign 加密哈希并不少针对于整个文件,而是针对于每一个 Page 的,保证了在 dyld 进行加载的时候,可以对每一个 page 进行独立验证。 - -等到程序运行时用到了才去内存中寻找虚拟地址对应的页帧,找不到才进行分配,这就是内存的惰性(延时)分配机制。 - - - -## 检测 - -根据 Instrucments 提供的工具的工作原理,写一个野指针探针工具去发现并定位问题。具体见[野指针监控工具](./1.74.md#zombieSniffer) \ No newline at end of file diff --git a/Chapter1 - iOS/1.41.md b/Chapter1 - iOS/1.41.md deleted file mode 100644 index 3dad364..0000000 --- a/Chapter1 - iOS/1.41.md +++ /dev/null @@ -1,154 +0,0 @@ -# iOS应用启动性能优化资料汇总 - -[WWDC](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#wwdc) - -[文章](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E6%96%87%E7%AB%A0) - -[工具](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E5%B7%A5%E5%85%B7) - -[代码](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E4%BB%A3%E7%A0%81) - -[偏门古董](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E5%81%8F%E9%97%A8%E5%8F%A4%E8%91%A3) - -[书籍](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E4%B9%A6%E7%B1%8D) - -[总结](https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/#%E6%80%BB%E7%BB%93) - -发现好资料就整理到这里,_随时更新,最后一次更新2018年8月6日_ - -# WWDC - -1. Optimizing App Startup Time - - 必看官方资料,从底层到上层[https://developer.apple.com/videos/play/wwdc2016/406/](https://developer.apple.com/videos/play/wwdc2016/406/) - -2. App Startup Time: Past, Present, and Future - - dyld层面的优化[https://developer.apple.com/videos/play/wwdc2017/413/](https://developer.apple.com/videos/play/wwdc2017/413/) - -3. Optimizing I/O for Performance and Battery Life - - IO是启动性能的重要影响部分[https://developer.apple.com/videos/play/wwdc2016/719/](https://developer.apple.com/videos/play/wwdc2016/719/) - -4. Practical Approaches to Great App Performance - - 现场一步一步解决性能问题[https://developer.apple.com/videos/play/wwdc2018/407/](https://developer.apple.com/videos/play/wwdc2018/407/) - -5. Using Time Profiler in Instruments - - TimeProfiler是必备好帮手[https://developer.apple.com/videos/play/wwdc2016/418/](https://developer.apple.com/videos/play/wwdc2016/418/) - -6. High Performance Auto Layout - - App首页如果是AutoLayout的,那么以后看来不是问题了[https://developer.apple.com/videos/play/wwdc2018/220/](https://developer.apple.com/videos/play/wwdc2018/220/) - -7. Core Image: Performance, Prototyping, and Python - - 首页当然也有大量的图片,了解Core Image[https://developer.apple.com/videos/play/wwdc2018/719/](https://developer.apple.com/videos/play/wwdc2018/719/) - -# 文章 - -**以下文章仅仅是收集,各家之谈,不要全信,也不要反对,各有道理,学习思路即可。** - -1. 即刻技术团队:iOS app 启动速度研究实践 - - 地址[https://zhuanlan.zhihu.com/p/38183046?from=1086193010&wm=3333\_2001&weiboauthoruid=1690182120](https://zhuanlan.zhihu.com/p/38183046?from=1086193010&wm=3333_2001&weiboauthoruid=1690182120)学习思路。 - -2. iOS Dynamic Framework 对App启动时间影响实测 - - [https://www.jianshu.com/p/3263009e9228](https://www.jianshu.com/p/3263009e9228)动态库的测试。可知:启动过程中尽量不要加载动态库了。 - -3. Optimizing Facebook for iOS start time - - [https://code.fb.com/ios/optimizing-facebook-for-ios-start-time/](https://code.fb.com/ios/optimizing-facebook-for-ios-start-time/)Facebook的思路。虽然Facebook的启动很慢。 - -4. Bugly: iOS App 启动性能优化 - - [https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA](https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA)这篇文章最后透露了一个很给力的思路。强烈推荐仔细看文章最后。 - -5. 今日头条iOS客户端启动速度优化 - - [https://techblog.toutiao.com/2017/01/17/iosspeed/](https://techblog.toutiao.com/2017/01/17/iosspeed/)文章开头的信息很多,但减少代码量,貌似很难行得通。 - -6. 如何精确度量 iOS App 的启动时间 - - [https://www.jianshu.com/p/c14987eee107](https://www.jianshu.com/p/c14987eee107)文章的思路可参考。 - -7. 优化 App 的启动时间 - - [http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/](http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/)主要是对WWDC的笔记,但仍然很给力。南萧玉,北子棋。这篇文章就是南萧玉所作。 - -8. 手淘iOS性能优化探索 - - [https://github.com/izhangxb/GMTC/blob/master/%E5%85%A8%E7%90%83%E7%A7%BB%E5%8A%A8%E6%8A%80%E6%9C%AF%E5%A4%A7%E4%BC%9AGMTC%202017%20PPT/%E6%89%8B%E6%B7%98iOS%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E6%8E%A2%E7%B4%A2%20.pdf](https://github.com/izhangxb/GMTC/blob/master/%E5%85%A8%E7%90%83%E7%A7%BB%E5%8A%A8%E6%8A%80%E6%9C%AF%E5%A4%A7%E4%BC%9AGMTC%202017%20PPT/%E6%89%8B%E6%B7%98iOS%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E6%8E%A2%E7%B4%A2%20.pdf)这是GMTC 2017手机淘宝专家的技术分享,可以参考。 - -9. iOS应用启动性能优化\(1\)-premain - - [https://everettjf.github.io/2018/05/26/ios-app-launch-performance-part1/](https://everettjf.github.io/2018/05/26/ios-app-launch-performance-part1/)仅仅是pre-main阶段的思路。作者说有后续的文章,但很久没动静了,不知道在搞什么。 - -10. 一种 hook objective c +load 的方法 - - [https://everettjf.github.io/2017/01/06/a-method-of-hook-objective-c-load/](https://everettjf.github.io/2017/01/06/a-method-of-hook-objective-c-load/)这篇文章的hook比较麻烦,其实还可以参考上面的一篇文章[https://www.jianshu.com/p/c14987eee107](https://www.jianshu.com/p/c14987eee107),这里有批量hook +load的代码。(未来我也有计划会把这些相关代码整理到一个repo中) - -11. 一种 hook C++ static initializers 的方法 - - [https://everettjf.github.io/2017/02/06/a-method-of-hook-static-initializers/](https://everettjf.github.io/2017/02/06/a-method-of-hook-static-initializers/)这篇文章的hook方法,有较大的可能是我首创,强烈推荐。手淘的分享中也提了这个方法。 - -12. 一种延迟 premain code 的方法 - - [https://everettjf.github.io/2017/03/06/a-method-of-delay-premain-code/](https://everettjf.github.io/2017/03/06/a-method-of-delay-premain-code/)通过学习Facebook的App中特有的section(参考文章[https://everettjf.github.io/2016/08/20/facebook-explore-section-fbinjectable/](https://everettjf.github.io/2016/08/20/facebook-explore-section-fbinjectable/)),发现的一种思路。 - -# 工具 - -1. TimeProfiler - - 都知道是啥。 - -2. AppleTrace - - [https://github.com/everettjf/AppleTrace](https://github.com/everettjf/AppleTrace)使用 HookZz hook了objc\_msgSend,会有较大性能损耗,但可根据相对比例来知道大概的耗时占比。另外也手动定义开始结尾生成chrome tracing。 - -3. DTrace - - 只能用于模拟器。使用方法可参考这本书:Advanced Apple Debugging & Reverse Engineering[https://store.raywenderlich.com/products/advanced-apple-debugging-and-reverse-engineering](https://store.raywenderlich.com/products/advanced-apple-debugging-and-reverse-engineering) - -4. Xcode 环境变量 - - DYLD\_PRINT\_STATISTIC 及其他类似环境变量[https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/DynamicLibraries/100-Articles/LoggingDynamicLoaderEvents.html](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/DynamicLibraries/100-Articles/LoggingDynamicLoaderEvents.html) - -# 代码 - -1. FastImageCache - - [https://github.com/path/FastImageCache](https://github.com/path/FastImageCache)优化图片加载的速度。空间换时间。 - -# 偏门古董 - -1. Code Size Performance Guidelines - - [https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html)页面最下面提出的思路很好,但文章是gcc时代的了。有没有clang时代对应的呢。 Improving Locality of Reference[https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/ImprovingLocality.html\#//apple\_ref/doc/uid/20001862-CJBJFIDD](https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/ImprovingLocality.html#//apple_ref/doc/uid/20001862-CJBJFIDD) - -# 书籍 - -1. Pro iOS Apps Performance Optimization - - 貌似比较古老,仅参考。 - -2. iOS and macOS Performance Tuning - - 很细致,我正在看。有中文翻译版。 - -3. High Performance iOS Apps - - 有中文翻译版。 - -# 总结 - -上面的文章我都看过,或者至少是正在看,总结下来,辅助大家优化启动性能。 - - - ---- - -转载于:https://everettjf.github.io/2018/08/06/ios-launch-performance-collection/ - diff --git a/Chapter1 - iOS/1.42.md b/Chapter1 - iOS/1.42.md deleted file mode 100644 index 460c6d1..0000000 --- a/Chapter1 - iOS/1.42.md +++ /dev/null @@ -1,84 +0,0 @@ -# App 数据安全篇 - -> 之前的研究了 web 站点的数据安全,同时也用[文章](https://github.com/FantasticLBP/Anti-WebSpider)记录下来分享给大家。接着又研究了下 App 的安全,同样写文章记录下来 - - - -## 现状 - -目前 App 的安全比较低,体现在哪?很多人在想用了 HTTPS 不是就很安全吗?其实并不是,专业的抓包工具还是可以抓 HTTPS 包。根据接口规律,做自动化请求接口,将数据保存窃取是我们不想看到的结果。所以如果只用了 HTTPS 还是不安全。 - -所以需要实现的安全表现在:1. App 数据防止抓包;2. 防止中间人攻击;3. 下下策。即使抓包成功拿到的数据也是密文。如果想解密,是不可逆的。 - - - -## 解决方案 - -1. App 数据防止抓包 - - 原理:抓包工具工作原理见[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/第四部分%20开发杂谈/4.10.md) - - ![App-Server](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/App-Server.png) - - **验证证书的真伪**其实一般来说这个过程应该是安全的,因为一般的证书都是由操作系统来管理。所以只要操作系统没有证书链验证等方面的 bug 是没有什么问题的,但是为了抓包其实我们是在操作系统中导入了中间人的 CA,这样中间人下发的公钥证书就可以被认为是合法的,可以通过验证的(中间人既承担了颁发证书,又承担了验证证书,通过验证)。 - - - - 措施: 客户端为了解决这个问题,最好的方式其实就是内嵌证书,比对一下这个证书到底是不是自己真正的“服务端”发来的,而不是中间被替换了。 - - - 跟服务端人员拿到 https 证书,导入 Xcode 工程项目中 - - - AFNetworking设置以下代码 - - ```objective-c - AFSecurityPolicy * policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate]; - manager.securityPolicy = policy; - ``` - - AF的安全策略会自动的在bundle里面查找公钥证书,建立https的时候进行比对。不一样直接就失败了。 - - AFNetworking 的 AFSSLPinningMode 的三个级别 - -   AFSSLPinningModeNone: (默认级别),客户端无条件信任任何下发的公钥证书 - -   AFSSLPinningModePublicKey: 客户端本地去验证服务端下发的公钥证书的 public keys部分。如果正确才通过 - -   AFSSLPinningModeCertificate: 客户端本地去验证服务端下发的公钥证书的所有部分。如果正确才通过 - - 这样做了之后,就可以即使手机上安装了抓包工具的CA,抓包工具也不能抓到包了。因为你的客户端在验证“服务端”下发的公钥证书的真伪的时候就不会通过“中间人”下发的公钥证书,也就不会建立起来https的连接了。 - -2. 防止中间人攻击 - - 即使我们的 App 被大神逆向了(iOS + 网络精通),抓到网络请求,然后原封不动去向服务器发起请求,但是服务端做了防重放,也是很安全的。所以防重放机制是 Server 端的安全措施 - - [防重放](https://www.cnblogs.com/yjf512/p/6590890.html) - -3. 密文,反向解密不可逆 - - 采用 RSA 非对称加密算法。 - - - iOS 端和 Server 端各生成自己的公钥和私钥 - - 使用 **openssl** 生成所需秘钥 - - - iOS 端生成的公钥和私钥定义为 **iOSPublicKey、iOSPrivateKey**,Server 端生成的公钥私钥定义为**ServerPublicKey、ServerPrivateKey**。将 **iOSPublicKey ** 给 Server 端使用,让它用 **iOSPublicKey ** 加密数据传给 iOS 端,iOS 端用 **iOSPrivateKey** 解密;Server 端将 **ServerPublicKey** 给 iOS 端,iOS 端用 **ServerPublicKey** 加密数据后上传给 Server 端,Server 端利用 **ServerPrivateKey** 去解密,这样就实现了数据传输过程中的加密与解密 - - - - ## 资料 - - [RSA算法原理(一)](http://www.ruanyifeng.com/blog/2013/06/rsa_algorithm_part_one.html) - - [RSA算法原理(二)](http://www.ruanyifeng.com/blog/2013/07/rsa_algorithm_part_two.htmll) - - [素数](https://zh.wikipedia.org/zh-cn/互質) - - [防重放](https://www.cnblogs.com/yjf512/p/6590890.html) - - - - - - - - diff --git a/Chapter1 - iOS/1.43.md b/Chapter1 - iOS/1.43.md deleted file mode 100644 index 2e09ea9..0000000 --- a/Chapter1 - iOS/1.43.md +++ /dev/null @@ -1,94 +0,0 @@ -# 调试方面的骚操作 - -1. 在日常开发中我们经常会封装某个功能模块然后暴露某个方法给外部。但是很多时候调用我们封装功能的人可能会不按照约定的方法传递参数。所以我们会使用断言。但是在线上的时候如果使用了断言,那么程序肯定会 **Crash** ,Xcode 提供了一个小功能可以解决这个问题。 - - `NS_BLOCK_ASSERTIONS `: 表明在 Release 状态下过滤 NSAssert,只需要这一个条件就可以过滤掉 NSAssert。 - 方法:在 “Build Settings” 下搜索 **Preprocessor Macros** ,然后在 Release 下面添加 NS_BLOCK_ASSERTIONS - -![](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。 - - -### 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 deleted file mode 100644 index 98d24cb..0000000 --- a/Chapter1 - iOS/1.44.md +++ /dev/null @@ -1,847 +0,0 @@ -# 一个 Hybrid SDK 设计与实现 - -> 随着移动浪潮的兴起,各种 App 层出不穷,极速发展的业务拓展提升了团队对开发效率的要求,这个时候纯粹使用 Native 开发技术成本难免会更高一点。而 H5 的低成本、高效率、跨平台等特性马上被利用起来了,形成一种新的开发模式: Hybrid App -> -> 作为一种混合开发的模式,Hybrid App 底层依赖于 Native 提供的容器(Webview),上层使用各种前端技术完成业务开发(现在三足鼎立的 Vue、React、Angular),底层透明化、上层多样化。这种场景非常有利于前端介入,非常适合业务的快速迭代。于是 Hybrid 火了。 -> -> 大道理谁都懂,但是按照我知道的情况,还是有非常多的人和公司在 Hybrid 这一块并没有做的很好,所以我将我的经验做一个总结,希望可以帮助广大开发者的技术选型有所帮助 - - - - -## 一、Hybrid 现状 - -可能早期都是 PC 端的网页开发,随着移动互联网的发展,iOS、Android 智能手机的普及,非常多的业务和场景都从 PC 端转移到移动端。开始有前端开发者为移动端开发网页。这样子早期资源打包到 Native App 中会造成应用包体积的增大。越来越多的业务开始用 H5 尝试,这样子难免会需要一个需要访问 Native 功能的地方,这样子可能早期就是懂点前端技术的 Native 开发者自己封装或者暴露 Native 能力给 JS 端,等业务较多的时候者样子很明显不现实,就需要专门的 Hybrid 团队做这个事情;量大了,就需要规矩,就需要规范。 - -总结: -1. Hybrid 开发效率高、跨平台、低成本 -2. Hybrid 从业务上讲,没有版本问题,有 Bug 可以及时修复 - -Hybrid 在大量应用的时候就需要一定的规范,那么本文将讨论一个 Hybrid 的设计知识。 - - Hybrid 、Native、前端各自的工作是什么 - - Hybrid 交互接口如何设计 - - Hybrid 的 Header 如何设计 - - Hybrid 的如何设计目录结构以及增量机制如何实现 - - 资源缓存策略,白屏问题... - - - - -## 二、Native 与前端分工 -在做 Hybird 架构设计之前我们需要分清 Native 与前端的界限。首先 Native 提供的是宿主环境,要合理利用 Native 提供的能力,要实现通用的 Hybrid 架构,站在大前端的视觉,我觉得需要考虑以下核心设计问题。 - - - -### 1. 交互设计 - -Hybrid 架构设计的第一要考虑的问题就是如何设计前端与 Native 的交互,如果这块设计不好会对后续的开发、前端框架的维护造成深远影响。并且这种影响是不可逆、积重难返。所以前期需要前端与 Native 好好配合、提供通用的接口。比如 - -1. Native UI 组件、Header 组件、消息类组件 -2. 通讯录、系统、设备信息读取接口 -3. H5 与 Native 的互相跳转。比如 H5 如何跳转到一个 Native 页面,H5 如何新开 Webview 并做动画跳转到另一个 H5 页面 - - - - -### 2. 账号信息设计 - -账号系统是重要且无法避免的,Native 需要设计良好安全的身份验证机制,保证这块对业务开发者足够透明,打通账户体系。 -举个例子,客户端提供了一个 WebView 容器,Native 侧的个人中心,用户是登陆态。但用户去访问 A 业务,A 业务的实现是前端实现的。访问页面时,页面内容都可以看到了,但是某个接口需要用户登录态,然后忽然跳转到登陆页,对于用户体验很不好。用户一脸懵逼,我不是登陆了吗?为什么还跳转到登陆页面,管你页面的技术实现是 Native 还是前端,对于用户来说,用户不是专业技术人员,不会判断是 Native 还是跨端方案,也不需要判断。 - -所以解决方案是 Native 和 Hybrid 打通账号体系,通过 WebView 去访问 H5 的时候,应该保持同样的登陆态,用户账号信息是打通的。 - -Todo: -WebView 的鉴权 - -举个例子:携程的动态化很高现在 RN 居多,前几年的时候大部分页面还是 Hybrid 架构。假设用户在浏览器里面访问了一个页面 A,输入手机号登陆成功了,也在页面 A 上完成了自己的业务。此时 - - -### 3. Hybrid 开发调试 - -功能设计、编码完并不是真正结束,Native 与前端需要商量出一套可开发调试的模型,不然很多业务开发的工作难以继续。 - -[iOS调试技巧](https://www.jianshu.com/p/f430caa81fa8) - -Android 调试技巧: -- App 中开启 Webview 调试(WebView.setWebContentsDebuggingEnabled(true); ) -- chrome 浏览器输入 chrome://inspect/#devices 访问可以调试的 webview 列表 -- 需要翻墙的环境 - - -![结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/HybridStructure.jpg) - - - - -## 三、Hybrid 交互设计 - -Hybrid 交互无非是 Native 调用 H5 页面JS 方法,或者 H5 页面通过 JS 调 Native 提供的接口。2者通信的桥梁是 Webview。 -业界主流的通信方法:1.桥接对象(时机问题,不太主张这种方式);2.自定义 Url scheme - -![通信设计](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Native-JS-Communication.png) - -App 自身定义了 url scheme,将自定义的 url 注册到调度中心,例如 -weixin:// 可以打开微信。 - -关于 Url scheme 如果不太清楚可以看看 [这篇文章](https://www.jianshu.com/p/253479ccc83a) - - - - -### 1. JS to Native - -Native 在每个版本都会提供一些 Api,前端会有一个对应的框架团队对其封装,释放业务接口。举例 - -``` -SDGHybrid.http.get() // 向业务服务器拿数据 -SDGHybrid.http.post() // 向业务服务器提交数据 -SDGHybrid.http.sign() // 计算签名 -SDGHybrid.http.getUA() // 获取UserAgent -``` - -``` -SDGHybridReady(function(arg){ - SDGHybrid.http.post({ - url: arg.baseurl + '/feedback', - params:{ - title: '点菜很慢', - content: '服务差' - }, - success: (data) => { - renderUI(data); - }, - fail: (err) => { - console.log(err); - } - }) -}) -``` - -前端框架定义了一个全局变量 SDGHybrid 作为 Native 与前端交互的桥梁,前端可以通过这个对象获得访问 Native 的能力 - - - - -### 2. Api 交互 - -调用 Native Api 接口的方式和使用传统的 Ajax 调用服务器,或者 Native 的网络请求提供的接口相似 -![Api交互](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/HybridApi.jpg) - -所以我们需要封装的就是模拟创建一个类似 Ajax 模型的 Native 请求。 - -![通信示例](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Hybrid-Ajax.jpg) - - - - -### 3. 格式约定 -交互的第一步是设计数据格式。这里分为请求数据格式与响应数据格式,参考 Ajax 模型: - -``` -$.ajax({ - type: "GET", - url: "test.json", - data: {username:$("#username").val(), content:$("#content").val()}, - dataType: "json", - success: function(data){ - renderUI(data); - } -}); -``` -``` -$.ajax(options) => XMLHTTPRequest -type(默认值:GET),HTTP请求方法(GET|POST|DELETE|...) -url(默认值:当前url),请求的url地址 -data(默认值:'') 请求中的数据如果是字符串则不变,如果为Object,则需要转换为String,含有中文则会encodeURI -``` - -所以 Hybrid 中的请求模型为: -``` -requestHybrid({ - // H5 请求由 Native 完成 - tagname: 'NativeRequest', - // 请求参数 - param: requestObject, - // 结果的回调 - callback: function (data) { - renderUI(data); - } -}); -``` -这个方法会形成一个 URL,比如: -`SDGHybrid://NativeRequest?t=1545840397616&callback=Hybrid_1545840397616¶m=%7B%22url%22%3A%22https%3A%2F%2Fwww.datacubr.com%2FApi%2FSearchInfo%2FgetLawsInfo%22%2C%22params%22%3A%7B%22key%22%3A%22%22%2C%22page%22%3A1%2C%22encryption%22%3A1%7D%2C%22Hybrid_Request_Method%22%3A0%7D` - -Native 的 webview 环境可以监控内部任何的资源请求,判断如果是 SDGHybrid 则分发事件,处理结束可能会携带参数,参数需要先 urldecode 然后将结果数据通过 Webview 获取 window 对象中的 callback(Hybrid_时间戳) - -数据返回的格式和普通的接口返回格式类似 -``` -{ - errno: 1, - message: 'App版本过低,请升级App版本', - data: {} -} -``` -这里注意:真实数据在 data 节点中。如果 errno 不为0,则需要提示 message。 - - -简易版本代码实现。 - -```javascript -//通用的 Hybrid call Native -window.SDGbrHybrid = window.SDGbrHybrid || {}; -var loadURL = function (url) { - var iframe = document.createElement('iframe'); - iframe.style.display = "none"; - iframe.style.width = '1px'; - iframe.style.height = '1px'; - iframe.src = url; - document.body.appendChild(iframe); - setTimeout(function () { - iframe.remove(); - }, 100); -}; - -var _getHybridUrl = function (params) { - var paramStr = '', url = 'SDGHybrid://'; - url += params.tagname + "?t=" + new Date().getTime(); - if (params.callback) { - url += "&callback=" + params.callback; - delete params.callback; - } - - if (params.param) { - paramStr = typeof params.param == "object" ? JSON.stringify(params.param) : params.param; - url += "¶m=" + encodeURIComponent(paramStr); - } - return url; -}; - - -var requestHybrid = function (params) { - //生成随机函数 - var tt = (new Date().getTime()); - var t = "Hybrid_" + tt; - var tmpFn; - - if (params.callback) { - tmpFn = params.callback; - params.callback = t; - window.SDGHybrid[t] = function (data) { - tmpFn(data); - delete window.SDGHybrid[t]; - } - } - loadURL(_getHybridUrl(params)); -}; - -//获取版本信息,约定APP的navigator.userAgent版本包含版本信息:scheme/xx.xx.xx -var getHybridInfo = function () { - var platform_version = {}; - var na = navigator.userAgent; - var info = na.match(/scheme\/\d\.\d\.\d/); - - if (info && info[0]) { - info = info[0].split('/'); - if (info && info.length == 2) { - platform_version.platform = info[0]; - platform_version.version = info[1]; - } - } - return platform_version; -}; -``` -Native 对于 H5 来说有个 Webview 容器,框架&&底层不太关心 H5 的业务实现,所以真实业务中 Native 调用 H5 场景较少。 - -上面的网络访问 Native 代码(iOS为例) - -```objective-c -typedef NS_ENUM(NSInteger){ - Hybrid_Request_Method_Post = 0, - Hybrid_Request_Method_Get = 1 -} Hybrid_Request_Method; - -@interface RequestModel : NSObject - -@property (nonatomic, strong) NSString *url; -@property (nonatomic, assign) Hybrid_Request_Method Hybrid_Request_Method; -@property (nonatomic, strong) NSDictionary *params; - -@end - - -@interface HybridRequest : NSObject - - -+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail; - -+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail{ - //处理请求不全的情况 - NSAssert(requestModel || success || fail, @"Something goes wrong"); - - NSString *url = requestModel.url; - NSDictionary *params = requestModel.params; - if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get) { - [AFNetPackage getJSONWithUrl:url parameters:params success:^(id responseObject) { - success(responseObject); - } fail:^{ - fail(); - }]; - } - else if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) { - [AFNetPackage postJSONWithUrl:url parameters:params success:^(id responseObject) { - success(responseObject); - } fail:^{ - fail(); - }]; - } -} - -``` - - - - -## 四、常用交互 Api - -良好的交互设计是第一步,在真实业务开发中有一些 Api 一定会由应用场景。 - - - -### 1. 跳转 -跳转是 Hybrid 必用的 Api 之一,对前端来说有以下情况: - - 页面内跳转,与 Hybrid 无关 - - H5 跳转 Native 界面 - - H5 新开 Webview 跳转 H5 页面,一般动画切换页面 - 如果使用动画,按照业务来说分为前进、后退。forward & backword,规定如下,首先是 H5 跳 Native 某个页面 - - ``` - //H5跳Native页面 - //=>SDGHybrid://forward?t=1446297487682¶m=%7B%22topage%22%3A%22home%22%2C%22type%22%3A%22h2n%22%2C%22data2%22%3A2%7D - requestHybrid({ - tagname: 'forward', - param: { - // 要去到的页面 - topage: 'home', - // 跳转方式,H5跳Native - type: 'native', - // 其它参数 - data2: 2 - } - }); - ``` - -H5 页面要去 Native 某个页面 - -``` -//=>SDGHybrid://forward?t=1446297653344¶m=%7B%22topage%22%253A%22Goods%252Fdetail%20%20%22%252C%22type%22%253A%22h2n%22%252C%22id%22%253A20151031%7D -requestHybrid({ - tagname: 'forward', - param: { - // 要去到的页面 - topage: 'Goods/detail', - // 跳转方式,H5跳Native - type: 'native', - // 其它参数 - id: 20151031 - } -}); -``` - -H5 新开 Webview 的方式去跳转 H5 - -``` -requestHybrid({ - tagname: 'forward', - param: { - // 要去到的页面,首先找到goods频道,然后定位到detail模块 - topage: 'goods/detail ', - //跳转方式,H5新开Webview跳转,最后装载H5页面 - type: 'webview', - //其它参数 - id: 20151031 - } -}); -``` - -back 与 forward 一致,可能会有 animatetype 参数决定页面切换的时候的动画效果。真实使用的时候可能会全局封装方法去忽略 tagname 细节。 - - - -### 2. Header 组件的设计 - -Native 每次改动都比较“慢”,所以类似 Header 就很需要。 -1. 主流容器都是这么做的,比如微信、手机百度、携程 -2. 没有 Header 一旦出现网络错误或者白屏,App 将陷入假死状态 - -PS: Native 打开 H5,如果 300ms 没有响应则需要 loading 组件,避免白屏 -因为 H5 App 本身就有 Header 组件,站在前端框架层来说,需要确保业务代码是一致的,所有的差异需要在框架层做到透明化,简单来说 Header 的设计需要遵循: -- H5 Header 组件与 Native 提供的 Header 组件使用调用层接口一致 -- 前端框架层根据环境判断选择应该使用 H5 的 Header 组件异或 Native 的 Header 组件 - -一般来说 Header 组件需要完成以下功能: - -1. Header 左侧与右侧可配置,显示为文字或者图标(这里要求 Header 实现主流图标,并且也可由业务控制图标),并需要控制其点击回调 - -2. Header 的 title 可设置为单标题或者主标题、子标题类型,并且可配置 lefticon 与 righticon(icon居中) - -3. 满足一些特殊配置,比如标签类 Header - -所以,站在前端业务方来说,Header 的使用方式为(其中 tagname 是不允许重复的): - -```javascript - //Native以及前端框架会对特殊tagname的标识做默认回调,如果未注册callback,或者点击回调callback无返回则执行默认方法 - // back前端默认执行History.back,如果不可后退则回到指定URL,Native如果检测到不可后退则返回Naive大首页 - // home前端默认返回指定URL,Native默认返回大首页 - this.header.set({ - left: [ - { - //如果出现value字段,则默认不使用icon - tagname: 'back', - value: '回退', - //如果设置了lefticon或者righticon,则显示icon - //native会提供常用图标icon映射,如果找不到,便会去当前业务频道专用目录获取图标 - lefticon: 'back', - callback: function () { } - } - ], - right: [ - { - //默认icon为tagname,这里为icon - tagname: 'search', - callback: function () { } - }, - //自定义图标 - { - tagname: 'me', - //会去hotel频道存储静态header图标资源目录搜寻该图标,没有便使用默认图标 - icon: 'hotel/me.png', - callback: function () { } - } - ], - title: 'title', - //显示主标题,子标题的场景 - title: ['title', 'subtitle'], - //定制化title - title: { - value: 'title', - //标题右边图标 - righticon: 'down', //也可以设置lefticon - //标题类型,默认为空,设置的话需要特殊处理 - //type: 'tabs', - //点击标题时的回调,默认为空 - callback: function () { } - } -}); -``` - -因为 Header 左边一般来说只有一个按钮,所以其对象可以使用这种形式: - -```javascript -this.header.set({ - back: function () { }, - title: '' -}); -//语法糖=> -this.header.set({ - left: [{ - tagname: 'back', - callback: function(){} - }], - title: '', -}); -``` - -为完成 Native 端的实现,这里会新增两个接口,向 Native 注册事件,以及注销事件: - -```javascript -var registerHybridCallback = function (ns, name, callback) { - if(!window.Hybrid[ns]) window.Hybrid[ns] = {}; - window.Hybrid[ns][name] = callback; -}; - -var unRegisterHybridCallback = function (ns) { - if(!window.Hybrid[ns]) return; - delete window.Hybrid[ns]; -}; -``` - -Native Header 组件实现: - -```javascript -define([], function () { - 'use strict'; - - return _.inherit({ - - propertys: function () { - - this.left = []; - this.right = []; - this.title = {}; - this.view = null; - - this.hybridEventFlag = 'Header_Event'; - - }, - - //全部更新 - set: function (opts) { - if (!opts) return; - - var left = []; - var right = []; - var title = {}; - var tmp = {}; - - //语法糖适配 - if (opts.back) { - tmp = { tagname: 'back' }; - if (typeof opts.back == 'string') tmp.value = opts.back; - else if (typeof opts.back == 'function') tmp.callback = opts.back; - else if (typeof opts.back == 'object') _.extend(tmp, opts.back); - left.push(tmp); - } else { - if (opts.left) left = opts.left; - } - - //右边按钮必须保持数据一致性 - if (typeof opts.right == 'object' && opts.right.length) right = opts.right - - if (typeof opts.title == 'string') { - title.title = opts.title; - } else if (_.isArray(opts.title) && opts.title.length > 1) { - title.title = opts.title[0]; - title.subtitle = opts.title[1]; - } else if (typeof opts.title == 'object') { - _.extend(title, opts.title); - } - - this.left = left; - this.right = right; - this.title = title; - this.view = opts.view; - - this.registerEvents(); - - _.requestHybrid({ - tagname: 'updateheader', - param: { - left: this.left, - right: this.right, - title: this.title - } - }); - - }, - - //注册事件,将事件存于本地 - registerEvents: function () { - _.unRegisterHybridCallback(this.hybridEventFlag); - this._addEvent(this.left); - this._addEvent(this.right); - this._addEvent(this.title); - }, - - _addEvent: function (data) { - if (!_.isArray(data)) data = [data]; - var i, len, tmp, fn, tagname; - var t = 'header_' + (new Date().getTime()); - - for (i = 0, len = data.length; i < len; i++) { - tmp = data[i]; - tagname = tmp.tagname || ''; - if (tmp.callback) { - fn = $.proxy(tmp.callback, this.view); - tmp.callback = t; - _.registerHeaderCallback(this.hybridEventFlag, t + '_' + tagname, fn); - } - } - }, - - //显示header - show: function () { - _.requestHybrid({ - tagname: 'showheader' - }); - }, - - //隐藏header - hide: function () { - _.requestHybrid({ - tagname: 'hideheader', - param: { - animate: true - } - }); - }, - - //只更新title,不重置事件,不对header其它地方造成变化,仅仅最简单的header能如此操作 - update: function (title) { - _.requestHybrid({ - tagname: 'updateheadertitle', - param: { - title: 'aaaaa' - } - }); - }, - - initialize: function () { - this.propertys(); - } - }); - -}); -``` - - - -### 3. 请求类 - -虽然 get 类请求可以用 jsonp 方式绕过跨域问题,但是 post 请求是一个拦路虎。为了安全性问题服务器会设置 cors 仅仅针对几个域名,Hybrid 内嵌静态资源可能是通过本地 file 的方式读取,所以 cors 就行不通了。另外一个问题是防止爬虫获取数据,由于 Native 针对网络做了安全性设置(鉴权、防抓包等),所以 H5 的网络请求由 Native 完成。可能有些人说 H5 的网络请求让 Native 走就安全了吗?我可以继续爬取你的 Dom 节点啊。这个是针对反爬虫的手段一。想知道更多的反爬虫策略可以看看我这篇文章 [Web反爬虫方案](https://github.com/FantasticLBP/Anti-WebSpider) - -![Web网络请求由Native完成](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/DataViaNative.png) - -这个使用场景和 Header 组件一致,前端框架层必须做到对业务透明化,业务事实上不必关心这个网络请求到底是由 Native 还是浏览器发出。 - -```javascript -HybridGet = function (url, param, callback) { - -}; -HybridPost = function (url, param, callback) { - -}; -``` - -真实的业务场景,会将之封装到数据请求模块,在底层做适配,在H5站点下使用ajax请求,在Native内嵌时使用代理发出,与Native的约定为 - -```javascript -requestHybrid({ - tagname: 'NativeRequest', - param: { - url: arg.Api + "SearchInfo/getLawsInfo", - params: requestparams, - Hybrid_Request_Method: 0, - encryption: 1 - }, - callback: function (data) { - renderUI(data); - } -}); -``` - - - - -## 五、常用 NativeUI 组件 - -一般情况 Native 通常会提供常用的 UI,比如 加载层loading、消息框toast - -```javascript -var HybridUI = {}; -HybridUI.showLoading(); -//=> -requestHybrid({ - tagname: 'showLoading' -}); - -HybridUI.showToast({ - title: '111', - //几秒后自动关闭提示框,-1需要点击才会关闭 - hidesec: 3, - //弹出层关闭时的回调 - callback: function () { } -}); -//=> -requestHybrid({ - tagname: 'showToast', - param: { - title: '111', - hidesec: 3, - callback: function () { } - } -}); -``` - -Native UI与前端UI不容易打通,所以在真实业务开发过程中,一般只会使用几个关键的Native UI。 - - - - -## 六、账号系统的设计 - -Webview 中跑的网页,账号登录与否由是否携带密钥 cookie 决定(不能保证密钥的有效性)。因为 Native 不关注业务实现,所以每次载入都有可能是登录成功跳转回来的结果,所以每次载入都需要关注密钥 cookie 变化,以做到登录态数据的一致性。 - - - -- 使用 Native 代理做请求接口,如果没有登录则 Native 层唤起登录页 -- 直连方式使用 ajax 请求接口,如果没登录则在底层唤起登录页(H5) - -```javascript -/* - 无论成功与否皆会关闭登录框 - 参数包括: - success 登录成功的回调 - error 登录失败的回调 - url 如果没有设置success,或者success执行后没有返回true,则默认跳往此url -*/ -HybridUI.Login = function (opts) { - //... -}; -//=> -requestHybrid({ - tagname: 'login', - param: { - success: function () { }, - error: function () { }, - url: '...' - } -}); -//与登录接口一致,参数一致 -HybridUI.logout = function () { - //... -}; -``` - - -在设计 Hybrid 层的时候,接口要做到对于处于 Hybrid 环境中的代码乐意通过接口获取 Native 端存储的用户账号信息;对于处于传统的网页环境,可以通过接口获取线上的账号信息,然后将非敏感的信息存储到 LocalStorage 中,然后每次页面加载从 LocalStorage 读取数据到内存中(比如 Vue.js 框架中的 Vuex,React.js 中的 Redux) - - - - -## 七、Hybrid 资源管理 - -Hybrid 的资源需要 `增量更新` 需要拆分方便,所以一个 Hybrid 资源结构类似于下面的样子 - -![Hybrid资源结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-07-08-Hybrid-ResourceStructure.png) - - - -假设有2个业务线:商城、购物车 - - - -```tex -WebApp -│- Mall -│- Cart -│ index.html //业务入口html资源,如果不是单页应用会有多个入口 -│ │ main.js //业务所有js资源打包 -│ │ -│ └─static //静态样式资源 -│ ├─css -│ ├─hybrid //存储业务定制化类Native Header图标 -│ └─images -├─libs -│ libs.js //框架所有js资源打包 -│ -└─static - ├─css - └─images -``` - - - - -## 八、增量更新 - -每次业务开发完毕后都需要在打包分发平台进行部署上线,之后会生成一个版本号。 - - -| Channel | Version | md5 | -| ------- | ------- | ----------- | -| Mall | 1.0.1 | 12233000ww | -| Cart | 1.1.2 | 28211122wt2 | - - -当 Native App 启动的时候会从服务端请求一个接口,接口的返回一个 json 串,内容是 App 所包含的各个 H5 业务线的版本号和 md5 信息。 - -拿到 json 后和 App 本地保存的版本信息作比较,发现变动了则去请求相应的接口,接口返回 md5 对应的文件。Native 拿到后完成解压替换。 - -全部替换完毕后将这次接口请求到的资源版本号信息保存替换到 Native 本地。 - -因为是每个资源有版本号,所以如果线上的某个版本存在问题,那么可以根据相应的稳定的版本号回滚到稳定的版本。 - - - - -## 九、体验优化 - -### 1. 静态直出 - -“直出”这个概念对前端同学来说,并不陌生。为了优化首屏体验,大部分主流的页面都会在服务器端拉取首屏数据后通过 NodeJs 进行渲染,然后生成一个包含了首屏数据的 Html 文件,这样子展示首屏的时候,就可以解决内容转菊花的问题了。 -当然这种页面“直出”的方式也会带来一个问题,服务器需要拉取首屏数据,意味着服务端处理耗时增加。 -不过因为现在 Html 都会发布到 CDN 上,WebView 直接从 CDN 上面获取,这块耗时没有对用户造成影响。 -手 Q 里面有一套自动化的构建系统 Vnues,当产品经理修改数据发布后,可以一键启动构建任务,Vnues 系统就会自动同步最新的代码和数据,然后生成新的含首屏 Html,并发布到 CDN 上面去。 - -我们可以做一个类似的事情,自动同步最新的代码和数据,然后生成新的含首屏 Html,并发布到 CDN 上面去 - -### 2. 离线预推 - -页面发布到 CDN 上面去后,那么 WebView 需要发起网络请求去拉取。当用户在弱网络或者网速比较差的环境下,这个加载时间会很长。于是我们通过离线预推的方式,把页面的资源提前拉取到本地,当用户加载资源的时候,相当于从本地加载,即使没有网络,也能展示首屏页面。这个也就是大家熟悉的离线包。 -手 Q 使用 7Z 生成离线包, 同时离线包服务器将新的离线包跟业务对应的历史离线包进行 BsDiff 做二进制差分,生成增量包,进一步降低下载离线包时的带宽成本,下载所消耗的流量从一个完整的离线包(253KB)降低为一个增量包(3KB)。 - -[手Q开源Hybrid框架VasSonic介绍,极致的页面加载速度优化](https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&mid=2247488218&idx=1&sn=21afe07eb642162111ee210e4a040db2&chksm=f951a799ce262e8f6c1f5bb85e84c2db49ae4ca0acb6df40d9c172fc0baaba58937cf9f0afe4&scene=27#wechat_redirect) - - - -### 3. 拦截加载 - -事实上,在高度定制的 wap 页面场景下,我们对于 webview 中可能出现的页面类型会进行严格控制。可以通过内容的控制,避免 wap 页中出现外部页面的跳转,也可以通过 webview 的对应代理方法,禁掉我们不希望出现的跳转类型,或者同时使用,双重保护来确保当前 webview 容器中只会出现我们定制过的内容。既然 wap 页的类型是有限的,自然想到,同类型页面大都由前端采用模板生成,页面所使用的 html、css、js 的资源很可能是同一份,或者是有限的几份,把它们直接随客户端打包在本地也就变得可行。加载对应的 url 时,直接 load 本地的资源。 -对于 webview 中的网络请求,其实也可以交由客户端接管,比如在你所采用的 Hybrid 框架中,为前端注册一个发起网络请求的接口。wap 页中的所有网络请求,都通过这个接口来发送。这样客户端可以做的事情就非常多了,举个例子,NSURLProtocol 无法拦截 WKWebview 发起的网络请求,采用 Hybrid 方式交由客户端来发送,便可以实现对应的拦截。 -基于上面的方案,我们的 wap 页的完整展示流程是这样:客户端在 webview 中加载某个 url,判断符合规则,load 本地的模板 html,该页面的内部实现是通过客户端提供的网络请求接口,发起获取具体页面内容的网络请求,获得填充的数据从而完成展示。 - - -NSURLProtocol能够让你去重新定义苹果的URL加载系统(URL Loading System)的行为,URL Loading System里有许多类用于处理URL请求,比如NSURL,NSURLRequest,NSURLConnection和NSURLSession等。当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,你不应该直接实例化一个NSURLProtocol,NSURLProtocol看起来像是一个协议,但其实这是一个类,而且必须使用该类的子类,并且需要被注册。                                        - - - -### 4. WKWebView 网络请求拦截 - -- 方法一(Native 侧): - 原生 WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此在 WKWebView 上直接使用 NSURLProtocol 是无法拦截请求的。 - - 但是由于 mPaas 的离线包机制强依赖网络拦截,所以基于此,mPaaS 利用了 WKWebview 的隐藏 api,去注册拦截网络请求去满足离线包的业务场景需求,参考代码如下: - - ```Objective-c - [WKBrowsingContextController registerSchemeForCustomProtocol:@"https"] - ``` - - 但是因为出于性能的原因,WKWebView 的网络请求在给主进程传递数据的时候会把请求的 body 去掉,导致拦截后请求的 body 参数丢失。 - - 在离线包场景,由于页面的资源不需要 body 数据,所以离线包可以正常使用不受影响。但是在 H5 页面内的其他 post 请求会丢失 data 参数。 - - 为了解决 post 参数丢失的问题,mPaas 通过在 js 注入代码,hook 了 js 上下文里的 XMLHTTPRequest 对象解决。 - - 通过在 JS 层把方法内容组装好,然后通过 WKWebView 的 messageHandler 机制把内容传到主进程,把对应 HTTPBody 然后存起来,随后通知 JS 端继续这个请求,网络请求到主进程后,在将 post 请求对应的 HttpBody 添加上,这样就完成了一次 post 请求的处理。整体流程可以参考如下: - ![ajax-时序图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-05-28-WKWebViewRequestHook) - 通过上面的机制,既满足了离线包的资源拦截诉求,也解决了 post 请求 body 丢失的问题。但是在一些场景还是存在一些问题,需要开发者进行适配。 - -- 方法二(JS 侧): - 通过 AJAX 请求的 hook 方式,将网络请求的信息代理到客户端本地。能拿到 WKWebView 里面的 post 请求信息,剩下的就不是问题啦。 - AJAX hook 的实现可以看这个 [Repo](https://github.com/wendux/Ajax-hook). - - - - -## 十、离线包 - -传统的 H5 技术容易受到网络环境影响,因而降低 H5 页面的性能。通过使用离线包,可以解决该问题,同时保留 H5 的优点。 - -**离线包** 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。 - -使用 H5 离线包可以给您带来以下优势: - -- **提升用户体验**:通过离线包的方式把页面内静态资源嵌入到应用中并发布,当用户第一次开启应用的时候,就无需依赖网络环境下载该资源,而是马上开始使用该应用。 -- **实现动态更新**:在推出新版本或是紧急发布的时候,您可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此,您无需通过应用商店审核,就能让用户及早接收更新。 - -![离线包下载流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Hybrid-OfflinePackageDownload.png) - -![离线包加载流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Hybrid-OfflinePackageLoad.png) - -## 十一、如何落地和推进 -如果开展 Hybrid 就存在一个问题,很多业务在迭代或者新开的时候就会选择用 H5 前端技术去实现了,但是如果团队内的客户端同学不懂前端或者不是很懂的时候,就会存在一个抵触的心理,觉得是前端在“侵占”、“蚕食”客户端的领地。所以我们做 Hybrid 跨端项目的时候就需要诚恳一些,抱着“双赢”的出发点去聊、去推进。 -- 你在咱们公司当前的项目1中不选择 Hyrbid 技术,将来的项目2、项目3,其他合作的同学可能就会拥抱 Hybrid 技术来水岸 -- 多端融合能力、跨端是趋势,即使在我们公司一直不做,你离开公司去新的公司,肯定也会遇到用跨端去写业务的场景,这是趋势,尽早拥抱吧 -- 你如果只做 Native 以后新的项目或者新的机会给你,你抓不住,不如趁此机和我合作,或者分配一些前端开发的小任务给你,趁此学会前端技术和 Hybrid 的设计,成为一个跨端工程师,点亮、丰富自己的技能树,日后项目的技术选型方面,类似小程序、Weex/RN/Flutter 方案等,上手就会很方便了,也在做技术方案调研、评估方面多一个可选项。 - -客户端同学都是程序员,都比较爱学习,拥抱新技术、新设计,早点拥抱技术红利,享受 Hybrid 设计哲学带来的思维增益。我们要实现的效果并不是前端去侵占、蚕食 Native 领地的效果,而是拥抱优雅的、高效的技术方案,去拓展业务上更多的可能性,也在技术方面增加更多的视野维度 \ No newline at end of file diff --git a/Chapter1 - iOS/1.45.md b/Chapter1 - iOS/1.45.md deleted file mode 100644 index e69de29..0000000 diff --git a/Chapter1 - iOS/1.46.md b/Chapter1 - iOS/1.46.md deleted file mode 100644 index fab93fb..0000000 --- a/Chapter1 - iOS/1.46.md +++ /dev/null @@ -1,1015 +0,0 @@ -# KVC && KVO - -> KVO 的实现原理是什么?如何手动触发 KVO?本文来探索下 iOS 中 KVO 底层细节 - -## 一、KVO 的高级用法 - -### 1. KVO 居然还有触发模式的说法? - -#### 触发模式 - -**KVO 的触发分为`自动触发模式`和`手动触发模式`2种**。通常我们使用的都是自动通知,注册观察者之后,当条件触发的时候会自动调用 `-(void)observeValueForKeyPath`。 - -如果需要实现手动通知,我们需要使用 `+automaticallyNotifiesObserversForKey` 方法返回 NO 即可。此时即使被观察对象的属性值发生了变化,也不会出发观察者的 `- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context` 方法。 - -```Objective-c -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - return NO; -} -``` - -如何手动触发(设置了 `automaticallyNotifiesObserversForKey` 返回 NO 的前提下)KVO ? - -**手动调用 `willChangeValueForKey`、 `didChangeValueForKey`** - -在需要触发 KVO 的地方,先调用 `willChangeValueForKey`,然后更改被观察对象的属性值,最后调用 `didChangeValueForKey` 方法。 - - - - - -QA:在需要触发 KVO 的地方,只调用`willChangeValueForKey`、 `didChangeValueForKey`,不修改被观察对象的属性值,KVO 会触发吗? - -会调用,只是值没有变化。 - -系统默认行为:当没有显式设置新值时,KVO 会尝试通过属性的访问器方法(`getter`)获取当前值作为 `newValue`。此时 `age` 未初始化,默认为 `0` - -说明,属性值改不改变不影响 KVO 的触发,只与 `willChangeValueForKey`、 `didChangeValueForKey` 的调用与否有关。 - -- **禁用自动 KVO 通知**:`+automaticallyNotifiesObserversForKey:返回 NO` 仅禁止属性赋值自动触发 KVO,**手动调用 `willChange/didChange` 仍会触发回调**。 -- **`change` 字典内容**:依赖新旧值的显式记录,若未正确设置属性值,KVO 会通过 `valueForKey:` 获取当前值。 -- **最佳实践**:在手动触发 KVO 时,始终在 `willChange` 和 `didChange` 之间修改属性值,并确保新旧值能被正确捕获 - - - -#### 使用场景 - -##### 批量属性修改后一次性通知 - -当需要同时修改多个关联属性时,自动触发 KVO 会导致多次回调,而手动触发可以合并为一次通知,提升性能。 - -Demo: 图形对象的 `frame` 更新 - -假设一个 `Rectangle` 对象有 `x`、`y`、`width`、`height` 四个属性,修改 `frame` 时需要同时更新这四个属性: - -```objective-c -// Rectangle.m -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - // 禁用自动触发 - if ([key isEqualToString:@"x"] || - [key isEqualToString:@"y"] || - [key isEqualToString:@"width"] || - [key isEqualToString:@"height"]) { - return NO; - } - return [super automaticallyNotifiesObserversForKey:key]; -} - -- (void)setFrameWithX:(CGFloat)x y:(CGFloat)y width:(CGFloat)width height:(CGFloat)height { - // 手动触发 KVO - [self willChangeValueForKey:@"x"]; - [self willChangeValueForKey:@"y"]; - [self willChangeValueForKey:@"width"]; - [self willChangeValueForKey:@"height"]; - - _x = x; - _y = y; - _width = width; - _height = height; - - [self didChangeValueForKey:@"x"]; - [self didChangeValueForKey:@"y"]; - [self didChangeValueForKey:@"width"]; - [self didChangeValueForKey:@"height"]; -} -``` - -##### **属性之间存在依赖关系** - -当一个属性的值依赖于其他属性时,需要手动触发其 KVO 通知。类似于 Vue 的计算属性一样。 - -Demo: `fullName` 依赖 `firstName` 和 `lastName` - -```objective-c -// Person.m -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - // 禁用自动触发 - if ([key isEqualToString:@"fullName"]) { - return NO; - } - return [super automaticallyNotifiesObserversForKey:key]; -} - -- (void)setFirstName:(NSString *)firstName { - [self willChangeValueForKey:@"fullName"]; - _firstName = firstName; - [self didChangeValueForKey:@"fullName"]; -} - -- (void)setLastName:(NSString *)lastName { - [self willChangeValueForKey:@"fullName"]; - _lastName = lastName; - [self didChangeValueForKey:@"fullName"]; -} - -- (NSString *)fullName { - return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName]; -} -``` - -##### **属性变更需要条件触发** - -当属性变更需要满足特定条件时才触发通知。 - -Demo: 数值范围校验 - -```objective-c -// TemperatureSensor.m -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - if ([key isEqualToString:@"temperature"]) { - return NO; // 禁用自动触发 - } - return [super automaticallyNotifiesObserversForKey:key]; -} - -- (void)setTemperature:(CGFloat)temperature { - // 温度变化超过 0.5 度才触发通知 - if (fabs(temperature - _temperature) > 0.5) { - [self willChangeValueForKey:@"temperature"]; - _temperature = temperature; - [self didChangeValueForKey:@"temperature"]; - } -} -``` - -##### 避免循环触发 - -当属性 A 的变更会触发属性 B 的变更,而属性 B 的变更又可能触发属性 A 的变更时,需要手动控制。 - -Demo: 双向关联对象 - -```objective-c -// Node.m (双向链表节点) -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - if ([key isEqualToString:@"next"]) { - return NO; // 禁用自动触发 - } - return [super automaticallyNotifiesObserversForKey:key]; -} - -- (void)setNext:(Node *)next { - // 手动解除旧节点的反向关联 - if (_next != next) { - [self willChangeValueForKey:@"next"]; - _next.previous = nil; // 可能触发 previous 的 KVO - _next = next; - _next.previous = self; // 可能触发 next 的 KVO - [self didChangeValueForKey:@"next"]; - } -} -``` - -##### **性能优化** - -当属性频繁变更但无需立即通知观察者时,手动触发可以合并多次变更为一次通知。 - -Demo: 实时数据流处理 - -```objective-c -// DataStreamProcessor.m -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - if ([key isEqualToString:@"buffer"]) { - return NO; // 禁用自动触发 - } - return [super automaticallyNotifiesObserversForKey:key]; -} - -- (void)appendData:(NSData *)data { - // 数据积累到阈值后一次性触发通知 - [self.internalBuffer appendData:data]; - if (self.internalBuffer.length >= 1024) { - [self willChangeValueForKey:@"buffer"]; - _buffer = [self.internalBuffer copy]; - [self.internalBuffer setLength:0]; - [self didChangeValueForKey:@"buffer"]; - } -} -``` - -注意: - -1. **成对调用**:`willChange` 和 `didChange` 必须成对出现。 -2. **线程安全**:确保 KVO 方法在同一线程调用。 -3. **性能权衡**:手动触发会增加代码复杂度,需根据场景选择。 - - - -### 2. KVO 如何优雅监听 property 嵌套的情况 - -假设 Person 对象有一个 dog 对象作为属性。 Dog 对象拥有 age、name 2个属性。现在想实现对 person 对象的 dog 监听,当 dog 对象的 name、age 任何一个属性改变时,都可以监听到改变,该怎么实现呢? - -版本1:不优雅的方案。手动依次将 dog 的每个属性,都被 Person 的观察者监听。 - -`[_p1 addObserver:self forKeyPath:@"_dog.age" options:(NSKeyValueObservingOptionNew) context:nil];` - -代码如下 - - - -看上去很麻烦,有没有优雅点的方案? - -版本2: 利用系统 API `+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0))` - -`+keyPathsForValuesAffectingValueForKey:` 是 KVO(Key-Value Observing)中用于 **声明属性依赖关系** 的方法,它允许开发者显式指定某个属性的值变化依赖于其他属性(或其键路径)。通过该方法,系统会自动监听依赖属性的变化,并在这些依赖属性变化时触发目标属性的 KVO 通知。 - -实现如下: - - - -注意:在对 Person 对象的 `dog` 属性进行监听后,Person 内部需要实现 `+`keyPathsForValuesAffectingValueForKey 方法,判断 key 为 `@"dog"` 后,想监听 dog 的哪个属性变化就通知 Person 对象的观察者收到响应的话就写上去。但注意绿色框里面,set 添加的内容,必须换个名字,比如 `@"_dog.name"`,不能是 `@"dog.name"`。这会导致循环依赖或逻辑矛盾 - - - -#### 使用场景 - -##### 计算属性 - -类似 Vue 的 Computed Property,当一个属性的值是基于其他属性计算得出时,可通过该方法声明依赖关系,确保计算属性的 KVO 通知自动触发。 - -Demo:`fullName` 依赖 `firstName` 和 `lastName` - -```objective-c -// Person.h -@property (nonatomic, copy) NSString *firstName; -@property (nonatomic, copy) NSString *lastName; -@property (nonatomic, readonly) NSString *fullName; // 计算属性 - -// Person.m -+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key { - NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key]; - if ([key isEqualToString:@"fullName"]) { - // 声明 fullName 依赖 firstName 和 lastName - keyPaths = [keyPaths setByAddingObjectsFromArray:@[@"firstName", @"lastName"]]; - } - return keyPaths; -} - -- (NSString *)fullName { - return [NSString stringWithFormat:@"%@ %@", self.firstName, self.lastName]; -} -``` - -##### **聚合属性(Aggregated Property)** - -当某个属性是多个子属性的聚合结果(如总和、平均值),可通过该方法声明依赖关系。 - -Demo:`totalPrice` 依赖多个商品项的 `price` 和 `quantity` - -```objective-c -// Order.h -@property (nonatomic, strong) NSArray *items; -@property (nonatomic, readonly) CGFloat totalPrice; // 聚合属性 - -// Order.m -+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key { - NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key]; - if ([key isEqualToString:@"totalPrice"]) { - // 声明 totalPrice 依赖所有 items 的 price 和 quantity - NSMutableArray *dependencies = [NSMutableArray array]; - for (OrderItem *item in self.items) { - [dependencies addObject:[NSString stringWithFormat:@"items.%@.price", item.itemId]]; - [dependencies addObject:[NSString stringWithFormat:@"items.%@.quantity", item.itemId]]; - } - keyPaths = [keyPaths setByAddingObjectsFromArray:dependencies]; - } - return keyPaths; -} - -- (CGFloat)totalPrice { - CGFloat sum = 0; - for (OrderItem *item in self.items) { - sum += item.price * item.quantity; - } - return sum; -} -``` - - - -##### **跨对象依赖(Cross-Object Dependency)** - -当属性依赖于其他对象的属性时,可通过键路径声明跨对象依赖。 - -Demo:Person 的 dog 属性本身就是一个对象,对象的值改变后通知观察者 - -```objective-c -// Person.h -@property (nonatomic, strong) Dog *dog; -// Person.m -+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key { - NSSet *keyPathSet = [super keyPathsForValuesAffectingValueForKey:key]; - if ([key isEqualToString:@"dog"]) { - keyPathSet = [[NSSet alloc] initWithObjects:@"_dog.name", @"_dog.age", nil]; - } - return keyPathSet; -} - -// VC -[_p1 addObserver:self forKeyPath:@"dog" options:(NSKeyValueObservingOptionNew) context:nil]; -self.p1.dog.age = 1; -self.p1.dog.name = @"Lucy"; -``` - - - -### 3. KVO 如何对容器类进行监听 - -可以在下面的 Demo1 中可以看到 KVO 无法直接对数组进行 KVO 监听。但系统为了方便,也提供了容器类的接口,比如针对 `NSMutableArray` 系统就提供了 `mutableArrayValueForKey` 接口。 - - - -那么虽然功能实现了,我们可以想想,这个接口背后做了哪些事? - -- NSMutableArray 实例调用 `mutableArrayValueForKey` 方法,即利用 Runtime 创建一个继承自 NSMutableArray 的子类 - -- 因为需要对 NSMutableArray 容器类进行监听,所以 NSMutableArray 方法调用都需要可以观察到。所以需要对这些方法进行重写,内部需要:先调用 `willChangeValueForKey` -> 再调用父类方法实现 -> 最后调用 `didChangeValueForKey` - - ```objective-c - - (void)addObject:(ObjectType)anObject; - - (void)insertObject:(ObjectType)anObject atIndex:(NSUInteger)index; - - (void)removeLastObject; - - (void)removeObjectAtIndex:(NSUInteger)index; - - (void)replaceObjectAtIndex:(NSUInteger)index withObject:(ObjectType)anObject; - ``` - -- OC 调用方法的本质就是利用对象的 isa 找到类对象,然后查找方法列表中的实现。为了调用方法可以走到重写的方法里,所以需要修改当前的 NSMutableArray 的实例对象的 isa 为新创建的类 - -- 如何触发?为 NSObject 添加分类即可。只要对集合类进行观察,就生成子类,修改 isa。拦截容器类的方法。 - - - -### 4. KVO 的问题与改进 - -#### 问题 - -KVO 存在一些问题,让我们用起来很不爽。 - -##### 野指针崩溃 - -**在调用 addObserver 后,KVO 并不会对观察者进行强引用。** - -问:`self.person1` 有没有强引用 `self` ? - -答案是否。因为当前控制器用 strong 指针拥有一个 person1 属性,此时如果 self.person1 也强引用了 self,则形成环,造成内存泄漏。系统不会这么设计,所以答案是否。 - -所以要注意观察者的生命周期,否则会导致观察者被释放带来的 Crash 问题。如果**观察者对象被释放,若未移除观察,则被观察对象的属性变化时,仍然会调用观察者的 `- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context` 方法。此时,观察者指向的内存可能已被回收或分配给其他对象,导致访问无效内存(`EXC_BAD_ACCESS` 崩溃)**。 - -所以 addObserver 和 removeObserver 需要成对存在 - -```objective-c -// Observer 被释放后未移除观察 -- (void)dealloc { - // 未调用 [object removeObserver:self forKeyPath:@"keyPath"]; -} -``` - -##### 内存泄漏 - -虽然 **KVO 不会强引用观察者**(Observer 是弱引用),但以下情况可能引发内存问题: - -- **多次添加观察者**:重复调用 `addObserver` 会导致多次监听同一属性,但未正确移除时,被观察对象会保留多余的观察记录。 -- **被观察对象生命周期长于观察者**:若被观察对象存活时间更长,未移除观察会导致观察者无法释放(如单例对象监听某个属性)。 - -Bad case - -```objective-c -// 多次添加观察者而未移除 -[self.object addObserver:self forKeyPath:@"value" options:NSKeyValueObservingOptionNew context:nil]; -[self.object addObserver:self forKeyPath:@"value" options:NSKeyValueObservingOptionNew context:nil]; -// 移除一次后仍残留一次观察 -[self.object removeObserver:self forKeyPath:@"value"]; -``` - -官方文档说明: - -> *Prior to iOS 9, if an object is observing a property when it is deallocated, the system will throw an exception. In iOS 9 and later, the system automatically removes the observer when it is deallocated.* -> —— [Apple Developer Documentation](https://developer.apple.com/documentation/objectivec/nsobject/1415099-removeobserver) - -iOS 9+ 的改进与兼容性 - -- iOS 9 及以上:当观察者或被观察对象被释放时,系统会自动清理 KVO 注册信息,减少崩溃风险。 -- iOS 8 及以下:仍需手动调用 `removeObserver`,否则可能导致崩溃。 - - - -#### 改进 - -- 添加 observer 后需要在 dealloc 方法中移除 -- 不能同同一属性,多次添加 observer -- 也不能注册1次(addObserver),2次移除(removeObserver)。必须严格对应 -- 在某个地方添加监听,在另一处 `observeValueForKeyPath` 方法里拿到变化。导致 VC 的代码量变大且很分散 - -可以用 RAC 或者 FBKVO - -FBKVO 优势: - -- **语法简洁易用** :相比苹果原生的 KVO API,FBKVO 的语法更加简洁直观,减少了大量的模板代码和繁琐的步骤,使得开发者可以更快速、更方便地实现对对象属性变化的观察,降低了代码的复杂度和出错的概率。 -- **自动管理观察者生命周期** :在原生 KVO 中,需要手动在合适的地方添加和移除观察者,容易出现忘记移除观察者而导致的崩溃问题。而 FBK VO够自动管理观察者的生命周期,当观察的对象被销毁或者观察者本身被销毁时,会自动移除相应的观察,有效避免了因观察者未及时移除而引发的异常,提高了代码的健壮性。 -- **支持 block 回调** :提供了基于 block 的观察回调方式,使得代码更加简洁明了,逻辑更加集中,便于阅读和维护,也更符合现代 iOS 开发中使用 block 的编程习惯。 -- **线程安全** :在多线程环境下,FBKVO 能够保证观察者注册、移除以及回调等操作的线程安全,避免了因线程问题导致的数据不一致和程序崩溃等风险,让开发者在处理多线程场景下的 KVO 操作时更加放心。 -- **丰富的观察选项** :除了支持原生 KVO 的观察选项外,还提供了一些额外的选项和功能,如可以方便地观察 NSArray 和 NSDictionary 等集合类的变化,以及对观察属性的变化进行更细致的控制和过滤等,满足了开发者在不同场景下的多样化需求。 - -FBKVO 工作原理: - -- **基于原生 KVO 进行封装** :FBKVO 在内部封装了苹果原生的 KVO 机制,通过创建一个中间类来桥接观察者和被观察对象,替开发者处理了原生 KVO 中繁琐的细节操作,如 addObserver:forKeyPath:options:context: 和 removeObserver:forKeyPath: 等方法的调用,以及 context 参数的管理等。 -- **利用关联对象存储观察信息** :由于在 Objective - C 中,类别(category)无法直接添加属性,FBKVO 使用关联对象技术将观察的相关信息(如观察的键路径、回调 block 等)存储在被观察对象上,从而实现对被观察对象的扩展,使其能够记录和管理自身的观察者信息。 -- **实现自动移除观察者机制** :FBKVO 通过在被观察对象的 dealloc 方法中插入代码,或者利用 NSObject 的 KVO 通知机制,在观察的对象或者观察者即将被销毁时,自动调用 removeObserver:forKeyPath: 等方法移除相应的观察者,确保了观察者与被观察对象之间的正确解绑,避免了野指针等潜在问题。 -- **block 转换为对象方法调用** :在原生 KVO 中,观察者的回调是通过对象的方法来实现的。FBKVO 将开发者提供的 block 回调封装成一个符合原生 KVO 回调格式的对象方法,当被观察对象的属性发生变化时,先调用原生的 KVO 回调方法,再将事件传递给对应的 block 回调,从而实现了 block 回调的机制,使代码更加简洁和灵活。 - - - -## KVO 实现机制 - -## KVO 底层实现分析 - -### Demo1 - - - -可以发现对成员变量添加观察者的时候,成员变量的值变化了,KVO 也是监听不到的 - -结论:**KVO 无法观察成员变量的变化** - -### Demo2 - - - -可以看到对 NSMutableArray 类型的属性添加了 KVO。然后点击屏幕,NSMutableArray 里添加了元素,但是观察方法没有触发。 - -对实验进行改进下 - - - -结论: **KVO 只可以对属性的 setter 方法起作用**。 - -通过 Demo1 和 Demo2 可以发现:**触发 KVO 的本质是必须要有属性的 setter,且触发属性的 setter。直接修改成员变量,是不会触发 setter** 的。 - -### Demo3 - -创建 Person 类,点击事件里触发属性值的改变 - - - -分析: - -- 添加过 KVO 的 person1,isa 为系统利用 Runtime 技术动态创建的类,名字为 `NSKVONotifying_Person` -- 没有添加过 KVO 的 person2,isa 为 Person 的类对象 -- `.height = 177` 本质是 `[self.person1 setHeight:177]` 也就是调用 set 方法。 - -在内存中的结构如下图 - - - -整个流程分析下: - -- self.person2 调用 setHeight 的时候,首先根据 self.person2 实例对象的 isa 找到 Person 类对象,然后在方法列表中找到 setHeight 方法,然后进行调用 -- self.person1 调用 setHeight 的时候,首先根据 self.person1 实例对象的 isa 找到 `NSKVONotifying_Person` 类对象,然后在方法列表中找到 setHeight 方法,然后进行调用。内部实现中,会调用 Foundation 的 `_NSSetIntValueAndNotify` 方法。 -- 然后调用: willSet、super setHeight、didSet 方法。 - -当我们按照 KVO 后动态生成的类名去创建一个新的类的时候,Xcode 会报错:`[general] KVO failed to allocate class pair for name NSKVONotifying_Person, automatic key-value observing will not work for this class`。因为自己创建的类名和系统将要动态创建的类名冲突了,并且 KVO 监听失效 - - - -### Demo4 - - - -分析: - -- 可以看到在对 self.person1 添加 KVO 之后,self.person1 的类对象改变了,也就是 self.person1的 isa 改变了,变为系统动态生成的新类 -- 在对 self.person1 添加 KVO 之后,self.person1 的 setHeight 方法的实现变了,添加前后 self.person2 的 setHeight 方法,都是 Person 类对象的 setHeight 方法实现。KVO 添加前后都未改变 -- 利用 `(IMP)方法地址` 查看,没有进行 KVO 的 `setHeight` 是在 `KVOExplore -[Person setHeight:] at Person.h:14)` 里。添加过 KVO 的是在 `Foundation _NSSetLongLongValueAndNotify` 里。且 `_NSSetLongLongValueAndNotify`是个 c 语言函数。 - - - -### Demo5 - - - -可以看到我们将 KVO 的数据类型改为 double 后,原本的 setHeight 是 `_NSSetLongLongValueAndNotify`,现在是 `_NSSetDoubleValueAndNotify` - -也可以借助于 `nm` 来查看所有 Foundation 关于 KVO 的方法 `nm Foundation | grep ValueAndNotify ` (需要自己提取真机上的 Foundation 符号表) - - - -### NSSet**ValueAndNotify 的内部实现 - - - -来对 Person 类增加一些打印方法 - - - - - -可以看出内部实现是: - -- 调用 `willChangeVlueForKey` - -- 调用原本的 setter - -- 调用 `didChangeValueForKey`。 - - - `didChangeValueForKey:` 会检查属性值是否实际变化,避免冗余通知 - - 通知的发送是线程安全的,但观察者的回调执行线程取决于注册时的上下文 - -- 在 `didChangeValueForKey` 内部会调用 KVO 的 `- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context` 方法 - - - -```objective-c -@interface Person() -@property (nonatomic, assign) double height; -@end -@implementation Person -- (void)setHeight:(double)height { - _height = height; -} - -@end - -@interface NSKVONotifying_Person: Person() - -@end - -@implementation NSKVONotifying_Person -- (void)setHeight:(double)height { - [self setDoubleValueAndNotify:height]; -} - -- (void)setDoubleValueAndNotify:(double)height { - [self willChangeValueForKey:@"height"]; - [super setHeight: height]; - [self didChangeValueForKey:@"height"]; -} - -- (void)didChangeValueForKey:(NSString *)key { - [observer observeValueForKeyPath:key ofObject:self change:@{} context:nil]; -} - -- (Class)class { - return [Person class]; -} - --(BOOL)_isKVOA { - return YES; -} - - -- (void)dealloc { - // 收尾工作 -} - -@end -``` - - - -### 重写 class 方法 - - - -可以看到利用 runtime api,在添加 KVO 之后,类对象为 `NSKVONotifying_Person` - -但是利用 class 方法,添加 KVO 之后,获取类对象依旧为 Person。所以推测系统在为某个对象添加 KVO 监听后,先利用 Runtime 生成一个继承自被监听类的基类。调用 `- (Class)class` 方法的本质就是先利用 isa 指针找到 `NSKVONotifying_Person` 类对象,然后在类对象的对象方法列表中查看有没有 `class` 方法的实现,系统应该是重写了 `-(Class)class` 方法,用于屏蔽底层实现。 - -好处是:屏蔽了 KVO 底层内部实现,隐藏了 `NSKVONotifying_Person` 的存在,通过 `-(Class)class` 方法告诉开发者添加 KVO 之后的类,依旧是 Person,本质上是继承自 Person 的类对象,能力没有改变。 - - - -### KVO 类的所有方法 - - - - - -利用 runtime api,打印添加 KVO 后,动态创建的 NSKVONotifying_Person 都存在什么方法? - -```objective-c -- setHeight: -- class -- dealloc -_isKVOA -``` - - - -QA:为什么新创建的类没有 getter? - -因为新创建的类是子类,父类中存在 getter。子类中增加的 setter 方法只是为了触发 KVO,getter 不影响。 - - - -QA:请描述系统如何实现一个对象的 KVO?KVO 的本质是什么 - -- 系统利用 runtime 的能力,动态创建一个监听对象的类的子类,子类命名格式为: `NSKVONotifying_类名`。 并且让 instance 对象的 isa 指向这个全新的子类 -- 当修改 instance 对象的属性时,会调用 Foundation 框架的 `_NSSet***ValueAndNotify` c 函数 -- 然后调用: - - `willChangeValueForKey` - - 原来的 setter - - `didChangeValueForKey`,且 didChangeValueForKey 内部会触发监听器(Observer)的监听方法(`- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context`)。也就是发消息 - - - -### 当没有 observer 观察任何一个 property 时,删除动态创建的子类 - -`[self.person removeObserver:self forKeyPath:@"height"];` 该代码调用后,会删除动态创建的子类。 - - - -### 为什么要选择是继承的子类而不是分类呢? - -对某个类的属性添加 KVO 观察后,系统会创建一个继承自该类的子类,子类的类对象没有 setter 方法,但是可以调用 setter 方法(子类在继承父类对象,子类对象调用调方法的时候先看看当前子类中是否有方法实现,如果不存在方法则通过 isa 指针顺着继承链向上找到父类中是否有方法实现,如果父类种也不存在方法实现,则继续向上找...直到找到 NSObject 类为止,系统会抛出几次机会给程序员补救,如果未做处理则奔溃)。但是为了可以触发 KVO,所以需要在保证原来业务逻辑的基础上,在业务逻辑之前调用 `willChangeValueForKey` ,在业务逻辑之后调用 `didChangeValueForKey`. - -为了实现这一诉求就必须使用继承,因为如果用分类实现了,则知道分类的方法会在 App 启动后经过 Runtime 进行方法整合后,会排在类对象的方法列表最前面,也就是会覆盖被监听类的属性 setter 方法。万一 setter 里面不只是简单的 `_name = name;` 而是有加锁、或者其他的业务逻辑,**使用 Category 会造成业务逻辑丢失的情况。所以必须使用继承实现**,内部再调用 `[super setName:value]` 即可。 - -```objective-c -- setter { - [self willChangeValueForKey:key] - [super setter:key] - [self didChangeValueForKey:key] -} -``` - -关于分类与子类的关系可以看看我之前的 [文章](1.50.md). - - - -## - -> Automatic key-value observing is implemented using a technique called isa-swizzling... When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class ... - -Apple 文档告诉我们:被观察对象的 `isa指针` 会指向一个中间类,而不是原来真正的类, - -通过对被观察的对象断点调试发现 Person 类在执行过 addObserveValueForKeyPath... 方法后 isa 改变了。NSKVONotifying_Person。 - -- KVO 是基于 Runtime 机制实现的 - -- 当某个类的属性第一次被观察的时候,系统会在运行期动态的创建该类的一个派生类(子类)。在派生类中重写任何被观察属性的 setter 方法。派生类在真正实现`通知机制` - -- 如果当前类为 Person,则生成的派生(子类)类名称为 `NSKVONotifying_Person` - -- 每个类对象中都有一个 `isa指针` 指向当前类,当一个类对象第一次被观察的时候,系统会偷偷将 isa 指针指向动态生成的派生类,从而在给被监控属性赋值时执行的是当前派生类的 `setter` 方法 - -- 键值观察通知依赖于 NSObject 的两个方法:`willChangeValueForKey:、didChangeValueForKey:` 。在一个被观察属性改变之前,调用 `willChangeValueForKey:` 记录旧的值。在属性值改变之后调用 `didChangeValueForKey:`,从而 `observeValueForKey:ofObject:change:context:` 也会被调用。 - -![KVO原理图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018_11_12_KVO.png) - - - -iOS 用什么方式实现对一个对象的 KVO?(KVO 的本质是什么) - -- 当对一个对象使用了 KVO 监听,系统会修改这个对象的 isa 指针,改为一个指向通过 Runtime 动态创建的子类 -- 子类拥有自己对监听属性的 setter 实现,内部会调用 - - willChangeValueForKey - - 原来的 setter 实现 - - didChangeValueForKey,这个方法内部又会调用监听器的 监听方法 `[observer observeValueForKey:ofObject:change:context:]` - -如何手动触发 KVO? - -```objective-c -[p willChangeValueForKey:@"height"]; -[p didChangeValueForKey:@"height"]; -``` - - - -### 模拟实现系统的 KVO - -1. 创建被观察对象的子类 - -2. 重写观察对象属性的 set 方法,同时调用 `willChangeValueForKey、didChangeValueForKey` - - 因为子类继承自父类,所以子类调用 setter 的时候,会调用成功,不过方法调用的时候是通过子类的 isa 找到子类对象的类对象,然后从类对象的方法列表里查看 setter,发现没有则通过子类的 superclass 找到父类,然后找到父类的类对象方法列表,成功找到了 setter 方法实现。 - - 但 KVO 的 setter 里需要在原来基础上做一些额外逻辑,所以需要重写 setter。在子类里重写 setter,本质就是为元类对象里添加一个新的 setter 方法。 - -3. 外界改变 isa 指针(class方法重写) - -我们用自己的类模拟系统的 KVO。 - -```objective-c -//NSObject+LBPKVO.h -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSObject (LBPKVO) - -- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context; - -@end - -NS_ASSUME_NONNULL_END - -//NSObject+LBPKVO.m -#import "NSObject+LBPKVO.h" -#import - -@implementation NSObject (LBPKVO) - - -- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context { - //生成自定义的名称 - NSString *className = NSStringFromClass(self.class); - NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className]; - //1. runtime 生成类 - Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0); - // 生成后不能马上使用,必须先注册 - objc_registerClassPair(myclass); - - //2. 重写 setter 方法 - class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@"); - //3. 修改 isa - object_setClass(self, myclass); - - //4. 将观察者保存到当前对象里面 - objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN); - - //5. 将传递的上下文绑定到当前对象里面 - objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN); -} - - -// -void setName (id self, SEL _cmd, NSString *name) { - NSLog(@"come here"); - //先切换到当前类的父类,然后发送消息 setName,然后切换当前子类 - //1. 切换到父类 - Class class = [self class]; - object_setClass(self, class_getSuperclass(class)); - //2. 调用父类的 setName 方法 - objc_msgSend(self, @selector(setName:), name); - - //3. 调用观察 - id observer = objc_getAssociatedObject(self, "observer"); - id context = objc_getAssociatedObject(self, "context"); - if (observer) { - objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context); - } - //4. 改回子类 - object_setClass(self, class); -} - -@end - - -//ViewController.m -- (void)viewDidLoad { - [super viewDidLoad]; - _person = [[Person alloc] init]; - _person.name = @"杭城小刘"; - _person.age = 23; - _person.hobbies = [@[@"iOS"] mutableCopy]; - NSDictionary *context = @{@"name": @"成吉思汗", @"hobby" : @"弯弓射大雕"}; - [_person lbpKVO_addObserver:self forKeyPath:@"hobbies" options:(NSKeyValueObservingOptionNew) context:(__bridge void * _Nullable)(context)]; - -} - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - _person.name = @"刘斌鹏"; - NSMutableArray *hobbies = [_person mutableArrayValueForKeyPath:@"hobbies"]; - [hobbies addObject:@"Web"]; -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{ - NSLog(@"%@",change); -} -``` - -KVO 的缺陷: - -KVO 虽然很强大,你只能重写 `-observeValueForKeyPath:ofObject:change:context: ` 来获得通知,想要提供自定义的 selector ,不行;想要传入一个 block 没门儿。感觉如果加入 block 就更棒了。 - -KVO 的改装: - -看到官方的做法并不是很方便使用,我们看到无数的优秀框架都支持 block 特性,比如 AFNetworking ,所以我们可以将系统的 KVO 改装成支持 block。 - - - -## KVC - -`setValueForKey` 用来设置对象的一层属性值修改。 - -`setValueForKeyPath` 可以设置对象的某个属性(属性本身是对象)的属性值修改 - - - -### KVC 设值原理 - -KVC 之后会触发 KVO 吗? - - - -发现 KVC 触发了 KVO。 - -问题来了:为什么 KVC 会触发 KVO?探究下 `setValueForKey` - -整个流程如下 - - - -`[self.person setValue:@10 forKey:@"age"]` 会先调用 `setKey:` 同名的方法,找不到则调用 `_setKey:` 的方法,如果还是找不到则调用 `+(BOOL)accessInstanceVariableDirectlt`,如果该方法返回 YES,则可以直接修改成员变量的值,会按照 `_key`、`_isKey`、`key`、`isKey` 的顺序寻找成员变量,如果找到则直接赋值,没找到则抛出异常 `NSUnknownKeyException` - -``` -@implementation Person -- (void)setAge:(int)age -{ - _age = age; -} - -- (void)_setAge:(int)age -{ - _age = age; -} - -+ (BOOL)accessInstanceVariableDirectlt -{ - return YES; -} - -@end -``` - - - -### 直接修改成员变量会触发 KVO 吗? - -比如在屏幕触目事件里修改 Person 对象的成员变量 `_age`。比如 `self.p1->_age = 30;` 这样是无法触发 KVO 的。 - -因为 KVO 的实现原理就是在 Runtime 动态生成类里拦截 setter 方法。在 setter 内部调用 `willChangeValueForKey`、`didChangeValueForKey` ,所以直接修改成员变量不会触发 KVO。 - -如果要在修改成员变量的基础上触发 KVO,则必须手动调用上面2个 API。比如下面的代码 - -```objective-c -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - [self.p1 willChangeValueForKey:@"age"]; - self.p1->_age = 30; - [self.p1 didChangeValueForKey:@"age"]; -} -``` - - - -### KVC 取值原理 - -`valueForKey` 原理 - -- 按照 getKey、key、isKey、_key 的顺序寻找方法实现,找到则直接调用方法,返回值 -- 如果没找到则调用 `+(BOOL)accessInstanceVariableDirectly` 方法,询问是否可以访问成员变量 - - 为 NO 则调用 `valueForUndefinedKey `并抛出 `NSUnknownKeyException`异常 - - 为 YES 则按照 ` __key`、`_isKey`、`key`、`isKey` 的顺序访问成员变量。找到哪个则返回值 - -- 都没找到则调用 `valueForUndefinedKey `并抛出 `NSUnknownKeyException`异常 - - - - - - - -### KVC 会破坏面向对象的原则吗? - -KVC 有违背面向对象编程思想吗?如果一个类的成员变量是私有的,也没有在 `.h` 中公开一些方法去设置、修改成员变量,那么外部直接通过 KVC 去修改值,是有违背面向对象编程思想的。 - -KVC 提供了对应的能力,去保护或者说支持面向对象的原则。 - - - -## 基本用法-字典快速赋值 - -KVC 可以将字典里面和 model 同名的 property 进行快速赋值 `setValuesForKeysWithDictionary`。 - -```objective-c -//前提:model 中的各个 property 必须和 NSDictionary 中的属性一致 -- (instancetype)initWithDic:(NSDictionary *)dic{ - BannerModel *model = [BannerModel new]; - [model setValuesForKeysWithDictionary:dic]; - return model; -} -``` - -但是这里会有2种特殊情况。 - -- 情况一:在 model 里面有 property 但是在 NSDictionary 里面没有这个值 - -运行上面的代码,代码不崩溃,只不过在输出值的时候输出了 null - -- 情况二:在 NSDictionary 中存在某个值,但是在 model 里面没有值 - -运行后编译成功,但是代码奔溃掉。原因是 KVC 。所以我们只需要实现这么一个方法。甚至不需要写函数体部分 - -``` -- (void)setValue:(id)value forUndefinedKey:(NSString *)key{ - -} -``` - -- 情况三:如果 Dictionary 和 Model 中的 property 不同名 - -我们照样可以利用 **setValue:forUndefinedKey:** 去处理 - -```objective-c -//model -@property (nonatomic,copy)NSString *name; -@property (nonatomic,copy)NSString *sex; -@property (nonatomic,copy) NSString* age; -//NSDictionary -NSDictionary *dic = @{@"username":@"张三",@"sex":@"男",@"id":@"22"}; - --(void)setValue:(id)value forUndefinedKey:(NSString *)key{ - if([key isEqualToString:@"id"]){ - self.age=value; - } - if([key isEqualToString:@"username"]){ - self.name=value; - } -} -``` - -- 情况四:如果我们观察对象的属性是数组,我们经常会观察不到变化,因为 KVO 是观察 setter 方法。我们可以用 `mutableArrayValueForKeyPath` 进行属性的操作 - -``` -NSMutableArray *hobbies = [_person mutableArrayValueForKeyPath:@"hobbies"]; -[hobbies addObject:@"Web"]; -``` - -- 情况五: 注册依赖键. - -KVO 可以观察属性的二级属性对象的所有属性变化。说人话就是“假如 Person 类有个 Dog 类,Dog 类有 name、fur、weight 等属性,我们给 Person 的 Dog 属性观察,假如 Dog 的任何属性变化是,Person 的观察者对象都可以拿到当前的变化值。我们只需要在 Person 中写下面的方法即可” - -``` -[self.person addObserver:self - forKeyPath:NSStringFromSelector(@selector(dog)) - options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew - context:ContextMark]; - -self.person.dog.name = @"啸天犬"; -self.person.dog.weight = 50; - - -// Person.m -+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key { - NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key]; - - if ([key isEqualToString:@"dog"]) { - NSArray *affectingKeys = @[@"name", @"fur", @"weight"]; - keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys]; - } - return keyPaths; -} -``` - - - -## 几个基本的知识点 - -1. KVO 观察者和属性被观察的对象之间不是强引用的关系 - -2. KVO 的触发分为`自动触发模式`和`手动触发模式`2种。通常我们使用的都是自动通知,注册观察者之后,当条件触发的时候会自动调用 `-(void)observeValueForKeyPath`。如果需要实现手动通知,我们需要使用下面的方法 - -```Objective-c -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - return NO; -} -``` - -3. 若类有实例变量 NSString *_foo, 调用 setValue:forKey: 是以 foo 还是 _foo 作为 key ? - -都可以 - -4. KVC 的 keyPath 中的集合运算符如何使用 -- 必须用在 **集合对象** 或者 **普通对象的集合属性** 上 - --简单的集合运算符有 @avg、@count、@max、@min、@sum - -5. KVO 和 KVC 的 keyPath 一定是属性吗? - 可以是成员变量 - -6. KVO 中 派生类的 setter 方法内部实现调用了 Foundation 框架中的 `_NSSetIntValueAndNotify`. - -7. 直接修改对象的成员变量会触发 KVO 吗? - - 不会。因为成员变量没有 setter. - - ``` - @interface Person: NSObject - { - @public: -     int age; - } - @end - ``` - - - diff --git a/Chapter1 - iOS/1.47.md b/Chapter1 - iOS/1.47.md deleted file mode 100644 index d50ec95..0000000 --- a/Chapter1 - iOS/1.47.md +++ /dev/null @@ -1,128 +0,0 @@ -# 金融 App 金额格式化 - -> 在一些金融类的 App 中,对于表示金额类的字符串,通常需要进行格式化后再显示出来。例如: -> -> 0 显示为:0.00 -> -> 123 显示为:123.00 -> -> 123.456 显示为:123.46 -> -> ​ 102000 显示为:102,000.00 -> -> ​ 10204500 显示为:10,204,500.00 -> -> ​ 它的规则为:个位数起每隔三位数字添加一个逗号,同时保留两位小数,也称为“千分位格式”。 - - - - - -### 方法一 - -​ 首先根据小数点 `.` 将传入的字符串分割为两部分,整数部分和小数部分(如果没有小数点,则补 `.00`,如果有多个小数点则报金额格式错误)。对于小数部分,只取前两位;然后对整数部分字符串进行遍历,从右到左,每三位数前插入一个逗号 `,`,最后再把两部分拼接起来,代码大致如图 1 和图 2 所示。 - - - -``` -- (void)method1{ - NSArray *temps = @[@"0",@"123",@"123.456",@"102000",@"10204500"]; - self.pricelabel.text = [self moneyFromat:temps[arc4random()%5]]; -} - -- (NSString *)moneyFromat:(NSString *)money{ - if (!money || money.length == 0) { - return money; - } - - BOOL hasPoint = NO; - if ([money rangeOfString:@"."].length > 0) { - hasPoint = YES; - } - - - NSMutableString *pointMoney = [NSMutableString stringWithString:money]; - if (hasPoint == NO) { - [pointMoney appendFormat:@".00"]; - } - - - NSArray *moneys = [pointMoney componentsSeparatedByString:@"."]; - if (moneys.count > 2) { - return pointMoney; - } - else if (moneys.count == 1) { - return [NSString stringWithFormat:@"%@.00",moneys[0]]; - } - else { - //整数部分:每隔3位插入一个“,” - NSString *frontMoney = [self stringFromToThreeBit:moneys[0]]; - if ([frontMoney isEqualToString:@""]) { - frontMoney = @"0"; - } - //拼接整数部分和消暑部分 - NSString *backMoney = moneys[1]; - if (backMoney.length == 1) { - return [NSString stringWithFormat:@"%@.%@",frontMoney,backMoney]; - } - else if (backMoney.length > 2) { - return [NSString stringWithFormat:@"%@.%@",frontMoney,[backMoney substringToIndex:2]]; - } - else { - return [NSString stringWithFormat:@"%@.%@",frontMoney,backMoney]; - } - } -} - -- (NSString *)stringFromToThreeBit:(NSString *)string{ - NSString *tempString = [string stringByReplacingOccurrencesOfString:@"," withString:@""]; - NSMutableString *mutableString = [NSMutableString stringWithString:tempString]; - NSInteger n = 2; - if (mutableString.length > 3) { - for (NSUInteger i = mutableString.length - 3; i > 0; i--) { - n++; - - if (n == 3) { - [mutableString insertString:@"," atIndex:i]; - n = 0; - } - } - } - return mutableString; -} -``` - - - -### 方法二 - -其实,苹果提供了 NSNumberFormatter 用来处理 NSString 和 NSNumber 之间的转化,可以满足基本的数字形式的格式化。我们通过设置 NSNumberFormatter 的 `numberStyle` 和 `positiveFormat` 属性,即可实现上述功能,非常简洁。 - -​ - -```objective-c -- (void)method2{ - NSArray *temps = @[@"0",@"123",@"123.456",@"102000",@"10204500"]; - self.pricelabel.text = [self formatDecimalNumber:temps[arc4random()%5]]; -} - -- (NSString *)formatDecimalNumber:(NSString *)string{ - if (!string || string.length == 0) { - return string; - } - NSNumber *number = @([string doubleValue]); - NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init]; - formatter.numberStyle = kCFNumberFormatterDecimalStyle; - formatter.positiveFormat = @"###,##0.00"; - formatter.positiveFormat = @"###,##0.00"; - NSString *amountString = [formatter stringFromNumber:number]; - return amountString; -} -``` - - - -### [参考资料](https://www.jianshu.com/p/817029422a72) - - - diff --git a/Chapter1 - iOS/1.48.md b/Chapter1 - iOS/1.48.md deleted file mode 100644 index f8fee87..0000000 --- a/Chapter1 - iOS/1.48.md +++ /dev/null @@ -1,3227 +0,0 @@ -# Category 底层原理 - -> 很多人都知道 Category、Extension 的用法,但是对于一些细节就不是很清楚了: -> -> - Category 中添加到属性、对象方法、类方法、协议在内存中是怎么存储的? -> - Category 中添加的到属性、对象方法、类方法、协议,是什么时候、如何与 Class 本身的属性、对象方法、类方法、协议合并的? -> - 假设一个 Person 类存在2个 Category,分别是 Person+Work、Person+Study,都有对象方法 play,当实例对象调用 play 的时候,会调用最后编译的 Category (Person+Study)里的 play 实现。但为什么 load 不是调用最后一个 Category 的 +load,而是3个都调用? -> -> 本文主要探索这3个知识点 - - - -## 类别(Category) - -### 文件特征 - -- 类别文件有2个,分别为 .h 和 .m -- 命名为: “类名+类别名.h”和“类名+类别名.m” - -### 文件内容格式 - -.h 文件格式 - -``` -#import "类名.h" - -@interface 类名 (类别名) -// 在此处声明方法 -@end -``` - -.m 文件格式 - -``` -#import "类名+类别名.h" - -@implementation 类名 (类别名) -// 在此处实现声明的方法 -@end -``` - -### 类别的作用 - -可以把类的实现分开在几个不同的源文件里,所以好处是: - -- 减少耽搁文件的代码行数 -- 可以把不痛的功能组织到不同的 category 里 -- 可以由多个开发者共同完成一个大的类,方便协作 -- 拓展当前类,为类添加方法 -- 声明私有方法 - -### 类别的局限性 - -- 无法向现有的类添加实例变量(编译器报“instance variables may not be placed in categories”)。Category 一般只为类提供方法的拓展,不提供属性的拓展。但是利用 Runtime 可以在 Category 中添加属性 - -- 方法名称冲突的情况下,如果 Category 中的方法与当前类的方法名称重名,Category 具有更高的优先级,类别中的方法将完全取代现有类中的方法(调用方法的时候不会去调用现有类里面的方法实现)。 - -- 当现有类具有多个 Category 的时候,如果每个 Category 都有同名的方法,那么在调用方法的时候肯定不会调用现有类的方法实现。系统根据编译顺序决定调用哪个 Category 下的方法实现。(可以在 Targets -> Build phases -> Compile Sources 下给多个 Category 更换顺序看看到底在执行哪个方法) - -### Category 的使用和注意 - -1. Category 中的方法如果和现有类方法一致,工程中任何调用当前类的方法的时候都会去调用 Category 里面的方法(比如:UIViewCtroller、UITableView这些)的方法时要慎重。因为用Category重写类中的方法会对子类造成很大的影响。比如:用Category 重写了 UIViewCtroller 的方法 A,那么如果你在工程中用到的所有继承自 UIViewCtroller 的子类,去调用方法 A 时,执行的都是 Category 中重写的方法 A,如果不幸的是,你写的方法 A 有 Bug,那么会造成整个工程中调用该方法的所有 UIViewCtroller 子类的不正常。除非你在子类中重写了父类的方法 A,这样子类调用方法 A 时是调用的自己重写的方法 A,消除了父类 Category 中重写方法对自己的影响 - -2. Category拓展方法按照有没有重写当前类中的方法,分为未重写的拓展方法和重写拓展方法。且类引用自己的 Category 时,只能在 .m 文件中引用(.h 文件引用自己的类别会报错)。子类引用父类的 Category 在 .h 或 .m 都可以。如果类调用 Category 中重写的方法,不用引入 Category 头文件,系统会自动调用 Category 中的重写方法 - -3. Category 中如果重写了 A 类从父类继承来的某方法,不会影响与 A 同层级的 B 类 - -4. 子类会不会继承父类的 Category: Category 中重写的方法会对子类造成影响,但是子类不会继承非重写的方法(现有类中没有的方法)。但是在子类中引入父类 Category 的声明文件后,子类就会继承 Category 的非重写方法。继承的表现是:当子类的方法和父类 Category 中的方法名完全相同,那么子类里的方法会覆盖掉父类 Category,相当于子类重写了继承自父类的方法 - -5. Category 的作用是向下有效的。即只会影响到该类的所有子类。比如 A 类和 B 类是继承自 Super 类的2个子类,当给 A 类添加一个 Category sayHello 方法,仅有A 类的子类才可以使用 sayHello 方法 - - - -## Category 底层原理 - -### Category 的真面目是 category_t 结构体 - -来一个简单的 Person 类,为其添加一个 Category,增加一些属性和类方法、对象方法、遵循协议 - -```objectivec -// Person -#import -NS_ASSUME_NONNULL_BEGIN -@interface Person : NSObject -@property (nonatomic, strong) NSString *name; -- (void)sayHi; -- (void)sleep; -@end -NS_ASSUME_NONNULL_END - -// Person.m -#import "Person.h" -@implementation Person -- (void)sayHi { - NSLog(@"Hello world"); -} -- (void)sleep{ - NSLog(@"Time to slepp"); -} -@end - -// Person+Study.h -#import "Person.h" -NS_ASSUME_NONNULL_BEGIN -@interface Person (Study) -@property (nonatomic, assign) NSInteger score; -- (void)study; -+ (void)sleep; -@end -NS_ASSUME_NONNULL_END - -// Person+Study.m -#import "Person+Study.h" -@implementation Person (Study) -- (void)study { - -} -+ (void)sleep { - NSLog(@"Time to sleep"); -} -- (void)setScore:(NSInteger)score { - -} -- (NSInteger)score { - return 100; -} -- (id)copyWithZone:(NSZone *)zone{ - return self; -} -@end -``` - -clang 转为 c++ 代码,具体指令为 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc Person+Study.m` - -查看 `Person+Study.cpp` 文件,可以看到 Category 本质是一个结构体 - -```c++ -struct _class_t { - struct _class_t *isa; - struct _class_t *superclass; - void *cache; - void *vtable; - struct _class_ro_t *ro; -}; - -struct _category_t { - const char *name; - struct _class_t *cls; - const struct _method_list_t *instance_methods; - const struct _method_list_t *class_methods; - const struct _protocol_list_t *protocols; - const struct _prop_list_t *properties; -}; -extern "C" __declspec(dllimport) struct objc_cache _objc_empty_cache; -#pragma warning(disable:4273) - -static struct /*_method_list_t*/ { - unsigned int entsize; // sizeof(struct _objc_method) - unsigned int method_count; - struct _objc_method method_list[4]; -} _OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_objc_method), - 4, - {{(struct objc_selector *)"study", "v16@0:8", (void *)_I_Person_Study_study}, - {(struct objc_selector *)"setScore:", "v24@0:8q16", (void *)_I_Person_Study_setScore_}, - {(struct objc_selector *)"score", "q16@0:8", (void *)_I_Person_Study_score}, - {(struct objc_selector *)"copyWithZone:", "@24@0:8^{_NSZone=}16", (void *)_I_Person_Study_copyWithZone_}} -}; - -static struct /*_method_list_t*/ { - unsigned int entsize; // sizeof(struct _objc_method) - unsigned int method_count; - struct _objc_method method_list[1]; -} _OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_objc_method), - 1, - {{(struct objc_selector *)"sleep", "v16@0:8", (void *)_C_Person_Study_sleep}} -}; - -static const char *_OBJC_PROTOCOL_METHOD_TYPES_NSCopying [] __attribute__ ((used, section ("__DATA,__objc_const"))) = -{ - "@24@0:8^{_NSZone=}16" -}; - -static struct /*_method_list_t*/ { - unsigned int entsize; // sizeof(struct _objc_method) - unsigned int method_count; - struct _objc_method method_list[1]; -} _OBJC_PROTOCOL_INSTANCE_METHODS_NSCopying __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_objc_method), - 1, - {{(struct objc_selector *)"copyWithZone:", "@24@0:8^{_NSZone=}16", 0}} -}; - -struct _protocol_t _OBJC_PROTOCOL_NSCopying __attribute__ ((used)) = { - 0, - "NSCopying", - 0, - (const struct method_list_t *)&_OBJC_PROTOCOL_INSTANCE_METHODS_NSCopying, - 0, - 0, - 0, - 0, - sizeof(_protocol_t), - 0, - (const char **)&_OBJC_PROTOCOL_METHOD_TYPES_NSCopying -}; -struct _protocol_t *_OBJC_LABEL_PROTOCOL_$_NSCopying = &_OBJC_PROTOCOL_NSCopying; - -static struct /*_protocol_list_t*/ { - long protocol_count; // Note, this is 32/64 bit - struct _protocol_t *super_protocols[1]; -} _OBJC_CATEGORY_PROTOCOLS_$_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - 1, - &_OBJC_PROTOCOL_NSCopying -}; - -static struct /*_prop_list_t*/ { - unsigned int entsize; // sizeof(struct _prop_t) - unsigned int count_of_properties; - struct _prop_t prop_list[1]; -} _OBJC_$_PROP_LIST_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_prop_t), - 1, - {{"score","Tq,N"}} -}; - -extern "C" __declspec(dllimport) struct _class_t OBJC_CLASS_$_Person; - -static struct _category_t _OBJC_$_CATEGORY_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = -{ - "Person", - 0, // &OBJC_CLASS_$_Person, - (const struct _method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Study, - (const struct _method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Study, - (const struct _protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Study, - (const struct _prop_list_t *)&_OBJC_$_PROP_LIST_Person_$_Study, -}; -static void OBJC_CATEGORY_SETUP_$_Person_$_Study(void ) { - _OBJC_$_CATEGORY_Person_$_Study.cls = &OBJC_CLASS_$_Person; -} -``` - -可以看到 `Person+Study` 的 Category 底层赋值代码如下,就是结构体对象的初始化(参考上面的结构体各个成员变量) - -```c++ -static struct _category_t _OBJC_$_CATEGORY_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = -{ - "Person", - 0, // &OBJC_CLASS_$_Person, - (const struct _method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Study, - (const struct _method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Study, - (const struct _protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Study, - (const struct _prop_list_t *)&_OBJC_$_PROP_LIST_Person_$_Study, -}; -``` - -其中 - -```c++ -struct _category_t { - const char *name; - struct _class_t *cls; - const struct _method_list_t *instance_methods; - const struct _method_list_t *class_methods; - const struct _protocol_list_t *protocols; - const struct _prop_list_t *properties; -}; -``` - -- `name` 是类的名字,不是 category 小括号里写的名字 -- `cls` 是要拓展的类对象,编译期间这个值不会有,在 runtime 加载时,才会根据 `name` 对应到类对象 -- `instance_methods `这个 category 所有的 `-` 方法(对象方法) -- `class_methods` 这个 category 所有的 `+` 方法(类方法) -- `protocols `这个 category 实现的 protocol -- `properties `这个 category 所有的 property,这也是 category 里面可以定义属性的原因,不过不会 `@synthesize` 实例变量,一般有需求添加实例变量属性时会采用 `objc_setAssociatedObject` 和 `objc_getAssociatedObject` 方法绑定方法绑定。 - -`_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Study` 结构体存放的是对象方法信息,如下 - -```c++ -static struct /*_method_list_t*/ { - unsigned int entsize; // sizeof(struct _objc_method) - unsigned int method_count; - struct _objc_method method_list[4]; -} _OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_objc_method), - 4, - {{(struct objc_selector *)"study", "v16@0:8", (void *)_I_Person_Study_study}, - {(struct objc_selector *)"setScore:", "v24@0:8q16", (void *)_I_Person_Study_setScore_}, - {(struct objc_selector *)"score", "q16@0:8", (void *)_I_Person_Study_score}, - {(struct objc_selector *)"copyWithZone:", "@24@0:8^{_NSZone=}16", (void *)_I_Person_Study_copyWithZone_}} -}; -``` - -`_OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Study` 结构体存放的是类方法信息,如下 - -```c++ -static struct /*_method_list_t*/ { - unsigned int entsize; // sizeof(struct _objc_method) - unsigned int method_count; - struct _objc_method method_list[1]; -} _OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_objc_method), - 1, - {{(struct objc_selector *)"sleep", "v16@0:8", (void *)_C_Person_Study_sleep}} -}; -``` - -`_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Study` 结构体存放的是遵循的协议信息,如下 - -```c++ -static struct /*_protocol_list_t*/ { - long protocol_count; // Note, this is 32/64 bit - struct _protocol_t *super_protocols[1]; -} _OBJC_CATEGORY_PROTOCOLS_$_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - 1, - &_OBJC_PROTOCOL_NSCopying -}; -``` - -`_OBJC_$_PROP_LIST_Person_$_Study` 存放的是 Category 中的属性信息,如下 - -```c++ -static struct /*_prop_list_t*/ { - unsigned int entsize; // sizeof(struct _prop_t) - unsigned int count_of_properties; - struct _prop_t prop_list[1]; -} _OBJC_$_PROP_LIST_Person_$_Study __attribute__ ((used, section ("__DATA,__objc_const"))) = { - sizeof(_prop_t), - 1, - 1, - {{"score","Tq,N"}} -}; -``` - -最后,这个类的 category 们生成了一个数组,存在了 `__DATA` 段下的 `__objc_catlist` section 里 - -```c++ -static struct _category_t *L_OBJC_LABEL_CATEGORY_$ [1] __attribute__((used, section ("__DATA, __objc_catlist,regular,no_dead_strip")))= { - &_OBJC_$_CATEGORY_Person_$_Study, -}; -``` - - - - - -看完上述信息,可以得出一个结论: - -在编译阶段,Class 和 Category 里面的数据是分开的。“将来”分类中的对象方法会塞到类对象中去,分类中的类方法会被塞到元类对象中去。这个“将来”是什么时候?后面继续探究 - -查看 [Objc 4 源代码](http://opensource.apple.com/tarballs/objc4/),Category 定义如下 - -```c++ -struct category_t { - const char *name; - classref_t cls; - WrappedPtr instanceMethods; - WrappedPtr classMethods; - struct protocol_list_t *protocols; - struct property_list_t *instanceProperties; - // Fields below this point are not always present on disk. - struct property_list_t *_classProperties; - - method_list_t *methodsForMeta(bool isMeta) const { - if (isMeta) return classMethods; - else return instanceMethods; - } - - property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi) const; - - protocol_list_t *protocolsForMeta(bool isMeta) const { - if (isMeta) return nullptr; - else return protocols; - } -}; -``` - - - -结论: - -- 为某个类添加的分类,分类中可能有属性、对象方法、类方法、遵循的协议、协议方法,本质都是存储到 `category_t` 结构体里面。 -- 当有多个分类的时候,是通过 category_t 数组来承载的 - - - -### category 中定义的方法,存储在哪? - -抛个问题:当对象调用方法的时候,不管这个方法是类自身的方法,还是通过分类添加的方法,本质都是通过 isa 指针去寻找方法实现,(如果是对象方法,则通过 instance 的 isa 去找到类对象,最后找到对象方法的实现去调用;如果是类对象方法,则通过 class 的 isa 找到元类对象,最后找到类方法的实现进行调用),那给 Category 添加的方法,是「**如何“塞到”类对象或者元类对象的方法列表中去的**」? - -带着问题查看 [objc4 的源代码](http://opensource.apple.com/tarballs/objc4/) `objc-os.mm` 文件中的 `_objc_init` 方法。 - -在 library 加载前由 libSystem dyld 调用,进行初始化工作。 - -```c++ -/*********************************************************************** -* _objc_init -* Bootstrap initialization. Registers our image notifier with dyld. -* Called by libSystem BEFORE library initialization time -**********************************************************************/ -void _objc_init(void) { - static bool initialized = false; - if (initialized) return; - initialized = true; - // fixme defer initialization until an objc-using image is found? - environ_init(); - tls_init(); - static_init(); - lock_init(); - exception_init(); - _dyld_objc_notify_register(&map_images, load_images, unmap_image); -} -``` - -`_objc_init` 内部会调用 `map_images` 方法,将文件中的 image(镜像)map 到内存,其内部如下 - -```c++ -/*********************************************************************** -* map_images -* Process the given images which are being mapped in by dyld. -* Calls ABI-agnostic code after taking ABI-specific locks. -* -* Locking: write-locks runtimeLock -**********************************************************************/ -void map_images(unsigned count, const char * const paths[], - const struct mach_header * const mhdrs[]) { - rwlock_writer_t lock(runtimeLock); - return map_images_nolock(count, paths, mhdrs); -} -``` - -`map_images` 内部会调用 `map_images_nolock` - -```c++ -void map_images_nolock(unsigned mhCount, const char * const mhPaths[], - const struct mach_header * const mhdrs[]) { - static bool firstTime = YES; - header_info *hList[mhCount]; - uint32_t hCount; - size_t selrefCount = 0; - - // Perform first-time initialization if necessary. - // This function is called before ordinary library initializers. - // fixme defer initialization until an objc-using image is found? - if (firstTime) { - preopt_init(); - } - - if (PrintImages) { - _objc_inform("IMAGES: processing %u newly-mapped images...\n", mhCount); - } - - // Find all images with Objective-C metadata. - hCount = 0; - - // Count classes. Size various table based on the total. - int totalClasses = 0; - int unoptimizedTotalClasses = 0; - { - uint32_t i = mhCount; - while (i--) { - const headerType *mhdr = (const headerType *)mhdrs[i]; - - auto hi = addHeader(mhdr, mhPaths[i], totalClasses, unoptimizedTotalClasses); - if (!hi) { - // no objc data in this entry - continue; - } - - if (mhdr->filetype == MH_EXECUTE) { - // Size some data structures based on main executable's size -#if __OBJC2__ - size_t count; - _getObjc2SelectorRefs(hi, &count); - selrefCount += count; - _getObjc2MessageRefs(hi, &count); - selrefCount += count; -#else - _getObjcSelectorRefs(hi, &selrefCount); -#endif - -#if SUPPORT_GC_COMPAT - // Halt if this is a GC app. - if (shouldRejectGCApp(hi)) { - _objc_fatal_with_reason - (OBJC_EXIT_REASON_GC_NOT_SUPPORTED, - OS_REASON_FLAG_CONSISTENT_FAILURE, - "Objective-C garbage collection " - "is no longer supported."); - } -#endif - } - - hList[hCount++] = hi; - - if (PrintImages) { - _objc_inform("IMAGES: loading image for %s%s%s%s%s\n", - hi->fname(), - mhdr->filetype == MH_BUNDLE ? " (bundle)" : "", - hi->info()->isReplacement() ? " (replacement)" : "", - hi->info()->hasCategoryClassProperties() ? " (has class properties)" : "", - hi->info()->optimizedByDyld()?" (preoptimized)":""); - } - } - } - - // Perform one-time runtime initialization that must be deferred until - // the executable itself is found. This needs to be done before - // further initialization. - // (The executable may not be present in this infoList if the - // executable does not contain Objective-C code but Objective-C - // is dynamically loaded later. - if (firstTime) { - sel_init(selrefCount); - arr_init(); - -#if SUPPORT_GC_COMPAT - // Reject any GC images linked to the main executable. - // We already rejected the app itself above. - // Images loaded after launch will be rejected by dyld. - - for (uint32_t i = 0; i < hCount; i++) { - auto hi = hList[i]; - auto mh = hi->mhdr(); - if (mh->filetype != MH_EXECUTE && shouldRejectGCImage(mh)) { - _objc_fatal_with_reason - (OBJC_EXIT_REASON_GC_NOT_SUPPORTED, - OS_REASON_FLAG_CONSISTENT_FAILURE, - "%s requires Objective-C garbage collection " - "which is no longer supported.", hi->fname()); - } - } -#endif - -#if TARGET_OS_OSX - // Disable +initialize fork safety if the app is too old (< 10.13). - // Disable +initialize fork safety if the app has a - // __DATA,__objc_fork_ok section. - - if (dyld_get_program_sdk_version() < DYLD_MACOSX_VERSION_10_13) { - DisableInitializeForkSafety = true; - if (PrintInitializing) { - _objc_inform("INITIALIZE: disabling +initialize fork " - "safety enforcement because the app is " - "too old (SDK version " SDK_FORMAT ")", - FORMAT_SDK(dyld_get_program_sdk_version())); - } - } - - for (uint32_t i = 0; i < hCount; i++) { - auto hi = hList[i]; - auto mh = hi->mhdr(); - if (mh->filetype != MH_EXECUTE) continue; - unsigned long size; - if (getsectiondata(hi->mhdr(), "__DATA", "__objc_fork_ok", &size)) { - DisableInitializeForkSafety = true; - if (PrintInitializing) { - _objc_inform("INITIALIZE: disabling +initialize fork " - "safety enforcement because the app has " - "a __DATA,__objc_fork_ok section"); - } - } - break; // assume only one MH_EXECUTE image - } -#endif - } - - if (hCount > 0) { - _read_images(hList, hCount, totalClasses, unoptimizedTotalClasses); - } - firstTime = NO; -} -``` - - `map_images_nolock` 会调用 `_read_images` 方法,用于初始化 map 后的 `image`,这里面干了很多的事情,像 load所有的类、协议和 category,著名的 `+ load` 方法就是这一步调用的。如下: - -```c++ -void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses) -{ - header_info *hi; - uint32_t hIndex; - size_t count; - size_t i; - Class *resolvedFutureClasses = nil; - size_t resolvedFutureClassCount = 0; - static bool doneOnce; - TimeLogger ts(PrintImageTimes); - - runtimeLock.assertWriting(); - -#define EACH_HEADER \ - hIndex = 0; \ - hIndex < hCount && (hi = hList[hIndex]); \ - hIndex++ - - if (!doneOnce) { - doneOnce = YES; - -#if SUPPORT_NONPOINTER_ISA - // Disable non-pointer isa under some conditions. - -# if SUPPORT_INDEXED_ISA - // Disable nonpointer isa if any image contains old Swift code - for (EACH_HEADER) { - if (hi->info()->containsSwift() && - hi->info()->swiftVersion() < objc_image_info::SwiftVersion3) - { - DisableNonpointerIsa = true; - if (PrintRawIsa) { - _objc_inform("RAW ISA: disabling non-pointer isa because " - "the app or a framework contains Swift code " - "older than Swift 3.0"); - } - break; - } - } -# endif - -# if TARGET_OS_OSX - // Disable non-pointer isa if the app is too old - // (linked before OS X 10.11) - if (dyld_get_program_sdk_version() < DYLD_MACOSX_VERSION_10_11) { - DisableNonpointerIsa = true; - if (PrintRawIsa) { - _objc_inform("RAW ISA: disabling non-pointer isa because " - "the app is too old (SDK version " SDK_FORMAT ")", - FORMAT_SDK(dyld_get_program_sdk_version())); - } - } - - // Disable non-pointer isa if the app has a __DATA,__objc_rawisa section - // New apps that load old extensions may need this. - for (EACH_HEADER) { - if (hi->mhdr()->filetype != MH_EXECUTE) continue; - unsigned long size; - if (getsectiondata(hi->mhdr(), "__DATA", "__objc_rawisa", &size)) { - DisableNonpointerIsa = true; - if (PrintRawIsa) { - _objc_inform("RAW ISA: disabling non-pointer isa because " - "the app has a __DATA,__objc_rawisa section"); - } - } - break; // assume only one MH_EXECUTE image - } -# endif - -#endif - - if (DisableTaggedPointers) { - disableTaggedPointers(); - } - - if (PrintConnecting) { - _objc_inform("CLASS: found %d classes during launch", totalClasses); - } - - // namedClasses - // Preoptimized classes don't go in this table. - // 4/3 is NXMapTable's load factor - int namedClassesSize = - (isPreoptimized() ? unoptimizedTotalClasses : totalClasses) * 4 / 3; - gdb_objc_realized_classes = - NXCreateMapTable(NXStrValueMapPrototype, namedClassesSize); - - ts.log("IMAGE TIMES: first time tasks"); - } - - - // Discover classes. Fix up unresolved future classes. Mark bundle classes. - for (EACH_HEADER) { - if (! mustReadClasses(hi)) { - // Image is sufficiently optimized that we need not call readClass() - continue; - } - - bool headerIsBundle = hi->isBundle(); - bool headerIsPreoptimized = hi->isPreoptimized(); - - classref_t *classlist = _getObjc2ClassList(hi, &count); - for (i = 0; i < count; i++) { - Class cls = (Class)classlist[i]; - Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized); - - if (newCls != cls && newCls) { - // Class was moved but not deleted. Currently this occurs - // only when the new class resolved a future class. - // Non-lazily realize the class below. - resolvedFutureClasses = (Class *) - realloc(resolvedFutureClasses, - (resolvedFutureClassCount+1) * sizeof(Class)); - resolvedFutureClasses[resolvedFutureClassCount++] = newCls; - } - } - } - - ts.log("IMAGE TIMES: discover classes"); - - // Fix up remapped classes - // Class list and nonlazy class list remain unremapped. - // Class refs and super refs are remapped for message dispatching. - - if (!noClassesRemapped()) { - for (EACH_HEADER) { - Class *classrefs = _getObjc2ClassRefs(hi, &count); - for (i = 0; i < count; i++) { - remapClassRef(&classrefs[i]); - } - // fixme why doesn't test future1 catch the absence of this? - classrefs = _getObjc2SuperRefs(hi, &count); - for (i = 0; i < count; i++) { - remapClassRef(&classrefs[i]); - } - } - } - - ts.log("IMAGE TIMES: remap classes"); - - // Fix up @selector references - static size_t UnfixedSelectors; - sel_lock(); - for (EACH_HEADER) { - if (hi->isPreoptimized()) continue; - - bool isBundle = hi->isBundle(); - SEL *sels = _getObjc2SelectorRefs(hi, &count); - UnfixedSelectors += count; - for (i = 0; i < count; i++) { - const char *name = sel_cname(sels[i]); - sels[i] = sel_registerNameNoLock(name, isBundle); - } - } - sel_unlock(); - - ts.log("IMAGE TIMES: fix up selector references"); - -#if SUPPORT_FIXUP - // Fix up old objc_msgSend_fixup call sites - for (EACH_HEADER) { - message_ref_t *refs = _getObjc2MessageRefs(hi, &count); - if (count == 0) continue; - - if (PrintVtables) { - _objc_inform("VTABLES: repairing %zu unsupported vtable dispatch " - "call sites in %s", count, hi->fname()); - } - for (i = 0; i < count; i++) { - fixupMessageRef(refs+i); - } - } - - ts.log("IMAGE TIMES: fix up objc_msgSend_fixup"); -#endif - - // Discover protocols. Fix up protocol refs. - for (EACH_HEADER) { - extern objc_class OBJC_CLASS_$_Protocol; - Class cls = (Class)&OBJC_CLASS_$_Protocol; - assert(cls); - NXMapTable *protocol_map = protocols(); - bool isPreoptimized = hi->isPreoptimized(); - bool isBundle = hi->isBundle(); - - protocol_t **protolist = _getObjc2ProtocolList(hi, &count); - for (i = 0; i < count; i++) { - readProtocol(protolist[i], cls, protocol_map, - isPreoptimized, isBundle); - } - } - - ts.log("IMAGE TIMES: discover protocols"); - - // Fix up @protocol references - // Preoptimized images may have the right - // answer already but we don't know for sure. - for (EACH_HEADER) { - protocol_t **protolist = _getObjc2ProtocolRefs(hi, &count); - for (i = 0; i < count; i++) { - remapProtocolRef(&protolist[i]); - } - } - - ts.log("IMAGE TIMES: fix up @protocol references"); - - // Realize non-lazy classes (for +load methods and static instances) - for (EACH_HEADER) { - classref_t *classlist = - _getObjc2NonlazyClassList(hi, &count); - for (i = 0; i < count; i++) { - Class cls = remapClass(classlist[i]); - if (!cls) continue; - - // hack for class __ARCLite__, which didn't get this above -#if TARGET_OS_SIMULATOR - if (cls->cache._buckets == (void*)&_objc_empty_cache && - (cls->cache._mask || cls->cache._occupied)) - { - cls->cache._mask = 0; - cls->cache._occupied = 0; - } - if (cls->ISA()->cache._buckets == (void*)&_objc_empty_cache && - (cls->ISA()->cache._mask || cls->ISA()->cache._occupied)) - { - cls->ISA()->cache._mask = 0; - cls->ISA()->cache._occupied = 0; - } -#endif - - realizeClass(cls); - } - } - - ts.log("IMAGE TIMES: realize non-lazy classes"); - - // Realize newly-resolved future classes, in case CF manipulates them - if (resolvedFutureClasses) { - for (i = 0; i < resolvedFutureClassCount; i++) { - realizeClass(resolvedFutureClasses[i]); - resolvedFutureClasses[i]->setInstancesRequireRawIsa(false/*inherited*/); - } - free(resolvedFutureClasses); - } - - ts.log("IMAGE TIMES: realize future classes"); - - // Discover categories. - for (EACH_HEADER) { - category_t **catlist = - _getObjc2CategoryList(hi, &count); - bool hasClassProperties = hi->info()->hasCategoryClassProperties(); - - for (i = 0; i < count; i++) { - category_t *cat = catlist[i]; - Class cls = remapClass(cat->cls); - - if (!cls) { - // Category's target class is missing (probably weak-linked). - // Disavow any knowledge of this category. - catlist[i] = nil; - if (PrintConnecting) { - _objc_inform("CLASS: IGNORING category \?\?\?(%s) %p with " - "missing weak-linked target class", - cat->name, cat); - } - continue; - } - - // Process this category. - // First, register the category with its target class. - // Then, rebuild the class's method lists (etc) if - // the class is realized. - bool classExists = NO; - if (cat->instanceMethods || cat->protocols - || cat->instanceProperties) - { - addUnattachedCategoryForClass(cat, cls, hi); - if (cls->isRealized()) { - remethodizeClass(cls); - classExists = YES; - } - if (PrintConnecting) { - _objc_inform("CLASS: found category -%s(%s) %s", - cls->nameForLogging(), cat->name, - classExists ? "on existing class" : ""); - } - } - - if (cat->classMethods || cat->protocols - || (hasClassProperties && cat->_classProperties)) - { - addUnattachedCategoryForClass(cat, cls->ISA(), hi); - if (cls->ISA()->isRealized()) { - remethodizeClass(cls->ISA()); - } - if (PrintConnecting) { - _objc_inform("CLASS: found category +%s(%s)", - cls->nameForLogging(), cat->name); - } - } - } - } - - ts.log("IMAGE TIMES: discover categories"); - - // Category discovery MUST BE LAST to avoid potential races - // when other threads call the new category code before - // this thread finishes its fixups. - - // +load handled by prepare_load_methods() - - if (DebugNonFragileIvars) { - realizeAllClasses(); - } - - - // Print preoptimization statistics - if (PrintPreopt) { - static unsigned int PreoptTotalMethodLists; - static unsigned int PreoptOptimizedMethodLists; - static unsigned int PreoptTotalClasses; - static unsigned int PreoptOptimizedClasses; - - for (EACH_HEADER) { - if (hi->isPreoptimized()) { - _objc_inform("PREOPTIMIZATION: honoring preoptimized selectors " - "in %s", hi->fname()); - } - else if (hi->info()->optimizedByDyld()) { - _objc_inform("PREOPTIMIZATION: IGNORING preoptimized selectors " - "in %s", hi->fname()); - } - - classref_t *classlist = _getObjc2ClassList(hi, &count); - for (i = 0; i < count; i++) { - Class cls = remapClass(classlist[i]); - if (!cls) continue; - - PreoptTotalClasses++; - if (hi->isPreoptimized()) { - PreoptOptimizedClasses++; - } - - const method_list_t *mlist; - if ((mlist = ((class_ro_t *)cls->data())->baseMethods())) { - PreoptTotalMethodLists++; - if (mlist->isFixedUp()) { - PreoptOptimizedMethodLists++; - } - } - if ((mlist=((class_ro_t *)cls->ISA()->data())->baseMethods())) { - PreoptTotalMethodLists++; - if (mlist->isFixedUp()) { - PreoptOptimizedMethodLists++; - } - } - } - } - - _objc_inform("PREOPTIMIZATION: %zu selector references not " - "pre-optimized", UnfixedSelectors); - _objc_inform("PREOPTIMIZATION: %u/%u (%.3g%%) method lists pre-sorted", - PreoptOptimizedMethodLists, PreoptTotalMethodLists, - PreoptTotalMethodLists - ? 100.0*PreoptOptimizedMethodLists/PreoptTotalMethodLists - : 0.0); - _objc_inform("PREOPTIMIZATION: %u/%u (%.3g%%) classes pre-registered", - PreoptOptimizedClasses, PreoptTotalClasses, - PreoptTotalClasses - ? 100.0*PreoptOptimizedClasses/PreoptTotalClasses - : 0.0); - _objc_inform("PREOPTIMIZATION: %zu protocol references not " - "pre-optimized", UnfixedProtocolReferences); - } - -#undef EACH_HEADER -} -``` - -仔细看 category 的初始化,循环调用了 `_getObjc2CategoryList` 方法, - -```c++ -// Look for a __DATA or __DATA_CONST or __DATA_DIRTY section -// with the given name that stores an array of T. -template -T* getDataSection(const headerType *mhdr, const char *sectname, - size_t *outBytes, size_t *outCount) -{ - unsigned long byteCount = 0; - T* data = (T*)getsectiondata(mhdr, "__DATA", sectname, &byteCount); - if (!data) { - data = (T*)getsectiondata(mhdr, "__DATA_CONST", sectname, &byteCount); - } - if (!data) { - data = (T*)getsectiondata(mhdr, "__DATA_DIRTY", sectname, &byteCount); - } - if (outBytes) *outBytes = byteCount; - if (outCount) *outCount = byteCount / sizeof(T); - return data; -} - -#define GETSECT(name, type, sectname) \ - type *name(const headerType *mhdr, size_t *outCount) { \ - return getDataSection(mhdr, sectname, nil, outCount); \ - } \ - type *name(const header_info *hi, size_t *outCount) { \ - return getDataSection(hi->mhdr(), sectname, nil, outCount); \ - } - -// function name content type section name -GETSECT(_getObjc2CategoryList, category_t *, "__objc_catlist"); -``` - -眼熟的 `__objc_catlist`,就是上面 category 存放的数据段了,可以串连起来了。 - - - -可以看到内部有 `Discover categories` 相关逻辑,里面和 category 方法相关的有 `remethodizeClass`,其实现如下 - -```c -static void remethodizeClass(Class cls){ - category_list *cats; - bool isMeta; - runtimeLock.assertWriting(); - isMeta = cls->isMetaClass(); - // Re-methodizing: check for more categories - if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) { - if (PrintConnecting) { - _objc_inform("CLASS: attaching categories to class '%s' %s", - cls->nameForLogging(), isMeta ? "(meta)" : ""); - } - attachCategories(cls, cats, true /*flush caches*/); - free(cats); - } -} -``` - -可以看到内部调用 `attachCategories` 方法。 `attachCategories` 方法传入 3个参数,第一个参数是类对象,比如 `[Person class]`,第二个参数是 Category 数组,比如 `[category_t(Person+Study), category_t(Person+Work)]`。内部实现如下 - -```c++ -static void attachCategories(Class cls, category_list *cats, bool flush_caches){ - if (!cats) return; - if (PrintReplacedMethods) printReplacements(cls, cats); - - bool isMeta = cls->isMetaClass(); - // fixme rearrange to remove these intermediate allocations - // 方法数组,是"二维数组"。比如:[[personWork 对象方法1, personWork 对象方法2, personWork 对象方法3], [personStudy 对象方法1, personStudy 对象方法2, personStudy 对象方法3]] - method_list_t **mlists = (method_list_t **) - malloc(cats->count * sizeof(*mlists)); - // 属性数组,"二维数组"。比如:[[personWork 属性1, personWork 属性2, personWork 属性3], [personStudy 属性1, personStudy 属性2, personStudy 属性3]] - property_list_t **proplists = (property_list_t **) - malloc(cats->count * sizeof(*proplists)); - // 协议数组,"二维数组"。比如:[[personWork 协议1, personWork 协议2, personWork 协议3], [personStudy 协议1, personStudy 协议2, personStudy 协议3]] - protocol_list_t **protolists = (protocol_list_t **) - malloc(cats->count * sizeof(*protolists)); - - // Count backwards through cats to get newest categories first - int mcount = 0; - int propcount = 0; - int protocount = 0; - int i = cats->count; - bool fromBundle = NO; - while (i--) { - // 取出某个分类。比如 Person+Work - auto& entry = cats->list[i]; - // isMeta 为 NO,取出分类中的对象方法 - method_list_t *mlist = entry.cat->methodsForMeta(isMeta); - if (mlist) { - // 将分类中的对象方法数组,放到 mlists 里面去。所以 mlists 是一个"二维数组"。由于 i 是 Category 数组总长度递减的,所以编译越后面的 Category 的对象方法越靠前 - mlists[mcount++] = mlist; - fromBundle |= entry.hi->isBundle(); - } - // 取出当前分类的属性数组 - property_list_t *proplist = - entry.cat->propertiesForMeta(isMeta, entry.hi); - if (proplist) { - // 将当前分类的属性数组,放到 proplists 中去,所以 proplists 是一个"二维数组" - proplists[propcount++] = proplist; - } - // 取出当前分类的协议数组 - protocol_list_t *protolist = entry.cat->protocols; - if (protolist) { - // 将当前分类的协议数组,放到 protolists 中去,所以 protolists 是一个"二维数组" - protolists[protocount++] = protolist; - } - } - // 取出类对象的 class_rw_t - auto rw = cls->data(); - prepareMethodLists(cls, mlists, mcount, NO, fromBundle); - // 将所有分类的对象方法,附加到类对象的方法列表后面 - rw->methods.attachLists(mlists, mcount); - free(mlists); - if (flush_caches && mcount > 0) flushCaches(cls); - // 将所有分类的属性,附加到类对象的属性列表后面 - rw->properties.attachLists(proplists, propcount); - free(proplists); - // 将所有分类的协议,附加到类对象的协议列表后面 - rw->protocols.attachLists(protolists, protocount); - free(protolists); -} -``` - -观察到采用 `i--` 的方式,当 while 循环结束的时候,方法数组 `mlists` 保存了全部分类中的方法,属性数组 `proplists` 保存了全部分类中的属性,协议数组 `protolists` 保存了所有分类所遵循的协议。 - -可以看到通过传入的类对象 `cls` 调用其 `cls->data()` 方法,找到对应的类 `class_rw_t` 信息,里面存放:方法列表、属性列表、协议列表信息。 - -```c -// 类对象结构体 -struct objc_class : objc_object { - // Class ISA; - Class superclass; - }cache_t cache; // formerly cache pointer and vtable - class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags - - class_rw_t *data() { - return bits.data(); - } -} - -struct class_rw_t { - uint32_t flags; - uint32_t version; - const class_ro_t *ro; - method_array_t methods; - property_array_t properties; - protocol_array_t protocols; - - class_rw_t* data() { - return (class_rw_t *)(bits & FAST_DATA_MASK); - } -} -``` - -可以看到最后调用 `attachLists`,内部实现如下 - -```c -void attachLists(List* const * addedLists, uint32_t addedCount) { - if (addedCount == 0) return; - - if (hasArray()) { - // many lists -> many lists - uint32_t oldCount = array()->count; - uint32_t newCount = oldCount + addedCount; - setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); - array()->count = newCount; - // 下面会有解释 - memmove(array()->lists + addedCount, array()->lists, - oldCount * sizeof(array()->lists[0])); - memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); - } - else if (!list && addedCount == 1) { - // 0 lists -> 1 list - list = addedLists[0]; - } - else { - // 1 list -> many lists - List* oldList = list; - uint32_t oldCount = oldList ? 1 : 0; - uint32_t newCount = oldCount + addedCount; - setArray((array_t *)malloc(array_t::byteSize(newCount))); - array()->count = newCount; - if (oldList) array()->lists[addedCount] = oldList; - memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); - } -} -``` - -其中关键函数 `memmove` 代表将 __src 中的前 __len 个字节长度移动到 __dst 中去。 - -```c++ -memmove(array()->lists + addedCount, array()->lists, - oldCount * sizeof(array()->lists[0])); -memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); -``` - -等价于 - -```c++ -memmove(类对象原来的方法列表 + addedCount, - 类对象原来的方法列表, - oldCount * sizeof(array()->lists[0])) - -memcopy(类对象原来的方法列表, - 所有分类的方法列表, - addedCount * sizeof(array()->lists[0])) -``` - -其中,`array()->lists` 代表类对象原来的方法列表、`oldCount * sizeof(array()->lists[0])` 代表类对象原来方法列表长度,`addedCount` 代表 category 方法列表长度。 - -c 数组指针 `array()->lists + addedCount` 可以代表其中的位置。 - -`memmove` 的效果是,将类原来的方法列表移动到第 n个(n为 category 方法列表长度位置,前面空出n个坑位,预留坑位给所有分类的方法) - -`memcopy` 效果将 Category 方法列表拷贝到类原方法列表的前面去。位置刚好是 `memmove` 留出的坑位。 - -过程如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-categoryattachLists.png) - -结果就是类方法列表中,最前面的就是所有分类的方法列表,最后是类自身的方法列表。 - -效果为 `[PersonWork Category 方法列表, PersonStudy Category 方法列表, Person类自身对象方法列表]` - -假设 Person 有 m1、m2 对象方法,Person+Study 分类有 m3、m4方法,Person+Sleep 分类有 m5、m6方法,(Person+Sleep 先编译)合并后的方法列表是 [m5, m6, m3, m4, m1, m2] - -总结: - -Category 编译之后 底层结构为 struct category_t,里面存储着分类的对象方法、类方法、属性、协议信息 - -程序运行的时候,runtime 会将 Category 中的数据,合并到类自身信息中(类对象、元类对象) - - - -QA: -1. Runtime 在将 category 方法和类方法合并的时候,以前版本是 - -```objective-c -memmove(array()->lists + addedCount, array()->lists, - oldCount * sizeof(array()->lists[0])); -memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); -``` - -而新版本为 - -```objective-c -for (int i = oldCount - 1; i >= 0; i--) - newArray->lists[i + addedCount] = array()->lists[i]; -for (unsigned i = 0; i < addedCount; i++) - newArray->lists[i] = addedLists[I] -``` - -为什么要改?给出专业和权威的回答,尽量参考官方文档 - -- 线程安全与原子性 -旧方案的潜在问题:旧版本直接在原内存区域操作(memmove 后接 memcpy),若此时其他线程访问方法列表,可能读到不一致的中间状态(如部分旧数据被覆盖、部分新数据未写入),导致崩溃或逻辑错误 -新方案的改进:新版本通过 分配新内存、完整构建新数组,最后一次性更新指针(如 array()->count 和 array()->lists),使得操作具备原子性。其他线程要么看到完整的旧数组,要么看到完整的新数组,避免了中间状态的不一致性 - -- 内存拓展的可靠性 -`relloc` 的结果不可预测,可能失败或返回新地址,导致原指针失效。若运行时其他模块持有旧指针的引用,会引发野指针问题 -新版本显式分配新内存(malloc),将旧数据迁移到新内存后替换指针。这种方式更可控,避免了 `realloc` 的不确定性,同时允许旧内存的安全释放 - -- 内存重叠与顺序控制 -虽 `memmove` 允许源和目标内存重叠,但在原数组基础上直接扩展时,若内存无法原地扩展(如后续内存被占用),`memmove` 可能覆盖有效数据或越界,导致不符合预期的行为 - -- 手动复制的灵活性 -面向未来,方便插入一些其他逻辑。 - - -2. 将 Category 信息和类自身信息合并放到运行时有什么好处?为什么不放到编译时搞定? -- 动态性。 - - 延迟绑定:Objective-C 的方法调用基于消息转发(Messaging),方法实现可以在运行时动态绑定。Category 的方法在启动时被合并到类的方法列表中,使得开发者可以在不修改原始类代码的情况下,动态扩展或替换方法。 - - 支持热更新与插件化:通过 dlopen 或动态库加载,可以在程序运行时加载新的 Category(如插件功能),实现功能扩展,而无需重新编译主程序。 -- 解决多 Category 方法冲突的灵活性 - 当多个 Category 定义了同名方法时,最后被加载的 Category 方法会覆盖之前的方法。这种策略虽然简单,但需要运行时动态合并: - 编译时无法确定 Category 的加载顺序(例如依赖动态库的加载顺序)。 - 运行时可以通过调整加载顺序实现方法覆盖的灵活控制。 - -如果放到编译时处理的潜在优势与取舍 -如果选择编译时合并 Category,可能带来: -- 性能提升:方法查找可能更快(无需运行时遍历 Category 列表)。 -- 更强的类型安全:编译时能检查所有方法冲突。 -但代价是: -- 丧失动态能力:如热修复、Method Swizzling、插件化等场景将无法实现。 -- 代码僵化:所有扩展必须在编译时确定,无法适应动态环境(如大型应用的模块化延迟加载) - -Objective-C 将 Category 合并推迟到运行时,是为了最大化语言的动态性和灵活性,支持热更新、AOP、插件化等高级场景。这种设计符合其“动态消息语言”的定位,但牺牲了部分编译时优化机会。选择编译时或运行时处理,本质是灵活性与性能/安全性的权衡,而 Objective-C 明确选择了前者。 - - -### QA - -#### 为什么二维数组打了引号? - -`method_array_t` 中存储了 `method_list_t`,`method_list_t` 的元素为 `method_list_t`, 为什么不直接称它为二维数组? - -严格来说,不是二维数组,只不过是 array 里添加的对象也是 array,且各个数组不等长,也不会补空。 - -为什么需要设计为这样的结构? - -调用方法,比如调用 load 是 runtime 加载的时候找到方法地址直接调用的。普通方法走的是消息机制,根据对象方法还是类方法,会根据isa 找类对象(对象方法)和元类对象(对象方法)信息中先从 cache中找方法是否有,没有再通过方法二维数组查找(二维是因为类存在分类,分类可能也有方法)没找到则通过 superclass 继续找父类对象或者父元类对象继续找,找到则执行并给当前类的 cache 方法散列表缓存下来。找到NSObject 还是没找到则走消息转发机制,起死回生几个阶段 - - 另外 load 调用会根据编译顺序决定,如果遇到某个类存在父类则先调用父类的load、再执行子类的 load。category 会按照编译顺序,runtime 会给方法进行重新组合顺序,源码显示最后 category 的方法会排到最前面。 - - 本类的 class method List 和 category methodList 都是 array,category List 会插在本类 method List 前面,匹配到 category method List 同名方法,本类就不调用了,看着有点像被"覆盖"了。 - - - -#### 分类中可以写属性吗? - -不可以。从源码角度来讲,查看分类的 category_t 结构体可以看到没有 `const ivar_list_t * ivars;` ,所以 category 声明属性底层只会生成 setter、getter 方法声明,没有实现。需要程序员利用 runtime 关联属性自己实现 - -同理,分类中也不可以添加成员变量,下面代码会报错。 - -```objective-c -@interface Person (Study) -{ - int _age; -} -@end -``` - - - -从代码设计角度来讲,假设一个 Person 类只有1个 `_age` 成员变量,其内存布局在编译阶段就可以确定。内存布局大概为: - -```objective-c -struct Person_IMPL { - Class isa; - int _age; -} -``` - -但 Category 是苹果利用 Runtime 实现的,是运行期动态修改 `class_rw_t` 决定的。 - - - -#### 为什么分类中的方法需要放在类自身方法列表的开头? - -查看源码为什么分类中的方法需要放在类自身方法列表的开头?因为需要优先保证 Category 中的方法优先被调用。 - - - -#### 分类中存在同名方法存在什么问题 - -对象调用方法的时候会根据对象的 isa 指针,找到类对象方法列表,然后查找方法,由于分类方法在方法列表的前面,类自身方法在方法列表的后面,所以当优先找到分类方法实现的时候就停止查找了,给人的感受就是,方法”被覆盖了 “ - -Demo: 为 Person 类创建2个 Category,分别存在同名方法 study,具有不同实现。在控制器的手势事件中打印方法列表 - -```objective-c -- (void)displayMethodName:(Class)cls { - unsigned int count; - Method *methodList = class_copyMethodList(cls, &count); - NSMutableString *methodNames = [NSMutableString string]; - for (int i = 0; i < count; i++) { - Method method = methodList[i]; - NSString *methodName = NSStringFromSelector(method_getName(method)); - [methodNames appendString:methodName]; - [methodNames appendString:@", "]; - } - free(methodList); - NSLog(@"className: %@, methodNames: %@", NSStringFromClass(cls) , methodNames); -} - -- (void)viewDidLoad { - [super viewDidLoad]; - self.p = [[Person alloc] init]; -} - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - [self.p study]; - [self displayMethodName:[Person class]]; -} -``` - - - -可以看到 sayHi 方法存在多个,但是由于 Category 同名的方法在方法列表的前面,所以类自身的方法实现”被覆盖了“(根据 isa 查找方法实现的时候,优先查找到 Category 的方法实现,则停止查找了) - - - -#### 2个分类存在同名方法,谁先调用 - -- 分类方法优先级高于类自身方法 -- 同样是分类方法,由编译顺序决定哪个方法会被调用(Xcode:Build Phases -> Compile Sources),编译顺序越后面的方法优先被调用 - -Demo: 为 Person 类创建2个 Category,分别存在同名方法 study,具有不同实现。探索编译顺序决定方法实现 - - - -2个对比实验: - -让 `Person+Study` 参与后编译 - - - -让 `Person+Learn` 参与后编译 - - - - - -## 拓展(Extension) - -### 文件特征 - -- 只存在一个文件 - -- 命名方式:“类名_拓展名.h” - - ``` - #import "类名.h" - @interface 类名 () - // 在此添加私有成员变量、属性、声明方法 - @end - ``` - -### 拓展的作用 - -1. 为类增加额外的属性、成员变量、方法声明 - -2. 一般将类拓展直接写到当前类的 .m 文件中。不单独创建 - -3. 一般私有的属性和方法写到类拓展中 - -4. 和 Category 类似,但是小括号里面没有拓展的名字 - -5. 拓展里面的属性和方法,会在编译阶段将相关数据和类本身合并(分类 Category 在编译期无法确定,只有在运行时才会合并到类对象、元类对象的属性、方法列表中去) - -### 拓展的局限性 - -1. Extension 中添加的属性、成员变量、方法属于私有(只可以在本类的 .m 文件中访问、调用。在其他类里面是无法访问的,同时子类也是无法继承的)。假如我们有这样一个需求,一个属性对外是只读的,对内是可以读写的,那么我们可以通过 Extension 实现。 - -2. 通常 Extension 都写在 .m 文件中,不会单独建立一个 Extension 文件。而且 Extension 必须写到 @implementation 上方,否则编译报错 - -3. 类拓展定义的方法和属性必须在类的实现文件中实现。如果单独定义类扩展的文件并且只定义属性的话,也需要将类实现文件中包含进类扩展文件,否则会找不到属性的 setter 和 getter 方法。 - - ```objectivec - //Web.h - #import "Person.h" - NS_ASSUME_NONNULL_BEGIN - @interface Web : Person - @end - NS_ASSUME_NONNULL_END - - //Web.m - #import "Web.h" - #import "Web+H5.h" - @interface Web () - @property (nonatomic, strong) NSString *skillStacks; - @end - @implementation Web - - - (void)test { - self.skills = @"iOS && Web && Node && Hybrid"; - self.skillStacks = @"iOS && Web && Node && Hybrid"; - } - - (void)show { - NSLog(@"%@",self.skillStacks); - } - @end - ``` - -4. 不能为系统类添加拓展 - - - -### QA - -Category 的实现原理是什么? - -Category 编译之后的底层实现是 struct category_t,里面存储着分类的对象方法、类方法、属性、协议信息。 - -程序运行过程中,runtime 会将 Category 的数据,合并到类信息中(类对象、元类对象) - - - -Category 和 Class Extension 的区别是什么? - -Class Extension 在编译的时候,它的数据就已经包含在类信息中。 - -Category 是在运行时,才将数据合并到类信息中(类对象、元类对象) - - - -## 总结 - -1. Category 能拓充实例方法、类方法、协议、属性(属性虽然不可以直接拓展,利用 Runtime 关联属性可以实现)、不能拓展成员变量(包含成员变量会报错) -2. 如果 Category 中声明了1个属性,那么 Category 只会生成 setter 和 getter 的声明,不会有实现 -3. 分类的方法本质是追加在当前类方法列表后,所以分类的方法会覆盖当前类的方法。 -4. Category 具有运行时决议的特点。为某个类添加的 Category 被 runtime 加载后,原本来的方法,可能会被优先找到分类中的方法实现,而“覆盖”(假的覆盖,只是优先查找到 category 的方法实现的时候,原始类的方法查找中止而已) -5. Category 可以为系统类添加分类,而拓展不行。 - - - -关于第3点,我们可以查看源代码印证下。去 opensource 下载 objc4 - -OC 入口函数`_objc_init` - -```objectivec -void _objc_init(void) -{ -    // ... - _dyld_objc_notify_register(&map_images, load_images, unmap_image); -} -``` - -之后注册各种镜像,那么 map_images 哪里来的? - -```objectivec -void -map_images_nolock(unsigned mhCount, const char * const mhPaths[], - const struct mach_header * const mhdrs[]) -{ - // ... - if (hCount > 0) { - _read_images(hList, hCount, totalClasses, unoptimizedTotalClasses); - } - firstTime = NO; -} -``` - -_read_images 方法内部会调用 remethodizeClass - -```objectivec -void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses) -{ -// ... -    if (cls->isRealized()) { -        remethodizeClass(cls); -// ... -} -``` - -remethodizeClass 内部会调用 attachCategories - -```objectivec -static void remethodizeClass(Class cls) -{ - category_list *cats; - bool isMeta; - - runtimeLock.assertWriting(); - - isMeta = cls->isMetaClass(); - - // Re-methodizing: check for more categories - if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) { - if (PrintConnecting) { - _objc_inform("CLASS: attaching categories to class '%s' %s", - cls->nameForLogging(), isMeta ? "(meta)" : ""); - } - - attachCategories(cls, cats, true /*flush caches*/); - free(cats); - } -} -``` - -attachCategories 会调用 attachLists - -```objectivec -static void -attachCategories(Class cls, category_list *cats, bool flush_caches) -{ - if (!cats) return; - if (PrintReplacedMethods) printReplacements(cls, cats); - - bool isMeta = cls->isMetaClass(); - - // fixme rearrange to remove these intermediate allocations - method_list_t **mlists = (method_list_t **) - malloc(cats->count * sizeof(*mlists)); - property_list_t **proplists = (property_list_t **) - malloc(cats->count * sizeof(*proplists)); - protocol_list_t **protolists = (protocol_list_t **) - malloc(cats->count * sizeof(*protolists)); - - // Count backwards through cats to get newest categories first - int mcount = 0; - int propcount = 0; - int protocount = 0; - int i = cats->count; - bool fromBundle = NO; - while (i--) { - auto& entry = cats->list[i]; - - method_list_t *mlist = entry.cat->methodsForMeta(isMeta); - if (mlist) { - mlists[mcount++] = mlist; - fromBundle |= entry.hi->isBundle(); - } - - property_list_t *proplist = - entry.cat->propertiesForMeta(isMeta, entry.hi); - if (proplist) { - proplists[propcount++] = proplist; - } - - protocol_list_t *protolist = entry.cat->protocols; - if (protolist) { - protolists[protocount++] = protolist; - } - } - - auto rw = cls->data(); - - prepareMethodLists(cls, mlists, mcount, NO, fromBundle); - rw->methods.attachLists(mlists, mcount); - free(mlists); - if (flush_caches && mcount > 0) flushCaches(cls); - - rw->properties.attachLists(proplists, propcount); - free(proplists); - - rw->protocols.attachLists(protolists, protocount); - free(protolists); -} -``` - -attachLists 内部会调用 realloc、memmove、memmcpy - -```objectivec -void attachLists(List* const * addedLists, uint32_t addedCount) { - if (addedCount == 0) return; - - if (hasArray()) { - // many lists -> many lists - uint32_t oldCount = array()->count; - uint32_t newCount = oldCount + addedCount; - setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); - array()->count = newCount; - memmove(array()->lists + addedCount, array()->lists, - oldCount * sizeof(array()->lists[0])); - memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); - } - else if (!list && addedCount == 1) { - // 0 lists -> 1 list - list = addedLists[0]; - } - else { - // 1 list -> many lists - List* oldList = list; - uint32_t oldCount = oldList ? 1 : 0; - uint32_t newCount = oldCount + addedCount; - setArray((array_t *)malloc(array_t::byteSize(newCount))); - array()->count = newCount; - if (oldList) array()->lists[addedCount] = oldList; - memcpy(array()->lists, addedLists, - addedCount * sizeof(array()->lists[0])); - } - } -``` - -最后会把类对象、元类对象、分类二元数组整体处理,结果为最后编译的分类在整合后数组的最前面,也就是为什么说分类和原类存在同名方法时,会被覆盖,且最后编译的分类的方法实现是会被调用的原因。 - -- 通过 Runtime 加载某个类所有的 Category - -- 所有的 Category 方法、属性、协议数据,合并到一个大数组中,后面参与编译的 Category 数据,会放在数组前面 - -- 合并后的 Category 数据(属性、方法、协议)插入到类原来数据的前面(比如class_rw_t->methods) - - - -## 底层剖析 load 方法 - -假设一个 Person 类存在2个 Category,分别是 Person+Work、Person+Study,都有对象方法 play,当实例对象调用 play 的时候,会调用最后编译的 Category (Person+Study)里的 play 实现。但为什么 load 不是调用最后一个 Category 的 +load,而是3个都调用? - -Demo 验证 - -```objectivec -@interface Person : NSObject -@end - -@interface Student : Person -@end - -@interface Student (Good) -@end - -@interface Student (Bad) -@end -// 其中每个类都存在3个方法 -+ (void)load{ - NSLog(@"%s", __func__); -} -+ (void)initialize{ - NSLog(@"%s", __func__); -} -- (void)test{ - NSLog(@"%s", __func__); -} -// Test -Student *st = [[Student alloc] init]; - -2022-04-16 01:35:22.237692+0800 Main[8752:2908124] +[Person load] -2022-04-16 01:35:22.238305+0800 Main[8752:2908124] +[Student load] -2022-04-16 01:35:22.238450+0800 Main[8752:2908124] +[Student(Good) load] -2022-04-16 01:35:22.238562+0800 Main[8752:2908124] +[Student(Bad) load] -2022-04-16 01:35:22.238664+0800 Main[8752:2908124] +[Person initialize] -2022-04-16 01:35:22.238733+0800 Main[8752:2908124] +[Student(Bad) initialize] -2022-04-16 01:35:22.238794+0800 Main[8752:2908124] -[Student(Bad) test] -``` - - - -### 为什么 load 方法不是按照 Category 编译顺序倒序调用 load 方法? - -看源代码 Objc4(我的版本是 objc4-838.1) - -```c -// objc-os.mm -/*********************************************************************** -* _objc_init -* Bootstrap initialization. Registers our image notifier with dyld. -* Called by libSystem BEFORE library initialization time -**********************************************************************/ -// Objc 启动会调用该方法,内部会调用 _dyld_objc_notify_register。其中 load_images 方法用于加载模块 -void _objc_init(void) -{ - static bool initialized = false; - if (initialized) return; - initialized = true; - - // fixme defer initialization until an objc-using image is found? - environ_init(); - tls_init(); - static_init(); - runtime_init(); - exception_init(); -#if __OBJC2__ - cache_t::init(); -#endif - _imp_implementationWithBlock_init(); - - _dyld_objc_notify_register(&map_images, load_images, unmap_image); - -#if __OBJC2__ - didCallDyldNotifyRegister = true; -#endif -} - -// objc-runtime-new.mm -// load_images 方法中最后会调用 call_load_methods 方法,用于调用全部的 +(void)load 方法 -void -load_images(const char *path __unused, const struct mach_header *mh) -{ - if (!didInitialAttachCategories && didCallDyldNotifyRegister) { - didInitialAttachCategories = true; - loadAllCategories(); - } - - // Return without taking locks if there are no +load methods here. - if (!hasLoadMethods((const headerType *)mh)) return; - - recursive_mutex_locker_t lock(loadMethodLock); - - // Discover load methods - { - mutex_locker_t lock2(runtimeLock); - prepare_load_methods((const headerType *)mh); - } - - // Call +load methods (without runtimeLock - re-entrant) - call_load_methods(); -} - -// objc-loadmethod.mm -/*********************************************************************** -* call_load_methods -* Call all pending class and category +load methods. -* Class +load methods are called superclass-first. -* Category +load methods are not called until after the parent class's +load. -* -* This method must be RE-ENTRANT, because a +load could trigger -* more image mapping. In addition, the superclass-first ordering -* must be preserved in the face of re-entrant calls. Therefore, -* only the OUTERMOST call of this function will do anything, and -* that call will handle all loadable classes, even those generated -* while it was running. -* -* The sequence below preserves +load ordering in the face of -* image loading during a +load, and make sure that no -* +load method is forgotten because it was added during -* a +load call. -* Sequence: -* 1. Repeatedly call class +loads until there aren't any more -* 2. Call category +loads ONCE. -* 3. Run more +loads if: -* (a) there are more classes to load, OR -* (b) there are some potential category +loads that have -* still never been attempted. -* Category +loads are only run once to ensure "parent class first" -* ordering, even if a category +load triggers a new loadable class -* and a new loadable category attached to that class. -* -* Locking: loadMethodLock must be held by the caller -* All other locks must not be held. -**********************************************************************/ -// 在调用 call_load_methods 方法内部,会先调用类的 +load 方法,再调用分类的 +load -void call_load_methods(void) -{ - static bool loading = NO; - bool more_categories; - - loadMethodLock.assertLocked(); - - // Re-entrant calls do nothing; the outermost call will finish the job. - if (loading) return; - loading = YES; - - void *pool = objc_autoreleasePoolPush(); - - do { - // 1. Repeatedly call class +loads until there aren't any more - // 先调用全部类的 +load - while (loadable_classes_used > 0) { - call_class_loads(); - } - - // 2. Call category +loads ONCE - // 再调用分类的 +load - more_categories = call_category_loads(); - - // 3. Run more +loads if there are classes OR more untried categories - } while (loadable_classes_used > 0 || more_categories); - - objc_autoreleasePoolPop(pool); - - loading = NO; -} - -/*********************************************************************** -* call_class_loads -* Call all pending class +load methods. -* If new classes become loadable, +load is NOT called for them. -* -* Called only by call_load_methods(). -**********************************************************************/ -// 该方法用于调用全部类的 +load 方法 -static void call_class_loads(void) -{ - int i; - - // Detach current loadable list. - struct loadable_class *classes = loadable_classes; - int used = loadable_classes_used; - loadable_classes = nil; - loadable_classes_allocated = 0; - loadable_classes_used = 0; - - // Call all +loads for the detached list. - for (i = 0; i < used; i++) { - Class cls = classes[i].cls; - // 从可加载的类数组 classes 中取出当前类的 method.这个类是 loadable_class 结构体,只有2个成员,cls 和 method,所以该 method 就是 +load 方法 - load_method_t load_method = (load_method_t)classes[i].method; - if (!cls) continue; - - if (PrintLoading) { - _objc_inform("LOAD: +[%s load]\n", cls->nameForLogging()); - } - // 方法指针。直接调用 +load 方法,而不是采用发消息的形式 - (*load_method)(cls, @selector(load)); - } - - // Destroy the detached list. - if (classes) free(classes); -} - -typedef void(*load_method_t)(id, SEL); - -struct loadable_class { - Class cls; // may be nil - IMP method; -}; - -struct loadable_category { - Category cat; // may be nil - IMP method; -}; - -/*********************************************************************** -* call_category_loads -* Call some pending category +load methods. -* The parent class of the +load-implementing categories has all of -* its categories attached, in case some are lazily waiting for +initalize. -* Don't call +load unless the parent class is connected. -* If new categories become loadable, +load is NOT called, and they -* are added to the end of the loadable list, and we return TRUE. -* Return FALSE if no new categories became loadable. -* -* Called only by call_load_methods(). -**********************************************************************/ -// 该方法用于调用分类的 +load 方法 -static bool call_category_loads(void) -{ - int i, shift; - bool new_categories_added = NO; - - // Detach current loadable list. - struct loadable_category *cats = loadable_categories; - int used = loadable_categories_used; - int allocated = loadable_categories_allocated; - loadable_categories = nil; - loadable_categories_allocated = 0; - loadable_categories_used = 0; - - // Call all +loads for the detached list. - for (i = 0; i < used; i++) { - Category cat = cats[i].cat; - // 从可加载的分类数组 cats 中取出当前分类,这个类是 loadable_category 结构体,只有2个成员,cat 和 method,所以该 method 就是 +load 方法 - load_method_t load_method = (load_method_t)cats[i].method; - Class cls; - if (!cat) continue; - - cls = _category_getClass(cat); - if (cls && cls->isLoadable()) { - if (PrintLoading) { - _objc_inform("LOAD: +[%s(%s) load]\n", - cls->nameForLogging(), - _category_getName(cat)); - } - // 直接调用 Category 的 +load,不是采用发消息的方式 - (*load_method)(cls, @selector(load)); - cats[i].cat = nil; - } - } - - // Compact detached list (order-preserving) - shift = 0; - for (i = 0; i < used; i++) { - if (cats[i].cat) { - cats[i-shift] = cats[i]; - } else { - shift++; - } - } - used -= shift; - - // Copy any new +load candidates from the new list to the detached list. - new_categories_added = (loadable_categories_used > 0); - for (i = 0; i < loadable_categories_used; i++) { - if (used == allocated) { - allocated = allocated*2 + 16; - cats = (struct loadable_category *) - realloc(cats, allocated * - sizeof(struct loadable_category)); - } - cats[used++] = loadable_categories[i]; - } - - // Destroy the new list. - if (loadable_categories) free(loadable_categories); - - // Reattach the (now augmented) detached list. - // But if there's nothing left to load, destroy the list. - if (used) { - loadable_categories = cats; - loadable_categories_used = used; - loadable_categories_allocated = allocated; - } else { - if (cats) free(cats); - loadable_categories = nil; - loadable_categories_used = 0; - loadable_categories_allocated = 0; - } - - if (PrintLoading) { - if (loadable_categories_used != 0) { - _objc_inform("LOAD: %d categories still waiting for +load\n", - loadable_categories_used); - } - } - - return new_categories_added; -} -``` - -阅读源码可以得出2个结论: - -1. `+load` 方法是系统启动后,系统主动调用的; -2. 在调用 `+load` 方法的时候,系统会先调用(可加载)类的 `+load` 方法,再调用分类的 `+load` 方法(先调用 `call_class_loads` 再调用 `call_category_loads`) -3. `call_class_loads`、`call_category_loads` 方法内部实现,是通过 `loadable_class` `loadable_category` 结构体的 method 成员值 ,通过 `load_method_t load_method = (load_method_t)classes[i].method` 找到 `+ load` 方法地址。最后直接调用 `(*load_method)(cls, SEL_load)` 方法本身,没有采用消息机制。 - - - -### 为什么总是父类的 +load 比子类先执行? - -无论在 Xcode 中怎么调节编译顺序,发现总是父类的 +load 比子类先执行,为什么?还是从源码角度白盒探究下。 - -梳理下思路:关于 +load 的执行顺序,类的 +load 比分类先执行,这一点认知是拉齐的吧。那我们聚焦下,看看类里面父类、子类这样的情况是如何调用 +load 的 。 - -```c++ -static void call_class_loads(void) -{ - int i; - - // Detach current loadable list. - struct loadable_class *classes = loadable_classes; - int used = loadable_classes_used; - loadable_classes = nil; - loadable_classes_allocated = 0; - loadable_classes_used = 0; - - // Call all +loads for the detached list. - for (i = 0; i < used; i++) { - Class cls = classes[i].cls; - load_method_t load_method = (load_method_t)classes[i].method; - if (!cls) continue; - - if (PrintLoading) { - _objc_inform("LOAD: +[%s load]\n", cls->nameForLogging()); - } - (*load_method)(cls, @selector(load)); - } - - // Destroy the detached list. - if (classes) free(classes); -} -``` - -我们在类的 +load 执行方法 `call_class_loads` 里看到,方法内部说顺序遍历并执行 +load 的。看到 `loadable_classes` 很可疑。它是不是决定了父类、子类的顺序? - -```c++ -void -load_images(const char *path __unused, const struct mach_header *mh) -{ - if (!didInitialAttachCategories && didCallDyldNotifyRegister) { - didInitialAttachCategories = true; - loadAllCategories(); - } - - // Return without taking locks if there are no +load methods here. - if (!hasLoadMethods((const headerType *)mh)) return; - - recursive_mutex_locker_t lock(loadMethodLock); - - // Discover load methods - { - mutex_locker_t lock2(runtimeLock); - prepare_load_methods((const headerType *)mh); - } - - // Call +load methods (without runtimeLock - re-entrant) - call_load_methods(); -} -``` - -发现在调用 `call_load_methods` 方法之前,有个 `prepare_load_methods` 方法。看名字,感觉像是在做调用 +load 方法前的准备工作。 - -```c++ -void prepare_load_methods(const headerType *mhdr) -{ - size_t count, i; - - runtimeLock.assertLocked(); - - classref_t const *classlist = - _getObjc2NonlazyClassList(mhdr, &count); - for (i = 0; i < count; i++) { - // 做一些前置定制动作 - schedule_class_load(remapClass(classlist[i])); - } - - category_t * const *categorylist = _getObjc2NonlazyCategoryList(mhdr, &count); - for (i = 0; i < count; i++) { - category_t *cat = categorylist[i]; - Class cls = remapClass(cat->cls); - if (!cls) continue; // category for ignored weak-linked class - if (cls->isSwiftStable()) { - _objc_fatal("Swift class extensions and categories on Swift " - "classes are not allowed to have +load methods"); - } - realizeClassWithoutSwift(cls, nil); - ASSERT(cls->ISA()->isRealized()); - - add_category_to_loadable_list(cat); - } -} - -/*********************************************************************** -* prepare_load_methods -* Schedule +load for classes in this image, any un-+load-ed -* superclasses in other images, and any categories in this image. -**********************************************************************/ -// Recursively schedule +load for cls and any un-+load-ed superclasses. -// cls must already be connected. -static void schedule_class_load(Class cls) -{ - if (!cls) return; - ASSERT(cls->isRealized()); // _read_images should realize - - if (cls->data()->flags & RW_LOADED) return; - - // Ensure superclass-first ordering - // 递归调用,传入当前类的父类 - schedule_class_load(cls->getSuperclass()); - // 将 cls 添加到 loadable_classes 数组的最后面 - add_class_to_loadable_list(cls); - cls->setInfo(RW_LOADED); -} - -void add_class_to_loadable_list(Class cls) -{ - IMP method; - - loadMethodLock.assertLocked(); - - method = cls->getLoadMethod(); - if (!method) return; // Don't bother if cls has no +load method - - if (PrintLoading) { - _objc_inform("LOAD: class '%s' scheduled for +load", - cls->nameForLogging()); - } - - if (loadable_classes_used == loadable_classes_allocated) { - loadable_classes_allocated = loadable_classes_allocated*2 + 16; - loadable_classes = (struct loadable_class *) - realloc(loadable_classes, - loadable_classes_allocated * - sizeof(struct loadable_class)); - } - // 将 - loadable_classes[loadable_classes_used].cls = cls; - loadable_classes[loadable_classes_used].method = method; - loadable_classes_used++; -} -``` - -通过源码,我们可以看出: - -- 系统会通过 `schedule_class_load` 方法,保证优先调用当前类的全部父类的加入到 `loadable_classes`,然后将当前类加入到 `loadable_classes` -- 最后执行 +load 的时候会按照 `loadable_classes` 里的顺序,依次调用 +load 方法 - -顺便看看分类的调用顺序是怎么控制的? - -```c++ -void prepare_load_methods(const headerType *mhdr) -{ - size_t count, i; - - runtimeLock.assertLocked(); - - classref_t const *classlist = - _getObjc2NonlazyClassList(mhdr, &count); - for (i = 0; i < count; i++) { - schedule_class_load(remapClass(classlist[i])); - } - - category_t * const *categorylist = _getObjc2NonlazyCategoryList(mhdr, &count); - for (i = 0; i < count; i++) { - category_t *cat = categorylist[i]; - Class cls = remapClass(cat->cls); - if (!cls) continue; // category for ignored weak-linked class - if (cls->isSwiftStable()) { - _objc_fatal("Swift class extensions and categories on Swift " - "classes are not allowed to have +load methods"); - } - realizeClassWithoutSwift(cls, nil); - ASSERT(cls->ISA()->isRealized()); - add_category_to_loadable_list(cat); - } -} - -/*********************************************************************** -* add_category_to_loadable_list -* Category cat's parent class exists and the category has been attached -* to its class. Schedule this category for +load after its parent class -* becomes connected and has its own +load method called. -**********************************************************************/ -void add_category_to_loadable_list(Category cat) -{ - IMP method; - - loadMethodLock.assertLocked(); - - method = _category_getLoadMethod(cat); - - // Don't bother if cat has no +load method - if (!method) return; - - if (PrintLoading) { - _objc_inform("LOAD: category '%s(%s)' scheduled for +load", - _category_getClassName(cat), _category_getName(cat)); - } - - if (loadable_categories_used == loadable_categories_allocated) { - loadable_categories_allocated = loadable_categories_allocated*2 + 16; - loadable_categories = (struct loadable_category *) - realloc(loadable_categories, - loadable_categories_allocated * - sizeof(struct loadable_category)); - } - - loadable_categories[loadable_categories_used].cat = cat; - loadable_categories[loadable_categories_used].method = method; - loadable_categories_used++; -} -``` - -可以看到通过 `schedule_class_load` 处理完类之后,分类是直接通过 `_getObjc2NonlazyCategoryList(mhdr, &count)` 获取的,之后也是直接添加到 `loadable_categories` 中去。 - -举个例子: - -存在下面 4个类 - -```objective-c -Class Person : NSObject -Class Person+Good : NSObject -Class Person+Bad : NSObject -Class Student : Person -Class Student+Good : Person -Class Student+Bad : Person -Class Cat: NSObject -Class Dog: NSObject -``` - -如果在 Xcode 的 Build Phases 中,顺序为 - -```objective-c -Cat.m -Dog.m -Student+Good.m -Student+Bad.m -Student.m -Person+Good.m -Person+Bad.m -Person.m - -运行后打印为: -- Cat load -- Dog load -- Person load -- Student load -- Student+Good + load -- Student+Bad + load -- Person+Good load -- Person+Bad load -``` - -如果在 Xcode 的 Build Phases 中,顺序为 - -```objective-c -Cat.m -Student+Good.m -Student+Bad.m -Student.m -Person+Good.m -Person+Bad.m -Person.m -Dog.m - -运行后打印为: -- Cat load -- Person load -- Student load -- Student+Good + load -- Student+Bad + load -- Person+Good load -- Person+Bad load -- Dog load -``` - - - -### +load 总结 - -- `+load` 方法是系统通过 Runtime 在加载类、分类的时候调用的 -- 每个类、分类的 `+load` 在程序运行过程中只调用1次 -- 调用顺序方面: - - 先调用类的 `+load` 方法 - - 存在继承关系的话,调用子类的 `+load` 之前会调用父类的 `+load` 方法(Runtime 会保证好,先调用父类的 `+load` ,再调用子类的 `+load` ) - - 不存在继承关系的话,会按照编译顺序调用 `+load`(先编译的先调用,编译顺序参考 Xcode 的 Build Phases -> Compile Sources 中的顺序) - - 再调用分类的 `+load` 方法 - - 按照编译顺序调用 `+load`(先编译的先调用) - - 全局数组 `loadable_classes`,确保后续运行时能按正确顺序(父类 → 子类 → 分类)调用这些方法 - - - -### QA - -1.Category 中有 `+load` 方法吗?`+load` 调用时机是什么时候?`+load` 可以继承吗? - -Category 存在 `+load` 方法。`+load` 是系统在启动阶段通过 runtime 来加载、准备(通过递归的手段保证,如果当前类存在父类,则会加入到 `loadable_classes` 中去),然后先调用类的 `+load`,再调用分类的 `+load`。 - -`+load` 可以继承。但是这个问法背后的想法有点神奇,因为继承的本质是面向对象,类继承了,方法会重写逻辑。但是 iOS 官方文档说 `+load` 适合做和本类相关的逻辑。所以这个继承就显得不那么合理。但是可以继承的,比如下面的代码: - -```objective-c -@interface Person : NSObject -@end - -@implementation Person -+ (load) { - NSLog(@"Person +load"); -} -@end - -@interface Student : Person -@end -@implementation Person -@end - -[Student load]; -// console -Person +load -``` - -可以看到,我们主动调用 `[Student load]` 由于 Student 自身没有实现 `+load`,由于存在继承,所以调用了 Person 的 `+load` 。手动调用 `+load` 本质上就是给 runtime 消息机制,等价于 `objc_msgSend([Student class], @selector(load))` ,通过 Student 类对象的 isa,找到 Student 元类对象,判断有没有类方法 `+load`,发现没有,则根据 Student 元类对象的 `superclass` 找到父类的元类对象,也就是 Person 的元类对象,发现有 `+load` 实现,即调用了 `+load` ,打印 `Person +load` - -2.为什么 load 方法打印顺序是这样的? - -因为调用 student alloc,相当于发送了消息。则肯定先执行 load 方法。类在 Runtime 启动阶段会调用 `schedule_class_load` 方法。方法内部递归调用,如果当前类存在父类则递归调用,否则将当前类加载到 loadable_classes 最后面。load 方法在本质上是执行 `call_load_methods`,方法地址是确定的(查看下面的源代码可以发现 `load` 方法是在编译期就可以确定的)。不走 `objc_msgSend` 这套流程。所以先打印父类 load、再打印子类 load、最后打印分类 load。如果存在多个分类,则按照编译顺序打印 load。 - - - -## 底层剖析 Initialize 方法 - -上 Demo - -```objectivec -@interface Person : NSObject -@end - -@interface Student : Person -@end - -@interface Student (Good) -@end -``` - -`Person *p1 = [[Person alloc] init];` 这句代码输出什么? - -这个比较简单,initialize 方法在类第一次收到消息的时候调用。所以输出 `+[Person initialize]` - -`Student *st = [[Student alloc] init];` 输出什么? - -```objectivec -+[Person initialize] -+[Student(Go od) initialize] -``` - -查看分类在 Runtime 加载类信息时候的调用原理可以知道,分类中的类方法、对象方法都会被加载原始类的前面去(initialize 是类方法)如下图: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-categoryattachLists.png) - - ### 为什么给子类发消息,父类和子类的 +initialize 都会被调用?且父类的先调用 - -梳理下目前掌握的信息:当类第一次接收消息,也就是第一次调用对象方法的时候,该类的 `+initialize` 方法会被调用。所以需要从 objc_msgSend 或者获取对象方法的角度去查看源码。聚焦下,以 `class_getInstanceMethod` 方法为入口,分析源码 - -```c++ -/*********************************************************************** -* class_getInstanceMethod. Return the instance method for the -* specified class and selector. -**********************************************************************/ -Method class_getInstanceMethod(Class cls, SEL sel) -{ - if (!cls || !sel) return nil; - - // This deliberately avoids +initialize because it historically did so. - - // This implementation is a bit weird because it's the only place that - // wants a Method instead of an IMP. - -#warning fixme build and search caches - - // Search method lists, try method resolver, etc. - lookUpImpOrForward(nil, sel, cls, LOOKUP_RESOLVER); - -#warning fixme build and search caches - - return _class_getMethod(cls, sel); -} -``` - -内部调用的是 `lookUpImpOrForward` - -```c++ -NEVER_INLINE -IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior) -{ - const IMP forward_imp = (IMP)_objc_msgForward_impcache; - IMP imp = nil; - Class curClass; - - runtimeLock.assertUnlocked(); - - if (slowpath(!cls->isInitialized())) { - // The first message sent to a class is often +new or +alloc, or +self - // which goes through objc_opt_* or various optimized entry points. - // - // However, the class isn't realized/initialized yet at this point, - // and the optimized entry points fall down through objc_msgSend, - // which ends up here. - // - // We really want to avoid caching these, as it can cause IMP caches - // to be made with a single entry forever. - // - // Note that this check is racy as several threads might try to - // message a given class for the first time at the same time, - // in which case we might cache anyway. - behavior |= LOOKUP_NOCACHE; - } - - // runtimeLock is held during isRealized and isInitialized checking - // to prevent races against concurrent realization. - - // runtimeLock is held during method search to make - // method-lookup + cache-fill atomic with respect to method addition. - // Otherwise, a category could be added but ignored indefinitely because - // the cache was re-filled with the old value after the cache flush on - // behalf of the category. - - runtimeLock.lock(); - - // We don't want people to be able to craft a binary blob that looks like - // a class but really isn't one and do a CFI attack. - // - // To make these harder we want to make sure this is a class that was - // either built into the binary or legitimately registered through - // objc_duplicateClass, objc_initializeClassPair or objc_allocateClassPair. - checkIsKnownClass(cls); - // 关注这里 - cls = realizeAndInitializeIfNeeded_locked(inst, cls, behavior & LOOKUP_INITIALIZE); - // runtimeLock may have been dropped but is now locked again - runtimeLock.assertLocked(); - curClass = cls; - - // The code used to lookup the class's cache again right after - // we take the lock but for the vast majority of the cases - // evidence shows this is a miss most of the time, hence a time loss. - // - // The only codepath calling into this without having performed some - // kind of cache lookup is class_getInstanceMethod(). - - for (unsigned attempts = unreasonableClassCount();;) { - if (curClass->cache.isConstantOptimizedCache(/* strict */true)) { -#if CONFIG_USE_PREOPT_CACHES - imp = cache_getImp(curClass, sel); - if (imp) goto done_unlock; - curClass = curClass->cache.preoptFallbackClass(); -#endif - } else { - // curClass method list. - method_t *meth = getMethodNoSuper_nolock(curClass, sel); - if (meth) { - imp = meth->imp(false); - goto done; - } - - if (slowpath((curClass = curClass->getSuperclass()) == nil)) { - // No implementation found, and method resolver didn't help. - // Use forwarding. - imp = forward_imp; - break; - } - } - - // Halt if there is a cycle in the superclass chain. - if (slowpath(--attempts == 0)) { - _objc_fatal("Memory corruption in class list."); - } - - // Superclass cache. - imp = cache_getImp(curClass, sel); - if (slowpath(imp == forward_imp)) { - // Found a forward:: entry in a superclass. - // Stop searching, but don't cache yet; call method - // resolver for this class first. - break; - } - if (fastpath(imp)) { - // Found the method in a superclass. Cache it in this class. - goto done; - } - } - - // No implementation found. Try method resolver once. - - if (slowpath(behavior & LOOKUP_RESOLVER)) { - behavior ^= LOOKUP_RESOLVER; - return resolveMethod_locked(inst, sel, cls, behavior); - } - - done: - if (fastpath((behavior & LOOKUP_NOCACHE) == 0)) { -#if CONFIG_USE_PREOPT_CACHES - while (cls->cache.isConstantOptimizedCache(/* strict */true)) { - cls = cls->cache.preoptFallbackClass(); - } -#endif - log_and_fill_cache(cls, imp, sel, inst, curClass); - } - done_unlock: - runtimeLock.unlock(); - if (slowpath((behavior & LOOKUP_NIL) && imp == forward_imp)) { - return nil; - } - return imp; -} -``` - -` cls = realizeAndInitializeIfNeeded_locked(inst, cls, behavior & LOOKUP_INITIALIZE);` 看上去是实现 initialize 相关逻辑的。 - -```c++ -/*********************************************************************** -* realizeAndInitializeIfNeeded_locked -* Realize the given class if not already realized, and initialize it if -* not already initialized. -* inst is an instance of cls or a subclass, or nil if none is known. -* cls is the class to initialize and realize. -* initializer is true to initialize the class, false to skip initialization. -**********************************************************************/ -static Class -realizeAndInitializeIfNeeded_locked(id inst, Class cls, bool initialize) -{ - runtimeLock.assertLocked(); - if (slowpath(!cls->isRealized())) { - cls = realizeClassMaybeSwiftAndLeaveLocked(cls, runtimeLock); - // runtimeLock may have been dropped but is now locked again - } - // 需要初始化并且没有被初始化过,则执行 if 里面的逻辑 - if (slowpath(initialize && !cls->isInitialized())) { - cls = initializeAndLeaveLocked(cls, inst, runtimeLock); - // runtimeLock may have been dropped but is now locked again - - // If sel == initialize, class_initialize will send +initialize and - // then the messenger will send +initialize again after this - // procedure finishes. Of course, if this is not being called - // from the messenger then it won't happen. 2778172 - } - return cls; -} -``` - -`realizeAndInitializeIfNeeded_locked` 内部判断,当 class 需要被初始化且没有初始化过的时候则执行 `initializeAndLeaveLocked` - -```c++ -// Locking: caller must hold runtimeLock; this may drop and re-acquire it -static Class initializeAndLeaveLocked(Class cls, id obj, mutex_t& lock) -{ - return initializeAndMaybeRelock(cls, obj, lock, true); -} - -/*********************************************************************** -* class_initialize. Send the '+initialize' message on demand to any -* uninitialized class. Force initialization of superclasses first. -* inst is an instance of cls, or nil. Non-nil is better for performance. -* Returns the class pointer. If the class was unrealized then -* it may be reallocated. -* Locking: -* runtimeLock must be held by the caller -* This function may drop the lock. -* On exit the lock is re-acquired or dropped as requested by leaveLocked. -**********************************************************************/ -static Class initializeAndMaybeRelock(Class cls, id inst, - mutex_t& lock, bool leaveLocked) -{ - lock.assertLocked(); - ASSERT(cls->isRealized()); - - if (cls->isInitialized()) { - if (!leaveLocked) lock.unlock(); - return cls; - } - - // Find the non-meta class for cls, if it is not already one. - // The +initialize message is sent to the non-meta class object. - Class nonmeta = getMaybeUnrealizedNonMetaClass(cls, inst); - - // Realize the non-meta class if necessary. - if (nonmeta->isRealized()) { - // nonmeta is cls, which was already realized - // OR nonmeta is distinct, but is already realized - // - nothing else to do - lock.unlock(); - } else { - nonmeta = realizeClassMaybeSwiftAndUnlock(nonmeta, lock); - // runtimeLock is now unlocked - // fixme Swift can't relocate the class today, - // but someday it will: - cls = object_getClass(nonmeta); - } - - // runtimeLock is now unlocked, for +initialize dispatch - ASSERT(nonmeta->isRealized()); - initializeNonMetaClass(nonmeta); - - if (leaveLocked) runtimeLock.lock(); - return cls; -} -``` - -重点在 `initializeNonMetaClass` 方法中 - -```c++ -/*********************************************************************** -* class_initialize. Send the '+initialize' message on demand to any -* uninitialized class. Force initialization of superclasses first. -**********************************************************************/ -void initializeNonMetaClass(Class cls) -{ - ASSERT(!cls->isMetaClass()); - - Class supercls; - bool reallyInitialize = NO; - - // Make sure super is done initializing BEFORE beginning to initialize cls. - // See note about deadlock above. - supercls = cls->getSuperclass(); - // 存在父类,并且父类没有被初始化,则递归调用 initializeNonMetaClass - if (supercls && !supercls->isInitialized()) { - initializeNonMetaClass(supercls); - } - - // Try to atomically set CLS_INITIALIZING. - SmallVector<_objc_willInitializeClassCallback, 1> localWillInitializeFuncs; - { - monitor_locker_t lock(classInitLock); - if (!cls->isInitialized() && !cls->isInitializing()) { - cls->setInitializing(); - reallyInitialize = YES; - - // Grab a copy of the will-initialize funcs with the lock held. - localWillInitializeFuncs.initFrom(willInitializeFuncs); - } - } - - if (reallyInitialize) { - // We successfully set the CLS_INITIALIZING bit. Initialize the class. - - // Record that we're initializing this class so we can message it. - _setThisThreadIsInitializingClass(cls); - - if (MultithreadedForkChild) { - // LOL JK we don't really call +initialize methods after fork(). - performForkChildInitialize(cls, supercls); - return; - } - - for (auto callback : localWillInitializeFuncs) - callback.f(callback.context, cls); - - // Send the +initialize message. - // Note that +initialize is sent to the superclass (again) if - // this class doesn't implement +initialize. 2157218 - if (PrintInitializing) { - _objc_inform("INITIALIZE: thread %p: calling +[%s initialize]", - objc_thread_self(), cls->nameForLogging()); - } - - // Exceptions: A +initialize call that throws an exception - // is deemed to be a complete and successful +initialize. - // - // Only __OBJC2__ adds these handlers. !__OBJC2__ has a - // bootstrapping problem of this versus CF's call to - // objc_exception_set_functions(). -#if __OBJC2__ - @try -#endif - { - callInitialize(cls); - - if (PrintInitializing) { - _objc_inform("INITIALIZE: thread %p: finished +[%s initialize]", - objc_thread_self(), cls->nameForLogging()); - } - } -#if __OBJC2__ - @catch (...) { - if (PrintInitializing) { - _objc_inform("INITIALIZE: thread %p: +[%s initialize] " - "threw an exception", - objc_thread_self(), cls->nameForLogging()); - } - @throw; - } - @finally -#endif - { - // Done initializing. - lockAndFinishInitializing(cls, supercls); - } - return; - } - - else if (cls->isInitializing()) { - // We couldn't set INITIALIZING because INITIALIZING was already set. - // If this thread set it earlier, continue normally. - // If some other thread set it, block until initialize is done. - // It's ok if INITIALIZING changes to INITIALIZED while we're here, - // because we safely check for INITIALIZED inside the lock - // before blocking. - if (_thisThreadIsInitializingClass(cls)) { - return; - } else if (!MultithreadedForkChild) { - waitForInitializeToComplete(cls); - return; - } else { - // We're on the child side of fork(), facing a class that - // was initializing by some other thread when fork() was called. - _setThisThreadIsInitializingClass(cls); - performForkChildInitialize(cls, supercls); - } - } - - else if (cls->isInitialized()) { - // Set CLS_INITIALIZING failed because someone else already - // initialized the class. Continue normally. - // NOTE this check must come AFTER the ISINITIALIZING case. - // Otherwise: Another thread is initializing this class. ISINITIALIZED - // is false. Skip this clause. Then the other thread finishes - // initialization and sets INITIALIZING=no and INITIALIZED=yes. - // Skip the ISINITIALIZING clause. Die horribly. - return; - } - - else { - // We shouldn't be here. - _objc_fatal("thread-safe class init in objc runtime is buggy!"); - } -} -``` - -可以看到,存在父类,并且父类没有被初始化,则递归调用 `initializeNonMetaClass`。如果不存在父类或者父类已经初始化了,则继续走下面的逻辑。会调用 `callInitialize` 方法 - -```c++ -void callInitialize(Class cls) -{ - ((void(*)(Class, SEL))objc_msgSend)(cls, @selector(initialize)); - asm(""); -} -``` - -可以看到,调用 initialize 方法的本质是通过 runtime 消息机制的能力。 - -至此,可以解释 Demo 中的现象了。 - -再举个例子 - -```objective-c -class Student: Person -class Person: NSObject -class Person+Good: NSObject -class Person+Bad: NSObject -class Teacher: Person -``` - -其中 Student、Teacher 都没有实现 initialize 方法。只有 Person 实现了、Person+Good 和 Person+Bad(编译顺序较后) 也实现了 initialize 方法. - -调用 - -```objective-c -[Student alloc] -[Teacher alloc] -``` - -打印输出 - -```objective-c -- Person+Bad initialize -- Person+Bad initialize -- Person+Bad initialize -``` - -为什么输出这样的信息? - -`[Student alloc]` 等价于 `objc_msgSend([Student Class], @sel(initialize))` 然后 `objc_msgSend` 汇编实现里会调用 `lookUpImpOrForward`,`lookUpImpOrForward` 内部会调用 `initializeNonMetaClass`,其内部实现会判断是否存在父类,存在父类且父类没有调用过 `initialize`,则会递归先调用当前类的父类的 `initialize` 方法。递归结束则调用 `callInitialize` 方法去完成当前类的 `initialize` - -所以上述代码底层类似于 -```Objective-c -if (Student 类没有初始化过) { -    if (Student 父类(Person 父类存在,但存在分类,也就是 Person+Bad)存在 && 父类没有初始化) { -     objc_msgSend(Person + Bad,@selector(initializ)) // Person+Bad initialize -    } - objc_msgSend([Student class],@selector(initializ))  // Student 没有 initialize 方法,则根据 isa 查找父类方法。打印 Person+Bad initialize   -} - - -if (Teacher 类没有初始化过) { - // Person 已经 initialize 过,if 里的逻辑进不去 -    if (Teacher 父类(Person 父类存在,但存在分类,也就是 Person+Bad)存在 && 父类没有初始化) { -     objc_msgSend(Person + Bad,@selector(initializ)) -    } - objc_msgSend([Teacher class],@selector(initializ))  // Teacher 没有 initialize 方法,则根据 isa 查找父类方法。打印 Person+Bad initialize   -} -``` - - - -### 为什么 `initialize`方法存在“覆盖”的情况? - -有个现象:打印了 Student Category 的 `initialize`,却没有打印 Student 自己的 `initialize`,为什么? - -查看 objc 源码发现调用 `initialize` 方法本质上就是通过 runtime 的 `objc_msgSend ` 发消息来实现的。也就是通过 isa 指针查看类对象或元类对象的方法列表中(方法列表中包含当前类各个 Category 的各个方法)查找方法实现。所以当找到方法列表中排列较前的 `initialize` 时,就不再继续查找方法实现了,也就出现了 `initialize` 被覆盖的情况了。 - -```c++ -void callInitialize(Class cls) -{ - ((void(*)(Class, SEL))objc_msgSend)(cls, @selector(initialize)); - asm(""); -} -``` - - - -### `+initialize` 总结 - -`+initialize` 和 `+load` 最大区别是 `+initialize` 是通过 `objc_msgSend` 进行调用的 - -- 调用方式:`load` 根据函数地址直接调用,`initialize` 是根据 `objc_msgSend` 调用的 - -- 调用时刻:load 是 runtime 加载类、分类的时候调用的。initialize 是在类第一次接收消息的时候调用的。每个类只会 initialize 一次,但是父类的 initialize 可能会调用多次(比如子类第一次接收消息,但是子类内部没有实现 `+initialize`,由于消息机制,父类如果实现了 `+initialize` 则会被调用。该父类至少调用2次) - -- 调用顺序: - - - load:先调用类的 load(先编译的类优先调用 load、调用子类的 load,会先调用父类的 load)、再调用分类的 load(先编译的分类,优先调用 load ) - - 如果子类没有实现 `+initialize` 则会调用父类的 `+initialize`(所以父类的 `+initialize` 可能会被调用多次) - - 如果分类实现了 `+initialize`,就会覆盖类本身的 `+initialize` 调用 - -查看源码,伪代码如下: - -```objective-c -if (自己没有初始化) { -    if (父类存在 && 父类没有初始化) { -     objc_msgSend(父类,@selector(initializ)) -    } - objc_msgSend(子类,@selector(initializ))     -} -``` - - - -## 为 Category 实现属性的 Setter 和 Getter - -为一个类写一个 `@property nonatomic, strong) NSString *name` 编译器会做3件事情: - -- 生成成员变量 `_name` - -- 在 `.h` 中生成 getter、setter 声明,即 `-(void)setName:(NSString *)name`、`-(NSString *)name` - -- 在 `.m` 中生成 setter、getter 的实现 - - ```objective-c - -(void)setName:(NSString *)name { - _name = name; - } - - -(NSString *)name { - return _name; - } - ``` - -但是为一个分类写 `@property nonatomic, strong) NSString *name` 编译器会做1件事情: - -- 在 `.h` 中生成 getter、setter 声明,即 `-(void)setName:(NSString *)name`、`-(NSString *)name` - -所以在 Category 中增加分类,需要我们自己实现,利用关联对象的技术。 - - - -| **objc_AssociationPolicy** | **对应的修饰符** | -| --------------------------------- | ----------------- | -| OBJC_ASSOCIATION_ASSIGN | assign | -| OBJC_ASSOCIATION_RETAIN_NONATOMIC | strong, nonatomic | -| OBJC_ASSOCIATION_COPY_NONATOMIC | copy, nonatomic | -| OBJC_ASSOCIATION_RETAIN | strong, atomic | -| OBJC_ASSOCIATION_COPY | copy, atomic | - -```objectivec -#import "Person.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface Person (Student) - -@property (nonatomic, strong) NSString *studyNumber; - -@end - -NS_ASSUME_NONNULL_END - - -#import "Person+Student.h" -#import - -@implementation Person (Student) - -- (void)sayHi { - NSLog(@"大家好,我叫%@,我今年%zd岁了",self.name,self.age); -} - -/* - * 传统的做法是在 setter 里面这样写 - _studyNumber = studyNumber; - ARC 自动管理内存 - MRC - [_studyNumber release]; - [studyNumber retain]; - _studyNumber = studyNumber; - - 但是在 Category里面不会生成对应的实例变量,因此我们可以利用 Runtime 为我们的 category 关联属性的值 - setter:objc_setAssociatedObject(self, @selector(firstView), firstView, OBJC_ASSOCIATION_RETAIN); - getter:objc_getAssociatedObject(self, @selector(firstView)); - } - */ -- (void)setStudyNumber:(NSString *)studyNumber { - objc_setAssociatedObject(self, @selector(studyNumber), studyNumber - , OBJC_ASSOCIATION_RETAIN); -} - -//@selector(studyNumber) -- (NSString *)studyNumber { - return objc_getAssociatedObject(self, @selector(studyNumber)); -} - -@end -``` - -说明: `objc_setAssociatedObject` 的第二个参数是`const void * _Nonnull key` 所以可以用 "studyNumber" 或者利用 `@selector()` 的特性返回的数据类型也满足,所以示例代码选用第二种方式。 - - - -给分类添加属性的时候,为了避免多人开发对于属性添加造成的覆盖,我们需要为属性起一个独特的名字。比如我们的工程是组件化、模块化开展的工程,那么我们可以为属性命名的时候在前面添加当前模块的前缀。 - -比如我们在 Login-Register-Module 模块为 NSURL 的 Category 添加一个 title 的属性的时候,可以这样命名 LR_Title。请查看下面的代码 - -```Objective-c -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSURL (Title) - -@property (nonatomic, copy) NSString *LR_title; - -@end - -NS_ASSUME_NONNULL_END - -#import "NSURL+Title.h" -#import - -@implementation NSURL (Title) - -- (void)setLR_title:(NSString *)LR_title -{ - objc_setAssociatedObject(self, @selector(LR_title), LR_title - , OBJC_ASSOCIATION_RETAIN); -} - -- (NSString *)LR_title -{ - return objc_getAssociatedObject(self, @selector(LR_title)); -} - -@end -``` - -QA: -1. 为什么 `category_t` 结构体里的属性命名为 `instanceProperties`? `classProperties` 何时使用? -普通的 property 就是 instanceProperties,而用 `@property (class, nonatomic, copy) NSString *name;` class 修饰的就是 `classProperties`。 -类属性是 Objective-C 在 Xcode 8 / LLVM 3.0 后引入的特性,旨在提供一种更简洁的方式声明与类相关的全局状态或行为。 - -属性描述中有 `class` 表明这是类属性,通过类名直接访问(如 ClassName.defaultName),而非实例属性。 -类属性存储在类的元类(metaclass)中,与实例属性完全隔离。 -尽管声明方式类似实例属性,类属性不会自动生成存取方法或存储,需开发者手动实现。 - -``` -// Person.h -@interface Person : NSObject -@property (class, nonatomic, copy) NSString *defaultName; -@end - -// Person.m -@implementation Person -static NSString *_defaultName = nil; - -+ (NSString *)defaultName { - return _defaultName; -} - -+ (void)setDefaultName:(NSString *)defaultName { - _defaultName = [defaultName copy]; // 执行 copy 操作 -} -@end - -// 使用 -// 设置类属性 -Person.defaultName = @"Anonymous"; -// 获取类属性 -NSString *name = Person.defaultName; -``` - -类属性的使用场景: -- 为类定义全局可访问的默认值,例如应用的主题颜色、默认语言等。`@property (class, nonatomic, strong) UIColor *appThemeColor;` -- 单例模式的替代方案.若只需一个简单的共享实例,类属性可以替代传统单例模式。 - - -2. 为什么要给 Category 添加的属性只有属性的 setter、getter,却没有成员变量和对应的 setter、getter 实现,为什么这么设计?存在什么优点 -为什么不能直接添加成员变量?运行时限制,Runtime 在程序加载时就确定了类的内存布局,包括成员变量的存储结构,如果允许在运行时动态添加成员变量,可能会破坏类的内存布局,导致兼容性问题 -编译时的静态性:成员变量的存储结构需要在编译时确定,而 Category 是一种运行时机制,无法在编译时修改类的结构 -设计哲学:OC 的设计哲学强调动态性,而动态性体现在方法上,而不是成员变量上。成员变量的静态性有助于保持类的稳定性和可预测性。 - -Objective-C 的 Category 设计允许动态添加方法,但不允许直接添加成员变量,这是基于运行时机制的限制和语言设计哲学的考虑。这种设计的优点在于灵活性和扩展性,同时避免了对类内存布局的破坏。通过关联对象等机制,开发者可以在 Category 中实现类似成员变量的功能,从而充分利用运行时的动态性。 - -## 关联对象的底层实现 - -技术实现的几个核心类: - -- AssociationsManager -- AssociationsHashMap -- ObjectAssociationMap -- ObjcAssociation - -查看 objc 源码 - -```c++ -// objc-runtime.mm -void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy) { - _object_set_associative_reference(object, (void *)key, value, policy); -} -``` - -简化版 - -```c++ -class AssociationsManager { - static AssociationsHashMap *_map; -} - -typedef DenseMap, ObjectAssociationMap> AssociationsHashMap; -typedef DenseMap ObjectAssociationMap; - -class ObjcAssociation { - uintptr_t _policy; - id _value; -} - -void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) { - // retain the new value (if any) outside the lock. - ObjcAssociation old_association(0, nil); - id new_value = value ? acquireValue(value, policy) : nil; - { - AssociationsManager manager; - AssociationsHashMap &associations(manager.associations()); - disguised_ptr_t disguised_object = DISGUISE(object); // 指针地址,按位取反 - // 新值有值 - if (new_value) { - // break any existing association. - // 从全局的 AssociationsHashMap 中根据对象地址按位取反后的结果为 key,查找对应的 ObjectAssociationMap - AssociationsHashMap::iterator i = associations.find(disguised_object); - // 如果找到对应的 ObjectAssociationMap - if (i != associations.end()) { - // secondary table exists - ObjectAssociationMap *refs = i->second; - // 从 ObjectAssociationMap 中,根据 key 查找有没有值 - ObjectAssociationMap::iterator j = refs->find(key); - if (j != refs->end()) { // 有值,则说明该 key 之前设置过关联对象,本次就做更新 - old_association = j->second; - j->second = ObjcAssociation(policy, new_value); - } else { // 没有值,则给 ObjectAssociationMap 中添加一个 ObjcAssociation 类型的值。ObjcAssociation 由 policy 和 newValue 构成 - (*refs)[key] = ObjcAssociation(policy, new_value); - } - } else { - // create the new association (first time). - // 如果没有找到则说明第一次为该对象设置关联对象,本次需要创建一个新的 ObjectAssociationMap - ObjectAssociationMap *refs = new ObjectAssociationMap; - // AssociationsHashMap 中以对象地址为 key,ObjectAssociationMap 为 value,新增一条数据 - associations[disguised_object] = refs; - // ObjectAssociationMap 中以关联对象传入的 key 为 key,value 为 ObjcAssociation 为 value 插入一条数据。ObjcAssociation 由 policy 和 newValue 组成 - (*refs)[key] = ObjcAssociation(policy, new_value); - object->setHasAssociatedObjects(); - } - } else { - // setting the association to nil breaks the association. - // 没有 newValue 则说明需要将 AssociationHashMap 中对象地址对应 ObjectAssociationMap 中 key 对应的的 value 移除 - AssociationsHashMap::iterator i = associations.find(disguised_object); - // 以对象地址为 key,从 AssociationsHashMap 中找到了 ObjectAssociationMap - if (i != associations.end()) { - ObjectAssociationMap *refs = i->second; - ObjectAssociationMap::iterator j = refs->find(key); - // 从 ObjectAssociationMap 中找到了 key 记录 - if (j != refs->end()) { - // 从 ObjectAssociationMap 中移除 ObjectAssociation - old_association = j->second; - refs->erase(j); - } - } - } - } - // release the old value (outside of the lock). - if (old_association.hasValue()) ReleaseValue()(old_association); -} - -``` - -梳理后,如下图所示: - - - -AssociationsManager 管理的 AssociationsHashMap 结构如下: - -```objective-c -{ - // Person 实例对象的地址 - "0x4927298732": { - "@selector(studyNumber)的地址" : { - "value": "2022122201", - "policy": "retain" - }, - "@selector(title)的地址": { - "value": "Hello category", - "policy": "retain" - } - }, - // UIView 实例对象的地址 - "0x3666444222": { - "@selector(backgroundColor)"的地址 : { - "value": "0xff0021", - "policy": "retain" - } - } -} -``` - - - -## Category 的使用场景 - -### 给现有类添加方法 - -拓展功能,最基础的 - - - -### 将类的实现代码分散到多个分类中 - -类中经常容易填满各种方法,而这些方法的代码则全部堆在一个巨大的实现文件中。可以通过 Category 机制,把类代码按照逻辑划分到几个分区中。 - -- 可以减少单个文件的体积 -- 可以把不同的功能组织到不同的category里 -- 可以由多个开发者共同完成一个类 -- 可以按需加载想要的category等等 - - - -比如 Person 类 - -```objective-c -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface Person : NSObject - -@property (nonatomic, copy, readonly) NSString *firstName; -@property (nonatomic, copy, readonly) NSString *lastName; -@property (nonatomic, copy, readonly) NSArray *friends; - -- (instancetype)initWithFirstName:(NSString *)firstName - andLastName:(NSString *)lastName; -#pragma mark - FriendShip methods -- (void)addFriend:(Person *)person; -- (void)removeFriends:(Person *)person; -- (BOOL)isFriendsWith:(Person *)person; - -#pragma mark - Work methods -- (void)performDaysWork; -- (void)takeVacationFromWork; - -#pragma mark - Play methods -- (void)goToTheCinema; -- (void)goToSportsGame; - -@end - -NS_ASSUME_NONNULL_END -``` - -拆分后 - -```objective-c -// Person.h -@interface Person : NSObject - -@property (nonatomic, copy, readonly) NSString *firstName; -@property (nonatomic, copy, readonly) NSString *lastName; -@property (nonatomic, copy, readonly) NSArray *friends; - -- (instancetype)initWithFirstName:(NSString *)firstName - andLastName:(NSString *)lastName; -@end - -// Person+FriendShip.h -@interface Person(FriendShip) -- (void)addFriend:(Person *)person; -- (void)removeFriends:(Person *)person; -- (BOOL)isFriendsWith:(Person *)person; -@end -// Person+Work.h -@interface Person(Work) -- (void)performDaysWork; -- (void)takeVacationFromWork; -@end -// Person+Play.h -@interface Person(Play) -- (void)goToTheCinema; -- (void)goToSportsGame; -@end -``` - -使用分类后,依然可以把整个类都定义在一个接口文件中,但把实现写到 Category 中。但如果分类越来越多或者觉得不合理,可以将相关 API 也放到 Category 的 `.h` 中。 - - - -### 声明私有方法 - - - - - -### UI 和点击事件处理的代码聚合 - -UIAlertView 的点击后的判断是通过 delegate 进行处理的。一个看上去看起来还好,但假如一个类里面存在多个 AlertView 呢。是不是就需要在代理方法里面判断 alertView 属于哪个,buttonIndex 属于哪个? UIAlertView 的视图创建和点击事件的处理放在2个地方,阅读起来不方便。 - -```objective-c -- (void)showAlertView { - UIAlertView *alertView = [UIAlertView alloc] initWithTitle:@"Question" message:@"What do you want to do?" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Continue", nil]; - [alertView show]; -} - -- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex { - if (buttonIndex == 0) { - [self handleCancel]; - } else { - [self handleContinue]; - } -} -``` - -接下去利用关联对象优化下代码 - -```objective-c -static void *AlertViewDialogHandlerKey = "AlertViewDialogHandlerKey"; - -- (void)showAlertView { - UIAlertView *alertView = [UIAlertView alloc] initWithTitle:@"Question" message:@"What do you want to do?" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Continue", nil]; - void(^clickHandler)(NSInteger index) = ^(NSInteger buttonIndex) { - if (buttonIndex == 0) { - [self handleCancel]; - } else { - [self handleContinue]; - } - }; - objc_setAssociatedObject(alertView, AlertViewDialogHandlerKey, clickHandler, OBJC_ASSOCIATION_COPY) - [alertView show]; -} - -- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex { - void(^clickHandler)(NSInteger buttonIndex) = objc_getAssociatedObject(self, AlertViewDialogHandlerKey); - clickHandler(buttonIndex); -} -``` - diff --git a/Chapter1 - iOS/1.49.md b/Chapter1 - iOS/1.49.md deleted file mode 100644 index c80fdca..0000000 --- a/Chapter1 - iOS/1.49.md +++ /dev/null @@ -1,979 +0,0 @@ -# MVC、MVP、MVVM - -## MVC 架构 - -MVC 模式下,软件被划分为视图(View:用户界面)、控制器(Controller:业务逻辑)、模型(Model:数据保存) - - - -![MVC架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-16-MVC.png) - -1. 用户操作 View,在 View 上面的事件都将被传递到 Controller 处理 -2. Controller 处理事件、请求网络,操作 Model 更新状态 -3. Model 将更新后的数据发送到 View,用户得到反馈 - -所有的通信都是单向的。 - - - -### Apple MVC 架构 - -效果等价于: - - - -最典型的就是 iOS 侧的 UITableView 的设计: - -- Controller 感知 View:UIViewController 通过 View 的形式持有 UITableView -- View 事件代理到 Controller:而 View 上的 UI 事件自身不处理,通过代理的形式交给 UIViewController 处理 -- Controller 感知 Model:UIViewController 负责从网络或者数据库中拉取数据,持有 Model。在合适的时机上,UIViewController 负责将 Model 数据交给 UIView 去展示(UITableView 的 cellforRow 代理方法中,cell.titleLabel.text = model.title 的形式展示上去) 。也就是说 View 无法感知 Model 的存在。 -- Model 的变化通过 Controller 传递到 View 上:View 的点击事件通过代理交给 Controller 处理,往往会涉及 Model 的变化。Model 变化后,还是不直接操作 View。依旧通过 Controller 操作 View 的接口,更新数据 - -优点: - -- View 可以重用。因为 View 不用感知 Model 的存在,View 展示的东西,外部通过访问修改 UI 控件属性的形式去实现。 - - ```objective-c - @interface GoodCell - @property (nonatomic, strong, readonly) UIImageView *goodsImageView; - @property (nonatomic, strong, readonly) UILabel *goodsNameLabel; - @end - - // GoodsListViewController - - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GoodsCell *cell = [tableView dequeueReusableCellWithIdentifier:GoodsCellID forIndexPath:indexPath]; - GoodsModel *model = self.goods[indexPath.row]; - cell.goodsImageView.image = [UIImage imageNamed:model.iamge]; - cell.goodsNameLabel.text = model.name; - return cell; - } - ``` - - - -- Model 可以重用。Model 也不用感知 View 的存在。 - -缺点: - -- Controller 过于臃肿。因为 View 和 Model 彼此独立,所以读取 Model 然后拼装数据,外部通过访问修改 UI 控件属性,负责展示 UI 的逻辑都放在 Controller 中,所以 Controller 就会很臃肿。 - - - -### MVC 架构变种 - - - -改变: - -- View 可以拥有 Model,感知 Model。一部分之前放在 Controller 里的逻辑,拆分到 View 里面了 - -优点: - -- 对 Controller 进行了瘦身,这符合“单一职责原则”,使 Controller 更专注于协调和业务逻辑。 - - 将 UI 配置逻辑内聚到 View 中,避免了 Controller 中冗长的控件操作代码(如 `cell.imageView.image = model.image;`) - -- 将 View 内部的细节进行了隐藏,更具备封装性。外界不知道 View 内部具体的实现(Apple MVC 会暴露 UI 控件,现在不用暴露了),更具封装性 - - ```objective-c - // GoodCell.h - @interface GoodCell - @property (nonatomic, strong) GoodsModel *model; - @end - - // GoodCell.m - @interface GoodCell() - @property (nonatomic, strong, readonly) UIImageView *goodsImageView; - @property (nonatomic, strong, readonly) UILabel *goodsNameLabel; - @end - - -(void)setModel:(GoodsModel *)model { - _model = model; - self.goodsImageView.image = [UIImage imageNamed:model.iamge]; - self.goodsNameLabel.text = model.name; - } - // GoodsListViewController - - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GoodsCell *cell = [tableView dequeueReusableCellWithIdentifier:GoodsCellID forIndexPath:indexPath]; - cell.model = self.goods[indexPath.row]; - return cell; - } - ``` - -缺点: - -- View 强依赖于 Model。 - -### 思考:View 强依赖于 Model 真的是缺点吗? - -这个缺点就真的是缺点吗?我一个商品的 GoodsView 展示,就需要展示商品图片、商品名称、商品原价、商品折扣价、销量等信息。单独暴露 UI 控件,然后访问的话很灵活也很独立,但工作量很大,为了 UI 的展示,让 View 持有 Model 真的是缺点吗? - - - -要回答是不是缺点,需要回答2个问题: - -1. 何时是合理的设计? - - - View 与 Model 高度绑定 - - 如果 GoodsCell 仅用于展示 GoodsModel,且2者生命周期一致,这种直接依赖反而合理,此时 View 与 Model 的耦合是**高内聚** 的体现 - - - 避免过度抽象 - - 强行解耦,为 View 抽象设计出一个通用接口,可能引入不必要的复杂度。比如为 GoodsCell 设计一个 `id` 协议,在仅有一个 Model 的场景下属于过度设计了 - -2. 何时可能成为问题? - - - 复用 View 展示不同 Model - - 如果希望 GoodsCell 复用展示其他数据(如 `DiscountGoodsModel`),之前那种 GoodsCell 依赖 GoodsModel 的设计,会导致导致代码难以拓展。此时可以考虑 `ViewModel` 模式或者**协议抽象**解耦 - - - Model 频繁变更 - - 如果 GoodsModel 的字段,比如 image 频繁变更,所有直接依赖它的 View 都需要同步修改,此时可以将 Model 到 View 的映射逻辑,迁移到独立的转换层,比如 `GoodsModelConverter` - -怎么样优化? - -1. 可将 Model 到 View 的映射逻辑移到 **独立的转换层**,隔离变化 - - 1. `GoodsModel` 结构如下: - - ```objective-c - // GoodsModel.h - @interface GoodsModel : NSObject - @property (nonatomic, copy) NSString *imageName; - @property (nonatomic, copy) NSString *name; - @property (nonatomic, assign) CGFloat price; - @end - ``` - - 现在需要将 `GoodsModel` 转换为 View 可直接使用的数据(如处理价格格式化、图片加载): - - 实现 GoodsModelConverter - - ```objective-c - // GoodsModelConverter.h - @interface GoodsModelConverter : NSObject - - // 将 GoodsModel 转换为字典(Key-Value 形式) - + (NSDictionary *)convertToDisplayData:(GoodsModel *)model; - - @end - - // GoodsModelConverter.m - @implementation GoodsModelConverter - - + (NSDictionary *)convertToDisplayData:(GoodsModel *)model { - // 处理价格格式化 - NSString *priceText = [NSString stringWithFormat:@"¥%.2f", model.price]; - - // 加载图片(假设 imageName 是本地资源名) - UIImage *image = [UIImage imageNamed:model.imageName]; - - return @{ - @"name": model.name, - @"price": priceText, - @"image": image - }; - } - - @end - ``` - - 修改 View 使用转换后的数据 - - ```objective-c - // GoodsCell.h - @interface GoodsCell : UITableViewCell - // 不再直接依赖 GoodsModel,而是通过字典传递数据 - - (void)configureWithDisplayData:(NSDictionary *)displayData; - @end - - // GoodsCell.m - @implementation GoodsCell { - UIImageView *_goodsImageView; - UILabel *_nameLabel; - UILabel *_priceLabel; - } - - - (void)configureWithDisplayData:(NSDictionary *)displayData { - _goodsImageView.image = displayData[@"image"]; - _nameLabel.text = displayData[@"name"]; - _priceLabel.text = displayData[@"price"]; - } - - @end - ``` - - Controller 调用 - - ```objective-c - // GoodsListViewController.m - - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GoodsCell *cell = [tableView dequeueReusableCellWithIdentifier:GoodsCellID forIndexPath:indexPath]; - GoodsModel *model = self.goods[indexPath.row]; - - // 通过 Converter 转换数据 - NSDictionary *displayData = [GoodsModelConverter convertToDisplayData:model]; - - [cell configureWithDisplayData:displayData]; - return cell; - } - ``` - - 如何应对 Model 的变化? - - 假设 GoodsModel 的 image 字段变为 remoteImageURL,则不需要每处修改 View 使用的地方,统一在 GoodsModelConverter 即可 - - ```objective-c - // GoodsModelConverter.m - + (NSDictionary *)convertToDisplayData:(GoodsModel *)model { - // 新增网络图片加载逻辑(伪代码) - UIImage *placeholderImage = [UIImage imageNamed:@"placeholder"]; - UIImage *image = placeholderImage; - - return @{ - @"name": model.name, - @"price": priceText, - @"image": image - }; - } - ``` - - View 和 Controller 无需任何修改。 - - - GoodsCell 仍然接收 displayData 字典,不用关心具体来源 - - Controller 仍然调用 convertToDisplayData 方法 - - 更优雅的方案是引入 ViewModel 对象。 - -2. 使用 ViewModel 模式 - - 将 Model 转换为 View 专用的数据结构,View 仅依赖 ViewModel: - - ```objective-c - // GoodsCellViewModel.h - @interface GoodsCellViewModel - @property (nonatomic, readonly) UIImage *image; - @property (nonatomic, readonly) NSString *name; - - (instancetype)initWithGoods:(GoodsModel *)goods; - @end - - // GoodsCell.h - @interface GoodsCell - @property (nonatomic, strong) GoodsCellViewModel *viewModel; - @end - ``` - - 优点: - - - View 与 Model 解耦,便于复用 - - ViewModel 可以封装数据转换逻辑(比如多语言本地化、图片加载、字符串格式化拼接) - -3. 通过抽象协议依赖 - - 定义 View 所需数据的协议,Model 实现该协议。 - - 定义 GoodsDisplayable 协议 - - ```objective-c - // GoodsDisplayable.h - @protocol GoodsDisplayable - @property (nonatomic, readonly) NSString *goodsImageName; - @property (nonatomic, readonly) NSString *goodsName; - @end - - // GoodsModel.h - @interface GoodsModel : NSObject - @end - - // GoodsCell.h - @interface GoodsCell - @property (nonatomic, strong) id displayData; - @end - ``` - - 在正常商品列表页面,Controller 逻辑为: - - ```objective-c - // GoodsListViewController.m - - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GoodsCell *cell = [tableView dequeueReusableCellWithIdentifier:GoodsCellID forIndexPath:indexPath]; - cell.displayData = self.goods[indexPath.row]; - return cell; - } - ``` - - 在打折商品列表页面,Controller 逻辑为: - - ```objective-c - // DiscountGoodsListViewController.m - - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - GoodsCell *cell = [tableView dequeueReusableCellWithIdentifier:GoodsCellID forIndexPath:indexPath]; - cell.displayData = self.goods[indexPath.row]; - return cell; - } - ``` - -现在我们可以尝试概括性回答该问题了: - -**View 持有 Model 是否是缺点,取决于具体场景:** - -- **单一用途、无复用需求**:直接依赖 Model 是合理选择,简化代码且无过度设计。 -- **需复用或 Model 不稳定**:通过 ViewModel 或协议解耦,提高灵活性。 - -最终,**没有绝对的最佳实践,只有适合场景的权衡**。Apple 的 MVC 变种在简单场景下有效,但在复杂场景需结合其他模式优化。 - - - - -## MVP 架构 - -MVP 模式将 Controller 改名为 Presenter,通信改变了通信方向 - -![MVP架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MVPArchStructure.png) - -1. 各部分之间的通信都是双向的 -2. Model 与 View 不发生联系,都通过 Presenter 传递 -3. View 层非常薄。不部署任何业务逻辑,称为“被动视图(Passive View)”,即没有任何主动性 -4. 而 Presenter 非常厚,所有的逻辑都部署在这层。比如在 Presenter 里组装 View 展示到 Controller 的 View 上,加载数据,展示到 View 上。 -5. 如果 Presenter 这一层很厚的话,可以继续拆,比如再增加一个 Interactor 的角色,专门处理处理 View UI 响应事件。这样子角色更多,职责也更清晰。维护也方便。 - - - -### 错误实现 - -举个例子: - -App 只有一个个人中心 ViewController,其 View 上只有1个展示个人信息的子 View,子 View 上只有1个展示头像 UIImageView 和 1个展示昵称的 UILabel。用 MVP 的思想实现如下 - -模型为 - -```objective-c -@interface PersonalInfoModel : NSObject -@property (copy, nonatomic) NSString *name; -@property (copy, nonatomic) NSString *image; -@end - -``` - -关键就是要增加一个 `Presenter` 的角色,负责组装 View 展示到 Controller 的 View 上,加载数据,展示到 View 上。 - -```objective-c -// PersonInfoPresenter.h -@interface PersonInfoPresenter : NSObject -- (instancetype)initWithController:(UIViewController *)controller; -@end - -// PersonInfoPresenter.m -@interface PersonInfoPresenter() -@property (weak, nonatomic) UIViewController *controller; -@end - -@implementation PersonInfoPresenter - -- (instancetype)initWithController:(UIViewController *)controller -{ - if (self = [super init]) { - self.controller = controller; - - // 创建View - PersonalInfoView *personalInfoView = [[PersonalInfoView alloc] init]; - personalInfoView.frame = CGRectMake(100, 100, 100, 150); - personalInfoView.delegate = self; - [controller.view addSubview:personalInfoView]; - - // 加载模型数据 - PersonalInfoModel *model = [[PersonalInfoModel alloc] init]; - model.name = @"杭城小刘"; - model.image = @"UnixKernel"; - - // 赋值数据 - [personalInfoView setName:app.name andImage:app.image]; - } - return self; -} - -#pragma mark - MJAppViewDelegate -- (void)personalInfoViewDidClick:(PersonalInfoView *)personalInfoView -{ - NSLog(@"presenter 监听了个人信息 View 的点击事件"); -} -``` - -PersonalInfoView - -```objective-c -#import - -@class PersonalInfoView; - -@protocol PersonalInfoViewDelegate -@optional -- (void)personalInfoViewDidClick:(PersonalInfoView *)personalInfoView; -@end - -@interface PersonalInfoView : UIView -- (void)setName:(NSString *)name andImage:(NSString *)image; -@property (weak, nonatomic) id delegate; -@end - - -@implementation MJAppView -- (instancetype)initWithFrame:(CGRect)frame { - if (self = [super initWithFrame:frame]) { - UIImageView *iconView = [[UIImageView alloc] init]; - iconView.frame = CGRectMake(0, 0, 100, 100); - [self addSubview:iconView]; - _iconView = iconView; - - UILabel *nameLabel = [[UILabel alloc] init]; - nameLabel.frame = CGRectMake(0, 100, 100, 30); - nameLabel.textAlignment = NSTextAlignmentCenter; - [self addSubview:nameLabel]; - _nameLabel = nameLabel; - } - return self; -} - -- (void)setName:(NSString *)name andImage:(NSString *)image { - _iconView.image = [UIImage imageNamed:image]; - _nameLabel.text = name; -} - -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - if (self.delegate && [self.delegate respondsToSelector:@selector(personalInfoViewDidClick:)]) { - [self.delegate personalInfoViewDidClick:self]; - } -} - -@end -``` - -在 ViewController 的使用 - -```objective-c -@interface ViewController () -@property (strong, nonatomic) PersonInfoPresenter *presenter; -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - self.presenter = [[PersonInfoPresenter alloc] initWithController:self]; -} - -@end -``` - -目前实现的效果是: - -- Presenter 负责了 View 的创建与展示,同时调用 Model 的能力,从数据库或者网络加载业务数据,然后更新到 View 上。使得 Controller 逻辑更加单一 -- 将大部分 View 的组装逻辑从 Controller 中抽离到 Presenter 中,实现了 Controller 的瘦身 -- Model 和 View 无法感知对方,也无法直接访问,保证了 Model 和 View 的复用能力 - - - -### 正确实现 - -目前的实现中,Presenter 中拥有了 View,对于 Presenter 的单元测试不好展开,该如何修改? - -- View 还是放在 Controller 中创造 -- 为了解耦,将 View UI 事件和数据获取抽成对应的协议 -- Model 依旧是薄薄的一层,数据获取放在 Service 里进行 - -定义 View 刷新协议 - -```objective-c -// PersonalInfoViewProtocol.h -@protocol PersonalInfoViewProtocol -- (void)displayName:(NSString *)name image:(NSString *)image; -@end -``` - -View 实现协议 - -```objective-c -// PersonalInfoView.h -@interface PersonalInfoView : UIView -@property (nonatomic, weak) id delegate; -@end - -// PersonalInfoView.m -@implementation PersonalInfoView -// 原有代码不变,但实现 displayName:image: 方法 -- (void)displayName:(NSString *)name image:(NSString *)image { - _iconView.image = [UIImage imageNamed:image]; - _nameLabel.text = name; -} - -- (void)didClickProfileView { - if (self.delegate && [self.delegate respondsToSelector:@selector(personalInfoViewDidClick:)]) { - [self.delegate personalInfoViewDidClick:self]; - } -} -@en -``` - -定义数据拉取协议 - -```objective-c -// PersonalInfoServiceProtocol.h -@protocol PersonalInfoServiceProtocol -- (void)fetchPersonalInfoWithCompletion:(void (^)(PersonalInfoModel *model))completion; -@end -``` - -定义数据拉取 Services - -```objective-c -// PersonalInfoService.h -@interface PersonalInfoService : NSObject -@end - -// PersonalInfoService.m -@implementation PersonalInfoService -- (void)fetchPersonalInfoWithCompletion:(void (^)(PersonalInfoModel *))completion { - // 模拟网络请求 - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - PersonalInfoModel *model = [[PersonalInfoModel alloc] init]; - model.name = @"杭城小刘"; - model.image = @"UnixKernel"; - dispatch_async(dispatch_get_main_queue(), ^{ - completion(model); - }); - }); -} -@end -``` - -重构 Presenter。loadData 数据加载成功后,调用其 View 的代理方法,刷新 UI - -```objective-c -// PersonInfoPresenter.h -@interface PersonInfoPresenter : NSObject -- (instancetype)initWithView:(id)view - service:(id)service; -- (void)loadData; -@end - -// PersonInfoPresenter.m -@interface PersonInfoPresenter() -// 重要地方:遵循相应协议的 View 对象 -@property (nonatomic, weak) id view; -// 重要地方:遵循相应协议的 Services 对象 -@property (nonatomic, strong) id service; -@end - -@implementation PersonInfoPresenter - -- (instancetype)initWithView:(id)view - service:(id)service { - if (self = [super init]) { - _view = view; - _service = service; - } - return self; -} - -- (void)loadData { - [self.service fetchPersonalInfoWithCompletion:^(PersonalInfoModel *model) { - [self.view displayName:model.name image:model.image]; - }]; -} - -#pragma mark - 点击事件处理(可选) -- (void)handleViewClick { - NSLog(@"Presenter 处理点击事件"); -} -@end -``` - -Controller 里组装和创建 Presenter,在 `viewDidLoad` 里面调用 presenter 的 loadData 方法,让其内部的 Services 加载网络数据。 - -```objc -// ViewController.m -@interface ViewController () -@property (nonatomic, strong) PersonInfoPresenter *presenter; -@property (nonatomic, strong) PersonalInfoView *infoView; -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - // 创建 View - self.infoView = [[PersonalInfoView alloc] initWithFrame:CGRectMake(100, 100, 100, 150)]; - self.infoView.delegate = self; - [self.view addSubview:self.infoView]; - - // 注入依赖 - id service = [[PersonalInfoService alloc] init]; - self.presenter = [[PersonInfoPresenter alloc] initWithView:self.infoView service:service]; - - // 触发数据加载 - [self.presenter loadData]; -} - -#pragma mark - PersonalInfoViewDelegate -- (void)personalInfoViewDidClick:(PersonalInfoView *)view { - [self.presenter handleViewClick]; -} -@end -``` - - - -关键改动点: - -1. **Presenter 不再持有 View**:通过协议与 View 通信,避免直接依赖具体类。 -2. **数据加载抽象为 Service**:Presenter 通过协议调用 Service,便于模拟不同场景。 -3. **布局职责回归 View**:Presenter 不再设置 `frame`,保证单一职责。 -4. **依赖注入**:Presenter 的 Service 和 View 通过初始化注入,测试时可替换为 Mock。 - - - -思考:如何一个复杂的购物车页面包含很多子 View,按照 MVP 模式,该怎么处理? - -- **模块化拆分**:每个功能单元独立为 View-Presenter-Service 组合 - - 可以把复杂的购物车 view 拆分为独立的几个 View。比如: - - - 商品列表 GoodsListView、商品列表 GoodsListPresenter、商品列表 GoodsListServices - - 商品统计信息 GoodsSummaryInfoView、商品统计信息 GoodsSummaryInfoPresenter、商品统计信息 GoodsSummaryInfoServices - - 营销活动展示 GoodsPromoptionView、营销活动展示 GoodsPromoptionPresenter、营销活动展示 GoodsPromoptionServices - - 每个 View 设计自己对应的 Presenter - -- Controller 依旧复杂 View 的组装,和各个 Presenter 的创建工作 - -- **协调机制**:使用 Coordinator 或响应式编程管理跨模块事件 - -- 其他流程和单个 View、单个 Presenter 没啥不同。区别在于复杂的业务下,Controller 会存在多个 View、多个 Presenter - - - -## MVVM 架构 - -MVVM 模式将 Presenter 改名为 ViewModel,基本上与 MVP 模式完全一致。 - -![MVVM架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MVVMArchStructure.png) - -区别在于:采用双向绑定的模式。View 的改变,自动反应在 ViewModel 上。 ViewModel 的改变会发应在 View 上。 - - -MVC 等到业务逻辑很复杂的时候被称为 Massive View Controller (重量级视图控制器)。这样的 Controller 后期维护看着阅读成本很高、不易于测试、维护。当时写这个代码的人离职后,几千行规模的代码在后期添加新功能或者修改 Bug 都是一件折磨人的事情。 - -![典型MVC架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-16-iOSMVC.png) - -看到 View 和 ViewController 是不同的技术组件,但是日常开发中它们总是成对的存在,为什么不考虑将他们的连接正规化呢? - -![存在问题](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-16-VController-Model.png) - -典型的 MVC 存在弊端就是 Controller 层非常复杂,很多逻辑都在里面,包括一些不是逻辑的“表示逻辑”(presentation logic)。用 MVVM 术语来说就是将那些 Model 数据转换为 View 可以呈现的东西。例如将一个 NSDate 格式化为一个“2018-11-17” 这样的 NSString。 - - -上图中缺少一个环节,一个专门用来处理所有的表示逻辑。称为 “ViewModel”。位于 ViewController 与 Model 之间。 - -![MVVM](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-16-iOSmvvm.png) - -MVVM 就是 MVC 的增强版,将展示层逻辑单独拎出来,即 ViewModel。iOS 使用 MVVM 可以降低 ViewController 的复杂性并使得表示逻辑易于测试。 - - -- MVVM 兼容当下的 MVC 机构 -- MVVM 增加应用的可测试性 -- MVVM 配合一个绑定机制效果最好 - - - -### **MVVM 核心改造点** - -1. **引入 ViewModel**:负责数据转换、业务逻辑,通过数据流驱动 UI 更新 -2. **数据双向绑定**:使用 ReactiveCocoa(RAC)建立 View 和 ViewModel 的绑定关系 -3. **事件命令化**:用户交互事件封装为 Command,由 ViewModel 处理 - - - -改造如下: - -定义 ViewModel(核心) - -```objc -// PersonalInfoViewModel.h -#import - -@class PersonalInfoModel; - -@interface PersonalInfoViewModel : NSObject - -// 输出属性(供 View 绑定) -@property (nonatomic, strong, readonly) RACSignal *nameSignal; -@property (nonatomic, strong, readonly) RACSignal *imageSignal; - -// 输入命令(处理 View 事件) -@property (nonatomic, strong, readonly) RACCommand *viewDidClickCommand; - -// 初始化方法(依赖注入) -- (instancetype)initWithService:(id)service; - -@end - -// PersonalInfoViewModel.m -@interface PersonalInfoViewModel() - -@property (nonatomic, strong) id service; -@property (nonatomic, strong) RACSubject *nameSubject; -@property (nonatomic, strong) RACSubject *imageSubject; - -@end - -@implementation PersonalInfoViewModel - -- (instancetype)initWithService:(id)service { - if (self = [super init]) { - _service = service; - _nameSubject = [RACSubject subject]; - _imageSubject = [RACSubject subject]; - - // 暴露信号 - _nameSignal = _nameSubject; - _imageSignal = _imageSubject; - - // 初始化命令 - [self setupCommands]; - [self loadData]; - } - return self; -} - -#pragma mark - 初始化命令 -- (void)setupCommands { - @weakify(self); - _viewDidClickCommand = [[RACCommand alloc] - initWithSignalBlock:^RACSignal *(id input) { - @strongify(self); - NSLog(@"ViewModel 处理点击事件"); - return [RACSignal empty]; - }]; -} - -#pragma mark - 数据加载 -- (void)loadData { - @weakify(self); - [self.service fetchPersonalInfoWithCompletion:^(PersonalInfoModel *model) { - @strongify(self); - // 更新数据流 - [self.nameSubject sendNext:model.name]; - [self.imageSubject sendNext:[UIImage imageNamed:model.image]]; - }]; -} - -@end -``` - -改造 View - -```objective-c -// PersonalInfoView.h(协议不再需要) -@interface PersonalInfoView : UIView - -// 暴露 UI 组件用于绑定 -@property (nonatomic, strong) UIImageView *iconView; -@property (nonatomic, strong) UILabel *nameLabel; - -// 绑定 ViewModel -- (void)bindViewModel:(PersonalInfoViewModel *)viewModel; - -@end - -// PersonalInfoView.m -#import - -@implementation PersonalInfoView - -- (instancetype)initWithFrame:(CGRect)frame { - // ... 原有初始化代码不变 ... -} - -- (void)bindViewModel:(PersonalInfoViewModel *)viewModel { - // 绑定数据到 UI - RAC(self.nameLabel, text) = [viewModel.nameSignal deliverOnMainThread]; - RAC(self.iconView, image) = [viewModel.imageSignal deliverOnMainThread]; - - // 绑定点击事件到 Command - @weakify(viewModel); - [self addGestureRecognizer:[[UITapGestureRecognizer alloc] - initWithTarget:self action:@selector(handleTap)]]; - [[self rac_signalForSelector:@selector(handleTap)] - subscribeNext:^(id x) { - @strongify(viewModel); - [viewModel.viewDidClickCommand execute:nil]; - }]; -} - -- (void)handleTap { - // 空方法,仅用于触发 RAC 信号 -} - -@end -``` - -改造 ViewController(胶水、组装) - -```objective-c -// ViewController.m -@interface ViewController () -@property (nonatomic, strong) PersonalInfoView *infoView; -@property (nonatomic, strong) PersonalInfoViewModel *viewModel; -@end - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - // 创建 View - self.infoView = [[PersonalInfoView alloc] initWithFrame:CGRectMake(100, 100, 100, 150)]; - [self.view addSubview:self.infoView]; - - // 创建 Service 和 ViewModel - id service = [[PersonalInfoService alloc] init]; - self.viewModel = [[PersonalInfoViewModel alloc] initWithService:service]; - - // 绑定 ViewModel - [self.infoView bindViewModel:self.viewModel]; -} - -@end -``` - - - -## 一个简单的例子 - -PersonModel -```objective-c -@interface Person : NSObject - -- (instancetype)initwithSalutation:(NSString *)salutation firstName:(NSString *)firstName lastName:(NSString *)lastName birthdate:(NSDate *)birthdate; - -@property (nonatomic, readonly) NSString *salutation; -@property (nonatomic, readonly) NSString *firstName; -@property (nonatomic, readonly) NSString *lastName; -@property (nonatomic, readonly) NSDate *birthdate; - -@end -``` -PersonViewController -```objective-c -- (void)viewDidLoad { - [super viewDidLoad]; - - if (self.model.salutation.length > 0) { - self.nameLabel.text = [NSString stringWithFormat:@"%@ %@ %@", self.model.salutation, self.model.firstName, self.model.lastName]; - } else { - self.nameLabel.text = [NSString stringWithFormat:@"%@ %@", self.model.firstName, self.model.lastName]; - } - - NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; - [dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"]; - self.birthdateLabel.text = [dateFormatter stringFromDate:model.birthdate]; -} -``` -上面是标准的 MVC。现在我们考虑用 ViewModel 增加改进下 - -PersonViewModel -```objective-c -@interface PersonViewModel : NSObject - -- (instancetype)initWithPerson:(Person *)person; - -@property (nonatomic, readonly) Person *person; - -@property (nonatomic, readonly) NSString *nameText; -@property (nonatomic, readonly) NSString *birthdateText; -- (instancetype)initWithPerson:(Person *)person; -@end - - -@implementation PersonViewModel - -- (instancetype)initWithPerson:(Person *)person { - self = [super init]; - if (!self) return nil; - - _person = person; - if (person.salutation.length > 0) { - _nameText = [NSString stringWithFormat:@"%@ %@ %@", self.person.salutation, self.person.firstName, self.person.lastName]; - } else { - _nameText = [NSString stringWithFormat:@"%@ %@", self.person.firstName, self.person.lastName]; - } - - NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; - [dateFormatter setDateFormat:@"EEEE MMMM d, yyyy"]; - _birthdateText = [dateFormatter stringFromDate:person.birthdate]; - - return self; -} - -@end -``` - -此时,我们的 ViewController 会很轻量 -```objective-c -- (void)viewDidLoad { - [super viewDidLoad]; - self.nameLabel.text = self.viewModel.nameText; - self.birthdateLabel.text = self.viewModel.birthdateText; -} -``` -可测试?View Controller 是出了名的难测试。因为传动的 VC 很重,在 MVVM 的世界里,代码各司其职,测试 View Controller 变得较为容易 -```objective-c -SpecBegin(Person) - NSString *salutation = @"Dr."; - NSString *firstName = @"first"; - NSString *lastName = @"last"; - NSDate *birthdate = [NSDate dateWithTimeIntervalSince1970:0]; - - it (@"should use the salutation available. ", ^{ - Person *person = [[Person alloc] initWithSalutation:salutation firstName:firstName lastName:lastName birthdate:birthdate]; - PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person]; - expect(viewModel.nameText).to.equal(@"Dr. first last"); - }); - - it (@"should not use an unavailable salutation. ", ^{ - Person *person = [[Person alloc] initWithSalutation:nil firstName:firstName lastName:lastName birthdate:birthdate]; - PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person]; - expect(viewModel.nameText).to.equal(@"first last"); - }); - - it (@"should use the correct date format. ", ^{ - Person *person = [[Person alloc] initWithSalutation:nil firstName:firstName lastName:lastName birthdate:birthdate]; - PersonViewModel *viewModel = [[PersonViewModel alloc] initWithPerson:person]; - expect(viewModel.birthdateText).to.equal(@"Thursday January 1, 1970"); - }); -SpecEnd -``` - - - -## VIPER 架构 - -`View-Interactor-Presenter-Entity-Routing` ,一种基于单一职责原则和清晰的模块界限的架构模式,包含五个主要组成部分: - -- View(视图层):负责显示用户界面和接收用户输入。它将用户输入传递给 Presenter -- Presenter(演示者):将从 View 层获取指令转到 Interactor 处理并接收处理后的数据,并将其格式化为适合 View 显示的格式,然后将这些数据传递给 View 去显示更新 -- Interactor(交互器):负责处理具体的业务逻辑。它获取数据(可能来自网络、数据库等),处理业务规则和数据,并将结果传递给Presenter -- Entity(实体层):应用的基本数据对象,类似 MVC 架构中的 Model 层,例如数据库对象或网络请求的 JSON 对象等 -- Routing(路由层):负责页面之间的导航逻辑跳转 - -VIPER 架构的优点在于模块职责划分清晰,模块间的耦合度低,特别适合大项目的开发, 其主要缺点是由于层的划分较多,增加了代码复杂性,对于小型项目而言可能会显得过度设计 - -这么看来 VIPER 很像前端中 Redux 的设计: - -- VIPER 相比 Redux 简化了 UI 事件 Action 和 ActionCreator 这个角色 -- Interactor 做的事情类似 Reducer ,都会根据对应的事件类型,判断如何处理数据。区别在于前端约定 Reducer 的实现必须是纯函数 - -除了角色不同外,VIPER 和 Redux 对于 UI 展示和事件响应、处理的整个流程很类似。所以可以理解为「 VIPER 是 Redux 在客户端的简易实现」。 - diff --git a/Chapter1 - iOS/1.5.md b/Chapter1 - iOS/1.5.md deleted file mode 100644 index 0fcbb0b..0000000 --- a/Chapter1 - iOS/1.5.md +++ /dev/null @@ -1,93 +0,0 @@ -# 事件响应者链 - - -实验1: - -定义 BaseView,在里面实现方法touchBegan,监听当前哪个类调用了该方法。 - -在控制器的界面上加5个颜色不同的view,每个view自定义view去实现,因此在不同的view上的手势就可以由不同的view拦截到。 - - - -![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 -//BaseView -#import "BaseView.h" - -@implementation BaseView --(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - NSLog(@"%@",[self class]); -} -``` - -结果:点击不同的View打印出不同的类名。 - -结论: - -* 触摸事件是从父控件传递到子控件的。 -* 点击了绿色(图上的2级)的view:UIApplication-> UIWindow -> UIViewController的view -> 绿色的view -* 点击了蓝色(图上的3级)的view:UIApplication-> UIWindow -> UIViewController的view -> 红棕色的view -> 蓝色的view -* 点击了黄色(图上的4级)的view:UIApplication -> UIWindow -> UIViewController的view -> 红棕色的view -> 蓝色的view -> 黄色的view - -注意:如果父控件不能接收触摸事件,那么这个父控件的子控件也不能接收触摸事件 - -#### 如何找到最合适的控件来接收触摸事件? - -* 自己能否接收触摸事件? -* 触摸点是否在自己身上? -* 从后往前遍历子控件,重复前面2个步骤 -* 如果没有符合条件的子控件,那么就自己最适合处理 - - -# 事件响应原理 - -产生的touch方法的默认做法是将事件顺着响应者链条向上传递,将事件交给上一个响应者处理。 - -#### 响应者链条 - -![响应者链条](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/reponseChain.png) - -#### 事件传递的完整过程 - -1. 先将事件对象由上往下传递(父控件传递给子控件),找到最合适的控件来处理 -2. 调用最合适控件的touch方法 -3. 如果调用了\[super touches...\]方法就会将事件顺着响应者链条向上传递,传递给上一个响应者 -4. 接着就会调用上一个响应者的touches...方法 - -#### 事件响应者 - -##### 如何判断该控件的上一个响应者? - -1. 如果当前这个view是控制器的view,那么上一个响应者就是控制器 -2. 如果当前这个view不是控制器的view,那么上一个响应者就是父控件。 - -事件传递给UIApplication后如果不处理的话,该事件会销毁掉。 - -控制器view上的子控件的touch...方法如果子控件不处理那么都会顺着响应者链条向上传递给上一层响应者对象,比如可以交给控制器处理。 - - - -## 手饰事件和点击事件的响应顺序 - -假如给某个 view 所在的父视图添加了手饰识别器。 - -**手势识别器的优先级**:如果你将 `UITapGestureRecognizer` 添加到了视图上,UIKit 会首先尝试识别手势。如果视图上添加了多个手势识别器,它们的识别顺序将根据它们被添加到视图的顺序或者它们的 `delaysTouchesBegan` 和 `delaysTouchesEnded` 属性来决定。 - - - -想要子 view 响应事件而不是被根视图拦截,则需要给手势识别器添加代理,实现代理方法 - -```objective-c -UIGestureRecognizer *gesture; -gesture.delegate = self; - -- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { - return NO; -} -``` - - - -如果 `UITapGestureRecognizer` 识别了一个手势,它可以通过设置 `cancelsTouchesInView` 属性为 `YES` 来取消视图上的触摸事件,这样点击事件就不会被进一步传递到视图控制器的 `touchesBegan` 或 `touchesEnded` 方法。 - diff --git a/Chapter1 - iOS/1.50.md b/Chapter1 - iOS/1.50.md deleted file mode 100644 index c7a4a16..0000000 --- a/Chapter1 - iOS/1.50.md +++ /dev/null @@ -1,25 +0,0 @@ -# 动态库和静态库 - -通常,我们的 Xcode 工程会依赖一些第三方库,包括:.a 静态库(Static Library)和 .framework 动态库(Dynamic Library)。 - -不过简单地把 .framework 后缀的文件称为“动态库”并不严谨,因为在 iOS/macOS 开发中,framework 又分为 “静态 framework” 和 “动态 framework”,区别如下: - -* 静态 framework:可以理解为是 .a 静态文件 + .h 公共头文件 + 资源文件 的集合,本质上与 .a 静态库是一致的; - -* 动态 framework:即真正意义上的动态库,一般包括动态二进制文件、头文件和资源文件等。 - -对于一个 Static Library 工程,其编译产物为 .a 静态二进制文件 + 公共 .h 头文件; - -对于一个 Framework 工程,其编译的最终产物是动态库还是静态库,我们可以通过在 Build Settings -> Linking -> Mach-O Type 中进行选择设置其值为 Dynamic Library 或者 Static Library。 - -此外,我们知道,对于一个 Mach-O 二进制文件,不管是 static 还是 dynamic,一般都包含了几种不同的处理器架构(Architectures),例如:i386, x86_64, armv7, armv7s, arm64 等。 - -Xcode 在编译链接时,对于静态库和动态库的处理方式是不同的。 - -对于静态库,在链接时(Linking Time),Xcode 会自动筛选出静态库中的不同 architecture 合并到对应处理器架构的主可执行二进制文件中;而在打包归档(Archive)时,Xcode 会自动忽略掉静态库中未用到的 architecture,例如会移除掉 i386, x86_64 等 Mac 上模拟器专用的架构。 - -而对于动态库,在编译打包时,Xcode 会直接拷贝整个动态 framework 文件到最终的 .ipa 包中,只有在 App 真正启动运行时,才会进行动态链接。但是苹果是不允许最终上传到 App Store Connect 后台的 .ipa 文件包含 i386, x86_64 等模拟器架构的,会报 Invalid 错误,所以对于工程中的动态 framework,我们在打 Release 正式包时,一般会通过执行命令或者脚本的方式移除掉这些 Invalid Architectures。 - -最后,如何在 Xcode 工程中添加这些静态/动态库呢? - -对于 “.a 静态库” 和 “静态 framework” ,直接拖拽到工程中,并勾选 Copy if needed 选项即可,无需其他设置。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.51.md b/Chapter1 - iOS/1.51.md deleted file mode 100644 index e392867..0000000 --- a/Chapter1 - iOS/1.51.md +++ /dev/null @@ -1,280 +0,0 @@ -# cocoapods 相关小技巧 - -## 1. 组件的地址 - -我们在做组件化的时候经常将一些业务模块封装打包,做成 pod 管理的形式,然后当在开发的时候需要修改一些模块化的代码。 - -当维护好组件的时候我们可能在一个新的工程设置好 podfile 引入组件,但是有可能需要继续修改组件的源代码,代码需要可编辑。所以我们可能需要将 Podfile 中的 pod 源修改为本地。 -然后安装 pod install 后就可以看到在项目文件里面有可编辑的组件代码 - -``` -pod 'GoodsCategoryModule', :path => '../GoodsCategoryModule' -#pod 'GoodsCategoryModule',:git => 'git@gitlab.xxx.com:forntend_ios/GoodsCategoryModule.git',:branch => 'release/Appstore' -``` - -注意点: -1. 我们本地的组件源代码需要和 pod 文件中的代码工程文件名称一致 -2. 注释掉远端仓库的地址 -path 后面是相对路径 - -## 2. 组件库使用 pch 头文件 - -```Objective-c -///一个区分开发和线上环境的Log。NSLog的本质是调用对象方法的 description 方法,所以线上代码使用NSLog会造成性能和安全问题 -#ifdef DEBUG - #define SafeLog(...) NSLog(__VA_ARGS__) -#else - #define SafeLog(...) -#endif -``` - -所以我们需要对各个组件库里面的 **NSLog** 进行改造,变成 **SafeLog**。没做特殊处理,当你在 pod 库的 pch 文件写好代码,发现主工程执行 pod install 之后,看到之前写的 SafeLog 代码不见了。所以我们需要指定 pch 文件。 -操作: -- 新建 **PrefixHeader.h** 头文件 -- 在当前 pod 库的 ***.podspec 文件中写如下代码 -```Ruby - s.prefix_header_contents = '#import "PrefixHeader.h"' -``` -说明:该代码的作用相当于将 PrefixHeader.h 文件写入到当前 pod 库 XQTriggerKit-prefix.pch 文件的最后一行。相当于 -```Objective-c -#ifdef __OBJC__ -#import -#else -#ifndef FOUNDATION_EXPORT -#if defined(__cplusplus) -#define FOUNDATION_EXPORT extern "C" -#else -#define FOUNDATION_EXPORT extern -#endif -#endif -#endif - -#import "PrefixHeader.h" -``` - -缺点:项目存在多个 pod 组件库。主工程修改 pch 还好,但是每个 pod 库都去新建 PrefixHeader.h 文件和 podspec 文件添加一行代码。工作量非常大。 - -改进方案:对 NSLog 进行 Hook。 - -步骤: -- 引入 fishhook 库 -- 新建 SafeLog.h 和 SafeLog.m 文件 -- 在 SafeLog.m 文件的 load 方法中对 NSLog 进行 hook,判断是 Debug 环境就打印。代码如下 - -```Objective-c -#import "SafeLog.h" -#import "fishhook.h" - -// orig_NSLog是原有方法被替换后 把原来的实现方法放到另一个地址中 -// new_NSLog就是替换后的方法了 -static void (*orig_NSLog)(NSString *format, ...); - -@implementation SafeLog - -void(new_NSLog)(NSString *format, ...) -{ - va_list args; - if (format) { - va_start(args, format); - NSString *message = [[NSString alloc] initWithFormat:format arguments:args]; - #ifdef DEBUG - orig_NSLog(@"%@", message); - #endif - va_end(args); - } -} - -+ (void)load -{ - rebind_symbols((struct rebinding[1]){ {"NSLog", new_NSLog, (void *)&orig_NSLog} }, 1); -} - -@end -``` - -## 3. CocoPods 指定版本号带 ~> 与不带的区别 - -- 带 ~> 是指库版本号的一个范围。大于等于指定的版本号,小于高一位的版本号 -- 不带 ~> 是指固定的版本号 - -``` -pod 'aaa', '~> 0.1.2' // 大于等于 0.1.2 且小于 0.2 -pod 'bbb', '1.1' 版本号指定为 1.1 - -``` - -## 4. 查看当前 Pod 库被何处引用 - -出于某种原因经常会需要查看当前 Pod 库被何处引用了(你需要修改组件库A,然后A修改完之后,可能需要在依赖组件库A的地方去修改版本号。这时候就需要查询了,例子可能不优雅,但是确实有需要查询引用的情况),有轮子 - -- 先下载所需要的库 -```Shell -gem install reversepoddependency -``` -- 利用脚本在我们的组件库 repo 中去查询 -``` -specbackwarddependency /Users/liubinpeng/.cocoapods/repos/51xianqu-xq_specs(本地CocoPod repo地址) xq_baaidumapkit(组件库名称) -``` - -鉴于这个命令比较长,且本人使用的是 iTerm2+Zsh,所以将命令写入到 .zshrc 文件中, source 编译链接过可以快速使用。例如 `repoanalysis xq_baaidumapkit` -```Shell -alias repoanalysis='specbackwarddependency /Users/liubinpeng/.cocoapods/repos/51xianqu-xq_specs' -``` - -![Pod组件库依赖分析](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-09-PodComponentAnalysis.png) - -具体的操作步骤可以参考我的这个[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/第六部分%20开发杂谈/6.11.md) - - -## 5. lint 的时候安装一些神奇的依赖 - - -在对 App 做应用包瘦身的时候发现了一些问题。某个组件库 lint 的时候通过终端的信息,发现安装了一些不是 podspec 里面指定的依赖仓库。百思不得其解,同事说可能是之前的某个版本依赖了这些项目。有了这个思路就好办事了。 - -![遇到问题](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-13-Cocopod-Lint-Cache.png) - -- 打开本地的 `/Users/liubinpeng/.cocoapods/repos` 文件夹。查看本地的私有 repo 的管理的所有的项目,找到出问题的 repo,进去删掉有问题的 tag。 -- 出问题的 repo 将远端的有问题的 tag 也删掉 -- 删除远端的 repo 仓库的有问题的 tag。 - -## 6. lint 失败 - 报错为 `error: invalid task ('StripNIB...` -```Shell -Build system information - error: invalid task ('StripNIB /Users/liubinpeng/Library/Developer/Xcode/DerivedData/App-fgbnpsgtrtstroctiqnanvyrfwyr/Build/Products/Release-iphonesimulator/XQLoginModule/SDGMemberCardBindViewController.nib') with mutable output but no other virtual output node (in target 'XQLoginModule') -``` -原因为 xib 和图片资源都属于资源文件,不可以放在源文件(Classes)中,需要放在 Assets 中。如果放到 Classes 文件夹中 lint 会报错。 - -## 7. 一台电脑安装了最新版本的,出问题删除最新版 Xcode,下载旧版本 Xcode,pod install 失败 - -```Shell -You need at least git version 1.8.5 to use CocoaPods -``` -- 可能是cocoapods安装成功了,但是链接Xcode的版本过低,所以需要更新Xcode -- 电脑安装了多个版本的Xcode,就需要修改链接Xcode路径,改成链接电脑比较高版本的Xcode。 - ```Shell - sudo xcode-select -switch /Applications/Xcode.app/Contents/Developer - ``` -## 8. 一台电脑安装了最新版本的,出问题删除最新版 Xcode,下载旧版本 Xcode,打开工程 Xib 报错 - -- sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService -- xcrun simctl erase all - - -## 9. 开发电脑上安装了旧版本 Cocopods,Mac 系统升级后, pod lint 失败 - -解决方案: 将 **fourflusher** 仓库中上的 [find.rb]((https://raw.githubusercontent.com/CocoaPods/fourflusher/master/lib/fourflusher/find.rb)) 文件中的 ruby 脚本复制到本地的 fourflusher 下. - -查找本地 fourflusher 文件夹所在位置. -```Shell -gem which fourflusher -``` -我的电脑 find.rb 文件所在位置. -`/Library/Ruby/Gems/2.6.0/gems/fourflusher-2.3.1/lib/fourflusher/find.rb` - -注意: 文件保存需要权限,所以加 sudo - -## 10. pod lint 产生的信息太多,一屏显示不全,但是出错之后我们可能需要去查看 error 信息,上下翻页不方便 - -解决方案: 利用脚本 ` >1.log 2>&1` 将当前的 pod lint 产生的信息写入文件. - -完整代码 - -```Ruby - pod lib lint --sources=****,**** --allow-warnings --verbose --use-libraries >1.log 2>&1 -``` - - -## 11. pod 库每次修改代码,主工程必须 clean 再安装才可以看到新改动的代码 - -解决方案: -```ruby -install! 'cocoapods', :disable_input_output_paths => true -``` - -## 12. pod 库太多,每次构建编译都很耗费时间 - -```ruby -install! 'cocoapods', generate_multiple_pod_projects: true -``` - -## 13. 卸载旧版本 cocoapods 安装新的 - -```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 -``` - -## 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. `pod lib create` 报错 `Ignoring ffi-1.16.3 because its extensions are not built` - -开始可能发现错误 - -Ignoring ffi-1.16.3 because its extensions are not built. Try: gem pristine ffi --version 1.16.3 -类似这样的错误 -``` -sudo gem install --user-install rexml -sudo gem install --user-install xcodeproj -``` - diff --git a/Chapter1 - iOS/1.52.md b/Chapter1 - iOS/1.52.md deleted file mode 100644 index 7de21b7..0000000 --- a/Chapter1 - iOS/1.52.md +++ /dev/null @@ -1,566 +0,0 @@ -# 开发效率提升利器 - -> 软件的生命周期贯穿产品的开发,测试,生产,用户使用,版本升级和后期维护等过程,只有易读,易维护的软件代码才具有生命力。 - -## 一、思路 - -最近重构项目组件,看到项目中存在一些命名和方法分块方面存在一些问题,结合平时经验和 [Apple官方代码规范](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CodingGuidelines/CodingGuidelines.html) 在此整理出 iOS 工程规范。提出第一个版本,如果后期觉得有不完善的地方,继续提出来不断完善,文档在此记录的目的就是为了大家的代码可读性较好,后来的人或者团队里面的其他人看到代码可以不会因为代码风格和可读性上面造成较大时间的开销。 - -先梳理出规范,然后使用一些脚本的方式,提高大家使用的便捷性与效率,最后开发一些协作脚本。 - -## 二、一些原则 - -1. 长的,描述性的方法和变量命名是好的。不要使用简写,除非是一些大家都知道的场景比如 VIP。不要使用 bgView,推荐使用 backgroundView - -2. 见名知意。含义清楚,做好不加注释代码自我表述能力强。(前提是代码足够规范) - -3. 不要过分追求技巧,降低代码可读性 - -4. 删除没必要的代码。比如我们新建一个控制器,里面会有一些不会用到的代码,或者注释起来的代码,如果这些代码不需要,那就删除它,留着偷懒吗?下次需要自己手写 - -5. 在方法内部不要重复计算某个值,适当的情况下可以将计算结果缓存起来 - -6. 尽量减少单例的使用。 - -7. 提供一个统一的数据管理入口,不管是 MVC、MVVM、MVP 模块内提供一个统一的数据管理入口会使得代码变得更容易管理和维护。 - -8. 除了 .m 文件中方法,其他的地方"{"不需要另起一行。 - ```Objective-c -- (void)getGooodsList - { - ... - } - -- (void)doHomework - { - if (self.hungry) { - - return; - - } - if (self.thirsty) { - - return; - - } - if (self.tired) { - - return; - - } - papapa.then.over; - } - - ``` - - ``` - -### 变量 - -1. 一个变量最好只有一个作用,切勿为了节省代码行数,觉得一个变量可以做多个用途。(单一原则) -2. 方法内部如果有局部变量,那么局部变量应该靠近在使用的地方,而不是全部在顶部声明全部的局部变量。 - -### 运算符 - -1. 1元运算符和变量之间不需要空格。例如:++n -2. 2元运算符与变量之间需要空格隔开。例如: containerWidth = 0.3 * Screen_Width -3. 当有多个运算符的时候需要使用括号来明确正确的顺序,可读性较好。例如: 2 << (1 + 2 * 3 - 4) - -### 条件表达式 - -1. 当有条件过多、过长的时候需要换行,为了代码看起来整齐些 - - ``` - //good - if (condition1() && - condition2() && - condition3() && - condition4()) { - // Do something - } - //bad - if (condition1() && condition2() && condition3() && condition4()) { // Do something } - ``` -2. 在一个代码块里面有个可能的情况时善于使用 `return` 来结束异常的情况。 - ``` -- (void)doHomework - { - if (self.hungry) { - - return; - - } - if (self.thirsty) { - - return; - - } - if (self.tired) { - - return; - - } - papapa.then.over; - } - ``` -3. 每个分支的实现都必须使用 {} 包含。 - - ``` - // bad - if (self.hungry) self.eat() - // good - if (self.hungry) { - self.eat() - } - ``` -4. 条件判断的时候应该是变量在左,条件在右。 if ( currentCursor == 2 ) { //... } -5. switch 语句后面的每个分支都需要用大括号括起来。 -6. switch 语句后面的 default 分支必须存在,除非是在对枚举进行 switch。 - - ``` - switch (menuType) { - case menuTypeLeft: { - ... - break; - } - case menuTypeRight: { - ... - break; - } - case menuTypeTop: { - ... - break; - } - case menuTypeBottom: { - ... - break; - } - } - ``` - -### 类名 - -1. 大写驼峰式命名。每个单词首字母大写。比如「申请记录控制器」ApplyRecordsViewController -2. 每个类型的命名以该类型结尾。 - - ViewController:使用 `ViewController` 结尾。例子:ApplyRecordsViewController - - View:使用 `View` 结尾。例子:分界线:boundaryView - - NSArray:使用 `s` 结尾。比如商品分类数据源。categories - - UITableViewCell:使用 `Cell` 结尾。比如 MyProfileCell - - Protocol:使用 `Delegate` 或者 `Datasource` 结尾。比如 XQScanViewDelegate - - Tool:工具类 - - 代理类:Delegate - - Service 类:Service - -### 类的注释 - -有时候我们需要为我们创建的类设置一些注释。我们可以在类的下面添加。 - -### 枚举 - -枚举的命名和类的命名相近。 - -``` -typedef NS_ENUM(NSInteger, UIControlContentVerticalAlignment) { - UIControlContentVerticalAlignmentCenter = 0, - UIControlContentVerticalAlignmentTop = 1, - UIControlContentVerticalAlignmentBottom = 2, - UIControlContentVerticalAlignmentFill = 3, -}; -``` - -### 宏 - -1. 全部大写,单词与单词之间用 `_` 连接。 -2. 以 `K` 开头。后面遵循大写驼峰命名。「不带参数」 - -``` -#define HOME_PAGE_DID_SCROLL @"com.xq.home.page.tableview.did.scroll" -#define KHomePageDidScroll @"com.xq.home.page.tableview.did.scroll" -``` - -### 属性 - -书写规则,基本上就是 `@property 之后空一格,括号,里面的 线程修饰词、内存修饰词、读写修饰词,空一格 类 对象名称` -根据不同的场景选择合适的修饰符。 - -``` -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, assign, readonly) BOOL loading; -@property (nonatomic, weak) id<#delegate#> delegate; -@property (nonatomic, copy) <#returnType#> (^<#Block#>)(<#parType#>); -``` - -### 单例 - -单例适合全局管理状态或者事件的场景。一旦创建,对象的指针保存在静态区,单例对象在堆内存中分配的内存空间只有程序销毁的时候才会释放。基于这种特点,那么我们类似 UIApplication 对象,需要全局访问唯一一个对象的情况才适合单例,或者访问频次较高的情况。我们的功能模块的生命周期肯定小于 App 的生命周期,如果多个单例对象的话,势必 App 的开销会很大,糟糕的情况系统会杀死 App。如果觉得非要用单例比较好,那么注意需要在合适的场合 tearDown 掉。 - -单例的使用场景概括如下: - -- 控制资源的使用,通过线程同步来控制资源的并发访问。 -- 控制实例的产生,以达到节约资源的目的。 -- 控制数据的共享,在不建立直接关联的条件下,让多个不相关的进程或线程之间实现通信。 - -```objective-c -+ (instancetype)sharedInstance -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - //because has rewrited allocWithZone use NULL avoid endless loop lol. - _sharedInstance = [[super allocWithZone:NULL] init]; - }); - - return _sharedInstance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone -{ - return [TestNSObject sharedInstance]; -} - -+ (instancetype)alloc -{ - return [TestNSObject sharedInstance]; -} - -- (id)copy -{ - return self; -} - -- (id)mutableCopy -{ - return self; -} - -- (id)copyWithZone:(struct _NSZone *)zone -{ - return self; -} -``` - -### 私有变量 - - 推荐以 `_` 开头,写在 .m 文件中。例如 NSString * _somePrivateVariable - -### 代理方法 - -1. 类的实例必须作为方法的参数之一。 -2. 对于一些连续的状态的,可以加一些 will(将要)、did(已经) -3. 以类的名称开头 - -```objective-c -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath; - -- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath; -``` - -### 方法 - -1. 方法与方法之间间隔一行 -2. 大量的方法尽量要以组的形式放在一起,比如生命周期函数、公有方法、私有方法、setter && getter、代理方法.. -3. 方法最后面的括号需要另起一行。遵循 Apple 的规范 -4. 对于其他场景的括号,括号不需要单独换行。比如 if 后面的括号。 -5. 如果方法参数过多过长,建议多行书写。用冒号进行对齐。 -6. 一个方法内的代码最好保持在50行以内,一般经验来看如果一个方法里面的代码行数过多,代码的阅读体验就很差(别问为什么,做过重构代码行数很长的人都有类似的心情) -7. 一个函数只做一个事情,做到单一原则。所有的类、方法设计好后就可以类似搭积木一样实现一个系统。 -8. 对于有返回值的函数,且函数内有分支情况。确保每个分支都有返回值。 -9. 函数如果有多个参数,外部传入的参数需要检验参数的非空、数据类型的合法性,参数错误做一些措施:立即返回、断言。 -10. 多个函数如果有逻辑重复的代码,建议将重复的部分抽取出来,成为独立的函数进行调用 - -```objective-c -- (instancetype)init -{ - self = [super init]; - if (self) { - <#statements#> - } - return self; -} - -- (void)doHomework:(NSString *)name - period:(NSInteger)second - score:(NSInteger)score; -``` - -11. 方法如果有多个参数的情况下需要注意是否需要介词和连词。很多时候在不知道如何抉择测时候思考下苹果的一些 API 的方法命名。 - ```objective-c - //good -- (instancetype)initWithAge:(NSInteger)age name:(NSString *)name; - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath; - -//bad - -- (instancetype)initWithAge:(NSInteger)age andName:(NSString *)name; - -- (void)tableView:(UITableView *)tableView :(NSIndexPath *)indexPath; - ``` -12. `.m` 文件中的私有方法需要在顶部进行声明 -13. 方法组之间也有个顺序问题。 -- 在文件最顶部实现属性的声明、私有方法的声明(很多人省去这一步,问题不大,但是蛮多第三方的库都写了,看起来还是会很方便,建议书写)。 -- 在生命周期的方法里面,比如 viewDidLoad 里面只做界面的添加,而不是做界面的初始化,所有的 view 初始化建议放在 getter 里面去做。往往 view 的初始化的代码长度会比较长、且一般会有多个 view 所以 getter 和 setter 一般建议放在最下面,这样子顶部就可以很清楚的看到代码的主要逻辑。 -- 所有button、gestureRecognizer 的响应事件都放在这个区域里面,不要到处乱放。 - -文件基本上就是 - -```objective-c -//___FILEHEADER___ - -#import "___FILEBASENAME___.h" -/*ViewController*/ - -/*View&&Util*/ - -/*model*/ - -/*NetWork InterFace*/ - -/*Vender*/ - -@interface ___FILEBASENAMEASIDENTIFIER___ () - -@end - -@implementation ___FILEBASENAMEASIDENTIFIER___ - - -#pragma mark - life cycle -- (void)viewWillAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; -} - -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - self.title = <#value#>; -} - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewDidAppear:animated]; - -} - -- (void)viewDidDisappear:(BOOL)animated -{ - [super viewDidAppear:animated]; - -} - -#ifdef DEBUG -- (void)dealloc -{ - NSLog(@"%s",__func__); -} -#endif - -#pragma mark - public Method - -#pragma mark - private method - -#pragma mark - event response - - - -#pragma mark - UITableViewDelegate - -#pragma mark - UITableViewDataSource -//...(多个代理方法依次往下写) - -#pragma mark - getters and setters - -@end -``` - -### 图片资源 - -1. 单个文件的命名 - 文件资源的命名也需要一定的规范,形式为:功能模块名_类别_功能_状态@nx.png - Setting_Button_search_selected@2x.png、Setting_Button_search_selected@3x.png - Setting_Button_search_unselected@2x.png、Setting_Button_search_unselected@3x.png -2. 资源的文件夹命名 - 最好也参考 App 按照功能模块建立对应的实体文件夹目录,最后到对应的目录下添加相应的资源文件。 - -### 注释 - -1. 对于类的注释写在当前类文件的顶部 -2. 对于属性的注释需要写在属性后面的地方。 //** 2.0.0 | -| a.**B**.c | 属于小部分内容的更新 | 1.0.2 -> 1.1.1 | -| a.b.**C** | 属于补丁更新 | 1.0.2 -> 1.0.3 | - -### 改进 - -我们知道了平时在使用 Xcode 开发的过程中使用的系统提供的代码块所在的地址和新建控制器、模型、view等的文件模版的存放文件夹地址后,我们就可以设想下我们是否可以定制自己团队风格的控制器模版、是否可以打造和维护自己团队的高频使用的代码块? - -答案是可以的。 - -Xcode 代码块的存放地址:`~/Library/Developer/Xcode/UserData/CodeSnippets` -Xcode 文件模版的存放地址:/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/Xcode/Templates/File Templates/ - -### 意义 - -1. 为了个人或者团队开发者的代码更加规范。Property的书写的时候的空格、线程修饰词、内存修饰词的先后顺序 -2. 提供大量可用的代码块,提高开发效率。比如在 Xcode 里面敲 UITableView_init 便可以自动懒加载创建一个 UITabelView 对象,你只需要设置在指定的位置写相应的参数 -3. 通过一些代码块提高代码规范、避免一些bug。比如曾看到过 block 属性用 strong 修饰的代码,造成内存泄漏。举个例子你在 Xcode 中输入 **Property_delegate** 就会出来 `@property (nonatomic, weak) id<<#delegate#>> delegate;`,你输入 **Property_block** 就会出来 `@property (nonatomic, copy) <#returnType#> (^<#Block#>)(<#parType#>);` - -## 三、 代码块的改造 - -我们可以将属性、控制器生命周期方法、单例构造一个对象的方法、代理方法、block、GCD、UITableView 懒加载、UITableViewCell 注册、UITableView 代理方法的实现、UICollectionVIew 懒加载、UICollectionVIewCell 注册、UICollectionView 的代理方法实现等等组织为 codesnippets - -### 思考 - -- 封装好 codesnippets 之后团队除了你编写这个项目的人如何使用?如何知道是否有这个代码块? - - 方案:先在团队内召开代码规范会议,大家都统一知道这个事情在。之后大家共同维护 codesnippets。用法见下 - - 属性:通过 **Property_类型** 开头,回车键自动补全。比如 Strong 类型,编写代码通过 Property_Strong 回车键自动补全成如下格式 - -```objective-c -@property (nonatomic, strong) <#Class#> *<#object#>; -``` - - 方法:以 **Method_关键词** 回车键确认,自动补全。比如 Method_UIScrollViewDelegate 回车键自动补全成 如下格式 - -```objective-c -#pragma mark - UIScrollViewDelegate -- (void)scrollViewDidScroll:(UIScrollView *)scrollView { - -} -``` - - 各种常见的 Mark:以 **Mark_关键词** 回车确认,自动补全。比如 Method_MethodsGroup 回车键自动补全成 如下格式 - -```objective-c -#pragma mark - life cycle -#pragma mark - public Method -#pragma mark - private method -#pragma mark - event response -#pragma mark - UITableViewDelegate -#pragma mark - UITableViewDataSource -#pragma mark - getters and setters -``` - -- 封装好 codesnippets 之后团队内如何统一?想到一个方案,可以将团队内的 codesnippets 共享到 git,团队内的其他成员再从云端拉取同步。这样的话团队内的每个成员都可以使用最新的 codesnippets 来编码。 - - 编写 shell 脚本。几个关键步骤: - - 1. 给系统文件夹授权 - 2. 在脚本所在文件夹新建存放代码块的文件夹 - 3. 将系统文件夹下面的代码块复制到步骤2创建的文件夹下面 - 4. 将当前的所有文件提交到 Git 仓库 - -## 四、文件模版的改造 - -我们观察系统文件模版的特点,和在 Xcode 新建文件模版对应。 - -![Xcode file template存放地址](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190304_filetemplates.png) - -所以我们新建 Custom 文件夹,将系统 Source 文件夹下面的 Cocoa Touch Class.xctemplate 复制到 Custom 文件夹下。重命名为我们需要的名字,我这里以“Power”为例 - -![自定义文件模版示例](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190319-filetemplateSelf.png) - -进入 PowerViewController.xctemplate/PowerViewControllerObjective-C - -修改 `___FILEBASENAME___.h` 和 `___FILEBASENAME___.m` 文件内容 - -![注意点1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190304-fileTmplates3.png) - -在替换 .h 文件内容的时候后面改为 UIViewController,不然其他开发者新建文件模版的时候出现的不是 UIViewController 而是我们的 PowerViewController - -![.m文件内容](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190304_filetemplates4.png) - -修改 TemplateInfo.plist - -![plist注意点](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190319-filetemplate5.png) - -思考: - -- 如何使用 - - 商量好一个标识(“Power”)。比如我新建了单例、控制器、Model、UIView、UITableViewCell、UICollectionViewCell6个模版,都以为 Power 开头。 - - ![模版用法](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20190319-filetemplate6.png) - -- 如何共享 - - 以 shell 脚本为工具。使用脚本将 git 云端的代码模版同步到本地 Xcode 文件夹对应的位置就可以使用了。关键步骤: - - 1. git clone 代码到脚本所在文件夹 - 2. 进入存放 codesnippets 的文件夹将内容复制到系统存放 codesnippets 的地方 - 3. 进入存放 file template 的文件夹将内容复制到系统存放 file template 的地方 - -## 六、使用 - -### 1. Xcode 中开发 - -- Property 属性。敲 **Property_** 自动联想,光标移动选中后敲回车自动补全 - -- Mark 标识。 敲 **Mark_** 自动联想,会展示各种常用的 Mark,光标移动选中后敲回车自动补全 - -- Method 方法。敲 **Method_** 自动联想,会展示各种常用的 Method,光标移动选中后敲回车自动补全 - -- GCD。敲 **GCD_** 自动联想,会展示各种常用的 GCD,光标移动选中后敲回车自动补全 - -- 常用 UI 控件的懒加载。敲 **_init** 自动联想,展示常用的 UI 控件的懒加载,光标移动选中后敲回车自动补全 - -- Delegate。敲 **Delegate_** 自动联想,会展示各种常用的 Delegate,光标移动选中后敲回车自动补全 - -- Notification。敲 **NSNotification_** 自动联想,会展示各种常用的 NSNotification 的代码块,比如发送通知、添加观察者、移除观察者、观察者方法的实现等等,光标移动选中后敲回车自动补全 - -- Protocol。敲 **Protocol_** 自动联想,会展示各种常用的 Protocol 的代码块,光标移动选中后敲回车自动补全 - -- 内存修饰代码块 - -- 工程常用 TODO、FIXME、Mark。敲 **Mark_** 自动联想,会展示各种常用的 Mark 的代码块,光标移动选中后敲回车自动补全 - -- 内存修饰代码块。敲 **Memory_** 自动联想,会展示各种常用的内存修饰的代码块,光标移动选中后敲回车自动补全 - -- 一些常用的代码块。敲 **Thread_** 等自动联想,选中后敲回车自动补全。 - -### 2. Code Snippet 同步 - -你可能是代码块的创建者,也可能是使用方,使用的时候直接先给脚本赋权 - -``` -chmod +x ./syncSnippets.sh // 为脚本设置可执行权限 -./syncSnippets.sh // 同步git云端代码块和文件模版到本地 -``` - -如果自己有新的代码块创建,觉得不错,想同步到远端 github repo,则可以使用下面的命令 - -```shell -chmod +x ./uploadMySnippets.sh // 为脚本设置可执行权限 -./uploadMySnippets.sh //将本地的代码块和文件模版同步到云端 -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-1008-CodeSnippetMethodGroup.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-1008-CodeSnippetProperty.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-1008-CodeSnippetLazyLoad.png) - -## PS - -**不断完善中。大家有好用或者不错的代码块或者文件模版希望参与到这个项目中来,为我们开发效率的提升添砖加瓦、贡献力量** - -目前新建了大概58个代码段和6个类文件模版(UIViewController控制器带有方法组、模型、线程安全的单例模版、带有布局方法的UIView模版、UITableViewCell、UICollectionViewCell模版) - -shell 脚本基本有每个函数和关键步骤的代码注释,想学习 shell 的人可以看看代码。[代码传送门](https://github.com/FantasticLBP/codesnippets) diff --git a/Chapter1 - iOS/1.53.md b/Chapter1 - iOS/1.53.md deleted file mode 100644 index 94d52a0..0000000 --- a/Chapter1 - iOS/1.53.md +++ /dev/null @@ -1,17 +0,0 @@ - -# 数据持久化方案 - -## 功能 -主要将一些网络获取下来的数据保存到 App 本地,增强用户体验、减小网络请求的次数。 - - -| 方案 | 存储量 | 数据类型 | 数据载体 | 特点 | 场景 | 缺点 | -|:-:| :-: | :-: | :-: | :-:| :-:| :-: | -| plist | 适合存储小数据量、并且属于一类的列表类的数据 | 基本数据类型、不支持存储自定义的对象 | 直接在plist文件上操作(可见) | 量小、不常变动| 省市列表、职场工作分类| 所有数据都存放在 root dictionary 里,每次都需要将整个 dictiona 读取出来访问所需的对象。数据较大的时候很费时间和空间 | -| 归档 | 存储数据量较大的数据对象 | 遵循NSCoding、NSCopying协议的对象| 必须依赖NSKeyedArchieve、NSUnKeyedArchieve的类方法或者对象方法进行存储 | 存储较为麻烦,需要实现对应的协议。归档需要实现`-(void)encodeWithCoder:(NSCoder*)aCoder;`解码需要实现`-(id)initWithCoder:(NSCoder*)aDecoder;`。除了遵循NSCoding协议外,对象要实现复制,需要实现`-(id)copyWithZone:(NSZone *)zone;` | 存储一些较小的数据 | 无法存储较大的数据和高效的查找 | -| NSUserDefault(偏好设置) | 适合存储数据量较少,有时也会存储一些比如状态开关的标准性值 | 存储OC所有的数据类型| 实例对象、基本数据 | 利用| App应用程序的配置信息、如版本号、app名称、用户权限、标志键值等| 无法存储自定义的对象 | -| 沙盒存储 | 存储较大量数据量的数据 | 基本存储 NSData 类型的数据| 文件 |依赖NSFileManager进行文件的写入和读取,过程较为复杂。Application:存放程序源代码,上架前经过数字签名,上架后不可修改。Documents:保存App运行时生成的需要持久化的数据,iTunes同步设备会同步该目录。例如将游戏数据保存到该目录下。tmp:保存App运行时产生的临时数据,程序结束将文件从该目录删除。iTunes同步设备不会同步该目录。Library/Caches:保存应用运行时生成的需要持久化的数据,iTunes同步设备时不会同步该目录。一般体积大、不需要备份的非重要数据,比如网络数据的缓存。Library/Preference:保存应用的偏好设置,比如OS的设置应用会在该目录下查找用户的设置信息。iTunes同步设备会同步该目录 | 图片、音视频。比如SDWebImage的文件缓存| 缓存太多,文件体积会非常大 | -| 数据库 | 存储大型数据量的数据 | OC所有的数据类型| 数据库文件 | 数据的增删改查,较为强大的数据库批量处理指令,SQL| 几乎每个大型App都有自己的数据库,比如微信、微博,为了较好的用户体验在每个小细节都有数据库技术| 需要新建数据库、建立连接、处理数据、关闭数据库连接。也不支持自定义的对象存储 | - - -## 2个概念:Relation、Object diff --git a/Chapter1 - iOS/1.54.md b/Chapter1 - iOS/1.54.md deleted file mode 100644 index 3f7aba2..0000000 --- a/Chapter1 - iOS/1.54.md +++ /dev/null @@ -1,74 +0,0 @@ -# 自定义工程中的头文件信息 - -> 我们打开 Xcode 工程的时候新建的文件顶部的信息非常的少且不是我们需要展示信息,看到很多的 GitHub 项目的顶部的头信息还是非常的花哨,所以在此记录如何写自定义模版的文章。 - -## 现状 - -``` -// -// MASLayoutConstraint.h -// Masonry -// -// Created by Jonas Budelmann on 3/08/13. -// Copyright (c) 2013 Jonas Budelmann. All rights reserved. -// -``` - -## 目标 - -``` -// -// SDGFasterEncoder.h -// XQ_Persistance -// -// Author: @杭城小刘 -// Github: https://github.com/FantasticLBP -// E-mail: wsbglbp@outlook.com -// -// Created by 杭城小刘 on 2019/1/23 -// -``` - -## 动手实现 - -我们利用 Xcode9 新特性,自定义文本宏,来实现上述的需求。 - - -### 步骤 - -1. 创建 .plist 文件 -2. 添加宏名称:FILEHEADER -3. 添加宏对应的值,即自定义的注释格式 -4. 将文件放置于起作用的文件目录下 - - 选中项目的 **.xcodeproj 文件 - - 显示包内容 - - 进入 xcshareddata 文件夹 - - 将之前完成的 IDETemplateMacros.plist 复制到xcshareddata 下面和 xcschemes 的同级目录 - - - 打开 XQ_Persistance.xcworkspace - - 显示包内容 - - 进入 xcuserdata 文件夹 - - 将 IDETemplateMacros.plist 复制进去,生效 - -### 模版 - -``` - - - - - FILEHEADER - -// ___FILENAME___ -// ___PACKAGENAME___ -// -// Author: @杭城小刘 -// Github: https://github.com/FantasticLBP -// E-mail: wsbglbp@outlook.com -// -// Created by 杭城小刘 on ___DATE___ -// - - - -``` diff --git a/Chapter1 - iOS/1.55.md b/Chapter1 - iOS/1.55.md deleted file mode 100644 index 508294a..0000000 --- a/Chapter1 - iOS/1.55.md +++ /dev/null @@ -1,761 +0,0 @@ -# 无痕埋点的设计与实现 - - -在移动互联网时代,对于每个公司、企业来说,用户的行为数据非常重要。重要到什么程度,用户在这个页面停留多久、点击了什么按钮、浏览了什么内容、什么手机、什么网络环境、App什么版本等都需要清清楚楚。一些大厂的蛮多业务成果都是基于用户操作行为进行推荐后二次转换。另一方面是以日志的作用帮助开发者分析线上问题的一种辅助手段。 - -那么有了上述的诉求,那么技术人员如何满足这些需求?引出来了一个技术点-“埋点” - - - -## 前置说明 - -看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。 - -- 埋点 SDK 叫 `UserAnalysis`,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 `UA` -- 给 Category 命名,规则为 `类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 `NSDate+ua_TimeStamp` -- 给 Category 的方法命名,规则为 `SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 `+ (long long)ua_currentTimestamp;` - - - -## 一. 埋点手段 - -业界中对于代码埋点主要有3种主流的方案:代码手动埋点、可视化埋点、无痕埋点。简单说说这几种埋点方案。 - -- 代码手动埋点:根据业务需求(运营、产品、开发多个角度出发)在需要埋点地方手动调用埋点接口,上传埋点数据。 -- 可视化埋点:通过可视化配置工具完成采集节点,在前端自动解析配置并上报埋点数据,从而实现可视化“无痕埋点” -- 无痕埋点:通过技术手段,完成对用户行为数据无差别的统计上传的工作。后期数据分析处理的时候通过技术手段筛选出合适的数据进行统计分析。 - - - -## 二. 技术选型 - - - -### 1. 代码手动埋点 - - 该方案情况下,如果需要埋点,则需要在工程代码中,写埋点相关代码。因为侵入了业务代码,对业务代码产生了污染,显而易见的缺点是**埋点的成本较高**、且违背了**单一原则**。 - - 例1:假如你需要知道用户在点击“购买按钮”时的相关信息(手机型号、App版本、页面路径、停留时间、动作等等),那么就需要在按钮的点击事件里面去写埋点统计的代码。这样明显的弊端就是在之前业务逻辑的代码上面又多出了埋点的代码。由于埋点代码分散、埋点的工作量很大、代码维护成本较高、后期重构很头痛。 - - 例2:假如 App 采用了 Hybrid 架构,当 App 的第一版本发布的时候 H5 的关键业务逻辑统计是由 Native 定义好关键逻辑(比如H5调起了Native的分享功能,那么存在一个分享的埋点事件)的桥接。假如某天增加了一个扫一扫功能,未定义扫一扫的埋点桥接,那么 H5 页面变动的时候,Native 埋点代码不去更新的话,变动的 H5 的业务就未被精确统计。 - - 优点:产品、运营工作量少,对照业务映射表就可以还原出相关业务场景、数据精细无须大量的加工和处理 - - 缺点:开发工作量大、前期需要和运营、产品指定的好业务标识,以便产品和运营进行数据统计分析 - - - -### 2. 可视化埋点 - - **可视化埋点的出现,是为解决代码埋点流程复杂、成本高、新开发的页面(H5、或者服务端下发的 json 去生成相应页面)不能及时拥有埋点能力** - - 前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到 view 的xpath 过程。 - -用户每次操作的控件,都生成一个 **xpath** 字符串,然后通过接口将 xpath 字符串(view在前端系统中的唯一定位。以 iOS 为例,App名称、控制器名称、一层层view、同类型view的序号:`GoodCell.21.RetailTableView.GoodsViewController.***App` 到真正的业务模块(“某App-商城控制器-分销商品列表-第21个商品被点击了”)的映射关系上传到服务端。xpath 具体是什么在下文会有介绍。 - -之后操作 App 就生成对应的 xpath 和埋点数据(开发者通过技术手段将从服务端获取的关键数据塞到前端的 UI 控件上。 iOS 端为例, UIView 的 `accessibilityIdentifier` 属性可以设置我们从服务端获取的埋点数据)上传到服务端。 - - 优点:数据量相对准确、后期数据分析成本低 - - 缺点:前期控件的唯一识别、定位都需要额外开发;可视化平台的开发成本较高;对于额外需求的分析可能会比较困难 - - - -### 3. 无痕埋点 - - 通过技术手段无差别地记录用户在前端页面上的行为。可以正确的获取 PV、UV、IP、Action、Time 等信息。 - - 缺点:前期开发统计基础信息的技术产品成本较高、后期数据分析数据量很大、分析成本较高(大量数据传统的关系型数据库压力大) - - 优点:开发人员工作量小、数据全面、无遗漏、产品和运营按需分析、支持动态页面的统计分析 - - - -### 4. 如何选择 - -结合上述优缺点,我们选择了**无痕埋点+可视化埋点结合**的技术方案。 - -怎么说呢?对于关键的业务开发结束上线后、通过可视化方案(类似于一个界面,想想看 Dreamwaver,你在界面上拖拖控件,简单编辑下就可以生成对应的 HTML 代码)点击一下绑定对应关系到服务端。 - -那么这个对应关系是什么?我们需要唯一定位一个前端元素,那么想到的办法就是不管 Native 和 Web 前端,控件或者元素来说就是一个树形层级,DOM tree 或者 UI tree,所以我们通过技术手段定位到这个元素,以 Native iOS 为例子,假如点击商品详情页的加入购物车按钮会根据 UI 层级结构生成一个唯一标识 `addCartButton.GoodsViewController.GoodsView.*BaoApp`。但是用户在使用 App 的时候,上传的是这串东西的 MD5 到服务端。 - -这么做有2个原因:服务端数据库存储这串很长的东西不是很好;埋点数据被劫持的话直接看到明文不太好。所以 MD5 再上传。 - - - -## 三. 操刀就干 - -一言以蔽之就是:**AOP -> Event Collector -> Event Cache -> Data Upload** -- AOP:通过 runtime hook 的能力做到提供合适的时机去生成点击事件的数据 -- Event Collector:将步骤1产生的数据统一收集(一般是一个内存存储的数据结构) -- Event Cache:mmap,当内存中的数据达到一定的阀值,或者应用程序的生命周期切换的时候将内存中的数据同步到缓存中(数据库、磁盘、文件) -- Data Uploader:制定一定的策略,当达到触发条件的时候再去上传数据(App达到阀值,生命周期的切换等);App从前台进入后台的时候去上传数据(后台线程保活策略);数据上传格式的选择(zip压缩文件、protoBuf) - -### 1. 数据的收集 - -实现方案由以下几个关键指标: - -- 现有代码改动少、尽量不要侵入业务代码去实现拦截系统事件 -- 全量收集 -- 如何唯一标识一个控件元素 - - - -### 2. 不侵入业务代码拦截系统事件 - -以 iOS 为例。我们会想到 **AOP(Aspect Oriented Programming)**面向切面编程思想。动态地在函数调用前后插入相应的代码,在 Objective-C 中我们可以利用 Runtime 特性,用 **Method Swizzling** 来 hook 相应的函数 - -为了给所有类方便地 hook,我们可以给 NSObject 添加个 Category,名字叫做 `NSObject+u a_MethodSwizzling` - -```objective-c -#pragma mark - public Method -+ (void)ua_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector -{ - ua_swizzleInstanceMethod(self, originalSelector, swizzledSelector); -} - -+ (void)ua_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector -{ - //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 - Class class2 = object_getClass(self); - ua_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); -} - -#pragma mark - private method - -void ua_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) -{ - Method originMethod = class_getInstanceMethod(class, originalSEL); - Method replaceMethod = class_getInstanceMethod(class, replacementSEL); - - if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod))) { - class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod)); - } else { - method_exchangeImplementations(originMethod, replaceMethod); - } -} -``` - - - -### 3. 全量收集 - -我们会想到 hook AppDelegate 代理方法、UIViewController 生命周期方法、按钮点击事件、手势事件、各种系统控件的点击回调方法、应用状态切换等等。 - -| 动作 | 事件 | -| :-----------------------------------------------------: | :-----------------------------------------: | -| App 状态的切换 | 给 Appdelegate 添加分类,hook 生命周期 | -| UIViewController 生命周期函数 | 给 UIViewController 添加分类,hook 生命周期 | -| UIButton 等的点击 | UIButton 添加分类,hook 点击事件 | -| UICollectionView、UITableView 等的 | 在对应的 Cell 添加分类,hook 点击事件 | -| 手势事件 UITapGestureRecognizer、UIControl、UIResponder | 相应系统事件 | - - - -以统计页面的打开时间和统计页面的打开、关闭的需求为例,我们对 UIViewController 进行 hook - - - -```objective-c -static char *ua_viewController_open_time = "ua_viewController_open_time"; -static char *ua_viewController_close_time = "ua_viewController_close_time"; - -@implementation UIViewController (uaka) - -// load 方法里面添加 dispatch_once 是为了防止手动调用 load 方法。 -+ (void)load -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - @autoreleasepool { - [[self class] ua_swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(ua_viewWillAppear:)]; - [[self class] ua_swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(ua_viewWillDisappear:)]; - } - }); -} - - -#pragma mark - private method - -- (void)ua_viewWillAppear:(BOOL)animated -{ - NSString *className = NSStringFromClass([self class]); - NSString *refer = [NSString string]; - //TODO:TODO 是否只埋本地有url的page - if ([self getPageUrl:className]) { - //设置打开时间 - [self setOpenTime:[NSDate dateWithTimeIntervalSinceNow:0]]; - if (self.navigationController) { - if (self.navigationController.viewControllers.count >=2) { - //获取当前vc 栈中 上一个VC - UIViewController *referVC = self.navigationController.viewControllers[self.navigationController.viewControllers.count-2]; - refer = [self getPageUrl:NSStringFromClass([referVC class])]; - } - } - if (!refer || refer.length == 0) { - refer = @"unknown"; - } - [UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; - } - - [self ua_viewWillAppear:animated]; -} - -- (void)ua_viewWillDisappear:(BOOL)animated -{ - NSString *className = NSStringFromClass([self class]); - if ([self getPageUrl:className]) { - [self setCloseTime:[NSDate dateWithTimeIntervalSinceNow:0]]; - [UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; - } - [self ua_viewWillDisappear:animated]; -} - -- (NSString *)p_calculationTimeSpend -{ - - if (![self getOpenTime] || ![self getCloseTime]) { - return @"unknown"; - } - NSTimeInterval aTimer = [[self getCloseTime] timeIntervalSinceDate:[self getOpenTime]]; - - int hour = (int)(aTimer/3600); - - int minute = (int)(aTimer - hour*3600)/60; - - int second = aTimer - hour*3600 - minute*60; - - return [NSString stringWithFormat:@"%d",second]; -} - - -#pragma mark - getter && setter - -- (void)setOpenTime:(NSDate *)openTime -{ - objc_setAssociatedObject(self,&ua_viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (NSDate *)getOpenTime -{ - return objc_getAssociatedObject(self, &ua_viewController_open_time); -} - -- (void)setCloseTime:(NSDate *)closeTime -{ - objc_setAssociatedObject(self,&ua_viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (NSDate *)getCloseTime -{ - return objc_getAssociatedObject(self, &ua_viewController_close_time); -} - - -@end -``` - - - -### 4. 如何唯一标识一个控件元素 - -**xpath** 是移动端定义可操作区域的唯一标识。既然想通过一个字符串标识前端系统中可操作的控件,那么 xpath 需要2个指标: - -- 唯一性:在同一系统中不存在不同控件有着相同的 xpath -- 稳定性:不同版本的系统中,在页面结构没有变动的情况下,不同版本的相同页面,相同的控件的 xpath 需要保持一致。 - -我们想到 Naive、H5 页面等系统渲染的时候都是以树形结构去绘制和渲染,所以我们以当前的 View 到系统的根元素之间的所有关键点(UIViewController、UIView、UIView容器(UITableView、UICollectionView等)、UIButton...)串联起来这样就唯一定位了控件元素。 - - - -为了精确定位元素节点,参看下图 - -假设一个 UIView 中有三个子 view,先后顺序是:label、button1、button2,那么深度依次为: 0、1、2。假如用户做了某些操作将 label1 从父 view 中被移除了。此时 UIView 只有 2 个子view:button1、button2,而且深度变为了:0、1。 - -![view层级](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-01-userTrack.png) - -可以看出仅仅由于其中某个子 view 的改变,却导致其它子 view 的深度都发生了变化。因此,在设计的时候需要注意,在新增/移除某一 view 时,尽量减少对已有 view 的深度的影响,调整了对节点的深度的计算方式:采用当前 view 位于其父 view 中的所有 **与当前 view 同类型** 子view 中的索引值。 - -我们再看一下上面的这个例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次为:0、1。可以看出,在这个例子中,label 的移除并未对 button1、button2 的深度造成影响,这种调整后的计算方式在一定程度上增强了 xpath 的抗干扰性。 - -另外,调整后的深度的计算方式是依赖于各节点的类型的,因此,此时必须要将各节点的名称放到 `viewPath` 中,而不再是仅仅为了增加可读性。 - - - -在标识控件元素的层级时,需要知道「当前 view 位于其父 view 中的所有 **与当前 view 同类型** 子view 中的索引值」。参看上图,如果不是同类型的话,则唯一性得不到保证。 - - - -### 5. 同类型的 view 的唯一定位问题 - -有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 `xxApp.GoodsViewController.GoodsTableView.GoodsCell`,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。 - -当然有解决方案啦。 - -找出当前元素在父层同类型元素中的索引。根据当前的元素遍历当前元素的父级元素的子元素,如果出现相同的元素,则需要判断当前元素是所在层级的第几个元素 - -对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:`GoodsCell-3.GoodsTableView.GoodsViewController.xxApp` - -```Objective-c -//UIResponder分类 -- (NSString *)ua_identifierKa -{ -// if (self.xq_identifier_ka == nil) { - if ([self isKindOfClass:[UIView class]]) { - UIView *view = (id)self; - NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath]; - NSMutableString *str = [NSMutableString string]; - //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode - NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; - if (!view.accessibilityIdentifier || [className isEqualToString:@"uaButton"]) { - [str appendString:sameViewTreeNode]; - [str appendString:@","]; - } - while (view.nextResponder) { - [str appendFormat:@"%@,", NSStringFromClass(view.class)]; - if ([view.class isSubclassOfClass:[UIViewController class]]) { - break; - } - view = (id)view.nextResponder; - } - self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]]; - // self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str]; - } -// } - return self.xq_identifier_ka; -} - -// UIView 分类 -- (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat -{ - NSString *classStr = NSStringFromClass([self class]); - //cell的子view - //UITableView 特殊的superview (UITableViewContentView) - //UICollectionViewCell - BOOL shouldUseSuperView = - ([classStr isEqualToString:@"UITableViewCellContentView"]) || - ([[self.superview class] isKindOfClass:[UITableViewCell class]])|| - ([[self.superview class] isKindOfClass:[UICollectionViewCell class]]); - if (shouldUseSuperView) { - return [self obtainIndexPathByView:self.superview]; - }else { - return [self obtainIndexPathByView:self]; - } -} - -- (NSString *)obtainIndexPathByView:(UIView *)view -{ - NSInteger viewTreeNodeDepth = NSIntegerMin; - NSInteger sameViewTreeNodeDepth = NSIntegerMin; - - NSString *classStr = NSStringFromClass([view class]); - - NSMutableArray *sameClassArr = [[NSMutableArray alloc]init]; - //所处父view的全部subviews根节点深度 - for (NSInteger index =0; index < view.superview.subviews.count; index ++) { - //同类型 - if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){ - [sameClassArr addObject:view.superview.subviews[index]]; - } - if (view == view.superview.subviews[index]) { - viewTreeNodeDepth = index; - break; - } - } - //所处父view的同类型subviews根节点深度 - for (NSInteger index =0; index < sameClassArr.count; index ++) { - if (view == sameClassArr[index]) { - sameViewTreeNodeDepth = index; - break; - } - } - return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth]; - -} -``` - -![页面唯一标识示意图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-09-UserTrack.png) - - - - - -### 6. 同类型的view,但是点击的意义却不一样。如何唯一标识? - -问题5说明的是在一个界面上有多个不同的 view,他们的类型是同一种(`CycleBannerView`,但是数据源不一样,那么当数据源长度大于1的时候会轮播,下面会展示 `UIPageControl`。如果数据源是1个,那么就不会轮播和展示 `UIPageControl`)。 - -情况6是同一种类型的 View,但是根据展示的内容不一样,点击的意义也不一样。也就是运营需要去知道用户到底点击的是哪一个。如下图所示,「立即抢购」和「分享赚佣金」是同一种类型的 View,但是点击意义不一样,需要我们需要唯一标识出来。之前的方法通过 **「viewPath 配合同类型的 view 去加索引值」** 的方式还是没有办法唯一标识出来。所以想到一个方案,给 NSObject 添加一个分类,在分类里面添加一个协议。让需要复用但需要唯一标识的 view 去实现协议方法,因为是给 NSObject 分类添加的协议,所以 view 不需要去指定遵循。 - -!["立即抢购"、"分享赚佣金"同类型view,但点击意义不一样](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack01.png) - - - -关键步骤: - -- 添加 NSObject 的 Category。在分类里面声明唯一标识的协议 - -- 在生成 viewPath 的地方去拿出当前 view 的唯一标识(view 调用协议方法)。然后拼接之前拿出的 viewPath - - - -```objective-c -//NSObject+uaUniqueIdentify.h -#import - -NS_ASSUME_NONNULL_BEGIN - -@class NSObject; -@protocol UniqueIdentify - -@optional -- (NSString *)setUniqueIdentifier; - -@end - -@interface NSObject (UniqueIdentify) - -@end - -NS_ASSUME_NONNULL_END - -//NSObject+ua_UniqueIdentify.m -#import "NSObject+ua_UniqueIdentify.h" - -@implementation NSObject (UniqueIdentify) - -@end -``` - - - -```objective-c -//MallTGoodTagView.h - -extern NSString * _Nonnull const ImmediateyPurchase; -extern NSString * _Nonnull const ShareToAward; - -//MallTGoodTagView.m -NSString *const ImmediateyPurchase = @"立即抢购"; -NSString *const ShareToAward = @"分享赚佣金"; - -- (NSString *)setUniqueIdentifier -{ - if (self.tagString) { - return self.tagString; - } else { - return NSStringFromClass([self class]); - } -} -``` - - - -```objective-c -//UIResponder Category 生成 viewPath -- (NSString *)ua_identifierKa -{ -// if (self.xq_identifier_ka == nil) { - if ([self isKindOfClass:[UIView class]]) { - UIView *view = (id)self; - NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath]; - NSMutableString *str = [NSMutableString string]; - //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode - NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; - if (!view.accessibilityIdentifier || [className isEqualToString:@"uaButton"]) { - [str appendString:sameViewTreeNode]; - [str appendString:@","]; - } - while (view.nextResponder) { - if ([view respondsToSelector:@selector(setUniqueIdentifier)]) { - NSString *unqiueIdentifier = [view setUniqueIdentifier]; - if (unqiueIdentifier) { - [str appendFormat:@"%@,", unqiueIdentifier]; - } - }00 - [str appendFormat:@"%@,", NSStringFromClass(view.class)]; - if ([view.class isSubclassOfClass:[UIViewController class]]) { - break; - } - view = (id)view.nextResponder; - } - self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@"%@",str]]; - // self.xq_identifier_ka = [NSString stringWithFormat:@"%@",str]; - } -// } - return self.xq_identifier_ka; -} -``` - -![改进版view唯一标识:立即抢购](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack3.png) - -![改进版view唯一标识:分享赚佣金](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-11-UserTrack2.png) - - - -### 7. 特殊 case - -根据在同一个 view 上会有多个 subview,那么生成的 xpath 会携带在同类型 views 中的索引,所以一个登录、注册按钮的 xpath 可能为 `btn1.LoginView.LoginViewController.*baoApp`、`bt21.LoginView.LoginViewController.*baoApp`。 - -问题来了,当版本 A 上线后运行了一段时间,上传并统计了数据。但是过了一段时间版本迭代,UI 为了 KPI 搞事情,把登录和注册按钮的位置换了一下,变成了注册、登录。按照之前的逻辑生成的 xpath 还是 `btn1.LoginView.LoginViewController.*baoApp`、`bt21.LoginView.LoginViewController.*baoApp`。那么新的 xpath 虽然唯一,但是点击产生的数据会和之前的埋点数据意义不一样。别怕,你忘了还有一步绑定的逻辑。可视化绑定这个步骤会把每次开发的功能,通过可视化界面去将 `xpath` 和功能模块名称绑定一下。 - -看下面的动图。所以不用担心虽然生成了唯一的 `xpath`,但是 App 在不同版本之间 UI 控件位置更换造成之前的统计数据在分析的时候不准确的问题。因为在绑定的时候就将新的 `xpath` 和功能名称进行了绑定,接口携带版本号。所以分析的时候注意版本号就好了。sql 一句话的事情。 - - - -![绑定页面唯一标识与功能描述的对应关系动图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-07-03-XpathBind.gif) - - - - - -### 8. 数据如何处理 - - -#### 1. 如何处理业务数据 - -利用系统提供的 `accessibilityIdentifier` 官方给出的解释是标识用户界面元素的字符串 - -```objective-c -/* - A string that identifies the user interface element. - default == nil -*/ -@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier API_AVAILABLE(ios(5.0)); -``` - -服务端下发唯一标识 - -接口获取的数据,里面有当前元素的唯一标识。比如在 UITableView 的界面去请求接口拿到数据,那么在在获取到的数据源里面会有一个字段,专门用来存储动态化的经常变动的业务数据。 - -```objective-c -cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString]; -``` - - - -#### 2. 基础数据 - -设计上分为2个 pod 库,一个是 `UserAnalysis`(专门用来 hook 机会需要的所有事件,页面停留时间、页面标识、view标识),另一个是 AppMonitor(专门用来提供基础数据、埋点数据的维护、上传机制)。所以在 AppMonitor 里面有个类叫做 UserTrackDataCenter 的类,专门提供一些基础数据(系统版本、操作系统、地理位置、网络等信息)。 - -对外暴露出一些方法,用来将埋点数据交给 AppMonitor 去维护埋点数据,达到合适的“机制”再去上传埋点数据到服务端。 - -```objective-c -+ (void)clickEventUuid:(NSString *)uuid otherParam:(NSDictionary *)otherParam spmContent:(NSDictionary *)spmContent -{ - if (uuid) { - NSMutableDictionary *params = [[NSMutableDictionary alloc] initWithDictionary:otherParam]; - params[SDGStatisticEventtagKey] = @"clickMonitorV1"; - NSMutableDictionary *valueDict = [[NSMutableDictionary alloc] initWithDictionary:spmContent]; - valueDict[@"xpath"] = uuid?:@""; - params[SDGStatisticEventtagValue] = valueDict?:@{}; - [[AppMonotior shareInstance] traceEvent:[AMStatisticEvent eventWithInfo:params]]; - } -} -``` - - - -### 9. 曝光时间的统计 - -曝光的意义是什么? - -我们的产品中可能有合作伙伴的广告,我们需要收取服务费。那如何计价?`CPM`(cost per Mille)每千人成本、`CPC`(cost per click)每点击成本、`CPA`(cost per action)每行动成本,根据这些指标来计算价格。或者自己的产品中运营人员在商城中投放了一次新的活动,为了这次活动在某个钻石展位放了设计人员精心设计的炫酷 Banner。这次活动后运营人员想分析在这个图片的作用下有多少人点击了这个活动页。 - - - -何为曝光? - -一个 view 或者一个组件或者一个资源位在屏幕上可见区域内停留的时间称为一次曝光。那么这个时间怎么统计?有一个点需要注意,那就是当用户在快速滑动的过程中页面上的元素或者组件都会在页面可见区域内快速闪过,那这种算一次曝光吗?当然不算啊,想了想设置了一个时间临界值,大于这个临界值那么算做一次有效的曝光。 - - - -#### 1. 有效曝光的判断 - -显示在屏幕可见区域如何判断?一个 View 显示在屏幕可见区域内,那么它肯定是经过从未初始化到初始化,再到设置 Frame 或者 Bounds 或者 Alpha 或者 Hidden 的。且它的根 view 一定是 UIWindow 对象。所以上面这句话进行分析整理就是下面的条件 - -- 自身 frame 的改变或者父视图 bounds 的改变 -- alpha 小于 0.1 或者 hidden 为 YES -- 根视图为 window - -对于上面的三点可以用 AOP 进行判断。 hook 掉相应的方法,然后处理判断是否在可见区域内显示。最后的一个点经过一番查找,看到了一个 api `- (void)didMoveToWindow;` ,根据它可以判断 view 是否显示到屏幕中(文档中说明:当它的 window 对象发送改变的时候会调用 view 的 `didMoveToWindow` 方法)。 - -``` -Tells the view that its window object changed. - -The default implementation of this method does nothing. Subclasses can override it to perform additional actions whenever the window changes. - -The window property may be nil by the time that this method is called, indicating that the receiver does not currently reside in any window. This occurs when the receiver has just been removed from its superview or when the receiver has just been added to a superview that is not attached to a window. Overrides of this method may choose to ignore such cases if they are not of interest. -``` - - - -#### 2. 曝光代码的执行效率优化 - -设想一下,某个复杂的页面可能是一个大的 UIViewController 顶部是店铺的基本信息,下面是 2个 UIViewController:左侧负责展示商品的一级、二级、三级分类,且负责选中和未选中的 UI 效果;右侧负责展示商品信息(顶部有商品的排序查找 ,下面是商品展示的 UICollectionView)。由于页面结构复杂,UI 层级嵌套严重,所以代码层面不注意的话,页面上计算量会比较大,CPU 负荷严重,直接影响着手机的 `耗电量`。改进的手段是在合适的地方提前 return 掉(比如 hidden 等于 YES 或者 aplha 小于 0.1 的时候)。 - - - -另外一个方面就是当用户在滑动页面到感兴趣的模块的时候,开始点击执行某个逻辑,但此时我们的无痕埋点的代码也在偷偷的工作,那么势必会对用户体验造成影响。该方案的改进是监听 `RunLoop`,等到 RunLoop 空闲的时候判断当前 view 是否是一次有效的曝光。 - - - -实际上发现某些 view 的判断会比较特殊,比如当在 UITableView 的 cell 判断的时候,我们发现 cell 的 superview 为 UITableViewWrapperView 时,我们使用 UITableViewWrapperView 的父视图来计算。 - -iOS 11 以下 UITableViewWrapperView 大小为屏幕中第一个完整的屏幕大小视图,且会随着 contentOffset 的改变而改变。所以当 UITableViewWrapperView 滑出屏幕可见区域的时候,cell 判断父视图是否可见的时候不准确。 - - - -整个流程见下面的流程图。 - -![整体流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-07-08-ComponentExposure.jpeg) - - - - - - - -### 10. 数据的上报 - -数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢? - -App 运行期间用户会点击非常多的数据,如果实时上传的话对于网络的利用率较低,所以需要考虑一个机制去控制用户产生的埋点数据的上传。 - -思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 `AppMonitor` 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。 - -App 应用状态的切换策略如下: - -- didFinishLaunchWithOptions:内存日志信息写入硬盘 -- didBecomeActive:上传 -- willTerimate:内存日志信息写入硬盘 -- didEnterBackground:内存日志信息写入硬盘 - -下面的代码是 App 埋点数据的保存与上传 - -```objective-c -// 将App日志信息写入到内存中。当内存中的数量到达一定规模(超过设置的内存中存储的数量)的时候就将内存中的日志存储到文件信息中 -- (void)joinEvent:(NSDictionary *)dictionary -{ - if (dictionary) { - NSDictionary *tmp = [self createDicWithEvent:dictionary]; - if (!s_memoryArray) { - s_memoryArray = [NSMutableArray array]; - } - [s_memoryArray addObject:tmp]; - if ([s_memoryArray count] >= s_flushNum) { - [self writeEventLogsInFilesCompletion:^{ - [self startUploadLogFile]; - }]; - } - } -} - -// 外界调用的数据传递入口(App埋点统计) -- (void)traceEvent:(AMStatisticEvent *)event -{ - // 线程锁,防止多处调用产生并发问题 - @synchronized (self) { - if (event && event.userInfo) { - [self joinEvent:event.userInfo]; - } - } -} - -// 将内存中的数据写入到文件中,持久化存储 -- (void)writeEventLogsInFilesCompletion:(void(^)(void))completionBlock -{ - NSArray *tmp = nil; - @synchronized (self) { - tmp = s_memoryArray; - s_memoryArray = nil; - } - if (tmp) { - __weak typeof(self) weakSelf = self; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - NSString *jsonFilePath = [weakSelf createTraceJsonFile]; - if ([weakSelf writeArr:tmp toFilePath:jsonFilePath]) { - NSString *zipedFilePath = [weakSelf zipJsonFile:jsonFilePath]; - if (zipedFilePath) { - [AppMonotior clearCacheFile:jsonFilePath]; - if (completionBlock) { - completionBlock(); - } - } - } - }); - } -} - -// 从App埋点统计压缩包文件夹中的每个压缩包文件上传服务端,成功后就删除本地的日志压缩包 -- (void)startUploadLogFile -{ - NSArray *fList = [self listFilesAtPath:[self eventJsonPath]]; - if (!fList || [fList count] == 0) { - return; - } - [fList enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { - if (![obj hasSuffix:@".zip"]) { - return; - } - - NSString *zipedPath = obj; - unsigned long long fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:zipedPath error:nil] fileSize]; - if (!fileSize || fileSize < 1) { - return; - } - // 调用接口上传埋点数据 - [self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) { - if ([completionResult isEqual:@"OK"]) { - [AppMonotior clearCacheFile:zipedPath]; - } - }]; - }]; -} -``` - -其实 App 内部的数据上报有很多场景,比如 [APM 监控](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md) 数据的上报、埋点数据的上报、业务线的数据上报等等,所以可以设计为[一个通用、可配置的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md)。 - - - -### 11. 总结 - -使用的时候就是在 hook 系统事件的时候,去调用统计页面上传数据 - -```objective-c -//UIViewController -[UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; // 页面出现 -[UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; //页面消失 -``` - - -![绑定页面唯一标识与功能描述的对应关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-03-07-UserTracet1.PNG) - -总结下来关键步骤: - -1. hook 系统的各种事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码 -2. 对于点击的元素按照视图树生成对应的唯一标识 `addCartButton.GoodsView.GoodsViewController.**App` 的 md5 值 -3. 在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 `addCartButton.GoodsView.GoodsViewController.**App` 对应了 `**App-商城模块-商品详情页-加入购物车功能`。 -4. 将所需要的数据存储下来 -5. 设计机制等到合适的时机去上传数据 - - - -## 四. 演示一个完整的埋点上报流程 - -埋伏模块分为2个pod组件库,`UserAnalysis` 负责拦截系统事件,拿到埋点数据。`AppMonitor` 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。 - - - -1. 通过接口获取数据,给对应的 view 的 `accessibilityIdentifier` 属性绑定埋点数据 - - ![接口拿到的数据](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack1.png) - - ![绑定埋点数据到view](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack2.png) - -2. hook 系统事件,点击拿到 view,获取 `accessibilityIdentifier` 属性值 - - ![hook系统事件获取accessibilityIdentifier](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack3.png) - -3. 将数据向的数据中心发送,数据中心处理数据(埋点数据结合App基础信息,图上 `UserTrackDataCenter` 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传 - - ![拦截系统事件后将数据交给数据中心处理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-05-userTrack4.png) \ No newline at end of file diff --git a/Chapter1 - iOS/1.56.md b/Chapter1 - iOS/1.56.md deleted file mode 100644 index 0c4bcdc..0000000 --- a/Chapter1 - iOS/1.56.md +++ /dev/null @@ -1,721 +0,0 @@ -# 反爬技术方案的研究与落地 - -> 对于内容型的公司,数据的安全性不言而喻。一个在线教育的平台,题目的数据很重要吧,但是被别人通过爬虫技术全部爬走了,那结果就是“凉凉”。再比说有个独立开发者想抄袭你的产品,通过抓包和爬虫手段将你核心的数据拿走,然后短期内做个网站和 App,短期内成为你的劲敌。成果:segmentfault 上发表过[文章](https://segmentfault.com/a/1190000017899193),获赞 152。 - - - - -## 一、大前端时代安全性如何做 - -大前端的数据安全主要分为:客户端、网页端。客户端简单介绍下。 - -### 1. 中间人盗用数据 - -目前 App 的网络通信基本都是用 HTTPS 的服务,但是随便一个抓包工具都是可以看到 HTTPS 接口的详细数据,为了做到防止抓包和无法模拟接口的情况。我们可以采取 HTTPS 证书的双向认证,这样子实现的效果就是中间人在开启抓包软件分析 App 的网络请求的时候,网络会自动断掉,无法查看分析请求的情况 - -### 2. 防重放 - -对于防止用户模仿我们的请求再次发起请求,我们可以采用 「防重放策略」,用户再也无法模仿我们的请求,再次去获取数据了。 - -虽然话题都是大前端时代的安全性,但是防重放策略篇幅较长,开了新的[章节](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter3%20-%20Server/3.8.md)。感兴趣的同学请移步查看。 - - - -### 3. App 内 H5 网络请求 - -对于 App 内的 H5 资源,反爬虫方案可以采用上面的解决方案,H5 内部的网络请求可以通过 Hybrid 层让 Native 的能力去完成网络请求,完成之后将数据回调给 JS。这么做的目的是往往我们的 Native 层有完善的账号体系和网络层以及良好的安全策略、鉴权体系等等。 - -
-JS端发起网络请求代码:点击展开 - - -```Javascript - var requestObject = { - url: arg.Api + "SearchInfo/getLawsInfo", - params: requestparams, - Hybrid_Request_Method: 0 - }; - requestHybrid({ - tagname: 'NativeRequest', - param: requestObject, - encryption: 1, - callback: function (data) { - renderUI(data); - } - }) -``` - -
- -
-Objective-C代码:点击展开 - - -```Objective-C - [self.bridge registerHandler:@"NativeRequest" handler:^(id data, WVJBResponseCallback responseCallback) { - - NSAssert([data isKindOfClass:[NSDictionary class]], @"H5 端不按套路"); - if ([data isKindOfClass:[NSDictionary class]]) { - - NSDictionary *dict = (NSDictionary *)data; - RequestModel *requestModel = [RequestModel yy_modelWithJSON:dict]; - NSAssert( (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) || (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get ), @"H5 端不按套路"); - - [HybridRequest requestWithNative:requestModel hybridRequestSuccess:^(id responseObject) { - - NSDictionary *json = [NSJSONSerialization JSONObjectWithData:responseObject options:NSJSONReadingMutableLeaves error:nil]; - responseCallback([self convertToJsonData:@{@"success":@"1",@"data":json}]); - - } hybridRequestfail:^{ - - LBPLog(@"H5 call Native`s request failed"); - responseCallback([self convertToJsonData:@{@"success":@"0",@"data":@""}]); - }]; - } - }]; -``` - -
- -### 4. 中间人攻击 - -针对抓包工具可以截获 HTTPS 数据的情况,我们可以对请求参数和返回内容再做一次 **RSA** 加密处理。 - -- 客户端和服务器各自生成公钥、私钥 -- 互相交换公钥 -- 通信流程:客户端利用服务端的公钥加密请求参数 -> 服务端收到请求后利用服务端的私钥解密,验证合法性 -> 做可能的逻辑处理(DB、缓存、ES),组装数据 -> 处理数据到合适的格式 -> 利用客户端公钥加密数据 -> 客户端收到数据后利用客户端私钥解密。(服务器主动发起的请求也是一样的逻辑) - -有些人觉得利用 RSA 加密虽然可以保证数据的安全,但是因为每次都是大量字符串的运算,觉得数据量大的情况下用 RSA 加解密会非常耗时。 - -对,肯定耗时,所以较好的做法就是将通信双方使用的密钥(对称加密)利用 **RSA** 的方式交换,然后两方在通信的时候,数据内容采用**对称加密**的方式进行。 - -但是私钥在本地如何存放呢?想到的办法就是将关键密钥的字符串提高到较高的安全级别,比如这个文件用加密保存。接下来推荐一个[工具](https://github.com/RNCryptor/RNCryptor),可以将代码文件进行加密保存和解密访问。 - -### 5. 自定义报文 - -数据加密、压缩 + 自定义报文 - -HTTP 1.1 版本存在大量的 Header 冗余信息,网络传输利用率低。接口可以抓包并模拟请求,安全性较低。基于此可以设计一套数据的加密. - -比如 `AES CBC 加密 + ZIP 压缩 + 自定义报文` 这套方案数据既安全又传输高效。 - - - - -## 二、爬虫工程师的爬虫手段 - -要做某个事情肯定需要先现状分析、可行性分析。问了几个爬虫工程师和个人经验,爬取数据主要分为: - -- 从渲染好的 html 页面直接找到感兴趣的节点,然后获取对应的文本 -- 去分析对应的接口数据,更加方便、精确地获取数据 - - - - -## 三、制定出 **Web 端反爬技术方案** - -从这2个角度(网页所见非所得、查接口请求没用)出发,制定了下面的反爬方案。 - -- 使用 HTTPS 协议 -- 登陆态下,单位时间内限制掉请求次数过多(等级1),则降低频率给账号返回数据 -- 登陆态下,单位时间内限制掉请求次数过多(等级2),则返回错误的数据给该账号 -- 登陆态下,单位时间内限制掉请求次数过多(等级3),则封锁该账号 -- 前端技术限制 (接下来是核心技术) - -该方案也可以覆盖 OCR 爬取场景。OCR 的前提是页面渲染完毕,页面所需业务数据需要通过接口获取。所以基于用户行为采集分析,基于日志分析用户在时间范围内的请求频次、用户行为是否正常,如果不正常,说明可能是爬虫程序,依据用户单位时间内情况恶略程度,可以采用降频、返回错误数据、封锁账号的策略。 - -### 1. 数据加密 - -比如需要正确显示的数据为“19950220” - -1. 先按照自己需求利用相应的规则(数字乱序映射,比如正常的0对应还是0,但是乱序就是 0 <-> 1,1 <-> 9,3 <-> 8,...)制作自定义字体(ttf) -2. 根据上面的乱序映射规律,求得到需要返回的数据 19950220 -> 17730220 -3. 对于第一步得到的字符串,依次遍历每个字符,将每个字符根据按照线性变换(**y=kx+b**),其中,**k 为当前的月份,b 为当月的号数**。线性方程的系数和常数项是根据当前的日期计算得到的。比如当前的日期为“2018-07-24”,那么线性变换的 k 为 7,b 为 24。 -4. 然后将变换后的每个字符串用“3.1415926”拼接返回给接口调用者。(为什么是3.1415926,因为对数字伪造反爬,所以拼接的文本肯定是数字的话不太会引起研究者的注意,但是数字长度太短会误伤正常的数据,所以用所熟悉的 Π) - -比如要对1995这个字符串进行加密(当前日期为07-24) - -1 -> 1*7 + 24 - -9 -> 9*7 + 24 - -5 -> 5*7 + 24 - -最后结果为: 1*7 + 24 + '3.1415926' + 9\*7 + 24 + '3.1415926'+ 9\*7 + 24 + '3.1415926'+ 5\*7 + 24 - - - -后端需要根据上一步设计的协议将数据进行加密处理**。**下面以 **Node.js** 为例讲解后端需要做的事情 - -- 首先后端设置接口路由 -- 获取路由后面的参数 -- 根据业务需要根据 SQL 语句生成对应的数据。如果是数字部分,则需要按照上面约定的方法加以转换。 -- 将生成数据转换成 JSON 返回给调用者 - -```javascript -// 服务端:数据的加工处理 -var JoinOparatorSymbol = "3.1415926"; -function encode(rawData, ruleType) { - if (!isNotEmptyStr(rawData)) { - return ""; - } - var date = new Date(); - var year = date.getFullYear(); - var month = date.getMonth() + 1; - var day = date.getDate(); - - var encodeData = ""; - for (var index = 0; index < rawData.length; index++) { - var datacomponent = rawData[index]; - if (!isNaN(datacomponent)) { - if (ruleType < 3) { - var currentNumber = rawDataMap(String(datacomponent), ruleType); - encodeData += (currentNumber * month + day) + JoinOparatorSymbol; - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - else { - encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol; - } - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - - } - if (encodeData.length >= JoinOparatorSymbol.length) { - var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length); - if (lastTwoString == JoinOparatorSymbol) { - encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length); - } - } -} -``` - -```javascript -//字体映射处理 -function rawDataMap(rawData, ruleType) { - - if (!isNotEmptyStr(rawData) || !isNotEmptyStr(ruleType)) { - return; - } - var mapData; - var rawNumber = parseInt(rawData); - var ruleTypeNumber = parseInt(ruleType); - //字体文件1下的数据加密规则 - if (ruleTypeNumber == 1) { - if (rawNumber == 1) { - mapData = 1; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 4; - } - else if (rawNumber == 4) { - mapData = 5; - } - else if (rawNumber == 5) { - mapData = 3; - } - else if (rawNumber == 6) { - mapData = 8; - } - else if (rawNumber == 7) { - mapData = 6; - } - else if (rawNumber == 8) { - mapData = 9; - } - else if (rawNumber == 9) { - mapData = 7; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - //字体文件2下的数据加密规则 - else if (ruleTypeNumber == 0) { - - if (rawNumber == 1) { - mapData = 4; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 3; - } - else if (rawNumber == 4) { - mapData = 1; - } - else if (rawNumber == 5) { - mapData = 8; - } - else if (rawNumber == 6) { - mapData = 5; - } - else if (rawNumber == 7) { - mapData = 6; - } - else if (rawNumber == 8) { - mapData = 7; - } - else if (rawNumber == 9) { - mapData = 9; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - //字体文件3下的数据加密规则 - else if (ruleTypeNumber == 2) { - - if (rawNumber == 1) { - mapData = 6; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 1; - } - else if (rawNumber == 4) { - mapData = 3; - } - else if (rawNumber == 5) { - mapData = 4; - } - else if (rawNumber == 6) { - mapData = 8; - } - else if (rawNumber == 7) { - mapData = 3; - } - else if (rawNumber == 8) { - mapData = 7; - } - else if (rawNumber == 9) { - mapData = 9; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - else if (ruleTypeNumber == 3) { - - if (rawNumber == 1) { - mapData = ""; - } - else if (rawNumber == 2) { - mapData = ""; - } - else if (rawNumber == 3) { - mapData = ""; - } - else if (rawNumber == 4) { - mapData = ""; - } - else if (rawNumber == 5) { - mapData = ""; - } - else if (rawNumber == 6) { - mapData = ""; - } - else if (rawNumber == 7) { - mapData = ""; - } - else if (rawNumber == 8) { - mapData = ""; - } - else if (rawNumber == 9) { - mapData = ""; - } - else if (rawNumber == 0) { - mapData = ""; - } - } else if (ruleTypeNumber == 4) { - var sources = ["年", "万", "业", "人", "信", "元", "千", "司", "州", "资", "造", "钱", "杭", "城", "小", "刘", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; - //判断字符串为汉字 - if (/^[\u4e00-\u9fa5]*$/.test(rawData)) { - - if (sources.indexOf(rawData) > -1) { - var currentChineseHexcod = rawData.charCodeAt(0).toString(16); - var lastCompoent; - var mapComponetnt; - var numbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; - var characters = ["a", "b", "c", "d", "e", "f"] - - if (currentChineseHexcod.length == 4) { - lastCompoent = currentChineseHexcod.substr(3, 1); - var locationInComponents = 0; - if (/[0-9]/.test(lastCompoent)) { - locationInComponents = numbers.indexOf(lastCompoent); - mapComponetnt = numbers[(locationInComponents + 1) % 10]; - } - else if (/[a-z]/.test(lastCompoent)) { - locationInComponents = characters.indexOf(lastCompoent); - mapComponetnt = characters[(locationInComponents + 1) % 6]; - } - mapData = "&#x" + currentChineseHexcod.substr(0, 3) + mapComponetnt + ";"; - } - } else { - mapData = `ff${rawData}`; - } - } else { - mapData = `ff${rawData}`; - } - } - return mapData; -} - -function encode(rawData, ruleType) { - if (!isNotEmptyStr(rawData)) { - return ""; - } - var date = new Date(); - var year = date.getFullYear(); - var month = date.getMonth() + 1; - var day = date.getDate(); - - var encodeData = ""; - for (var index = 0; index < rawData.length; index++) { - var datacomponent = rawData[index]; - if (!isNaN(datacomponent)) { - if (ruleType < 3) { - var currentNumber = rawDataMap(String(datacomponent), ruleType); - encodeData += (currentNumber * month + day) + JoinOparatorSymbol; - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - else { - encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol; - } - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - - } - if (encodeData.length >= JoinOparatorSymbol.length) { - var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length); - if (lastTwoString == JoinOparatorSymbol) { - encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length); - } - } - console.log(encodeData); - return encodeData; -} -``` - - - -```javascript -// 根据路由,返回数据 -module.exports = { - "GET /api/products": async (ctx, next) => { - ctx.response.type = "application/json"; - ctx.response.body = { - products: products - }; - }, - - "GET /api/solution1": async (ctx, next) => { - - try { - var data = fs.readFileSync(pathname, "utf-8"); - ruleJson = JSON.parse(data); - rule = ruleJson.data.rule; - } catch (error) { - console.log("fail: " + error); - } - - var data = { - code: 200, - message: "success", - data: { - name: "@杭城小刘", - year: LBPEncode("1995", rule), - month: LBPEncode("02", rule), - day: LBPEncode("20", rule), - analysis : rule - } - } - - ctx.set("Access-Control-Allow-Origin", "*"); - ctx.response.type = "application/json"; - ctx.response.body = data; - }, - - - "GET /api/solution2": async (ctx, next) => { - try { - var data = fs.readFileSync(pathname, "utf-8"); - ruleJson = JSON.parse(data); - rule = ruleJson.data.rule; - } catch (error) { - console.log("fail: " + error); - } - - var data = { - code: 200, - message: "success", - data: { - name: LBPEncode("建造师",rule), - birthday: LBPEncode("1995年02月20日",rule), - company: LBPEncode("中天公司",rule), - address: LBPEncode("浙江省杭州市拱墅区石祥路",rule), - bidprice: LBPEncode("2万元",rule), - negative: LBPEncode("2018年办事效率太高、负面基本没有",rule), - title: LBPEncode("建造师",rule), - honor: LBPEncode("最佳奖",rule), - analysis : rule - } - } - ctx.set("Access-Control-Allow-Origin", "*"); - ctx.response.type = "application/json"; - ctx.response.body = data; - }, - - "POST /api/products": async (ctx, next) => { - var p = { - name: ctx.request.body.name, - price: ctx.request.body.price - }; - products.push(p); - ctx.response.type = "application/json"; - ctx.response.body = p; - } -}; -``` - - - -### 2. 字体文件 - -**前端拿到数据后再解密,解密后根据自定义的字体 Render 页面**。 - -- 先将拿到的字符串按照“3.1415926”拆分为数组 -- 对数组的每1个数据,按照“线性变换”(y=kx+b,k和b同样按照当前的日期求解得到),逆向求解到原本的值。 -- 将步骤2的的到的数据依次拼接,再根据 ttf 文件 Render 页面上。 - -```css -// 前端:CSS应用字体文字 style.css -@font-face { - font-family: "NumberFont"; - src: url('http://127.0.0.1:8080/Util/analysis'); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -@font-face { - font-family: "CharacterFont"; - src: url('http://127.0.0.1:8080/Util/map'); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -h2 { - font-family: "NumberFont"; -} - -h3,a{ - font-family: "CharacterFont"; -} -``` - - - - -```javascript -// 前端根据服务端返回的数据逆向解密 -$("#year").html(getRawData(data.year,log)); - -// util.js -var JoinOparatorSymbol = "3.1415926"; -function isNotEmptyStr($str) { - if (String($str) == "" || $str == undefined || $str == null || $str == "null") { - return false; - } - return true; -} - -function getRawData($json,analisys) { - $json = $json.toString(); - if (!isNotEmptyStr($json)) { - return; - } - - var date= new Date(); - var year = date.getFullYear(); - var month = date.getMonth() + 1; - var day = date.getDate(); - var datacomponents = $json.split(JoinOparatorSymbol); - var orginalMessage = ""; - for(var index = 0;index < datacomponents.length;index++){ - var datacomponent = datacomponents[index]; - if (!isNaN(datacomponent) && analisys < 3){ - var currentNumber = parseInt(datacomponent); - orginalMessage += (currentNumber - day)/month; - } else if(analisys == 4){ - orginalMessage += $json.split("ff").filter((idx, value) => idx !== '' ).join(""); - } else { - //其他情况待续,本 Demo 根据本人在研究反爬方面的技术并实践后持续更新 - } - } - return orginalMessage; -} -``` - - - -比如后端返回的是323.14743.14743.1446,根据我们约定的算法,可以的到结果为1773 - -根据 ttf 文件 Render 页面 -![自定义字体文件](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180724-184215.png) -上面计算的到的1773,然后根据ttf文件,页面看到的就是1995 - - - -### 3. JS 混淆 - -为了防止爬虫人员查看 JS 研究问题(假设一般的爬虫工程师不会是资深前端开发,对于混淆的 JS 会头大),所以对 JS 的文件进行了加密处理。如果你的技术栈是 Vue 、React 等,webpack 为你提供了 JS 加密的插件,也很方便处理 - -个人觉得这种方式还不是很安全。于是想到了各种方案的组合拳。比如 - - - - -##  四、 反爬升级版 - -上述方案对于一个前端经验丰富的爬虫开发者来说,上面的方案可能还是会存在被破解的可能,所以在之前的基础上做了升级版本 - -### 1. 字体文件不要固定 - -虽然请求的链接是同一个,但是根据当前的时间戳的最后一个数字取模,比如 Demo 中对4取模,有4种值 0、1、2、3。这4种值对应不同的字体文件,所以当爬虫绞尽脑汁爬到1种情况下的字体时,没想到再次请求,字体文件的规则变掉了 - -### 2. 针对数字可以用不同的映射策略 - -前面的规则是字体问题乱序,但是只是数字映射规则打乱掉。比如 **1** -> **4**, **5** -> **8**。其实可以针对数据做**数字映射乱序**和 **unicode 乱序**2种策略。接下来的套路就是每个数字对应一个 **unicode 码** ,然后制作自己需要的字体,可以是 .ttf、.woff 等等。 - -![网页检察元素得到的效果](https://fantasticlbp.gitbooks.io/knowledge-kit/content/assets/WX20180726-161418.png) -![接口返回数据](https://fantasticlbp.gitbooks.io/knowledge-kit/content/assets/WX20180726-161429.png) - - - -### 3. 词云 - - 对于你站点频率最高的词云,做一个汉字映射,也就是自定义字体文件,步骤跟数字一样。先将常用的汉字生成对应的 ttf 文件;根据下面提供的链接,将 ttf 文件转换为 svg 文件,然后在下面的“字体映射”链接点进去的网站上面选择前面生成的 svg 文件,将svg文件里面的每个汉字做个映射,也就是将汉字专为 unicode 码(注意这里的 unicode 码不要去在线直接生成,因为直接生成的东西也就是有规律的。我给的做法是先用网站生成,然后将得到的结果做个简单的变化,比如将“e342”转换为 “e231”);然后接口返回的数据按照我们的这个字体文件的规则反过去映射出来。 - -### 4. 图片显示 - -将网站的重要字体,将 html 部分生成图片,这样子爬虫要识别到需要的内容成本就很高了,需要用到 OCR。效率也很低。所以可以拦截钓一部分的爬虫 - -### 5. Canvas 指纹 - -看到携程的技术分享“反爬的最高境界就是 Canvas 的指纹,原理是不同的机器不同的硬件对于 Canvas 画出的图总是存在像素级别的误差,因此我们判断当对于访问来说大量的 canvas 的指纹一致的话,则认为是爬虫,则可以封掉它。 - - - -## 五、反爬技术关键步骤 - -我将关键步骤做成了一个 checklist,你可以按照下面的步骤来落地。 - -- [ ] 先根据你们的产品找到常用的关键词,生成**词云** -- [ ] 根据词云,将每个字生成对应的 unicode 码 -- [ ] 将词云包括的汉字做成一个字体库 -- [ ] 将字体库 .ttf 做成 svg 格式,然后上传到 [icomoon](https://icomoon.io/app/#/select/font) 制作自定义的字体,但是有规则,比如 **“年”** 对应的 **unicode 码**是 **“\u5e74”** ,但是我们需要做一个 **恺撒加密** ,比如我们设置 **偏移量** 为1,那么经过**恺撒加密** **“年”**对应的 **unicode** 码是**“\u5e75”** 。利用这种规则制作我们需要的字体库 -- [ ] 在每次调用接口的时候服务端做的事情是:服务端封装某个方法,将数据经过方法判断是不是在词云中,如果是词云中的字符,利用规则(找到汉字对应的 unicode 码,再根据凯撒加密,设置对应的偏移量,Demo 中为1,将每个汉字加密处理)加密处理后返回数据 -- [ ] 客户端做的事情: - - [ ] 先引入我们前面制作好的汉字字体库 - - [ ] 调用接口拿到数据,显示到对应的 Dom 节点上 - - [ ] 如果是汉字文本,我们将对应节点的 css 类设置成汉字类,该类对应的 font-family 是我们上面引入的汉字字体库 - - - -传送门 - -- [ttf转svg](https://everythingfonts.com/ttf-to-svg) -- [字体映射规则](https://icomoon.io/app/#/select/font) - -- [ttf转svg](https://convertio.co/zh/font-converter/) - - - - - -## 六、实现的效果 - - -1. 页面上看到的数据跟审查元素看到的结果不一致 -2. 去查看接口数据跟审核元素和界面看到的三者不一致 -3. 页面每次刷新之前得出的结果更不一致 -4. 对于数字和汉字的处理手段都不一致 - -这几种组合拳打下来。对于一般的爬虫就放弃了 - -具体代码可以访问 [Demo ](https://github.com/FantasticLBP/Anti-WebSpider) - -```shell -// 客户端。先查看本机 ip -// 在 Demo/Spider-develop/Solution/Solution1.js 和 Demo/Spider-develop/Solution/Solution2.js 里面将接口地址修改为本机 ip -// 服务端 先安装依赖 -$ cd REST/ -$ npm install -$ node app.js - -$ cd Demo -$ node file-Server.js -// Server is runnig at http://127.0.0.1:8080/ -``` - - - -![数字反爬-网页显示效果、审查元素、接口结果情况1](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180810-151046@2x.png) - ![数字反爬-网页显示效果、审查元素、接口结果情况2](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180810-151203@2x.png) - ![数字反爬-网页显示效果、审查元素、接口结果情况3](https://github.com/FantasticLBP/knowledge-kit/blob/master/assets/WX20180810-151239@2x.png?raw=true) - ![数字反爬-网页显示效果、审查元素、接口结果情况4](https://github.com/FantasticLBP/knowledge-kit/blob/master/assets/WX20180810-151308@2x.png?raw=true) - ![汉字反爬-网页显示效果、审查元素、接口结果情况1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095006%402x.png) - ![汉字反爬-网页显示效果、审查元素、接口结果情况2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095124%402x.png) - - - - - -![效果演示](https://raw.githubusercontent.com/FantasticLBP/Anti-WebSpider/master/Anti-WebSpider.gif) - - - -## 七 、价值和思考 - -其实经常有很多来自不同端的开发者和我聊安全问题。交流下来发现有些人可以明白设计的有点,有些人还是没有明白。这里我总结下: - -- 爬虫与反爬技术,没有终点。都是需要在衡量 ROI 的情况下, 找到符合业务、技术现状的“最佳”解决方案 - -- 每次刷新,页面显示的数据固定,但是网络接口数据、审查元素看到的数据,都是不断变化的。且汉字字符、数字字符都不一样 - -- OCR 可以爬取数据,但是成本较高。同样可以利用其他策略,比如同一个浏览器 canvas 指纹的情况下,短时间多次请求某些数据,则认为是非法行为,可以延迟返回数据、返回错误数据、账号封锁等策略。 - - OCR 的对策也有,比如根据单位时间内限制掉请求次数划分等级。OCR 的前提是页面渲染完毕,页面所需业务数据需要通过接口获取。所以基于用户行为采集分析,基于日志分析用户在时间范围内的请求频次、用户行为是否正常,如果不正常,说明可能是爬虫程序,依据用户单位时间内情况恶略程度,可以采用降频、返回错误数据、封锁账号的策略。 - -爬虫工程师要么从接口爬取数据、要么观察分析页面结构找到目标数据的 xPath 获取 DOM 节点对应的数据。从这2个角度出发,当前的设计方案解决了该问题 - -可能有些人就会问:**爬虫工程师一般会不需要关心技术如何实现,直接用无头浏览器“原封不动”的去请求,直接拿到数据不就好了** - -其实你仔细想想,无头浏览器是可以去请求,但是本质上就是对数据的只读而已。因为该方案的设计就是基于:**字体文件映射 + 数据线性加密**。**无头浏览器拿到的数据其实和审查元素看到的 DOM 节点内的数据是一个效果的。所以是无效数据。** - - - - - -以上是第一阶段的安全性总结,后期会从更多、更深入的角度剖析大前端安全性的策略和方案 - - - -补充: - -- 关于 Hybrid 的更多内容,可以看看这篇文章 [Awesome Hybrid](https://github.com/FantasticLBP/knowledge-kit/blob/master/%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86%20iOS/1.46.md) -- [基于canvas绘图的网页信息防采集技术研究](https://www.doc88.com/p-9186178372509.html) \ No newline at end of file diff --git a/Chapter1 - iOS/1.57.md b/Chapter1 - iOS/1.57.md deleted file mode 100644 index cdfb983..0000000 --- a/Chapter1 - iOS/1.57.md +++ /dev/null @@ -1,72 +0,0 @@ -# 自动布局 - -## 1. iOS开发之AutoLayout中的Content Hugging Priority和 Content Compression Resistance Priority解析 - -- Content Hugging Priority:直译成中文就是“内容拥抱优先级”,从字面意思上来看就是两个视图,谁的“内容拥抱优先级”高,谁就优先环绕其内容。稍后我们会根据一些示例进行介绍。 - -- Content Compression Resistance Priority:该优先级直译成中文就是“内容压缩阻力优先级”。也就是视图的“内容压缩阻力优先级”越大,那么该视图中的内容越难被压缩。而该优先级小的视图,则内容优先被压缩。稍后我们也会通过相应的实例来看一下这个优先级的具体表现。 - -这两个属性是可以在Storyboard中直接设置的,选中要设置的控件,在右边约束一栏里边就有Content Hugging Priority以及Content Compression Resistance Priority的设置地方。Content Hugging Priority的水平和竖直方向的默认值都是250,而Content Compression Resistance Priority的水平和竖直的默认值是750。我们可以在此对该值进行设置。 - - -假如要实现界面上 Label1、Label2、Label3。Label1宽度固定,Label3右侧对齐,且Label3必须显示完全,Label2距离左侧5px,右侧和Label3连在一起,当Label3文字较多的时候,Label2显示不全,显示省略号,Label3文字较少,则Label2完全显示 - - -![Label3文字较多](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-06-autolayout1.jpg) - -![Label3文字较少](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-04-06-autolayout2.jpg) - - -下面是代码实现。 - -```Objective-c -UILabel *label1 = [[UILabel alloc] initWithFrame:CGRectZero]; - label1.text = @"生生世世"; - label1.font = [UIFont systemFontOfSize:15]; - label1.textColor = [UIColor brownColor]; - label1.textAlignment = NSTextAlignmentLeft; - label1.backgroundColor = [UIColor yellowColor]; - - [self.view addSubview:label1]; - - [label1 mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.equalTo(self.view); - make.top.equalTo(self.view).offset(100); - make.width.mas_equalTo(64); - make.height.mas_equalTo(30); - }]; - - UILabel *label2 = [[UILabel alloc] initWithFrame:CGRectZero]; - label2.text = @"杭城小刘杭城小刘杭城小刘杭城小刘杭城小刘杭城小刘杭城小刘"; - label2.textColor = [UIColor purpleColor]; - label2.textAlignment = NSTextAlignmentLeft; - label2.backgroundColor = [UIColor grayColor]; - - [self.view addSubview:label2]; -// [label2 setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; - - [label2 setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; - [label2 mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.equalTo(label1.mas_right).offset(5); - make.top.equalTo(self.view).offset(100); - make.height.mas_equalTo(30); - }]; - - - - UILabel *label3 = [[UILabel alloc] initWithFrame:CGRectZero]; - label3.text = @"刘"; - label3.textColor = [UIColor redColor]; - label3.textAlignment = NSTextAlignmentRight; - label3.backgroundColor = [UIColor blueColor]; - - [self.view addSubview:label3]; - [label3 setContentCompressionResistancePriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal]; -// [label3 setContentHuggingPriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal]; - [label3 mas_makeConstraints:^(MASConstraintMaker *make) { - make.right.equalTo(self.view).offset(-10); - make.top.equalTo(self.view).offset(100); - make.height.mas_equalTo(30); - make.left.equalTo(label2.mas_right); - }]; -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.58.md b/Chapter1 - iOS/1.58.md deleted file mode 100644 index 951f22a..0000000 --- a/Chapter1 - iOS/1.58.md +++ /dev/null @@ -1,19 +0,0 @@ -# Swift 版本迁移问题总结 - -> 工程中存在一部分代码逻辑是 Swift 实现的,每次 Swift 版本升级、Xcode 版本升级或许意味着你需要对 Swfit 实现的这部分代码进行升级改动,此篇文章就记录 Swift 每次升级时踩过的坑 - -## Swift 5 && Xcode 10.2 的踩坑 - -1. 一部分改动是由于系统通知的名称改变造成的 - -| 改动前 | 改动后 | -|:--:|:--:| -| .UIKeyboardWillShow | UIResponder.keyboardWillShowNotification | -| UIAlertControllerStyle.alert | UIAlertController.Style.alert | -| UIAlertActionStyle.cancel | UIAlertAction.Style.alert | -| UIControlState.normal | UIControl.State.normal | -| UIEdgeInsetsMake(0, 20, 0, 20) | UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) | -| XQNetworkingManager.jsonManager | XQNetworkingManager.useJSONSerializer | -| NSNotification.Name.UITextFieldTextDidChange | UITextField.textDidChangeNotification | - -2. 当你修改完语法问题的时候,编译工程,发现还是存在一些问题。大体意思是说 Swift 的编译版本不再支持。所以我们需要选中 targets ,切换到 「Build Settings」 下面,搜索 「Swift Language Version」,在后面勾选合适的 Swift 版本。在这里我选择了 Swift 5 diff --git a/Chapter1 - iOS/1.59.md b/Chapter1 - iOS/1.59.md deleted file mode 100644 index fda8a50..0000000 --- a/Chapter1 - iOS/1.59.md +++ /dev/null @@ -1,20 +0,0 @@ -# 零散知识 - -1. 为什么线上代码尽量不要使用 NSLog("%@", person)? - 因为NSLog使用%@输出本质上是调用了对象的 description 方法,所以代码中存在大量的 NSLog 的时候。会造成性能问题。 - 解决方案:使用宏定义判断当前代码的运行环境,DEBUG 模式下才输出 NSLog,否则就空实现。 - ```objective-c - #ifdef DEBUG - ///一个区分开发和线上环境的Log。NSLog的本质是调用对象方法的 description 方法,所以线上代码使用NSLog会造成性能和安全问题 - #define SafeLog(...) NSLog(__VA_ARGS__) - #else - #define SafeLog(...) - #endif - ``` -2. 如果我们想在某个函数或者方法参数指定参数的类型的话,使用 `id` 编译器不会在编译阶段对其真正的类型做检查,如果我们想指定为一个类的对象或者一个类的子类对象的时候可以使用 `__kindof` 。 - ```Objective-c - - (void)test:(__kindof UIView *)view - { - view.subviews; - } - ``` diff --git a/Chapter1 - iOS/1.6.md b/Chapter1 - iOS/1.6.md deleted file mode 100644 index e017909..0000000 --- a/Chapter1 - iOS/1.6.md +++ /dev/null @@ -1,146 +0,0 @@ -# 双列表联动 - - -> 用过了那么多的外卖App,总结出一个规律,那就是“所有的外卖App都有双列表联动功能”。哈哈哈哈,这是一个玩笑。 -> -> 这次我也需要开发具有联动效果的双列表。也是首次开发这种类型的UI,记录下步骤与心得 - -#### 一、关键思路 - -* 懒加载左右2个UITableView -* 根据需要自定义Cell -* 2个UITableView加载到界面上的时候注意下部剧就好 -* 因为需要联动效果,所有左侧的UITableView一般是大的分类,右边的UITableView一般是大分类小的小分类,所以有了这样的特点 - * 左边的UITableView是只有1个section和n个row - * 右边的UITableView具有n个section(这里的section 个数恰好是左边UITableView的row数量),且每个section下的row由对应的数据源控制 - -#### 二、第一版代码 - -``` -#pragma mark -- UITableViewDelegate --(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{ - if (tableView == self.leftTablview) { - return 1; - } - return self.datas.count; -} - --(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{ - if (tableView == self.leftTablview) { - return self.datas.count; - } - QuestionCollectionModel *model = self.datas[section]; - NSArray *questions =model.questions; - return questions.count; -} - --(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{ - if (tableView == self.leftTablview) { - return LeftCellHeight; - } - return RightCellHeight; -} - --(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{ - if (tableView == self.leftTablview) { - PregnancyPeriodCell *cell = [tableView dequeueReusableCellWithIdentifier:PregnancyPeriodCellID forIndexPath:indexPath]; - if (self.collectionType == CollectionType_Wrong || self.collectionType == CollectionType_Miss) { - QuestionCollectionModel *model = self.datas[indexPath.row]; - cell.week = model.tag; - } - - return cell; - } - QuestionCell *cell = [tableView dequeueReusableCellWithIdentifier:QuestionCellID forIndexPath:indexPath]; - QuestionCollectionModel *model = self.datas[indexPath.section]; - NSArray *questions =model.questions; - QuestionModel *questionModel = questions[indexPath.row]; - cell.model = questionModel; - return cell; -} - - --(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{ - if (tableView == self.leftTablview) { - NSIndexPath *indexpath = [NSIndexPath indexPathForRow:0 inSection:indexPath.row]; - [self.rightTableview scrollToRowAtIndexPath:indexpath atScrollPosition:UITableViewScrollPositionTop animated:YES]; - } -} - --(void)scrollViewDidScroll:(UIScrollView *)scrollView{ - if (scrollView == self.rightTableview) { - NSIndexPath *indexpath = [self.rightTableview indexPathsForVisibleRows].firstObject; - NSIndexPath *leftScrollIndexpath = [NSIndexPath indexPathForRow:indexpath.section inSection:0]; - [self.leftTablview selectRowAtIndexPath:leftScrollIndexpath animated:YES scrollPosition:UITableViewScrollPositionMiddle]; - - } -} -``` - -缺陷:虽然实现了效果,但是有缺陷。点击左侧的UITableView,右侧的UITableViewe滚动到相应的位置,这是没问题的,但是滚动 - -右边,需要根据右边indexPath.section将选中左侧相应的indexPath。这样左侧选中的时候,又会触发右边滚动的事件,整体看上去不是很流畅。 - -#### 三、解决方案 - -观察了下,发现右侧滚动的时候左侧会上下选中,所以也就是只要让右侧滚动的时候,左侧的UITableView单方向选中,不要滚动就好,所以由于UITableView也是UIScrollview,所以在scrollViewDidScroll方法中判断右侧的UITableView是向上还是向下滚动,以此作为判断条件来让左侧的UITableView选中相应的行。 - -且之前是在scrollview代理方法中让左侧的tableview选中,这样子又会触发左侧tableview的选中事件,从而导致右侧的tablview滚动,造成不严谨的联动逻辑 - -改进后的方法: - -1. 点击左侧的UITableView,在代理方法didSelectRowAtIndexPath中拿到相应的indexPath.row,计算出右侧UITableView需要滚动的indexPath的位置。 - ``` - [self.rightTableview scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:indexPath.row] atScrollPosition:UITableViewScrollPositionTop animated:YES]; - ``` -2. 在willDisplayCell和didEndDisplayingCell代理方法中选中左侧UITableView相应的行。 - -``` --(void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{ - - if (tableView == self.rightTableview && !self.isScrollDown && self.rightTableview.isDragging ) { - [self.leftTablview selectRowAtIndexPath:[NSIndexPath indexPathForRow:indexPath.section inSection:0] animated:YES scrollPosition:UITableViewScrollPositionTop]; - } -} - - - --(void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{ - if (tableView == self.rightTableview && self.isScrollDown && self.rightTableview.isDragging) { - [self.leftTablview selectRowAtIndexPath:[NSIndexPath indexPathForRow:indexPath.section+1 inSection:0] animated:YES scrollPosition:UITableViewScrollPositionTop]; - - } -} - - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(nonnull NSIndexPath *)indexPath -{ - if (self.leftTablview == tableView) - { - [self.rightTableview scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:indexPath.row] atScrollPosition:UITableViewScrollPositionTop animated:YES]; - }else{ - NSLog(@"嗡嗡嗡"); - } -} - - -#pragma mark - UIScrollViewDelegate - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView{ - - static CGFloat lastOffsetY = 0; - - UITableView *tableView = (UITableView *)scrollView; - if (self.rightTableview == tableView){ - self.isScrollDown = (lastOffsetY < scrollView.contentOffset.y); - lastOffsetY = scrollView.contentOffset.y; - } - -} -``` - -##### 效果图 - -![效果图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2017-09-24%2015_35_52.gif) - -附上Demo:[Demo](https://github.com/FantasticLBP/BlogDemos) diff --git a/Chapter1 - iOS/1.60.md b/Chapter1 - iOS/1.60.md deleted file mode 100644 index 9b04c54..0000000 --- a/Chapter1 - iOS/1.60.md +++ /dev/null @@ -1,699 +0,0 @@ -# App 瘦身之道 - -App 的包大小做优化的目的就是为了节省用户流量,提高用户的下载速度,也是为了用户手机节省更多的空间。另外 App Store 官方规定 App 安装包如果超过 150MB,那么不可以使 OTA(over-the-air)环境下载,也就是只可以在 WiFi 环境下载,企业或者独立开发者万万不想看到这一点。免得失去大量的用户。 - -同时如果你的 App 需要适配 iOS7、iOS8 那么官方规定主二进制 text 段的大小不能超过 60MB。如果不能满足这个标准,则无法上架 App Store。 - -另一种情况是 App 包体积过大,对用户更新升级率也会有很大影响。 - -所以应用包的瘦身迫在眉睫。 - - - -App 瘦身一般指的是安装包(IPA),主要由**可执行文件、资源组成**。 - -对于产物的分析,可以查看可执行文件的具体组成。 - -Xcode - Build Setting - Write Link Map File 设置为 YES。修改 Path to Link Map File 即可。 - -可借助第三方工具解析LinkMap文件: [GitHub - huanxsd/LinkMap: 检查每个类占用空间大小工具](https://github.com/huanxsd/LinkMap) - - - -## 1. App Thinning - -App Thinning 是指 iOS9 以后引入的一项优化,官方描述如下 - -> The App Store and operating system optimize the installation of iOS, tvOS, and watchOS apps by tailoring app delivery to the capabilities of the user’s particular device, with minimal footprint. This optimization, called app thinning, lets you create apps that use the most device features, occupy minimum disk space, and accommodate future updates that can be applied by Apple. Faster downloads and more space for other apps and content provides a better user experience. - -Apple 会尽可能,自动降低分发到具体用户时,所需要下载的 App 大小。其中包含三项主要功能:Slicing、Bitcode、On-Demand Resources。 - -App Thinning 是苹果公司推出的一项改善 App 下载进程的新技术,主要为了解决用户下载 App 耗费过高流量的问题,同时还可以节省用户设备存储空间。 - -### 1.1 Slicing - -![Slicing](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-AppSlicing.jpeg) - -当向 App Store Connect 上传 .ipa 后,App Store Connect 构建过程中,会自动分割该 App,创建特定的变体(variant)以适配不同设备。然后用户从 App Store 中下载到的安装包,即这个特定的变体,这一过程叫做 Slicing。 - -> Slicing 是创建、分发不同变体以适应不同目标设备的过程 - -而变体之间的差异,又具体体现在架构和资源上。换句话说,App Slicing 仅向设备传送与之相关的资源(取决于屏幕分辨率、系统架构等等) - -其中,2x 和 3x 的细分,要求图片在 **Assets** 中管理。Bundle 内的则会同时包含。 - -![变体](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-AppVariant.jpeg) - -### 1.2 Bitcode - -> Bitcode is an intermediate representation of a compiled program. Apps you upload to iTunes Connect that contain bitcode will be compiled and linked on the App Store. Including bitcode will allow Apple to re-optimize your app binary in the future without the need to submit a new version of your app to the App Store. - -Bitcode 是一种程序`中间码`。包含 Bitcode 配置的程序将会在 App Store Connect 上被重新编译和链接,进而对可执行文件做优化。这部分都是在服务端自动完成的。所以假如以后 Apple 新推出了新的 CPU 架构或者以后 LLVM 推出了一系列优化,我们不需要重新为其发布新的安装包了。Apple Store 会为我们自动完成这步。然后提供对应的 variant 给具体设备 - -对于 iOS 而言,Bitcode 是可选的(Xcode7 以后创建的新项目默认开启),watchOS、tvOS 则是必须的。 - -开启位置:Build Settings -> Enable Bitcode -> 设置为 YES - -开启 Bitcode,有这么2点需要注意: - -- 全部都要支持。我们所依赖的静态库、动态库、Cocoapods 管理的第三方库,都需要开启 Bitcode。否则会编译失败 - -- 奔溃定位。开启 Bitcode 后最终生成的可执行文件是 Apple 自动生成的,同时会产生新的符号表文件,所以我们无法使用自己包生成的 dYSM 符号化文件来进行符号化。 - -> For Bitcode enabled builds that have been released to the iTunes store or submitted to TestFlight, Apple generates new dSYMs. You’ll need to download the regenerated dSYMs from Xcode and then upload them to Crashlytics so that we can symbolicate crashes.For Bitcode enabled apps, ensure that you have checked “Include app symbols for your application…” so that we can provide the most accurate crash reports. - -上面是 fabric 中关于 Downloading Bitcode dYSMs 的描述: - -在上传到 App Store 时需勾选“Includ app symbols for your application...”。勾选之后 Apple 会自动生成对应的 dYSM,然后可以在 Xcode -> Window -> Organizer 中,或者在 Apple Store Connect 中下载对应的 dYSM 来进行符号化 - -![App Connect-dYSM](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-AppConnectYSM.jpeg) - -![Xcode-dYSM](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-XcodedYSM.jpeg) - -那么 Bitcode 会对 App Thining 有什么作用? - -在 New Features in Xcode7 中有这么一段描述: - -> Bitcode. When you archive for submission to the App Store, Xcode will compile your app into an intermediate representation. The App Store will then compile the bitcode down into the 64 or 32 bit executables as necessary. - -即,App Store 会再按需将这个 bitcode 编译进 32/64 位的可执行文件。 -所以网上铺天盖地地说 Bitcode 完成了具体架构的拆分,从而实现瘦包 - -### 1.3 on-Demand Resources - -on-Demand Resource 即一部分图片可以被放置在苹果的服务器上,不随着 App 的下载而下载,直到用户真正进入到某个页面时才下载这些资源文件。 - -![on-DemandResources](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-on-DemandResources.png) - -应用场景:相机应用的贴纸或者滤镜、关卡游戏等 - -如需支持 iOS9 以下系统,那么无法使用这个功能,否则上传会失败 - -## 2 包体积 - -2个概念 - -- .ipa (iOS Application Package):iOS 应用程序归档文件,即提交到 App Store Connect 的文件 - -- .app (Application):应用的具体描述,即安装到 iOS 设备上的文件 - -当我们拿到 Archive 后的 .ipa,使用解压软件打开后,Payload 目录下存放的就是 .app 文件,二者大小相当 - -包体积,评判标准是以 App Store 上看到的为准。但是上传到 App Store Connect 处理完后,会自动帮我们生成具体设备上看到的大小。如下: - -![App Store 包大小](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2018-11-15-AppVolume.jpeg) - -这其中:又可以分为2类: Universal 和具体设备 -Universal 指通用设备,即未应用 App slicing 优化,同时包含了所有架构、资源。所以包体积会比较大 - -观察 .ipa 的大小和 Universal 对应的包大小相当,稍微小一点,因为 App Store 对 .ipa 做了加密处理 - -有时候下载 App 会提示“此项目大于 150MB,除非此项目支持增量下载,否则您必须连接至 WiFi 才能下载”。150MB 针对的是下载大小。 - -- 下载大小:通过 WiFi 下载的压缩 App 大小 -- 安装大小:此 App 将在用户设备上占用磁盘空间的大小 - -所以我们要瘦包,关键在于减小 .app 文件的大小。 - -### 2.1 Architectures - -如果不支持32位以及 iOS8 ,去掉 armv7 ,可执行文件以及库会减小,即本地 .ipa 也会减小 - -### 2.2 Resources - -资源的优化也就是平时的细心与审查。 - -图片、内置素材、Bundle、多语言、Json、字体、脚本、Plist、音频 - -图片:Assets.car -Bundle: 非放在 Asset Catlog 中管理的图片资源。包括 Bundle,散落的 png、jpg 等 - -瘦包具体的方式: - -- 无用资源的删除 -- 重复文件的删除 -- 大文件压缩 -- 图片管理方式规范 -- on-Demand Resource(游戏的、前置关卡依赖、滤镜App 等的依赖资源,建议用这种方式动态下载图片资源) - -#### 2.2.1 无用文件的删除 - -无用文件主要包含:无用图片、无用非图片部分。 - -非图片部分:资源较少,使用方式固定。比如音频、字体。需要手动排查 -图片部分:主要使用一个开源的 Mac App [LSUnusedResources](https://github.com/tinymind/LSUnusedResources) 进行冗余图片的排查。 - -删除无用的图片过程,可以概括为下面6步: - -1. 通过 find 命令获取 App 安装包中的所有资源文件 -2. 设置用到的资源类型。比如 gif、jpg、jpeg、png、webp -3. 使用正则匹配出在源码中使用到的资源名,比如 pattern = @"@"(.+?)"" -4. 使用 find 命令找到篇所有资源文件,再去源码中找到使用到的资源文件,2个集合的差集就是无用资源了。 -5. 确认无用资源后可以使用 NSFileManager 进行文件的删除。 - -如果不想重新写一个工具,那么可以直接使用开源的工具 LSUnusedResources - -但是存在一点问题。会出现误报,因为不同的项目,图片使用方式不一样。 - -``` -- (BOOL)containsSimilarResourceName:(NSString *)name { - NSString *regexStr = @"([-_]?\\d+)"; - NSRegularExpression* regexExpression = [NSRegularExpression regularExpressionWithPattern:regexStr options:NSRegularExpressionCaseInsensitive error:nil]; - NSArray* matchs = [regexExpression matchesInString:name options:0 range:NSMakeRange(0, name.length)]; - //... -} -``` - -源码中的正则表达式处理的情况并不是很准确。可以根据自己的情况修改正则即可 - -#### 2.2.2 图片资源的压缩 - -删除了无用的资源,那么对于资源这块还是有操作的空间的,比如图片资源的压缩。目前压缩比较好的方案就是 WebP,它是谷歌公司的一个开源项目。 - -WebP 的优势: - -- 压缩率高。支持有损和无损2种方式,比如将 Gif 图可以转换为 Animated WebP,有损模式下可以减小 64%,无损模式下可以减小 19% -- WebP 支持 Alpha 透明和 24-bit 颜色数,不会像 PNG8 那样因为色彩不够出现毛边。 - -Google 公司在开源 WebP 的同时,还提供了一个图片压缩工具 [cwebp](https://developers.google.com/speed/webp/docs/precompiled)。 -压缩完之后使用 WebP 格式的图片还需使用 libwebp 进行解析,参考这个[Demo](https://github.com/carsonmcdonald/WebP-iOS-example)。 - -缺点:WebP 在 CUP 消耗和解码时间上会比 PNG 高2倍,所以我们做选择的时候需要取舍。 - -#### 2.2.3 重复文件删除 - -重复文件,即两个内容完全一致的文件。但是文件命名不一样。 - -借助 [fdupes](https://github.com/adrianlopezroche/fdupes) 这个开源工具,校验各资源的 MD5。 - -fdupes 是 Linux 下的一个工具,它由 Adrian Lopez 用 C 语言编写并基于 MIT 许可证发行,该应用程序可以在指定的目录及子目录中查找重复的文件。fdupes 通过对比文件的 MD5 签名,以及逐字节比较文件来识别重复内容,fdupes 有各种选项,可以实现对文件的列出、删除、替换为文件副本的硬链接等操作。 - -文件对比从以下顺序开始: -大小对比 > 部分 MD5 签名对比 > 完整 MD5 签名对比 > 逐字节对比 - -执行结束后会在命令行展示出来,所以需要我们人工将这些文件确认对比后删除掉。 - -#### 2.2.4 大文件压缩 - -图片本身的压缩,建议使用 ImageOptim。它整合了 Win、Linux 上诸多著名图片处理工具的特色,比如 PNGOUT、AdvPNG、Pngcrush、OptiPNG、JpegOptim、Gifsicle 等。 -Bundle 内的图片资源必须压缩,因为 Xcode 并不会对其进行压缩。所以做好将图片都用 Assets 管理。 - -Xcode 提供给我们2个编译选项来帮助压缩图像: - -- Compress PNG Files: 打包的时候自动对图片进行无损压缩。使用的工具为 pngcrush,压缩比蛮高。 -- Remove Text Medadata From PNG Files:移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息 - -#### 2.2.5 图片管理方式规范 - -##### 2.2.5.1 主工程中的图片管理 - -工程中所有使用的 Asset Catlog 管理的图片(在 .xcassets 文件夹下)最终都会输出到 Asset.car 内。不在 Asset.car 内的都归为 Bundle 管理。 - -- xcassets 里面的图片。只能通过 imageNamed 加载。 Bundle 里面的图片还可以通过 imageWithContentsOfFile 等方式 -- xcassets 里面的 @2x、@3x 会根据具体设备分发,不会同时包含。Bundle 都包含(不进行 App Slicing) -- xcassets 内可以对图片进行 Slicing,即裁剪和拉伸、Bundle 不支持 -- Bundle 内支持多语言,Images.xcassets 不支持 - -> 使用 imageNamed 创建的 UIImage 会被立即加入到 NSCache 中(解码后的 Image Buffer),直到收到内存警告的时候才会释放不使用的 UIImage。而 imageWithContentsOfFile 会每次重新申请内存,相同图片不会缓存,所以 xcassets 内的图片,加载后会产生缓存 - -综上:常用的、较小的图建议存放在 Images.xcassets 内管理。大图放在 Bundle 内管理。 - -这里讲一个插曲了,曾经很多文章都在谈一个结论,那就是「图片放在 Images.xcassets 里面更加快速且节省空间,直接放在 bundle 里面会比较慢」。我做过实验,实验环境和结论如下。使用 Instruments 测量耗时。 - -
-点击展开 - -```Objective-C -//实验1 -NSMutableArray *images = [NSMutableArray array]; -for (NSInteger index = 0; index < 10; index++) { - UIImage *image = [UIImage imageNamed:@"icon-iOS"]; - [images addObject:image]; -} -self.imageView.image = images.lastObject; -//实验2 -NSMutableArray *images = [NSMutableArray array]; -for (NSInteger index = 0; index < 10; index++) { - NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"iOS" ofType:@"png"]; - [UIImage imageNamed:@"icon-iOS"]; - UIImage *image = [UIImage imageWithContentsOfFile:imagePath]; - [images addObject:image]; -} -self.imageView.image = images.lastObject; -``` - -
- -Timeprofile-imageNamedFromAssets -![Timeprofile-imageNamedFromAssets](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-07-Timeprofile-imageNamedFromAssets.png) - -TimeProfile-imageWithContentsOfFile -![TimeProfile-imageWithContentsOfFile](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-07-TimeProfile-imageWithContentsOfFile.png) - -Timeprofile-UIImageNamedFromFolder -![Timeprofile-UIImageNamedFromFolder](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-07-Timeprofile-UIImageNamedFromFolder.png) - -Images.xcassets : - -- 图片大小要精确,不要出现图片太大的情况 -- 不要存放大图,不然会产生缓存 -- 不要存 jpg 图片,打包会变大 -- 图片不需要额外压缩(有人做过实验,对放入 assets 里面的图片进行压缩后打包发现包体积反而增大,怀疑是 Xcode 的编译选项 Compress PNG Files 自动对图片进行压缩,2种压缩起了冲突反而增大) - -##### 2.2.5.2 各个 pod 库中的图片管理 - -CocoPods 中两种资源引用方式介绍下: - -- resource_bundles - - > We strongly recommend library developers to adopt resource bundles as there can be name collisions using the resources attribute. - > 允许定义当前的 pod 库的最远包的名称和文件。用 hash 形式声明,key 是 bundle 的名称,value 是需要包含文件的通配 patterns - > CocoPods 官方强烈推荐该方法引用资源,因为 key-value 可以避免相同资源的名称冲突 -- resources - - > We strongly recommend library developers to adopt resource bundles as there can be name collisions using the resources attribute. Moreover, resources specified with this attribute are copied directly to the client target and therefore they are not optimised by Xcode. - > 使用该方法引用资源,被指定的资源会被拷贝进 target 工程的 main bundle 中。 - -说说项目中的情况吧:在工程中之前是通过 resource_bundles 引用资源的。资源是放在 Resources 目录下的图片引用。查询资料后说「如果图片资源放到 .xcasset 里面 Xcode 会帮我们自动优化、可以使用 Slicing 等(这里不仅仅指的是 resource_bundle 下的 xcassets」。所以动手将各个 Pod 库里面的图片全都通过 Assets Catalog 的方式进行处理。 - -![Pod组件库图片处理前后对比](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-08-Cocopod-Assets.png) - -步骤: - -- 在各个 Pod 组件库里面的 Resources 目录下新建 Asset Catalog 文件,命名为 Images.xcassets - -- 将 Resources 里面零散的图片资源拖进 Images.xcassets 里面 - -- 修改每个组件库的 podspec 文件 - -
- - 点击展开 - - ``` - s.resource_bundles = { - 'XQ_UI' => ['XQ_UI/Assets/*.xcassets'] - } -
- ``` - -- 主工程执行 pod install - -话说 `resources` 和 `resource_bundles` 都可以使用 Asset Catalog,那么有何区别? - -- resources 只会将资源文件 copy 到 target 工程,最后和 target 工程的图片资源以及同样使用该方式的 Pod 库的图片资源共同打包到一个 `Assets.car` 中。因此图片资源会有混乱的可能。 -- resource_bundles 会生成一个你在 `podspec` 中指定名称的 bundle,且在 bundle 中也会生成一个 Assets.car。所以图片是肯定不会混乱的,但是图片的访问方式需要注意。 - -解决方法:为每个 pod 新建一个图片的分类,比如 UIImage+XQUIModule。然后访问图片的时候通过 `[UIImage xquiModuleImageNamed:@"pull"]` 访问。 - -
-点击展开 - -```Objective-C -#import "UIImage+XQUIModule.h" -#import - -@implementation UIImage (XQUIModule) - -+ (nonnull UIImage *)xquiModuleImageNamed:(nonnull NSString *)name -{ - return [UIImage imageNamed:name inBundleName:@"XQ_UI"]; -} -@end - -//UIImage+Bundle.m -#import "UIImage+Bundle.h" - -@implementation UIImage (Bundle) - -+ (nullable UIImage *)imageNamed:(NSString *)name inBundleName:(nullable NSString *)bundleName { - NSBundle *bundle = [NSBundle bundleWithURL:[[NSBundle mainBundle] URLForResource:bundleName withExtension:@"bundle"]]; - return [UIImage imageNamed:name inBundle:bundle compatibleWithTraitCollection:nil]; -} -@end -``` - -
- -#### 2.2.6 矢量图的使用 - -事实上,对于 App 里面的单色图标,比如左上角的返回按钮、底部的 tabBar等,只要是单色的纯色图标都是可以使用矢量图代替的,比如 PDF、ttf 字体图标等。这样就不需要添加 @2x、@3x 图标,节省了空间。 - -iOS 中如何使用 ttf 矢量图,可以查看这个 [Repo](https://github.com/FantasticLBP/IconFont_Demo) - -## 3. Executable file - -### 3.1 编译选项优化 - -#### 3.1.1 Generate Debug Symbols - -> Enables or disables generation of debug symbos. When debug symbols are enabled, the level of detail can be controller by the build 'Level of Debug Symbols' Setting. - -调试符号是在编译时形成的。当 Generate Debug Symbols 选项为 YES 的时,每个源文件在编译成 .o 文件时,编译参数多了 -g 和 -gmodules 两项。打包会生成 symbols 文件。设置为 NO 则 ipa 中不会生成 symbol 文件,可以减少 ipa 大小。但会影响到崩溃的定位。保持默认的开启,不做修改。 - -#### 3.1.2 Asset Catalog Compiler - -optimization 选项设置为 space 可以减少包大小 -默认选项,不做修改。 - -#### 3.1.3 Dead Code Stripping - -> For statically linked executables, dead-code stripping is the process of removing unreferenced code from the executable file. If the code is unreferenced, it must not be used and therefore is not needed in the executable file. Removing dead code reduces the size of your executable and can help reduce paging. - -删除静态链接的可执行文件中未引用的代码 - -Debug 设置为 NO, Release 设置为 YES 可减少可执行文件大小。 - -Xcode 默认会开启此选项,C/C++/Swift 等静态语言编译器会在 link 的时候移除未使用的代码,但是对于 Objective-C 等动态语言是无效的。因为 Objective-C 是建立在运行时上面的,底层暴露给编译器的都是 Runtime 源码编译结果,所有的部分应该都是会被判别为有效代码。 - -带来的好处: -- App 可执行文件体积减少 -- 减少 App 分页(分页过多会容易引起内存引起缺页异常) - -默认选项,不做修改。 - -#### 3.1.4 Apple Clang - Code Generation - -Optimization Level 编译参数决定了程序在编译过程中的两个指标:编译速度和内存的占用,也决定了编译之后可执行结果的两个指标:速度和文件大小。 -Build Settings -> code Generation -> Optimization Level -默认情况下,Debug 设定为 None[-O0] ,Release 设定为 Fastest,Smallest[-Os]。 - -- None[-O0]。 Debug 默认级别。不进行任何优化,直接将源代码编译到执行文件中,结果不进行任何重排,编译时比较长。主要用于调试程序,可以进行设置断点、改变变量 、计算表达式等调试工作。 - -- Fast[-O,O1]。最常用的优化级别,不考虑速度和文件大小权衡问题。与-O0级别相比,它生成的文件更小,可执行的速度更快,编译时间更少。 - -- Faster[-O2]。在-O1级别基础上再进行优化,增加指令调度的优化。与-O1级别相,它生成的文件大小没有变大,编译时间变长了,编译期间占用的内存更多了,但程序的运行速度有所提高。 - -- Fastest[-O3]。在-O2和-O1级别上进行优化,该级别可能会提高程序的运行速度,但是也会增加文件的大小。 - -- Fastest Smallest[-Os]。Release 默认级别。这种级别用于在有限的内存和磁盘空间下生成尽可能小的文件。由于使用了很好的缓存技术,它在某些情况下也会有很快的运行速度。 - -- Fastest, Aggressive Optimization[-Ofast]。 它是一种更为激进的编译参数, 它以点浮点数的精度为代价。 - -默认选项,不做修改。 - -#### 3.1.5 Swift Compiler - Code Generation - -Xcode 9.3 版本之后 Swift 编译器提供了新的 Optimization Level 选项来帮助减少 Swift 可执行文件的大小: - -- No optimization[-Onone]:不进行优化,能保证较快的编译速度。 -- Optimize for Speed[-O]:编译器将会对代码的执行效率进行优化,一定程度上会增加包大小。 -- Optimize for Size[-Osize]:编译器会尽可能减少包的大小并且最小限度影响代码的执行效率。 - -> We have seen that using -Osize reduces code size from 5% to even 30% for some projects. But what about performance? This completely depends on the project. For most applications the performance hit with -Osize will be negligible, i.e. below 5%. But for performance sensitive code -O might still be the better choice. - -官方提到,-Osize 根据项目不同,大致可以优化掉 5% - 30% 的代码空间占用。 相比 -0 来说,会损失大概 5% 的运行时性能。 如果你的项目对运行速度不是特别敏感,并且可以接受轻微的性能损失,那么 -Osize 是首选。 - -除了 -O 和 -Osize, 还有另外一个概念也值得说一下。 就是 Single File 和 Whole Module 。 在之前的 XCode 版本,这两个选项和 -O 是连在一起设置的,Xcode 9.3 中,将他们分离出来,可以独立设置: - -Single File 和 Whole Module 这两个模式分别对应编译器以什么方式处理优化操作。 - -- Single File:逐个文件进行优化,它的好处是对于增量编译的项目来说,它可以减少编译时间,对没有更改的源文件,不用每次都重新编译。并且可以充分利用多核 CPU,并行优化多个文件,提高编译速度。但它的缺点就是对于一些需要跨文件的优化操作,它没办法处理。如果某个文件被多次引用,那么对这些引用方文件进行优化的时候,会反复的重新处理这个被引用的文件,如果你项目中类似的交叉引用比较多,就会影响性能。 - -- Whole Module: 将项目所有的文件看做一个整体,不会产生 Single File 模式对同一个文件反复处理的问题,并且可以进行最大限度的优化,包括跨文件的优化操作。缺点是,不能充分利用多核处理器的性能,并且对于增量编译,每次也都需要重新编译整个项目。 - -如果没有特殊情况,使用默认的 Whole Module 优化即可。 它会牺牲部分编译性能,但的优化结果是最好的。 - -故,在 Relese 模式下 -Osize 和 Whole Module 同时开启效果会最好! - -#### 3.1.6 Strip Symbol Information - -1、Deployment Postprocessing -2、Strip Linked Product -3、Strip Debug Symbols During Copy -4、Symbols hidden by default - -设置为 YES 可以去掉不必要的符号信息,可以减少可执行文件大小。但去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。 - -Symbols Hidden by Default 会把所有符号都定义成”private extern”,详细信息见官方文档。 - -故,Release 设置为 YES,Debug 设置为 NO。 - -#### 3.1.7 Exceptions - -在 iOS微信安装包瘦身 一文中,有提到: - -> 去掉异常支持,Enable C++ Exceptions 和 Enable Objective-C Exceptions 设为 NO,并且 Other C Flags 添加 `-fno-exceptions`,可执行文件减少了27M,其中 __gcc_except_tab 段减少了17.3M,__text 减少了 9.7M,效果特别明显。可以对某些文件单独支持异常,编译选项加上 `-fexceptions` 即可。但有个问题,假如 ABC 三个文件,AC 文件支持了异常,B 不支持,如果 C 抛了异常,在模拟器下 A 还是能捕获异常不至于 Crash,但真机下捕获不了。去掉异常后,Appstore 后续几个版本 Crash 率没有明显上升。 - -个人认为关键路径支持异常处理就好,像启动时 NSCoder 读取 setting 配置文件得要支持捕获异常,等等 - -看这个优化效果,感觉发现了新大陆。关闭后验证.. 毫无感知,基本没什么变化。 - -可能和项目中用到比较少有关系。故保持开启状态。 - - - -潜在问题与解决方案 - -问题 1:依赖异常的标准库组件失效 - -- 现象:如 `std::vector` 在内存不足时直接崩溃。 -- 解决: - - 使用 `std::nothrow` 分配内存。 - - 替换为无异常的容器实现(如自定义或第三方库)。 - -问题 2:第三方库依赖异常 - -- 现象:链接时报错(如库中未定义异常相关符号)。 -- 解决: - - 重新编译第三方库,确保其也启用 `-fno-exceptions`。 - - 隔离异常代码,通过 C 接口封装调用。 - -问题 3:代码中残留 `try`/`catch` - -- 现象:编译错误 `error: exception handling disabled`。 -- 解决: - - 全局搜索并删除所有异常处理代码。 - - 使用宏或条件编译隔离异常代码(不推荐)。 - - - -替代方案:若需保留部分异常逻辑但优化性能,可考虑**局部禁用异常**:通过 `#pragma clang exception_behavior disable` 或函数级属性控制。 - -```c++ -#pragma clang exception_behavior disable -void criticalFunction() { - // 此函数内禁用异常处理 -} -#pragma clang exception_behavior enable -``` - -#### 3.1.8 Link-Time Optimization - -Link-Time Optimization 是 LLVM 编译器的一个特性,用于在 link 中间代码时,对全局代码进行优化。这个优化是自动完成的,因此不需要修改现有的代码;这个优化也是高效的,因为可以在全局视角下优化代码。 - -苹果在 WWDC 2016 中,明确提出了这个优化的概念,What’s New in LLVM。并且说在苹果内部已经广泛地使用这个优化方法进行编译。 - -它的优化主要体现在如下几个方面: - -1. 多余代码去除(Dead code elimination):如果一段代码分布在多个文件中,但是从来没有被使用,普通的 -O3 优化方法不能发现跨中间代码文件的多余代码,因此是一个“局部优化”。但是Link-Time Optimization 技术可以在 link 时发现跨中间代码文件的多余代码。 - -2. 跨过程优化(Interprocedural analysis and optimization):这是一个相对广泛的概念。举个例子来说,如果一个 if 方法的某个分支永不可能执行,那么在最后生成的二进制文件中就不应该有这个分支的代码。 - -3. 内联优化(Inlining optimization):内联优化形象来说,就是在汇编中不使用 “call func_name” 语句,直接将外部方法内的语句“复制”到调用者的代码段内。这样做的好处是不用进行调用函数前的压栈、调用函数后的出栈操作,提高运行效率与栈空间利用率。 - -在新的版本中,苹果使用了新的优化方式 Incremental,大大减少了链接的时间。建议开启。 - -总结,开启这个优化后,一方面减少了汇编代码的体积,一方面提高了代码的运行效率。 - -### 3.2 代码瘦身 - -代码的优化,即通过删除无用类、无用方法、重复方法等,来达到可执行文件大小的减小。 -而如何筛选出符合条件的无用类、方法,则需要通过一些工具来完成(fui) - -**编写 LLVM 插件检测处重复代码,未被调用的代码。** - -扫描无用代码的基本思路都是查找已经使用的方法/类和所有的类/方法,然后从所有的类/方法当中剔除已经使用的方法/类剩下的基本都是无用的类/方法,但是由于 Objective-C 是动态语言,可以使用字符串来调用类和方法,所以检查结果一般都不是特别准确,需要二次确认。目前市面上的扫描的思路大致可以分为 3 种: - -- 基于 Clang 扫描 -- 基于可执行文件扫描 -- 基于源码扫描 - -先谈几个概念。 - -可执行文件就是 **Mach-O** 文件,其大小是油代码量决定的,通常情况下,对可执行文件进行瘦身,就是找到并删除无用代码的过程。找到无用代码的过程类比找到无用图片的思路。 - -- 找到类和方法的全集 -- 找到使用过的类和方法集合 -- 取2者差集得到无用代码集合 -- 工程师确认后,删除即可 - -LinkMap 文件分为3部分:Object File、Section、Symbols。 - -​ - -- Object File:包含了代码工程的所有文件 -- Section:描述了代码段在生成的 Mach-O 里的偏移位置和大小 -- Symbols:会列出每个方法、类、Block,以及它们的大小 - -先说说如何快速找到方法和类的全集? - -我们可以通过 **LinkMap** 来获得所有的代码类和方法的信息。获取 LinkMap 可以通过将 Build Setting 里面的 **Write Link Map File** 设置为 YES,然后指定 **Path to Link Map File** 的路径就可以得到每次编译后的 LinkMap 文件了。 -![c](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-06-LinkMap-Xcode.png) - -产出的 LinkMap 阅读起来比较累,github 有个[可视化项目](https://github.com/jayden320/LinkMap) 用来查看 LinkMap 文件。 - -#### 3.2.1 基于 clang 扫描 - -基本思路是基于 clang AST。追溯到函数的调用层级,记录所有定义的方法/类和所有调用的方法/类,再取差集。具体原理参考 如何使用 Clang Plugin 找到项目中的无用代码,目前只有思路没有现成的工具。 - -#### 3.2.2 基于可执行文件扫描(LinkMap 结合 Mach-O 找无用代码) - -上面我们得知可以通过 LinkMap 统计出所有的类和方法,还可以清晰地看到代码所占包大小的具体分布,进而有针对性地进行代码优化。 - -![LinkMap-Object file](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-05-LinkMap-ObjectFile.png) - -![LinkMap-Sections](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-05-LinkMap-Sections.png) - -![LinkMap-Symbols](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-05-LinkMap-Symbols.png) - -得到了代码的全集信息后,我们还需要找到已经使用过的方法和类,这样才可以获取差集,找到无用代码。所以接下来就谈谈如何通过 Mach-O 取到使用过的类和方法。 - -Objective-C 中的方法都会通过 **objc_msgSend** 来调用,而 objc_msgSend 在 Mach-O 文件里是通过 **_objc_selrefs** 这个 **section** 来获取 selector 这个参数的。 - - 里是被调用过的类, **objc_superrefs** 是调用过 super 的类(继承关系)。通过 _objc_classrefs 和 _objc_superrefs,我们就可以找出使用过的类和子类。 - -那么,Mach-O 文件中的 _objc_selrefs、_objc_classrefs、_objc_superrefs 如何查看呢? - -1. 使用 otool 等命令逆向可执行文件中引用到的类/方法和所有定义的类/方法,然后计算差集。具体参考iOS微信安装包瘦身,目前只有思路没有现成的工具。 -2. 使用 [MachOView](https://github.com/gdbinit/MachOView) 查看。但是这个项目运行不起来,这个新的 [Repo](https://github.com/fangshufeng/MachOView) 可以运行起来。 - -下面举例说明: - -前置条件:先运行项目,在生成的 Products 目录下的 BridgeLabiPhone.app 解压,取出对应的和工程同名的 BridgeLabiPhone。然后运行上面的 Github 项目。可以看到运行了一个 Mac App。点击顶部的菜单栏里面的 File->Open。选择电脑上的 BridgeLabiPhone.app 选择里面的 BridgeLabiPhone。见下图 - -![Mach-O-inspect](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-07-Mach-O-Inspect.png) - -由于 Objective-C 是一门动态语言,所以检测出的结果仍旧需要我们2次确认。 - -#### 3.2.3 基于源码扫描 - -一般都是对源码文件进行字符串匹配。例如将 A *a、[A xxx]、NSStringFromClass("A")、objc_getClass("A") 等归类为使用的类,@interface A : B 归类为定义的类,然后计算差集。 - -基于源码扫描 有个已经实现的工具 - fui,但是它的实现原理是查找所有 #import "A" 和所有的文件进行比对,所以结果相对于上面的思路来说可能更不准确。 - -#### 3.2.4 通过 AppCode 查找无用代码 - -AppCode 提供了 Inspect Code 来诊断代码,其中含有查找无用代码的功能。它可以帮助我们查找出 AppCode 中无用的类、无用的方法甚至是无用的 import ,但是无法扫描通过字符串拼接方式来创建的类和调用的方法,所以说还是上面所说的 基于源码扫描 更加准确和安全。 - -![AppCode-code inspect](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-05-CodeClean.png) - -说明:AppCode检测出了实际上需要的大部分场景的问题,但是由于 Objective-C 是一门动态性语言,所以 AppCode 检测出无用的方法等都需要工程师自己再次确认后删除。(在我们的工程中有一些和 H5 交互的桥接方法,因此 AppCode 视为 Unused Method,但是你删除的话,那就自己哭去吧 😭)。实际经验告诉我,使用 AppCode 的时候如果工程比较大,则整个 code inspect 会非常耗时(给你打个预防针哦,笔芯) - -- 无用类:Unused class 是无用类,Unused import statement 是无用类引入声明,Unused property 是无用的属性; -- 无用方法:Unused method 是无用的方法,Unused parameter 是无用参数,Unused instance variable 是无用的实例变量,Unused local variable 是无用的局部变量,Unused value 是无用的值; -- 无用宏:Unused macro 是无用的宏。 -- 无用全局:Unused global declaration 是无用全局声明。 - -#### 3.2.5 运行时真正检测类是否用过 - -通过上述手段找到并删除了无用代码。App 不断上线迭代蛮多代码都不会被调用了(业务被砍掉了)。这种方式下这些无用的代码也是可以被删除的。 - -通过 Objective-C 的 runtime 源码,我们可以找到如何判断一个类是否初始化过的函数。 - -```Objective-c -#define RW_INITIALIZED (1<<29) -bool isInitialized() { - return getMeta()->data()->flags & RW_INITIALIZED; -} -``` - -isInitialized 的结果会保存到元类的 class_rw_t 结构体的 flags 信息里, flags 的 1<<29 位记录的就是这个类是否初始化了的信息,而 flags 的其他位记录的信息,可以查看 rumtime 的源码 - -```Objective-c -// 类的方法列表已修复 -#define RW_METHODIZED (1<<30) - -// 类已经初始化了 -#define RW_INITIALIZED (1<<29) - -// 类在初始化过程中 -#define RW_INITIALIZING (1<<28) - -// class_rw_t->ro 是 class_ro_t 的堆副本 -#define RW_COPIED_RO (1<<27) - -// 类分配了内存,但没有注册 -#define RW_CONSTRUCTING (1<<26) - -// 类分配了内存也注册了 -#define RW_CONSTRUCTED (1<<25) - -// GC:class 有不安全的 finalize 方法 -#define RW_FINALIZE_ON_MAIN_THREAD (1<<24) - -// 类的 +load 被调用了 -#define RW_LOADED (1<<23) -``` - -既然可以在运行的期间知道类是否初始化了,那么就可以找出哪些类未初始化,即可以找到在真实环境里面没有用到的类并删除掉。 - -## 4. App Extension - -App Extension 的占用,都放在 Plugin 文件夹内。它是独立打包签名,然后再拷贝进 Target App Bundle 的。 -关于 Extension,有两个点要注意: - -静态库最终会打包进可执行文件内部,所以如果 App Extension 依赖了三方静态库,同时主工程也引用了相同的静态库的话,最终 App 包中可能会包含两份三方静态库的体积。 - -动态库是在运行的时候才进行加载链接的,所以 Plugin 的动态库是可以和主工程共享的,把动态库的加载路径 Runpath Search Paths 修改为跟主工程一致就可以共享主工程引入的动态库。 - -所以,如果可能的话,把相关的依赖改成动态库方式,达到共享。 - -## 5. 静态库瘦身 - -项目中都会引入第三方静态库。通过 lipo 工具可以查看支持的指令集,比如查看微博 SDK -终端切换到微博 SDK 的目录下执行下面命令 - -- 静态库指令集信息查看:`lipo -info libname.a(或者libname.framework/libname)` - -```Shell -lipo -info libWeiboSDK.a -//Architectures in the fat file: libWeiboSDK.a are: armv7 arm64 i386 x86_64 -``` - -我们知道 i386、x86_64 是模拟器的指令集。所以我们可以模拟器版本的指令集。因为 armv7 也可以兼容 armv7s。所以 armv7s 也可以删除了。只保留 armv7 和 arm64 - -- 静态库拆分:`lipo 静态库文件路径 -thin CPU架构 -output 拆分后的静态库文件路径` -- 静态库合并:`lipo -create 静态库1文件路径 静态库2文件路径... 静态库n文件路径 -output 合并后的静态库文件径` - -```Shell -lipo libWeiboSDK.a -thin armv7 -output libWeiboSDK-armv7.a -lipo libWeiboSDK.a -thin arm64 -output libWeiboSDK-arm64.a -lipo create libWeiboSDK-armv7.a libWeiboSDK-arm64.a -output libWeiboSDK.device.a -``` - -通过上面的操作我们将静态库里面支持模拟器的指令集给去掉了,所以模拟器是无法跑代码的,如何解决? - -1. 平时使用包含模拟器指令集的静态库,在 App 发布的时候去掉 -2. 如果使用 Cocoapods 管理可以使用2份 Podfile 文件。一份包含指令集一份不包含,发布的时候切换 Podfile 文件即可。或者一份 Podfile 文件,但是配置不同的环境设置 - -补充2个说明: - -1. dSYM 文件 - 符号表文件 .dSYM 文件是从 Mach-O 文件中抽取调试信息而得到的文件目录,实际用于保存调试信息的是 DWARF 文件 -- 自动生成。Xcode 会在工程编译或者归档的时候自动生成 .dSYM 文件,在 Buld setting 设置中有开关可以设置去关掉 .dSYM 文件 - -- 手动生成。通过脚本从 Mach-O 文件中提取出来。 - - ``` - $ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/dsymutil /Users/wangzz/Library/Developer/Xcode/DerivedData/YourApp-cqvijavqbptjyhbwewgpdmzbmwzk/Build/Products/Debug-iphonesimulator/YourApp.app/YourApp -o YourApp.dSYM - ``` - - 该方式通过 dsymutil 工具,从项目编译结果 .app 目录下的 Mach-O 文件中提取出调试符号表文件。Xcode 在归档的时候是通过它生辰的 .dSYM 文件 -2. DWARF 文件 - DebuggingWith Arbitrary Record Formats 是 ELF 和 Mach-O 等文件格式中用来存储和处理调试信息的标准格式,.dSYM 文件中真正保存符号表数据的是 DWARF 文件。DWARF 文件中不同的数据都保存在相应的 section 中。 - -最后的一个对比效果图: -![瘦身效果图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-09-AppThinning-Comparation.png) - -总结:瘦身技术常见操作就这些,但是维持应用包体积的瘦身却是一个观念,从日常开发到线上发布都需要有这个意识。这样当你在写代码的时候就会考虑同样一个效果,你的具体实现手段是怎么样的。比如为了一个稍微炫酷的效果就要引入一个很大的三方库,有了“瘦身”的意识,你很大可能就是自己动手撸一个代码。比如一些无用资源的管理方式、有用的图片资源的高效管理方式等等。有了意识,行动自然会往这个方面去靠。(😂大道理一套一套的。我也不想的,毕竟是playboy) - -其中遇到了一个神奇的问题。lint 的时候看到一些未使用的依赖库。见 [问题](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.53.md) - -**By the way:** -如果在应用包瘦身方面有其他的做法,请告知,完善文章。 - -参考文章: - -- [Humble Assets Catalog](http://lingyuncxb.com/2019/04/14/HumbleAssetCatalog/) -- [关于 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.61.md b/Chapter1 - iOS/1.61.md deleted file mode 100644 index 2dc4145..0000000 --- a/Chapter1 - iOS/1.61.md +++ /dev/null @@ -1,551 +0,0 @@ -# App 启动时间优化与二进制重排 - -## 启动分类 - -- 冷启动(Cold Launch):点击 App 图标启动前,进程不在系统中。需要系统新创建一个进程并加载 Mach-O 文件,dyld 从 Mach-O 头信息中读取依赖(Load Commands),从动态库共享缓存中读取并链接,经历一次完整的启动过程。(dyld 加载 Mach-O 完整的流程可以查看我[另一篇文章](./1.91.md)) -- 热启动(Warm Launch):App 在冷启动后,用户将 App 退后台。此阶段,App 的进程还在系统中,用户重新启动进入 App 的过程,开发对该阶段能做的事情非常少。 - -所以我们聊启动时间优化,通常是指冷启动。此外启动慢,也就是看到主页面的过程很慢,都是发生在主线程上的。 - -为了量化启动时间,要么自定义 APM 监控。要么利用 Xcode 提供的启动时间统计。通过添加环境变量可以打印出APP的启动时间分析(Edit scheme -> Run -> Arguments) - -- `DYLD_PRINT_STATISTICS` 设置为1 - -- 如果需要更详细的信息,那就将 `DYLD_PRINT_STATISTICS_DETAILS` 设置为1 - - - -## 启动阶段划分 - -App 冷启动可以划分为3大阶段: - -- 第一阶段:进程创建到 main 函数执行(dyld、runtime) - -- 第二阶段:main 函数到 `didFinishLaunchingWithOptions` - -- 第三阶段:`didFinishLaunchingWithOptions` 到业务首页加载完成 - -这里说的阶段都是某一个步骤的最后一步。比如第一阶段的 main 函数执行的结束时刻 - -```shell -xnu_run () { - t1 -    //... -} - -main () { - // .. - // t2 -} -``` - - - -## 第一阶段:进程创建到 main 函数执行(dyld、runtime) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AppLaunchingTime.png) - -这一阶段主要包括 dyld 和 Runtime 、main 函数的工作。 - -### dyld - -iOS 的可执行文件都是 Mach-O 格式,所以 App 加载过程就是加载 Mach-O 文件的过程。 - -```c -struct mach_header_64 { - uint32_t magic; // 64位还是32位 - cpu_type_t cputype; // CPU 类型,比如 arm 或 X86 - cpu_subtype_t cpusubtype; // CPU 子类型,比如 armv8 - uint32_t filetype; // 文件类型 - uint32_t ncmds; // load commands 的数量 - uint32_t sizeofcmds; // load commands 大小 - uint32_t flags; // 标签 - uint32_t reserved; // 保留字段 -}; -``` - -加载 Mach-O 文件,内核会先 fork 进程,并为进程分配虚拟内存、为进程创建主线程、代码签名等,用户态 dyld 会对 Mach-O 文件做库加载和符号解析。 - -细节可以查看代码,在 xnu 的 `kern_exec.c` 中 - -```c -int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval) { - // 字段设置 - ... - int is_64 = IS_64BIT_PROCESS(p); - struct vfs_context context; - struct uthread *uthread; // 线程 - task_t new_task = NULL; // Mach Task - ... - - context.vc_thread = current_thread(); - context.vc_ucred = kauth_cred_proc_ref(p); - - // 分配大块内存,不用堆栈是因为 Mach-O 结构很大。 - MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO); - imgp = (struct image_params *) bufp; - - // 初始化 imgp 结构里的公共数据 - ... - - uthread = get_bsdthread_info(current_thread()); - if (uthread->uu_flag & UT_VFORK) { - imgp->ip_flags |= IMGPF_VFORK_EXEC; - in_vfexec = TRUE; - } else { - // 程序如果是启动态,就需要 fork 新进程 - imgp->ip_flags |= IMGPF_EXEC; - // fork 进程 - imgp->ip_new_thread = fork_create_child(current_task(), - NULL, p, FALSE, p->p_flag & P_LP64, TRUE); - // 异常处理 - ... - - new_task = get_threadtask(imgp->ip_new_thread); - context.vc_thread = imgp->ip_new_thread; - } - - // 加载解析 Mach-O - error = exec_activate_image(imgp); - - if (imgp->ip_new_thread != NULL) { - new_task = get_threadtask(imgp->ip_new_thread); - } - - if (!error && !in_vfexec) { - p = proc_exec_switch_task(p, current_task(), new_task, imgp->ip_new_thread); - - should_release_proc_ref = TRUE; - } - - kauth_cred_unref(&context.vc_ucred); - - if (!error) { - task_bank_init(get_threadtask(imgp->ip_new_thread)); - proc_transend(p, 0); - - thread_affinity_exec(current_thread()); - - // 继承进程处理 - if (!in_vfexec) { - proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task()); - } - - // 设置进程的主线程 - thread_t main_thread = imgp->ip_new_thread; - task_set_main_thread_qos(new_task, main_thread); - } - ... -} -``` - -Mach-O 文件非常大,系统为 Mach-O 分配一块大内存 imgp,接下来会初始化 imgp 里的公共数据。内存处理完,__mac_execve 会通过 `fork_create_child` 函数 fork 出一个新的进程,然后通过 `exec_activate_image` 函数解析加载 Mach-O 文件到内存 imgp 中。最后调用 `task_set_main_thread_qos` 设置新 fork 出来进程的主线程。 - -```c -struct execsw { - int (*ex_imgact)(struct image_params *); - const char *ex_name; -} execsw[] = { - { exec_mach_imgact, "Mach-o Binary" }, - { exec_fat_imgact, "Fat Binary" }, - { exec_shell_imgact, "Interpreter Script" }, - { NULL, NULL} -}; -``` - -可以看到 Mach-O 文件解析使用 `exec_mach_imgact` 函数。该函数内部调用 `load_machfile` 来加载 Mach-O 文件,解析 Mach-O 文件后得到 load command 信息,通过映射方式加载到内存中。`activate_exec_state()` 函数处理解析加载 Mach-O 后的结构信息,设置执行 App 的入口点。 - -之后会通过 `load_dylinker()` 函数来解析加载 dyld,然后将入口地址改为 dyld 入口地址。至此,内核部分就完成 Mach-O 文件的加载,剩下的就是用户态 dyld 加载 App 了。 - -dyld 入口函数为 `_dyld_start`,dyld 属于用户态进程,不在 xnu 中,具体实现可以查看 [dyld/dyldStartup.s at master · opensource-apple/dyld · GitHub](https://github.com/opensource-apple/dyld/blob/master/src/dyldStartup.s),`_dyld_start` 会加载 App 动态库,处理完成后会返回 App 的入口地址。然后执行 App 的 main 函数。 - -dyld(dynamic link editor),Apple 的动态链接器,可以用来装载 Mach-O 文件(可执行文件、动态库等) - -启动 APP 时,dyld 所做的事情有 - -- 装载 APP 的可执行文件,同时会递归加载所有依赖的动态库 - -- 当 dyld 把可执行文件、动态库都装载完毕后,会通知 Runtime 进行下一步的处理。 - 其中包括 ASLR,rebase、bind。 - -QA:这里的通知 Runtime 怎么理解? -查看 objc4 的源代码 `objc-os.mm` 文件中的 `_objc_init` 方法 - -```c -/*********************************************************************** -* _objc_init -* Bootstrap initialization. Registers our image notifier with dyld. -* Called by libSystem BEFORE library initialization time -**********************************************************************/ -void _objc_init(void){ - static bool initialized = false; - if (initialized) return; - initialized = true; - // fixme defer initialization until an objc-using image is found? - environ_init(); - tls_init(); - static_init(); - lock_init(); - exception_init(); - _dyld_objc_notify_register(&map_images, load_images, unmap_image); -} -``` - -方法注释说的很明白,被 dyld 所调用。 - -dyld 加载解析 Mach-O,根据 Mach-O 的 Load Commands 读取所需的动态库、静态库去加载。从 Mach-O 的 `LC_LOAD_DYLINKER` load command 中,根据 name 路径信息,然后加载,dyld 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库。 - -对于动态库,分为系统动态库和开发者写的动态库: - -- 系统共享缓存:系统动态库,Apple 为了启动优化在 iOS 3.1及以后的版本中,Apple 将系统库文件打包合并成一个大的缓存文件,存放在 `/System/Library/Caches/com.apple.dyld/ ` 目录下,以减少冗余并优化内存使用。 - - 检查共享缓存:当应用程序启动时,dyld 首先检查共享缓存中是否已经包含了所需的系统库。共享缓存是一种优化机制,用于存储系统级别的动态库,以便多个应用程序可以重用这些库,减少内存占用并加快加载速度。 - - 映射到地址空:如果动态库在共享缓存中,dyld 将直接从缓存中映射库到应用程序的地址空间,而不是从磁盘加载 - - 验证和解密:对于加密的 Mach-O 文件(例如应用商店发布的应用程序),dyld 将解密并验证代码签名以确保安全性 - - Rebase & Binding:由于存在 ASLR 和系统共享缓存库的存在,dyld 会进行 rebase 和 binding。解析符号真正的地址。具体可以看[FishHook 原理](./1.88.md) 和[DYLD 及 Mach-O ](./1.91.md) 文章 -- 开发者编写的动态库: - - 解析依赖:dyld 从应用程序的主可执行文件开始,解析出所有依赖的动态库,包括开发者添加的自定义动态库。 - - 加载 Mach-O 文件:对于每个依赖的动态库,dyld 会找到对应的 Mach-O 文件,并进行加载。 - - 读取和映射:dyld 打开并读取 Mach-O 文件,然后使用 mmap 系统调用来将文件的内容映射到内存中。 - - 依赖递归加载:如果动态库本身还依赖其他库,dyld 会递归地加载这些依赖库。 - - 符号解析和绑定:与系统库类似,dyld 也会对自定义动态库进行 Rebase 和 Binding,确保所有符号引用都是正确的。 - - 初始化:加载完成后,dyld 会调用动态库中的初始化代码,例如 C++ 的静态构造函数和 Objective-C 的 +load 方法 - - - -到了 dyld3 之后,带来了**启动闭包**技术。 - -dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录 - -闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容: - -- dependends:依赖动态库列表 -- fixup:bind & rebase 的地址 -- initializer-order:初始化调用顺序 -- optimizeObjc: Objective C 的元数据 -- 其他:main entry, uuid… - -为什么闭包能提高启动速度呢? - -这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method...)解析非常慢 - - - -### Runtime - -启动 APP 时,Runtime 所做的事情有 - -- 调用 `map_images` 进行可执行文件内容的解析和处理 - -- 在 `load_images` 中调用 `call_load_methods`,调用所有 Class、Category 的 `+load`方法 - -- 进行各种 objc 结构的初始化(注册 Objc 类 、初始化类对象等等) - -- 调用 C++ 静态初始化器和 `__attribute__((constructor))` 修饰的函数 - -到此为止,可执行文件和动态库中所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 Runtime 所管理 - - - -## 第二阶段:main 函数到 didFinishLaunchingWithOptions - -APP的启动由 dyld 主导,将可执行文件加载到内存,顺便加载所有依赖的动态库。并由Runtime 负责加载成 objc 定义的结构。所有初始化工作结束后,dyld 就会调用 main 函数 - -接下来就是 `UIApplicationMain` 函数。main 函数内部其实没啥逻辑,可能会存在一些防止逆向相关的安全代码。这部分对启动耗时没啥影响,可以忽略先。 - -AppDelegate 的`application:didFinishLaunchingWithOptions:` 方法。此阶段主要是各种 SDK 的注册、初始化、APM 监控系统的启动、热修复 SDK 的设置、App 初始化数据的加载、IO 等等。 - - - -## 第三阶段:`didFinishLaunchingWithOptions` 到业务首页加载完成 - -这部分主要是首页渲染相关逻辑,不同的场景,首页的定义可能不一样。比如未登录的时候首页就是登陆页、否则就是某个主页 - -- 首屏数据的网络/DB IO 读取 - -- 渲染数据的计算 - - - -## 启动优化 - -### 第一阶段 - -#### dyld - -- 减少动态库数量:过多的动态库会增加 dyld 的解析和加载时间。优化库的依赖关系,合并功能相似的库。(定期清理不必要的动态库,iOS 规定开发者写的动态库不能超过6个) -- 使用静态库:考虑将动态库转换为静态库,以减少运行时的加载和链接时间。 -- 懒加载:对于非启动必需的动态库,可以推迟到实际需要时再加载。 -- 减少 Objective-C 元数据:Objective-C 的类、分类和选择器数量会影响 dyld 的 Binding 时间。减少这些元数据的数量可以加快启动速度 -- 优化 C++ 虚函数:C++ 中的虚函数需要在运行时进行解析,这会增加 dyld 的工作量。尽量减少虚函数的使用或使用其他设计模式替代 -- 利用 Swift 结构体:Swift 的结构体是值类型,它们在编译时会进行优化,减少运行时的符号解析需求 -- 优化 +load 方法:Objective-C 中的 +load 方法会在类或分类加载时执行,这可能会影响启动速度。尽量避免在 +load 方法中执行耗时操作,或者使用 +initialize 方法替代,后者只有在类被实际使用时才会调用 -- 二进制重排:接下去单独的篇章会讲。 -- 利用 dyld 缓存:iOS 13 引入的 dyld 3 可以生成“启动闭包”(launch closure),预先处理一些加载和链接工作,加快启动速度。 -- 使用 Xcode 的分析工具:利用 Xcode 的分析工具识别启动过程中的性能瓶颈。单点问题单点追踪分析。 -- 关注 dyld 版本变更:iOS 13 引入了 dyld 3,它在性能上有所改进,但也可能带来兼容性问题。了解 dyld 版本变更对 App 启动性能的影响,如果有需要,请根据需要进行适配。 - - - -#### Runtime - -- 用 `+initialize` 方法和 `dispatch_once` 取代所有的 `__attribute__((constructor))`、C++静态构造器、ObjC 的 `+load` - -- +load 方法中的代码可以监控等 App 启动完成后才去执行。或使用 + initialize 方法。一个 +load 方法中如果执行 hook 方法替换,大约影响4ms。 - -- 减少 Objc 类、分类的数量、减少 selector 数量(定期清理不必要的类、分类)。推荐工具 fui。 - -- 减少 C++ 虚函数数量 - -- Swift 尽量使用 struct - -- 控制 C++ 的全局变量的数据 - -### 第二阶段 - -- 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在 `application:didFinishLaunchingWithOptions` 方法中 -- SDK 初始化遵循规范。什么时候注册、什么时候启动 -- 任务启动器:其实就是按照一定的规则进行任务编排。将任务分类: - - 那些任务是需要在 App 启动完成前主线程同步执行的 - - 那些任务是需要在 App 启动完成前主线程异步执行的 - - 那些任务是需要在 App 启动完成后编排的 - - 闲时主线程队列(监听 runloop 状态,`KCFRunLoopBeforeWaiting` 时执行,在 `KCFRunLoopAfterWaiting` 时停止) - - 异步串行队列 - - 异步并行队列 - - 闲时异步串行队列 - - - -- 二进制重排 -- 方法耗时统计(time profiler、os_signpost) - -AppDelegate 中 `application:didFinishLaunchingWithOptions` 函数阶段一般有很多业务代码介入,大多数启动时间问题都是在此阶段造成的。 - -- 比如二方、三方 SDK 的注册、初始化。这其中有些 SDK 比如 APM 是有必要放在很早的阶段。有些则不需要,比如日志 SDK 的初始化 -- 梳理业务,非必要的延迟加载、启动 - -### 第三阶段 - -很多时候,需要梳理出那些功能是首屏渲染所需要的初始化功能,那些是非首页需要的功能,按照业务场景梳理并治理。 - -QA: - -静态库、动态库? - -静态库:.o文件集合。静态库编译、链接后就不存在了,变为可执行文件了 - -动态库:一个已经链接完全的镜像。已经被静态链接过 - -动态库不可以变为静态库。静态库可以变为动态库。 - -静态库缺点:产物体积比较大,影响包大小(大)。链接到 App 之后,App 体积会比较小(??)静态库 strip - -动态库缺点:除了系统动态库之外,没有真正意义上的动态库(不会放到系统的共享缓冲区) - -适用场景: - -静态库不影响启动时间、动态库代码保密性好。 - -Framework 只是打包方式,动态、静态库都可以支持。甚至可以存放资源文件。 - - - -## 二进制重排 - -### 虚拟内存、物理内存、内存分页、ASLR - -早期:应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题: - -- 当前进程只需要在合适的地址基础上,加减一些地址,就能访问到下一个进程所使用的内存,安全性很低 - -- 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。 - -基于上述2个问题,诞生了虚拟内存技术。App 进程通过内存管理单元(Memory Management Unit, MMU)来管理的。MMU 使用一张特殊的表来跟踪虚拟内存地址和物理内存地址之间的映射关系。这张表通常被称为页表(Page Table) - -内存分页: - -- 页表结构:页表包含了一系列的表项,每个表项对应虚拟内存中的一个页(通常是 4KB)。每个表项包含了该虚拟页对应的物理页的信息,包括物理页的起始地址和一些状态信息。 -- 虚拟地址到物理地址的转换:当程序访问一个虚拟地址时,MMU 查看页表来找到对应的表项,然后根据表项中的信息将虚拟地址转换为物理地址。 -- 页表缓存(TLB - Translation Lookaside Buffer):为了提高地址转换的速度,MMU 通常会有一个 TLB,它缓存了最近访问的页表项。这样,对于频繁访问的地址,转换过程可以更快地完成 -- 页错误处理:如果一个虚拟地址在页表中没有对应的条目,就会发生页错误。操作系统的页错误处理程序会被调用,它负责加载缺失的页到物理内存中,并更新页表。 -- 内存分配:当应用程序请求内存时,操作系统会分配虚拟地址空间,并在页表中创建相应的条目。如果需要,操作系统还会分配物理内存页,并将其与虚拟页关联起来。 - -当物理内存满的时候,会发生覆盖。用户使用的活跃的数据,覆盖内存中最不活跃的数据那一页。对应现实的表现就是:iPhone 上永远可以较好的打开一个 App,比如打开了 App1、App2、App3...,等打开使用了一阵子后,内存紧张,我们尝试切换打开最早的 App1,会发现 App1 重新启动了,之前用的功能 A 的页面1,已经不见了,经历一个新的启动流程。 - - - -但虚拟内存方案带来一个问题。比如黑客不断探索发现,某个重要的功能位于第3页,是不是完全可以通过固定的地址去访问?? - -因为早期物理内存方案下,App 启动后位于什么地址是不确定的。有了虚拟内存后,App 内符号的地址都是从0到4G,都是相对地址。 - -为了解决该问题,Apple 又推出了 ASLR 技术。核心就是为了解决虚拟内存地址固定不变的问题。就不能通过固定的地址访问内存或者数据了。 - - - -### 内存缺页异常 - -每个进程在创建加载时,会被分配一个大小大概为1~2倍真实地内存的连续虚拟地址空间,让当前软件认为自己拥有一块很大内存空间。实际上是把磁盘的一小部分作为假想内存来使用。 - -CPU 不直接和物理内存打交道,而是通过 MMU(Memory Manage Unit,内存管理单元),MMU 是一种硬件电路,速度很快,主要工作是内存管理,地址转换是功能之一。 - -每个进程都会有自己的页表 `Page Table` ,页表存储了进程中虚拟地址到物理地址的映射关系,所以就相当于地图。MMU 收到 CPU 的虚拟地址之后就开始查询页表,确定是否存在映射以及读写权限是否正常。 - -iOS 程序在进行加载时,会根据一 page 大小16kb 将程序分割为多页,启动时部分的页加载进真实内存,部分页还在磁盘中,中间的调度记录在一张内存映射表(Page Table),这个表用来调度磁盘和内存两者之间的数据交换。 - - - -如上图,App 运行时执行某个任务时,会先访问虚拟页表,如果页表的标记为1,则说明该页面数据已经存在于内存中,可以直接访问。如果页表为0,则说明数据未在物理内存中,这时候系统会阻塞进程,叫做**缺页中断(page fault)**,进程会从用户态切换到内核态,并将缺页中断交给内核的 `page Fault Handler` 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。1次缺页异常耗时较少,用户感知不到,但是在 App 启动阶段,容易发生缺页异常,如果发生几十次、几百次,这对于 App 启动时间来说,影响较大。 - - - -因为磁盘访问速度较慢,所以 page in 比较耗时,而且 iOS 不仅仅是将数据加载到内存中,还要对这页做 Code Sign 签名认证,所以 iOS 耗时更长 - -Tips:Code Sign 加密哈希并不少针对于整个文件,而是针对于每一个 Page 的,保证了在 dyld 进行加载的时候,可以对每一个 page 进行独立验证。 - -等到程序运行时用到了才去内存中寻找虚拟地址对应的页帧,找不到才进行分配,这就是内存的惰性(延时)分配机制。 - - - -为了提高效率和方便管理,对虚拟内存和物理内存进行分页(Page)管。进程在访问虚拟内存的一个 page 而对应的物理内存却不存在(没有被加载到物理内存中),则会触发一次缺页异常(缺页中断),然后分配物理内存,有需要的话会从磁盘 mmap 读入数据。 - -启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。 - -二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。 - -一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。 - -Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」。 - - - -核心就是:二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。 - -一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。 - - - -### 如何获取启动阶段的 Page Fault 次数 - -Instrucments 中的 System Trace 可以查看详细信息。 - - - -### 获取符号顺序 - -可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 `Order File`、`Write Link Map File` 参数)。 - -设置 Xcode: `Build Settings` -> `Write Link Map Files` 为 YES。 - - - -分析: - -- 可以看到符号顺序是根据链接顺序来决定的 -- 符号的链接顺序并非是 App 方法真正的执行顺序。 - -可以调整下 Person 类中2个方法的顺序,也可以看到新生成的 linkMap 符号顺序变了。 - - - -所以是有空间进行操作的,让符号链接顺序按照 App 启动阶段方法执行顺序来进行,这个抓手就是 `Order File`。 - - - -### 有没有办法将 App 启动需要的方法集中收拢? - -其实二进制重排 Apple 自己本身就在用,查看 `objc4` 源码的时候就发现了身影 - - - -Xcode 使用的链接器为 `ld`。 ld 有个参数 `-order_file` 。order_file 中的符号会按照顺序排列在对应 section 的开始。 - -Xcode 的 Build Setting GUI 面板也支持配置。 - -1. 在 Xcode 的 Build Settings 中设置 **Order File**,Write Link Map Files 设置为 YES(进行观察) - -2. 如果你给 Xcode 工程根目录下指定一个 order 文件,比如 `refine.order`,则 Xcode 会按照指定的文件顺序进行二进制数据重排。分析 App 启动阶段,将优先需要加载的函数、方法,集中合并,利用 Order File,减小缺页异常,从而减小启动时间。 - - - -### 实践 - -第一步:编写 `.order` 文件。编写顺序是结合业务逻辑和代码顺序,然后再原始的 linkmap 文件中,将符号复制,写入到新创建的 `.order` 文件中。 - -第二步:Build Settings -> Order File 中设置 `.order` 文件的位置。 - -第三步:编译,查看新的 linkmap 文件,验证符号编译顺序是否和 order file 一致。 - - - - - - - -### 如何拿到启动时刻所调用的所有方法名称 - -二进制的原理很简答。最核心的、最难的就是如何获取到 App 启动阶段的所有方法。可能有 OC、Swift、C/C++ 的方法。 - -fishhook `objc_msgSend` 只可以拿到所有的 OC 符号。那 c/c++、Swift、block 怎么拿到? - -Clang 插桩,才可以 hook OC、C/C++、block、Swift 全部的方法调用。 - - - -其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以hook 肯定不行、fishhook 也不行,用 clang 插桩可行。 - -在 [Clang 10 documentation](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs) 中可以看到 LLVM 官方对 SanitizerCoverage 的详细介绍,包含了示例代码。 - -简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 `__sanitizer_cov_trace_pc_` 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。 - -也可以看[精准测试最佳实践](./1.108.md)这篇文章,查看详细插桩原理。 - -步骤: - -- 在 Xcode Build Setting 下搜索 “Other C Flags”,在后面添加 `-fsanitize-coverage=trace-pc-guard`。如果观察包含 Swift 代码,还需要在 “Other Swift Flags” 中加入 `-sanitize-coverage=func` 和 `-sanitize=undefined`。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用 - - 如果是 Cocoapods 管理。可以脚本处理 - - ```ruby - post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard' - config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined' - end - end - end - ``` - -- 在工程入口文件添加2个方法来解决编译报错问题 `__sanitizer_cov_trace_pc_guard_init`、`__sanitizer_cov_trace_pc_guard` - -- clang 插桩原理就是给每个(oc、c)方法、block 等方法内部第一行添加 hook 代码,来实现 AOP 效果。所以在 `__sanitizer_cov_trace_pc_guard` 内部将函数的名称打印出来,最后可以统一写入 order 文件 - - - -- 收集 App 启动过程中的函数调用,生成 `.order` 文件 - - 做了个封装,假设我们 App 启动完成的重点是 AppDelegate 的 `didFinishLaunchingWithOptions` 方法。在这里一行调用,便可获得 App 启动阶段的 `.order` 文件。 - - - -- 最后修改 Build Setting 中的 "Order File" 配置项,值为 `.order` 文件的路径信息。 - -完整 Demo 可以查看 [BlogDemos:BinarayOrderExplore](https://github.com/FantasticLBP/BlogDemos/tree/master/BinarayOrderExplore) - - - -## 总结 - -启动优化思路主要是先监控发现具体的启动时间和启动阶段对应的各个任务,有了具体数据,才可以谈优化。 - -- 删除启动项 - -- 如果不能删除,则延迟启动项。启动结束后找合适的时机预热 - -- 不能延迟的可以使用并发,多线程优势 - -- 启动阶段必须的任务,如果不适合并发,则利用技术手段将代码加速 - -## 参考 - -- [# 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%](https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q) - -- [iOS 启动优化+监控实践](https://www.jianshu.com/p/17f00a237284) diff --git a/Chapter1 - iOS/1.62.md b/Chapter1 - iOS/1.62.md deleted file mode 100644 index 1e2ba37..0000000 --- a/Chapter1 - iOS/1.62.md +++ /dev/null @@ -1,534 +0,0 @@ -# OCLint 实现 Code Review - 给你的代码提提质量 - -工程代码质量,一个永恒的话题。好的质量的好处不言而喻,团队成员间除了保持统一的风格和较高的自我约束力之外,还需要一些工具来统计分析代码质量问题。 - -本文就是针对 OC 项目,提出的一个思路和实践步骤的记录,最后形成了一个可以直接用的脚本。如果觉得文章篇幅过长,则直接可以下载[脚本](https://github.com/FantasticLBP/knowledge-kit/tree/master/assets/auto_Lint.sh) - -> OCLint is a static code analysis tool for improving quality and reducing defects by inspecting C, C++ and Objective-C code and looking for potential problems ... - -从官方的解释来看,它通过检查 C、C++、Objective-C 代码来寻找潜在问题,来提高代码质量并减少缺陷的静态代码分析工具 - - - -## OCLint 的下载和安装 - -有3种方式安装,分别为 Homebrew、源代码编译安装、下载安装包安装。 -区别: -- 如果需要自定义 Lint 规则,则需要下载源码编译安装 -- 如果仅仅是使用自带的规则来 Lint,那么以上3种安装方式都可以 - - -### 1. Homebrew 安装 - -在安装前,确保安装了 homebrew。步骤简单快捷 - -```Shell -brew tap oclint/formulae -brew install oclint -``` - - -### 2. 安装包安装 - -- 进入 OCLint 在 Github 中的[地址](https://github.com/oclint/oclint/releases),选择 Release。选择最新版本的安装包(目前最新版本为:oclint-0.13.1-x86_64-darwin-17.4.0.tar.gz) -- 解压下载文件。将文件存放到一个合适的位置。(比如我选择将这些需要的源代码存放到 Document 目录下) -- 在终端编辑当前环境的配置文件,我使用的是 zsh,所以编辑 .zshrc 文件。(如果使用系统的终端则编辑 .bash_profile 文件) -```Shell -OCLint_PATH=/Users/liubinpeng/Desktop/oclint/build/oclint-release -export PATH=$OCLint_PATH/bin:$PATH -``` -- 将配置文件 source 一下。 -```Shell -source .zshrc // 如果你使用系统的终端则执行 soucer .bash_profile -``` -- 验证是否安装成功。在终端输入 `oclint --version` - - -### 3. 源码编译安装 - -- homebrew 安装 CMake 和 Ninja 这2个编译工具 -```Shell -brew install cmake ninja -``` - -- 进入 Github 搜索 OCLint,clone 源码 -```Shell -gc https://github.com/oclint/oclint -``` - -- 进入 oclint-scripts 目录,执行 ./make 命令。这一步的时间非常长。会下载 oclint-json-compilation-database、oclint-xcodebuild、llvm 源码以及 clang 源码。并进行相关的编译得到 oclint。且必须使用翻墙环境不然会报 timeout。如果你的电脑支持翻墙环境,但是在终端下不支持翻墙,可以查看我的这篇[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/第六部分%20开发杂谈/6.11.md) -```Shell -./make -``` - -- 编译结束,进入同级 build 文件夹,该文件夹下的内容即为 oclint。可以看到 `build/oclint-release`。方式2下载的安装包的内容就是该文件夹下的内容。 - -- cd 到根目录,编辑环境文件,比如我 zsh 对应的 .zshrc 文件。编辑下面的内容 -```Shell - OCLint_PATH=/Users/liubinpeng/Desktop/oclint/build/oclint-release - export PATH=$OCLint_PATH/bin:$PATH -``` - -- source 下 .zhsrc 文件 -```Shell -source .zshrc // source .bash_profile -``` - -- 进入 `oclint/build/oclint-release` 目录执行脚本 -```Shell -cp ~/Documents/oclint/build/oclint-release/bin/oclint* /usr/local/bin/ -ln -s ~/Documents/oclint/build/oclint-release/lib/oclint /usr/local/lib -ln -s ~/Documents/oclint/build/oclint-release/lib/clang /usr/local/lib -``` -这里使用 ln -s,把 lib 中的 clang 和 oclint 链接到 /usr/local/bin 目录下。这样做的目的是为了后面如果编写了自己创建的 lint 规则,不必要每次更新自定义的 rule 库,必须手动复制到 /usr/local/bin 目录下。 - -- 验证下 OCLint 是否安装成功。输入 oclint --version - -![OCLint-验证安装成功](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-15-OCLint-Verify.png) - -注意:如果你采用源码编译的时候直接 clone 官方的源码会有问题,编译不过,所以提供了一个可以编译过的[版本](https://github.com/FantasticLBP/oclint)。分支切换到 llvm-7.0。 - - -### 4. xcodebuild 的安装 -xcode 下载安装好就已经成功安装了 - - -### 5. xcpretty 的安装 - -先决条件,你的机器已经安装好了 Ruby gem. - -```Shell -gem install xcpretty -``` - - - -## 二、 自定义 Rule - -OClint 提供了 70+ 项的检查规则,你可以直接去使用。但是某些时候你需要制作自己的检测规则,接下来就说说如何自定义 lint 规则。 - - -1. 进入 ~/Document/oclint 目录,执行下面的脚本 - -```shell -oclint-scripts/scaffoldRule CustomLintRules -t ASTVisitor -``` -其中,*CustomLintRules* 就是定义的检查规则的名字, *ASTVisitor* 就是你继承的 lint 规则 - -可以继承的规则有:ASTVisitor、SourceCodeReader、ASTMatcher。 - -2. 执行上面的脚本,会生成下面的文件 -- Documents/oclint/oclint-rules/rules/custom/CustomLintRulesRule.cpp -- Documents/oclint/oclint-rules/test/custom/CustomLintRulesRuleTest.cpp - -3. 要方便的开发自定义的 lint 规则,则需要生成一个 xcodeproj 项目。切换到项目根目录,也就是 Documents/oclint,执行下面的命令 -```Shell - mkdir Lint-XcodeProject - cd Lint-XcodeProject - touch generate-lint-rules.sh - chmod +x generate-lint-rules.sh -``` - 给上面的 generate-lint-rules.sh 里面添加下面的脚本 - - ```Shell - #! /bin/sh -e - cmake -G Xcode \ - -D CMAKE_CXX_COMPILER=../build/llvm-install/bin/clang++ \ - -D CMAKE_C_COMPILER=../build/llvm-install/bin/clang \ - -D OCLINT_BUILD_DIR=../build/oclint-core \ - -D OCLINT_SOURCE_DIR=../oclint-core \ - -D OCLINT_METRICS_SOURCE_DIR=../oclint-metrics \ - -D OCLINT_METRICS_BUILD_DIR=../build/oclint-metrics \ - -D LLVM_ROOT=../build/llvm-install/ ../oclint-rules - ``` - -4. 执行 generate-lint-rules.sh 脚本(./generate-lint-rules.sh)。如果出现下面的 Log 则说明生成 xcodeproj 项目成功 - -![生成编写lint规则的xcodeproj工程1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-OCLint-Rule-Xcodeproj.png) -![生成编写lint规则的xcodeproj工程2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-OCLint-Xcode-Rules.png) - -5. 打开步骤4生成的项目,看到有很多文件夹,代表 oclint 自带的 lint 规则,我们自定义的 lint 规则在最下面。 -![编写lint自定义规则的代码文件夹](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-OCLint-custom-rule-inXcodeproj.png) - -关于如何自定义 lint 规则的具体还没有深入研究,这里给个例子 - -
-点击查看示例代码 - -```C -#include "oclint/AbstractASTVisitorRule.h" -#include "oclint/RuleSet.h" - -using namespace std; -using namespace clang; -using namespace oclint; -#include - -class MVVMRule : public AbstractASTVisitorRule -{ -public: - virtual const string name() const override - { - return "Property in 'ViewModel' Class interface should be readonly."; - } - - virtual int priority() const override - { - return 3; - } - - virtual const string category() const override - { - return "mvvm"; - } - - virtual unsigned int supportedLanguages() const override - { - return LANG_OBJC; - } - -#ifdef DOCGEN - virtual const std::string since() const override - { - return "0.18.10"; - } - - virtual const std::string description() const override - { - return "Property in 'ViewModel' Class interface should be readonly."; - } - - virtual const std::string example() const override - { - return R"rst( -.. code-block:: cpp - - @interface FooViewModel : NSObject // This is a "ViewModel" Class. - - @property (nonatomic, strong) NSObject *bar; // should be readonly. - - @end - )rst"; - } - - virtual const std::string fileName() const override - { - return "MVVMRule.cpp"; - } - -#endif - - virtual void setUp() override {} - virtual void tearDown() override {} - - /* Visit ObjCImplementationDecl */ - bool VisitObjCImplementationDecl(ObjCImplementationDecl *node) - { - ObjCInterfaceDecl *interface = node->getClassInterface(); - - bool isViewModel = interface->getName().endswith("ViewModel"); - if (!isViewModel) { - return false; - } - for (auto property = interface->instprop_begin(), - propertyEnd = interface->instprop_end(); property != propertyEnd; property++) - { - clang::ObjCPropertyDecl *propertyDecl = (clang::ObjCPropertyDecl *)*property; - if (propertyDecl->getName().startswith("UI")) { - addViolation(propertyDecl, this); - } - auto attrs = propertyDecl->getPropertyAttributes(); - bool isReadwrite = (attrs & ObjCPropertyDecl::PropertyAttributeKind::OBJC_PR_readwrite) > 0; - if (isReadwrite && isViewModel) { - addViolation(propertyDecl, this); - } - } - return true; - } -}; - -static RuleSet rules(new MVVMRule()); -``` -
- -6. 修改自定义规则后就需要编译。成功后在 Products 目录下会看到对应名称的 CustomLintRulesRule.dylib 文件,就需要复制到 /Documents/oclint/oclint-release/lib/oclint/rules。讲道理,生成新的 lint rule 文件,需要把新的 dylib 文件复制到 /usr/local/lib。因为我们在源代码安装的第4部,设置了 ln -s 链接,所以不需要每次复制到相应文件夹。 - -但是还是比较麻烦,每次都需要编译新的 lint rule 之后需要将相应的 dylib 文件复制到源代码目录下的 oclint-release/lib/oclint/rules 目录下,本着「可以偷懒绝不动手」的原则,在自定义的 rule 的 target 中,在 Build Phases 选项下 CMake PostBuild Rules 中的脚本下将下面的代码复制进去 - -```Shell -cp /Users/liubinpeng/Documents/oclint/Lint-XcodeProject/rules.dl/Debug/libCustomLintRulesRule.dylib /Users/liubinpeng/Documents/oclint/build/oclint-release/lib/oclint/rules/libCustomLintRulesRule.dylib -``` - -7. 规则限定的3个类说明: -```Shell -RuleBase -| -|-AbstractASTRuleBase -| |_ AbstractASTVisitorRule -| |_AbstractASTMatcherRule -| -|-AbstractSourceCodeReaderRule -``` -- AbstractSourceCodeReaderRule:eachLine 方法,读取每行的代码,如果想编写的规则是需要针对每行的代码内容,则可以继承自该类 -- AbstractASTVisitorRule:可以访问 AST 上特定类型的所有节点,可以检查特定类型的所有节点是递归实现的。在 **apply** 方法内可以看到代码实现。开发者只需要重载 bool visit* 方法来访问特定类型的节点。其值表明是否继续递归检查 -- AbstractASTMatcherRule:实现 setUpMatcher 方法,在方法中添加 matcher,当检查发现匹配结果时会调用 callback 方法。然后通过 callback 方法来继续对匹配到的结果进行处理 - -8. 知其所以然 -oclint 依赖与源代码的语法抽象树(AST)。开源 clang 是 oclint 获的语法抽象树的依赖工具。你如果想对 AST 有个了解,可以查看这个[视频](https://www.youtube.com/watch?v=VqCkCDFLSsc&feature=youtu.be),相关讲解https%3A%2F%2Fjonasdevlieghere.com%2Funderstanding-the-clang-ast%2F) - -如果想查看某个文件的 AST 结构,你可以进入该文件的命令行,然后执行下面的脚本 -```Shell -clang -Xclang -ast-dump -fsyntax-only main.m -``` - -## 三、 Homebrew 方式安装的 oclint 如何使用自定义规则 - -1. 查看 OCLint 安装路径 -```Shell -which oclint -// 输出:/usr/local/bin/oclint -ls -al /usr/local/bin/oclint -// 输出:本机安装路径 -``` - -2. 把上面生成的新的 lint rule 下的 dylib 文件复制到步骤1得到的额本机安装路径下 - - - -## 四、 使用 oclint - - -### 在命令行中使用 - -1. 如果项目使用了 Cocopod,则需要指定 -workspace xxx.workspace -2. 每次编译之前需要 clean - - -实操: - -- 进入项目 -```Shell -cd /Workspace/Native/iOS/lianhua -``` -- 查看项目基本信息 -```Shell -xcodebuild -list -//输出 -information about project "BridgeLabiPhone": - Targets: - BridgeLabiPhone - lint - - Build Configurations: - Debug - Release - - If no build configuration is specified and -scheme is not passed then "Release" is used. - - Schemes: - BridgeLabiPhone - lint -``` - -- 编译 -```Shell -xcodebuild -scheme BridgeLabiPhone -workspace BridgeLabiPhone.xcworkspace clean && xcodebuild -scheme BridgeLabiPhone -workspace BridgeLabiPhone.xcworkspace -configuration Debug | xcpretty -r json-compilation-database -o compile_commands.json -``` - -编译成功后,会在项目的文件夹下出现 compile_commands.json 文件 - -- 生成 html 报表 -```Shell -oclint-json-compilation-database -e Pods -- -report-type html -o oclintReport.html -``` - -看到有报错,但是报错信息太多了,不好定位,利用下面的脚本则可以将报错信息写入 log 文件,方便查看 - -```Shell -oclint-json-compilation-database -e Pods -- -report-type html -o oclintReport.html 2>&1 | tee 1.log -``` - -报错信息是:**oclint: error: one compiler command contains multiple jobs:** -查找资料,解决方案如下 -- 将 Project 和 Targets 中 Building Settings 下的 COMPILER_INDEX_STORE_ENABLE 设置为 **NO** -- 在 podfile 中 target 'xx' do 前面添加下面的脚本 - -```Shell -post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['COMPILER_INDEX_STORE_ENABLE'] = "NO" - end - end -end -``` - -然后继续尝试编译,发现还是报错,但是报错信息改变了,如下 - -![generate-lintresult-html-error](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-OCLint-Report-HTML.png) - -看到报错信息是默认的警告数量超过限制,则 lint 失败。事实上 lint 后可以跟参数,所以我们修改脚本如下 - -```Shell -oclint-json-compilation-database -e Pods -- -report-type html -o oclintReport.html -rc LONG_LINE=9999 -max-priority-1=9999 -max-priority-2=9999 -max-priority-3=9999 -``` - -生成了 lint 的结果,查看 html 文件可以具体定位哪个代码文件,哪一行哪一列有什么问题,方便修改。 - ![lint-result-html-report](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-oclint-result-html.png) - -- 如果项目工程太大,整个 lint 会比较耗时,所幸 oclint 支持针对某个代码文件夹进行 lint -```Shell -oclint-json-compilation-database -i 需要静态分析的文件夹或文件 -- -report-type html -o oclintReport.html 其他的参数 -``` - -- 参数说明 - -| 名称 | 描述 | 默认阈值 | -| ----------------------- | ---------------------------- | ---- | -| CYCLOMATIC_COMPLEXITY | 方法的循环复杂性(圈负责度) | 10 | -| LONG_CLASS | C类或Objective-C接口,类别,协议和实现的行数 | 1000 | -| LONG_LINE | 一行代码的字符数 | 100 | -| LONG_METHOD | 方法或函数的行数 | 50 | -| LONG_VARIABLE_NAME | 变量名称的字符数 | 20 | -| MAXIMUM_IF_LENGTH | `if`语句的行数 | 15 | -| MINIMUM_CASES_IN_SWITCH | switch语句中的case数 | 3 | -| NPATH_COMPLEXITY | 方法的NPath复杂性 | 200 | -| NCSS_METHOD | 一个没有注释的方法语句数 | 30 | -| NESTED_BLOCK_DEPTH | 块或复合语句的深度 | 5 | -| SHORT_VARIABLE_NAME | 变量名称的字符数 | 3 | -| TOO_MANY_FIELDS | 类的字段数 | 20 | -| TOO_MANY_METHODS | 类的方法数 | 30 | -| TOO_MANY_PARAMETERS | 方法的参数数 | 10 | - - - - - - -### 在 Xcode 中使用 - -- 在项目的 TARGETS 下面,点击下方的 "+" ,选择 cross-platform 下面的 Aggregate。输入名字,这里命名为 Lint -![Xcode中创建lint的target](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-LintTarget.png) - -- 选择对应的 TARGET -> lint。在 Build Phases 下 Run Script 下写下面的脚本代码 -```Shell -export LC_CTYPE=en_US.UTF-8 -cd ${SRCROOT} -xcodebuild -scheme BridgeLabiPhone -workspace BridgeLabiPhone.xcworkspace clean && xcodebuild -scheme BridgeLabiPhone -workspace BridgeLabiPhone.xcworkspace -configuration Debug | xcpretty -r json-compilation-database -o compile_commands.json && oclint-json-compilation-database -e Pods -- -report-type xcode -``` - -- 说明,虽然有时候没有编译通过,但是看到如下图的关于代码相关的 warning 则达到目的了。 -![Xcode中Lint结果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-lint-result-inXcode.png) - -- lint 结果如下,根据相应的提示信息对代码进行调整。当然这只是一种参考,不一定要采纳 lint 给的提示。 -![Xcode中显示lint结果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-05-23-lint-in-Xcode.png) - - -## 脚本化 - -每次都在终端命令行去写 lint 的脚本,效率很低,所以想做成 shell 脚本。需要的同学直接直接拷贝进去,直接在工程的根目录下使用,我这边是一个 Cocopod 工程。拿走拿走别客气 - -```Shell -#!/bin/bash - -COLOR_ERR="\033[1;31m" #出错提示 -COLOR_SUCC="\033[0;32m" #成功提示 -COLOR_QS="\033[1;37m" #问题颜色 -COLOR_AW="\033[0;37m" #答案提示 -COLOR_END="\033[1;34m" #颜色结束符 - -# 寻找项目的 ProjectName -function searchProjectName () { -# maxdepth 查找文件夹的深度 -find . -maxdepth 1 -name "*.xcodeproj" -} - -function oclintForProject () { - # 预先检测所需的安装包是否存在 - if which xcodebuild 2>/dev/null; then - echo 'xcodebuild exist' - else - echo '🤔️ 连 xcodebuild 都没有安装,玩鸡毛啊? 🤔️' - fi - - if which oclint 2>/dev/null; then - echo 'oclint exist' - else - echo '😠 完蛋了你,玩 oclint 却不安装吗,你要闹哪样 😠' - echo '😠 乖乖按照博文:https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.63.md 安装所需环境 😠' - fi - if which xcpretty 2>/dev/null; then - echo 'xcpretty exist' - else - gem install xcpretty - fi - - - # 指定编码 - export LANG="zh_CN.UTF-8" - export LC_COLLATE="zh_CN.UTF-8" - export LC_CTYPE="zh_CN.UTF-8" - export LC_MESSAGES="zh_CN.UTF-8" - export LC_MONETARY="zh_CN.UTF-8" - export LC_NUMERIC="zh_CN.UTF-8" - export LC_TIME="zh_CN.UTF-8" - export xcpretty=/usr/local/bin/xcpretty # xcpretty 的安装位置可以在终端用 which xcpretty找到 - - searchFunctionName=`searchProjectName` - path=${searchFunctionName} - # 字符串替换函数。//表示全局替换 /表示匹配到的第一个结果替换。 - path=${path//.\//} # ./BridgeLabiPhone.xcodeproj -> BridgeLabiPhone.xcodeproj - path=${path//.xcodeproj/} # BridgeLabiPhone.xcodeproj -> BridgeLabiPhone - - myworkspace=$path".xcworkspace" # workspace名字 - myscheme=$path # scheme名字 - - # 清除上次编译数据 - if [ -d ./derivedData ]; then - echo -e $COLOR_SUCC'-----清除上次编译数据derivedData-----'$COLOR_SUCC - rm -rf ./derivedData - fi - - # xcodebuild clean - xcodebuild -scheme $myscheme -workspace $myworkspace clean - - - # # 生成编译数据 - xcodebuild -scheme $myscheme -workspace $myworkspace -configuration Debug | xcpretty -r json-compilation-database -o compile_commands.json - - if [ -f ./compile_commands.json ]; then - echo -e $COLOR_SUCC'编译数据生成完毕😄😄😄'$COLOR_SUCC - else - echo -e $COLOR_ERR'编译数据生成失败😭😭😭'$COLOR_ERR - return -1 - fi - - # 生成报表 - oclint-json-compilation-database -e Pods -- -report-type html -o oclintReport.html \ - -rc LONG_LINE=200 \ - -disable-rule ShortVariableName \ - -disable-rule ObjCAssignIvarOutsideAccessors \ - -disable-rule AssignIvarOutsideAccessors \ - -max-priority-1=100000 \ - -max-priority-2=100000 \ - -max-priority-3=100000 - - if [ -f ./oclintReport.html ]; then - rm compile_commands.json - echo -e $COLOR_SUCC'😄分析完毕😄'$COLOR_SUCC - else - echo -e $COLOR_ERR'😢分析失败😢'$COLOR_ERR - return -1 - fi - echo -e $COLOR_AW'将为您自动打开 lint 的分析结果...'$COLOR_AW - # 用 safari 浏览器打开 oclint 的结果 - open -a "/Applications/Safari.app" oclintReport.html -} - -oclintForProject -``` - -同类型的文章: -- [开发效率提升利器](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.52.md) -- [oclint介绍](https://github.com/hdw09/CIHexoBlog/blob/master/source/_posts/OClint学习笔记.md) -- [自定义oclint规则](https://github.com/hdw09/CIHexoBlog/blob/master/source/_posts/OCLint-自定义规则101.md) \ No newline at end of file diff --git a/Chapter1 - iOS/1.63.md b/Chapter1 - iOS/1.63.md deleted file mode 100644 index 5f54bb7..0000000 --- a/Chapter1 - iOS/1.63.md +++ /dev/null @@ -1,47 +0,0 @@ -# 苹果官方开源资料 - -- [苹果最新开源 opensource 网站](https://developer.apple.com/opensource/) -- [旧版本苹果开源资料](https://opensource.apple.com) -- [苹果开发者](https://developer.apple.com/develop/) -- [苹果 github](https://github.com/apple) - -## 视频 -WWDC -- [视频分类汇总](https://developer.apple.com/videos/topics/) -- [编译器和LLVM](https://developer.apple.com/videos/developer-tools/compiler-and-llvm) - -## 源码 - -- [开源苹果](https://opensource.apple.com/source/) -- [dyld源代码](https://opensource.apple.com/tarballs/dyld/) -- [iOS11 源码](https://opensource.apple.com/release/ios-110.html) - - JavaScriptCore-7604.1.38.0.7 推荐 - - WebKit-7604.1.38.0.7 - - WebKit2-7604.1.38.0.7 - - libiconv-51 -- [objective-c 运行时 源码](https://opensource.apple.com/source/objc4/objc4-723/runtime/) -- [objective-c 消息机制汇编源码](https://opensource.apple.com/source/objc4/objc4-723/runtime/Messengers.subproj/) - -## 文档 -- [官方新文档入口](https://developer.apple.com/documentation) -- [UI 官方指南](https://developer.apple.com/design/human-interface-guidelines/) -- [Block ABI](https://clang.llvm.org/docs/Block-ABI-Apple.html) - -## 旧版本文档汇总(有些补充或者更底层些) -- [旧版本文档](https://developer.apple.com/library/archive/navigation/) -- [WebKit Objective-C 编码指南](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/DisplayWebContent/DisplayWebContent.html) -- [Concurrency 并发编程指南](https://developer.apple.com/library/archive/documentation/General/Conceptual/ConcurrencyProgrammingGuide/Introduction/Introduction.html) -- [Kernel 内核编码指南](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/build/build.html) - -## 工具 - -- [下载中心](https://developer.apple.com/download/) - -## 第三方资料 - - -- [cassowary 布局算法](https://constraints.cs.washington.edu/cassowary/) -- [fishhook - 高级 hook 框架源码](https://github.com/facebook/fishhook) -- [可运行 runtime 项目](https://github.com/RetVal/objc-runtime) -- [InjectionPlugin - 热加载 DEBUG iOS 代码 插件](https://github.com/johnno1962/InjectionIII) -- [第三方开源]() \ No newline at end of file diff --git a/Chapter1 - iOS/1.64.md b/Chapter1 - iOS/1.64.md deleted file mode 100644 index 3fdc061..0000000 --- a/Chapter1 - iOS/1.64.md +++ /dev/null @@ -1,122 +0,0 @@ -# 组件化、模块化、插件、子应用、框架、库理解 - - - -> 作为大前端时代下开发的我们,经常会被组件化、模块化、框架、库、插件、子应用等术语所迷惑。甚至有些人将组件化和模块化的概念混混为一谈。大量的博客和文章将这些概念混淆,误导了诸多读者。所以本文的目的主要是结合作者本人前后端、移动端等经验,谈谈这几个概念。 - - -## 组件 - -组件,最初的目的是为了**代码重用**。功能相对单一、独立。在整个系统结构中位于最底层,被其他代码所依赖。组件是 **“纵向分层”** - - -## 模块 - -模块,最初的目的是将同一类型的代码整合在一起,所以模块的功能相对全面、复杂些,但都同属于一个业务。不同模块之间也会存在相互依赖的关系,但大多数情况下这种相互依赖的关系只是业务之间的相互跳转。所以不同模块之间的地位是平级的。模块是 **“横向分块”** - -因为从代码组织层面上讲,组件化开发是纵向分层,模块化是横向分块。所以模块化和组件化之间没有什么必然的联系。你可以将工程中的代码,按照功能模块进行逻辑上的拆分,然后将代码实现,按照模块化开发的思想,只需相应的代码按照**高内聚**的方式进行整合。假如一个 iOS 工程,使用 cocoapods 组织代码,将模块 A 相关的代码进行整理打包。 - -但是这样结果就是你的 App 工程虽然按照模块化的方式进行组织开发,那么某个功能模块进行修改或者升级的时候只需要修改相应模块的代码。假如个人中心模块和购物车模块都有数据持久化的代码。在不使用组件化开发的时候可能在2个模块的代码里面都有数据持久化的代码。这样一个地方有问题改动,另一个也要改动,这样工程组织方式不友好且代码复用率低。 - -那么在实际的项目中我们一般是组件化结合模块化一起开发的。 - - -|类别| 目的 | 特点 | 接口 | 成果 | 架构定位 | -|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:| -|组件化|重用、解耦|高重用、低耦合|无统一接口|基础库、基础组件|纵向分层| -|模块化|封装、隔离|高内聚、低耦合|有统一接口|业务模块、业务框架|横向切块| - - -参考: -- https://blog.csdn.net/blog_jihq/article/details/79191008 -- https://blog.csdn.net/blog_jihq/article/details/80669616 - - -![MVC架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-MVC.png) - - - - - -![组件化结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-Components-Structures.png) - -- 各个组件彼此独立,互不影响。 -- 组件通过组件管理器(也被叫做 ComponentManager、Router、MediumBus)通信。通过中介者进行通信。 -- 公共库基础服务基本不变,所以需要下沉到公共组。所以这部分工作可以交给底层架构组到同学去做。 -- 业务开发专心去做业务开发、业务流程的相关 - - - -![组件化与传统架构对比](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentStructureComparation.png) - -- 左侧最基础的 MVC 架构 -- 右侧是经过组件化之后的工程目录。 LianJiaClient 里面就是最基础的 AppDelegate,也就是 App 的启动入口。LJComponent 目录下按照彼此独立的功能拆分为多个组件。每个组件按照真实的物理文件夹,划分为多个工程文件夹,每个组件内部按照 MVC 组织,比如 UI、Model、Service、Logic、Connector -- 工程文件和物理文件最好一一对应。好理解、好找 - - - -## 如何实施组件化 - -1. 制定代码规范基础服务独立成库 - - 什么叫公共基础组件?和业务无关的技术功能 - - - - ![组件抽取](./..assets/2021-03-02-ComponentPickUp.png) - -2. 单个组件内部可以按照合适的架构组织,比如 MVC 和一些分层,比如 service - - ![单个组件结构示意图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-SingleComponentStructure.png) - - - -3. 组件之间通信包括2部分:组件之间页面跳转、组件之间服务的调用 - - - 页面跳转 - - url 导航去中心化。如果集中放到 Router 的导航方法内,则该方法可能会很长(n个组件,每个组件内m个页面,则需要 n*m 个组合)。每个业务组件,内部某个地方集中处理该组件内可能需要用到的注册 url 并返回对应的 vc,把 VC 返回给 ComponentManager,然后决定跳转方式(push、present) - - ![组件间互相访问](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-CompentsVisit.png) - - - 服务调用 - - ![组件通信代码](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-CompontentCommunicateCode.png) - - ![组件间服务通信](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentsVisitByService.png) - - - -4. 进一步优化。动态性 - - ![组件通信 url 动态下发](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentURLViaNetwork.png) - - ComponentManager 在根据 lianjia://ModuleOverSeaHouseList 去匹配,然后发现有 url 则不跳转本地,直接打开 H5 - -5. 服务调用传递参数不方便。NSDictionary 组装很麻烦,可以将公共 Model 下沉,作为一个 Pod - - ![Model下沉](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentStructureModelInDeep.png) - -6. 组件化架构 - - ![链家组件化架构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-LianJiaComponentStructure.png) - -7. 工程组织方式 - - ![链家工程化代码结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-LianJiaComponentProject.png) - -8. 遇到的问题 - - ![链家组件化过程中遇到的问题](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-LianJiaComponentIssue.png) - - 重复资源问题:图标可以用 iconfont,并且可以控制颜色;或者在打包编译阶段,使用 shell、ruby 脚本去删除重复图片(局限性:只能图片名,对比像素比较麻烦) - -9. 建议 - - ![组件化改造建议](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentSuggestion.png) - -10. 总结 - - ![组件化经验小结](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2021-03-02-ComponentTheory.png) - -Protocol:我遵循你这个组织的协议,则我就可以加入你这个组织,比如某个组件遵循协议,然后就可以统一调度管理。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.65.md b/Chapter1 - iOS/1.65.md deleted file mode 100644 index 7292eca..0000000 --- a/Chapter1 - iOS/1.65.md +++ /dev/null @@ -1,89 +0,0 @@ -# 多端融合方案 - -## SwfitUI - -SwiftUI 自亮相以来,全网就在讨论其与 React、Flutter 之间的关系。 - -首先是与 Flutter 的对比,Flutter 的思路是从 0 开始,即语言、基础库、渲染引擎、排版引擎即框架本身全部由自己实现,其渲染引擎 Skia 只需要操作系统为其提供一个 GL Context 便可以完成所有图形渲染,这使得其跨平台性变得十分强大,到目前为止 Windows、Linux、macOS、Fuchsia 都已经得到了 Flutter 官方的支持。 - -这种做法我认为有利有弊,首先好处是所有平台下行为一致,不管是滚动视图、Material Design 控件还是模糊效果这些在其他平台没有的都得到了全平台的支持,开发者并不需要为这些去做平台间的适配,反观 React Native… 当然缺点也是存在的,Flutter 这种做法类似于游戏引擎,平台提供的 UI 特性它一概不用,因此 Flutter View 与原生视图的交互就没有那么容易了,同时新的 Dart 语言貌似也不是非常受社区和开发者喜爱。 - -SwiftUI 没有像 Flutter 那样从头再来,这个全新的框架依旧使用了 UIKit、AppKit 等作为基础。但它并不是一个 UIKit 的声明式封装 - -许多基础组件,像 Text、Button 等都并不是直接使用 UILabel、UIButton 而是一个名为 DisplayList.ViewUpdater.Platform.CGDrawingView 的 UIView 子类。它们使用了自定义绘制,但又集成于 UIKit 的环境中,因此我猜测 SwiftUI 只提供了组件的自定义渲染和布局引擎,它使用到的底层技术还是 Core Animation、Core Graphics、Core Text 等。使用自定义绘制去实现组件可以理解成为跨平台提供便利,毕竟一个按钮还要区分 UIButton、NSButton 来实现未免有些麻烦。但是部分复杂的控件还是采用了 UIKit 中已有的类,比如 UISwitch 等。由于未脱离 UIKit 体系,嵌入一个 UIView 非常容易,你不需要搞什么外部纹理(Flutter 需要),因为它们的上下文是同一个,坐标系也是同一个。 - -所以我认为 SwiftUI 更加类似 React Native,使用系统框架提供的组件,只不过绘制和布局可以自己来实现,这在 SwiftUI 之前也有相关的框架这样实践的,比如 Yoga、ComponentKit 等。 - - -SwiftUI 是声明式的 UI 开发方式。关于声明式和命令式的介绍可以查看这篇[博文](https://github.com/FantasticLBP/knowledge-kit/blob/master/第七部分%20设计模式/7.1.md)。声明式开发框架 SwiftUI、React、Flutter、Vue 等都具备下面的特点。 - -- 使用各自的 DSL 来描述UI 该长什么样子(样式模版),而不是一句句代码描述来告诉系统该如何一步步构建 UI -- 声明所需要的数据部分 -- 框架内部通过模版和数据部分,负责渲染绘制 -- 数据发送变动 -- 框架根据最新的数据和样式模版计算出最新的样式声明 -- 最新的样式声明和之前的样式声明比较,计算出差值。系统重新绘制 - -```Swift -@State var name: String = "Tom" -var body: some View { - Text("Hello \(name)") -} -``` - -在 SwiftUI 中, view 是由纯数据结构描述的。因此这些数据的创建和差分计算都不会带来太多的性能开销。 - -## React && React Native - -先谈谈 React 吧。React 的优秀的地方在于:Virtual DOM、JSX、单向数据流等等。但是谈谈 React 这些框架为什么可以做 Web 也可以做跨端解决方案 RN。传统的 Web 开发是基于命令式编程的方式,监听事件、发起请求、操作 DOM、刷新页面。想想看,每次数据变动了都需要刷新 DOM,然后用户就可以在浏览器上看到了最新的数据,DOM 的操作是很耗费资源的。怎么理解这句话,其实 DOM 对象本身就是一个 JS 对象,所以 JS 对于 JS 对象的操作来说性能耗费基本不用考虑,微乎其微。但是 DOM 每次变动到真实 UI 的渲染是非常耗费性能的(触发浏览器的布局和绘制)。至于浏览器的布局重绘原理我会新开文章进行讨论和总结,可以先看看文章底部的参考资料。 - -在 React 中使用数据(state、props)+ 样式模版(JSX)的方式开发 UI。以下是 React 大致的工作原理 - -- 设置页面所需要的数据 State、props -- 创建页面的模版样式部分 JSX -- 根据数据和样式模版生成 Virtual DOM -- 页面首次渲染的时候先根据 Virtual DOM 生成真实的 UI -- 数据变动(setState)结合「批更新策略」 -- 根据变动后的数据和模版样式生成新的 Virtual DOM -- 根据 Diff 算法计算变动的 Virtual DOM 部分 -- 根据变动的 Virtual DOM 部分去绘制 UI - -什么是 Virtual DOM? - -Virtual DOM 就是一个 JS 对象,用来描述真实的 DOM。看看下面的例子 - -```HTML -
    -
  1. Item 1
  2. -
  3. Item 2
  4. -
  5. Item 3
  6. -
-``` - -转换为 Virtual DOM - -```Javascript -var olElement = { - tagName: 'ol', - props: { - id: 'ol-list' - }, - children: [ - {tagName: 'li', props: {class: 'item'}, children: ['Item 1']}, - {tagName: 'li', props: {class: 'item'}, children: ['Item 2']}, - {tagName: 'li', props: {class: 'item'}, children: ['Item 3']} - ] -} -``` - -所以 Virtual DOM 抽象出来后就很方便了,在 Web 端可以去渲染到真实的 DOM;在 Native 端可以去映射到 Native UI 组件上。所以 React 有了 Virtual DOM 便可以在 Web 端和 Native 端大展拳脚。 - - - -## 参考资料 - -- [浏览器的布局绘制与DOM操作](https://blog.csdn.net/sinat_32434539/article/details/77894009) -- [前端必读:浏览器内部工作原理](https://www.cnblogs.com/rainy-shurun/p/5603686.html) -- [深度理解 Virtual DOM](https://www.cnblogs.com/wubaiqing/p/6726429.html) - - diff --git a/Chapter1 - iOS/1.66.md b/Chapter1 - iOS/1.66.md deleted file mode 100644 index ede0c65..0000000 --- a/Chapter1 - iOS/1.66.md +++ /dev/null @@ -1,242 +0,0 @@ -# 移动端网络层优化 - -当关心 App 的用户体验的时候,不得不考虑网络层相关的问题。因为一个 App 通常来说网络层的操作占据了大多数的场景。几乎每个成熟的 iOS 项目都有一个网络模块,大部分的网络请求都是基于 HTTP 完成,iOS 端采用成熟的 AFNetworking 很容易完成一个功能简单的网络模块,但是使用起来往往会有大量的问题。所以网络层优化是需要大量的经验和知识水平的。对数据的分析和调研、用户反馈,现总结网络层相关的优化手段。 -优化方面: -1. 速度:网络请求速度如何进一步提升 -2. 弱网:移动端网络环境随时变化,经常出现网络连接很不稳定可用性差的情况。怎样在这种情况下最大限度最快完成网络请求 -3. 安全:怎样防止被第三方窃听。篡改或冒充,防止运营商劫持,同时有不影响性能 - - - -## 一、速度 - -正常一条网络请求需要经过的流程是: -1. DNS 解析。请求 DNS 服务器,获取域名对应的 IP 地址 -2. 与服务器建立连接。包括 TCP 三次握手,安全协议同步流程 -3. 连接建立完成,发送、接收数据,解码数据 - -这里存在3个优化点: -1. 直接使用 IP 地址,去除 DNS 解析步骤 -2. 不要每次请求都重新建立连接,复用连接或一直使用同一条连接(长连接) -3. 压缩数据,减小传输数据的大小 - -### 1. DNS - -DNS 完整的解析流程很长,会先从本地系统缓存读取,若没有就到最近的 DNS 服务器取,若没有再到主域名服务器取,每一层都有缓存,但为了域名解析的实时性。每一层缓存都设有过期时间,这种 DNS 解析机制有几个缺点: -- 缓存时间设置过长,域名更新不及时。设置时间短,大量 DNS 解析请求影响请求毒素 -- 域名劫持。容易被中间人攻击,或者运营商劫持。把域名解析道第三方 IP 地址,据统计劫持率高达 7% -- DNS 解析过程不受控制,无法保证最快的解析速度 -- 一次请求只可以借此一个域名 - -为了解决上述问题,就有了 HTTPDNS。原理就是代替系统的 DNS 解析工作,解决上述问题。 -- 域名解析与请求分离,所有请求都直接使用 IP 地址,无需 DNS 解析,App 定时请求 HTTPDNS 服务器更新 IP 地址即可 -- 通过签名等方式,保证 HTTPDNS 请求的安全,避免被劫持 -- DNS 解析由自己控制。可以保证根据用户所在地返回就近的 IP 地址。或根据客户端测速结果使用最快的 IP -- 一次请求可以解析多个域名 - -对于 DNS 解析的情况,业界主流做法就是 HTTPDNS 或者内置 Server IP 列表。客户端直接访问 HTTPDNS 接口,获取业务在域名配置系统上配置的访问延迟最优的 IP,获取到 IP 后就直接往此 IP 发送业务协议请求,不再需要本地 DNS 服务器进行解析,从根本上解决了劫持问题。同时可以降低网络延迟,提高连接的成功率。 - -建立的 Server IP 列表,是在本地缓存一个 IP 映射表,可以在 App 启动时请求接口下发更新。访问其他的服务的时候根据映射拿到 IP 再发出请求。 - -![DNS服务器解析示意图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-06-14-DNSLookUP.png) - -绝大多数的 App 的第一步都是 DNS 解析,解析请求回根据当时的网络情况不同而不同,各平台的 DNS 缓存策略存在差异,因此对于移动 App 网络性能会产生影响。App 网络情况跟很多因素都相关。但是 DNS 是第一步也是最重要的一环。 - -1. 降低 DNS 请求带来的延迟 - 客户端 App 请求第一步是 DNS 解析。但是由于 Cache 的存在,使得大部分的解析请求都不会产生任何延迟。各平台都有自己的 Cache 过期策略。像 iOS 系统一般都是 24h 后过期。还有就是从飞行模式切换回来、开关机、重置网络设置等都会导致 DNS Cache 的清除。所以一般情况用户在第二天打开你的 App 都会经历一次完整的 DNS 解析请求。网络情况差的时候会明显的请求总耗时增加。如果可以直接跳过 DNS 解析这一步,就可以提高网络性能了。 - -2. 预防 NDS 劫持 - DNS 劫持指的是改变 DNS请求返回的结果,将目的域名对应的 IP 指向另一个地址。一般有两种方式,一种是通过病毒的方式改变本机配置的 DNS 服务器地址,二是通过攻击正常的 DNS 服务器而改变其行为。不管何种方式都会影响 App 的业务请求,如果遇到恶意的攻击还会衍生出各种安全问题。客户端自己做 DNS 到 IP 地址的映射就绕过了向 DNS 服务器请求而可能被遭到攻击的可能,让劫持者无从下手 - -3. 服务器动态部署 - DNS 映射实际上是模拟了 DNS 请求的解析行为。如果客户端将自己的位置信息(例如ip地址、国家码等)上传给服务器,服务器就可以根据位置信息就近推荐合适的 Server IP 地址。从而减小了整体网络请求延迟、实现了动态部署 - -如何设计自己的 DNS 映射机制? - -DNS 服务器做的事情就是输入一个域名,输出一个 IP 地址,做自己的 DNS 映射机制就是在客户端维护一个这样的映射文件。不过这个映射文件可以根据服务器在 App 端进行更新。还需要具备一定的容错处理。 - -- 一个打包到 App 包里面的默认域名 IP 映射文件,这样就可以避免第一次去服务器取配置文件带来的延迟 -- 一个定时器可以每隔一段时间**通过签名等方式(避免被劫持)**去服务器获取最新的域名映射文件,并保存到本地 -- 每次取到最新的映射文件后,保存到本地,并将上次的映射文件保存作为备份。一旦出现线上配置错误的情况,不至于导致请求无法处理 -- 如果映射文件不能处理域名映射,那么可以回滚到使用默认的 DNS 解析服务 -- 如果映射后的一个 IP 持续请求失败,那么应从机制上避免这个问题。也就是需要一个无效使用的淘汰机制 -- 无效的 IP 可以上报到服务器。发现问题解决问题 - -在 iOS 端实践。大致有3个角色:mapper、validator、reporter - -- mapper - mapper 是负责和外部交互的部分。主要负责在输入 domain name 的情况下输出 ip。同时校验来自应用层请求成功和失败的信息。失败的情况下需要将 ip 进一步验证,以确定是真的无效。如果无效则进行上报。同时还负责更新机制 - -- validator - 负责在接收到请求失败的 ip 时,对这个 ip 进行有效性验证。检测的强弱规则可以自定义。但是一般规则是在后台线程使用这个 ip 进行多次尝试,如果都不成功则告诉 mapper 这个 ip 确实无用,如果成功则说明有效。(某次失败有可能意味着当时的网络环境不稳定) - -- reporter - 主要负责告诉 server 整个 mapping 机制的健康状况。在出现某个 ip 导致请求失败并由 validator 校验多次还是失败的情况下需要上报到服务端,让服务端去维护或验证。(有可能是在配置的时候少打了个字母 😂) - -根据公司业务情况进行改造。比如采用服务器定时更新映射文件的这一步骤,可以更改为 socket 长链接通道在需要更新时 push,或利用 HTTP2.0 的 server push 机制。还有上报机制。还可以对请求的总量、成功率、映射成功率等数据做侦测。 - -### 2. 连接 - -第二个问题,连接建立的耗时问题,这里主要的优化思路是复用连接,不用每次请求都重新建立连接,如何更有效率地复用连接,可以说是网络请求速度优化里最主要的点了,并且这里的优化在不断的演进中,值得关注 - -#### 2.1 keep-alive - -HTTP 协议里有个 `keep-alive`,HTTP1.1 默认开启,一定程度上缓解了每次请求都需要进行 TCP 三次握手建立连接的耗时。原理是请求完成后不立即释放连接,而是放入**连接池**中。若此时有另一个请求要发出,如果请求的端口和域名在复用池里面有一致的,那么就直接拿出连接池中的连接进行发送和接收数据,少了建立连接的耗时。 - -实际上,现在无论是客户端还是浏览器都默认开启了 keep-alive,对同个域名不会再有每发一个请求就进行一次建连的情况,纯短连接已经不存在了。但是有问题,就是这个 keep-alive 的短连接一次只能发送接收一个请求,在上一个请求处理完成之前。无法接受新的请求。若同时发起多个请求,就有两种情况: - - 1. 若串行发送请求,可以一直复用一个连接,但速度很慢,每个请求都需要等待上个请求完成再进行发送 - 2. 若并行发送请求,那么首次每个请求都要进行 TCP 三次握手建立新的连接,虽然第二次可以复用连接池里面的这堆连接,但若连接池里面保留的过多,对服务端资源产生交大浪费,若限制了保持的连接数,并行请求超出的连接仍每次需要建立连接。对于这个问题新一代的 HTTP2.0 提出了多路复用解决方案。 - -#### 2.2 多路复用 - -HTTP2 的多路复用机制一样是复用连接。但它复用的这条连接支持同时处理多条请求,所有请求都可以在这条连接上进行,也就是解决了上面说的并发请求需要建立多条连接带来的问题。 - -![HTTP2多路复用](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-06-14-MultiplesRoutes.png) - -HTTP1.1 的协议里,在一个连接里传输数据都是串行顺序传输的,必须等上一个请求全部处理完成后,下一个请求才能进行处理,导致这些请求期间这条连接并不是满带宽传输的,即使是 HTTP1.1 的 pipelining 可以同时发送多个 Request,但 response 仍是按请求的顺序串行返回,只要其中一个 response 稍微大一点或发送错误,就会阻塞住后面的请求。 - -HTTP2 这里的多路复用协议解决了这些问题,它把在连接里传输的数据都封装成一个个 stream,每个 stream 都有标识,stream 的发送和接收可以是乱序的,不依赖顺序,也就不会有阻塞的问题,接收端可以根据 stream 的标识去区分属于哪个请求,再进行数据拼接,得到最终数据。 - -解释下多路复用这个词,多路可以认为是多个连接,多个操作,复用就是 复用一条连接或一个线程。HTTP2 这里是连接的多路复用,网络相关的还有一个 I/O 的多路复用(select/epoll),指通过事件驱动的方式让多个网络请求返回的数据在同一条线程里完成读写。 - -客户端来说,iOS9 以上 NSURLSession 原生支持 HTTP2,只要服务端也支持就可以直接使用,Android 的 okhttp3 以上也支持了 HTTP2,国内一些大型 APP 会自建网络层,支持 HTTP2 的多路复用,避免系统的限制以及根据自身业务需要增加一些特性,例如微信的开源网络库 [mars](https://github.com/Tencent/mars/issues?page=2&q=is%3Aissue+is%3Aopen),做到一条长连接处理微信上的大部分请求,多路复用的特性上基本跟 HTTP2 一致。 - - -#### 2.3 TCP队头阻塞 - -HTTP2 的多路复用看起来是完美的解决方案,但还有个问题,就是队头阻塞,这是受限于 TCP 协议,TCP 协议为了保证数据的可靠性,若传输过程中一个 TCP 包丢失,会等待这个包重传后,才会处理后续的包。HTTP2的多路复用让所有请求都在同一条连接进行,中间有一个包丢失,就会阻塞等待重传,所有请求也就被阻塞了。 - -对于这个问题不改变 TCP 协议就无法优化,但 TCP 协议依赖操作系统实现以及部分硬件的定制,改进缓慢,于是 GOOGLE 提出 QUIC 协议,相当于在 UDP 协议之上再定义一套可靠传输协议,解决 TCP 的一些缺陷,包括队头阻塞。具体解决原理网上资料较多,可以看看。 - -QUIC 处于起步阶段,少有客户端接入,QUIC 协议相对于 HTTP2 最大的优势是对TCP队头阻塞的解决,其他的像安全握手 0RTT / 证书压缩等优化 TLS1.3 已跟进,可以用于 HTTP2,并不是独有特性。TCP 队头阻塞在 HTTP2 上对性能的影响有多大,在速度上 QUIC 能带来多大提升待研究。 - -### 3. 数据 - -第三个问题,传输数据大小问题。数据对请求速度的影响分两方面,一是压缩率,二是解压序列化反序列化的速度。目前最流行的两种数据格式是 json 和 protobuf。json 是字符串,protobuf 是二进制。即使采用各种压缩算法压缩后,protobuf 仍会比 json 小。protobuf 在数据量和序列化速度上均占优势。 - -压缩算法多种多样,且在不断演进。最新出得 Brotli 和 [Z-standard](https://github.com/facebook/zstd) 实现了更高的压缩率。Z-standard 可以根据业务数据样本训练出适合的字典,进一步提高压缩率。是目前最好的压缩算法 - -除了传输数据的 body 大小,每个 HTTP 协议头的数据也不可忽视,HTTP2 里对 HTTP 协议头也进行了压缩,HTTP 头大多是重复数据,固定的字段如 method 可以用静态字典,不固定但多个请求重复的字段例如 cookie 用动态字典,可以打到非常高的压缩率。可以查看这篇[文章](https://imququ.com/post/header-compression-in-http2.html)查看介绍。 - - -总结:通过 HTTPDNS,连接多路复用,更好的压缩算法,可以把网络请求的速度优化到不错的程度了。接下来看看弱网环境和安全方面的手段吧 - -## 弱网 - -手机无线网络环境不稳定,针对弱网的优化,微信有较多的实践和分享 - -1. 提升连接的成功率 - 复合连接。建立连接时,阶梯式并发连接,其中一条连通后其他连接都关闭。这个方案结合串行和并发的优势。提高弱网下的连接成功率,同时又不会增加服务器资源消耗 - - ![弱网复合连接](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-06-14-BadNetwork.png) - -2. 制定最合适的超时时间 - 对总读写超时(从请求到响应的超时)、首包超时、包包超时(两个数据段之间的超时)时间制定不同的计算方案,加快对超时的判断,减少等待时间,尽早重试。这里的超时时间还可以根据网络状态动态设定 - -3. 调优 TCP 参数,使用 TCP 优化算法 - 对于服务端的 TCP 协议参数进行调优。以及开启各种优化算法使得业务特性和移动端网络环境,包括 RTO 初始值,混合慢启动,TLP、F-RTO 等 - -针对弱网的优化未成为标准方案,系统网络库没有内置,不过前两个客户端优化微信的开源网络库 [mars](https://github.com/Tencent/mars) 有实现。 - -## 安全 - -标准安全协议 `TLS` 保证了网络传输的安全,前身是 SSL,不断在演进。我们日常使用的 HTTPS 就是 HTTP 协议加上 TLS 安全协议。 - -安全协议概括性地说解决两个问题:1. 保证安全;2. 降低加密成本 - -在保证安全上: -1. 使用加密算法组合对传输数据加密,避免被窃听和篡改, -2. 认证对方身份,避免被第三方冒充 -3. 加密算法保持灵活可更新,防止定死算法被破解后无法更换,禁用已破解的算法。 - -降低加密成本上: -1. 用对称加密算法加密传输数据,解决非对称加密算法的性能低以及长度限制问题 -2. 缓存安全协议握手后的密钥数据,加快第二次建连的速度。 -3. 加快握手过程:2RTT -> 0RTT。加快握手的思路,就是原本客户端和服务端需要协商使用什么算法后才能加密发送数据,变成通过内置的公钥和默认的算法,在握手的同时就把数据发出去,也就是不需要等待握手就开始发送数据,打到 0RTT - -想详细看看 TLS 的可以看看这篇[文章](https://blog.helong.info/blog/2015/09/07/tls-protocol-analysis-and-crypto-protocol-design/) - -目前基本主流都支持 TLS 1.2。iOS 网络库默认使用 TLS 1.2,Android 4.4 以上支持 1.2。 - - -## 其他优化方案 - -- 域名合并:淘宝、美团等公司公布的解决方案中都有提到,就是将公司原来的很多域名都合并到较少的几个域名。为什么?因为 HTTP 的通道复用就是基于域名划分的。如果域名只有几个,那么多数请求都可以在长连接通道进行,这样就可以降低延迟、增加成功率 -- 预热,尽早建立长连接。这样其他的业务请求就可以复用长连接通道。加快访问速度。因为每次建立连接都需要经过 DNS 域名解析、TCP 三次握手等漫长步骤。建立长连接的时机可以考虑:冷启动、前后台切换、网络切换等 -- 如果情况允许,可以将网络切换到 HTTP 2.0,解决了 HTTP1.1 的 head of blocking ,降低了网络延迟,提供了更强大的多路复用技术。还加入了流量控制、新的二进制格式、Server Push、请求优先级和依赖等待等特性。 -- 建立多通道。比如携程、艺龙、美团等公司都有自己的 TCP、UDP 通道。具有多域名共用通道。 -- 有些超级大厂还自研了协议。比如 QUIC -- 加入 CDN 加速,动态静态资源分离 -- 对于类似埋点的业务数据请求,可以合并请求,减小流量。另外结合埋点数据压缩上传 -- App 网络情况诊断 -- 根据网络情况,动态设置超时时间等 - - - -## 最后 - -网络层涉及的学问非常多,需要懂得多端的重视才可以提出靠谱的解决方案。希望不断认识不断思考。 - - -## 参考资料 - -- [2016年携程App网络服务通道治理和性能优化实践](https://chuansongme.com/n/466033251461) - - -## 参考点: -- 移动调度 - 1. DNS(DNS劫持、运营商DNS层次不齐、1RTT请求DNS、不支持LDC多中心调度、不支持自定义调度)。移动调度优势:LDC多中心调度、异地多活快速容灾、白名单问题排查 -- 接口设计优化 - 1. 慢逻辑监控 - 2. 多次查询优化 - 3. 接口 cache 等 -- 静态资源、图片等相关策略 - 1. 使用更快的图片格式(WebP等) - 2. 不同网络的不同图片下发 - 3. 资源合并、压缩(combo) - 4. 图片压缩(webp) -- 让用户觉得快 - 1. 优先级加载 - 2. 异步加载 -- 减小数据包大小和优化包量 - 1. 推广 Protocol Buffer 等序列化方式 - 2. 接入 SYNC -- 监控体系建设 - 1. 全链路数据打通,问题剖析一杆子到底 - 2. 多维评价模型、监控预警、数据化研发 - 3. 管理决策有依据,结果有数据 - - -## 疑难杂症 - -1. 有人遇到使用网络经常出现内存泄漏的情况。我觉得这是属于基础功不扎实的情况,因为 [AFHTTPSessionManager manager] 它返回的对象持有一个 session。且 session 的 delegate 对象也是强引用。AFHTTPSessionManager 的父类是 AFURLSessionManager。AFURLSessionManager initWithSessionConfiguration 底层就是 `self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];` 所以会强引用 - 解决方案: - - 每次请求完网络后需要给 [AFHTTPSessionManager manager] 这种方式初始化的 manager 释放掉。比如 AFNetWorking 提供的 *invalidateSessionCancelingTasks* 方法。 - - 将 AFHTTPSessionManager 对象做成单例获取,这样带来另一个好处,NSURLSession 不销毁,另外的请求继续发起的时候不需要初始的网络握手,达到「链路复用」的功能。 - -``` -//AFURLSessionManager.h -@property (readonly, nonatomic, strong) NSURLSession *session; - -// AFHTTPSessionManager.m - - - -// NSURLSessionConfiguration -@property (nullable, readonly, retain) id delegate; -``` - - -```Objective-C -- (void)invalidateSessionCancelingTasks:(BOOL)cancelPendingTasks { - if (cancelPendingTasks) { - [self.session invalidateAndCancel]; - } else { - [self.session finishTasksAndInvalidate]; - } -} -``` -2. 在 iOS 10.3 系统上存在 SSL 证书校验的问题。报错信息如下图。目前没有找到具体原因和解决方案,如果有人有解决方案请联系我。 -![报错信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-08-29-NetworkError2.png) -![问题信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-08-29-NetworkError1.jpg) diff --git a/Chapter1 - iOS/1.67.md b/Chapter1 - iOS/1.67.md deleted file mode 100644 index 6f76006..0000000 --- a/Chapter1 - iOS/1.67.md +++ /dev/null @@ -1,11 +0,0 @@ -# iOS工程编译速度优化 - -要提升编译速度,我们首先要知道有没有提升?那就需要一个量化的标准。下面的命令让 Xcode 告诉你编译耗费多久时间。 - -```shell -defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES -``` - -## ccache - -提高编译速度需要依靠缓存的能力, [ccache](https://ccache.dev) 是一个靠谱的缓存。基于编译器层面。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.68.md b/Chapter1 - iOS/1.68.md deleted file mode 100644 index b3d6442..0000000 --- a/Chapter1 - iOS/1.68.md +++ /dev/null @@ -1,572 +0,0 @@ -# 守护你的App安全 - -> 从 Web 安全一样,所有的攻防离不开一句话“在合理范围内保证 App 安全,让攻击者增加破解成本,让一部分人三思而后行战术性放弃”。 - - - -## ptrace 简易版本 - -在iOS系统中,`ptrace` 被用于防止应用程序被调试。`ptrace` 函数提供了一种机制,允许一个进程监听和控制另一个进程,并且可以检测被控制进程的内存和寄存器中的数据。在iOS开发中,`ptrace` 可以用于实现断点调试和系统调用跟踪,但它也常被用于反调试措施 - -通过传递 `PT_DENY_ATTACH` 标志,它允许应用程序设置一个标志,以防止其他调试器附加。如果其他调试器尝试附加,则进程将终止。 - - - -可以使用类似下面的代码来防止别人破解、逆向。 - -```objective-c -#import - -__BEGIN_DECLS - int ptrace(int _request, pid_t _pid, caddr_t _addr, int _data); -__END_DECLS - -void disable_gdb(void) { - ptrace(PT_DENY_ATTACH, 0, 0, 0); -} - -int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { -#if DEBUG - // 非 DEBUG 模式下禁止调试 - disable_gdb(); -#endif - // Setup code that might create autoreleased objects goes here. - appDelegateClassName = NSStringFromClass([AppDelegate class]); - } - return UIApplicationMain(argc, argv, nil, appDelegateClassName); -} -``` - - - -## ptrace 安全吗 - -但上述方式安全吗?一个简单的 fishhook 都可以破解掉。 - -第一步:创建一个 AppHook 的动态库,和一个 AppHookProtoctor 的 iOS App - -第二步:AppHook 里面在 `+load` 方法里使用 fishhook 对 ptrace 进行 hook,判断 `PT_DENY_ATTACH` 则绕过 - -```c++ -int hooked_ptrace(int _request, pid_t _pid, caddr_t _addr, int _data) { - if (_request == PT_DENY_ATTACH) { - return 0; - } - return ptrace_pointer(_request, _pid, _addr, _data); -} -``` - -第三步:在 App 的 main.m 中调用 disable_gdb 来禁止调试。 - - - -结果:可以看到对 ptrace 使用 fishhook hook 之后,ptrace 并没有让 App 进程结束掉。也就是 ptrace 失效了,并不安全。 - - - -## ptrace 安全性改进 - -我们知道 fishhook 的原理是根据符号表进行 rebind 的,那是不是可以通过该原理绕开? - -`ptrace `是系统函数,dyld 会在启动阶段进行 rebase、rebind,遍历 Mach-O 文件的 `__DATA` 段中的 `__nl_symbol_ptr` 和 `__la_symbol_ptr` 两个 section。通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,Fishhook 能够找到需要替换的函数,并修改其地址。想了解 fishhook 详细工作原理可以查看这篇文章:[fishhook 原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.88.md) - - - -我们禁止 fishhook,对 Xcode 添加一个符号断点 `ptrace`,如下所示。 - - - -我们可以看到 ptrace 位于 `libsystem_kernel.dylib` 动态库中。lldb 模式下通过 `image list` 查看所有的 image 信息。 - -可以看到当前电脑模拟器运行情况下,libsystem_kernel.dylib 位于 `/usr/lib/system/libsystem_kernel.dylib` 路径。这个路径是我电脑调试环境下的路径。真机路径不一样。 - -通过 dlopen、dlsym 的方式来找到 ptrace 符号地址,再去执行,这种方式的本质是没有走符号表的流程。 - -Demo 如下: - -```objective-c -#ifndef DEBUG - // 非 DEBUG 模式下禁止调试 - char *ptraceLibPath = "/usr/lib/system/libsystem_kernel.dylib"; - void *handler = dlopen(ptraceLibPath, RTLD_LAZY); - int (*ptrace_pointer)(int _request, pid_t _pid, caddr_t _addr, int _data); - ptrace_pointer = dlsym(handler, "ptrace"); - if (ptrace_pointer) { - ptrace_pointer(PT_DENY_ATTACH, 0, 0, 0); - } -#endif -``` - - - -工程运行后会发现,App 启动后立马 crash 结束进程。说明这种(通过 dlsym 找到符号地址) ptrace 的防护是有效的 - - - -对代码进行修改,整洁一些,如下所示: - -```c++ -typedef int (*ptrace_ptr_t)(int _request, pid_t _pid, caddr_t _addr, int _data); - -void disable_gdb(void) { - // 简易版:容易被 FishHook 进行符号表的修改,从而破解 ptrace 的拦截 - // ptrace(PT_DENY_ATTACH, 0, 0, 0); - - // 安全版本 - void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW); - ptrace_ptr_t ptrace_ptr = dlsym(handle, "ptrace"); - ptrace_ptr(PT_DENY_ATTACH, 0, 0, 0); - dlclose(handle); -} -``` - - - - - -该方式通过 dlopen、dlsym 的方式延迟、动态的找到 ptrace 的符号地址,没有走符号表的逻辑,避开了 fishhook 的工作流程,从而更安全一些。 - - - - - -## sysctl 简易版本 - -`sysctl` 函数是一个系统调用,用于获取或设置系统相关的信息。这个函数提供了一种机制来查询或修改系统的状态信息,比如系统配置参数、统计数据等。 - -在 Linux 和类 Unix 系统中用于查看和修改内核参数。然而,在 iOS 逆向工程中,`sysctl` 也常被用于检测应用程序是否正在被调试 - -```c++ -int sysctl(int *, u_int, void *, size_t *, void *, size_t); -``` - -参数解释: - -- `name`: 一个指向整数数组的指针,数组中的每个元素代表一个级别的OID(对象标识符),用于指定要查询或设置的系统信息。 -- `namelen`: `name` 数组的长度,即OID的级别数。 -- `oldp`: 一个指向缓冲区的指针,用于接收查询到的现有值。如果设置值,这个参数可以是NULL。 -- `oldlenp`: 一个指向 `size_t` 的指针,用于指定 `oldp` 缓冲区的大小,并在调用后返回实际读取的数据大小。 -- `newp`: 一个指向新值的指针,用于设置系统信息。如果只是查询,这个参数可以是NULL。 -- `newlen`: 新值的大小 - -返回值: - -- 如果成功,返回0 -- 如果失败,返回 -1 - -其中传递的结构体引用,`info.kp_proc.p_flag` 字段,用于判断进程是否处于调试状态。是二进制的0、1。第12位,为1代表处于调试状态。反之不是。 - -思考:如何正确二进制判断某一位是0还是1?用特定位置填充1,其他位填充0来处理。按位与之后,特定位置为1,说明之前是1,否则就是0. - - - -```c++ -#define P_TRACED 0x00000800 /* Debugged process being traced */ -``` - -`info.kp_proc.p_flag` 判断系统提供了一个 `P_TRACED`,按位与用来判断是否是调试模式。 - - - - - -使用 - -```objective-c -bool isInDebugMode(void) { - int name[4]; - name[0] = CTL_KERN; // 内核 - name[1] = KERN_PROC; // 查询进程 - name[2] = KERN_PROC_PID; // 通过进程 id 来查找 - name[3] = getpid(); - - struct kinfo_proc info; // 接收查询信息,利用结构体传递引用 - size_t infoSize = sizeof(info); - int resultCode = sysctl(name, sizeof(name)/sizeof(*name), &info, &infoSize, 0, 0); - assert(resultCode == 0); - return info.kp_proc.p_flag & P_TRACED; -} -``` - -结合定时器,每间隔1秒进行检查一下,运行起来发现处于 debug 模式,则调用 `exit(0)` 结束进程。 - - - -为什么调用 `exit`而不是 `abort`? - -- exit(0):函数是C标准库中的一个函数,用于正常退出程序。当调用时,程序会执行清理操作,比如关闭打开的文件、释放分配的资源等。它允许程序在退出前执行一些清理工作,比如调用 `atexit` 注册的函数。`exit(0)` 表示程序正常退出,返回状态码为0。 - -- abort: 函数也是C标准库中的一个函数,但它用于异常或紧急情况下的退出。当调用时,程序会立即终止,不会进行任何清理工作,比如关闭文件或释放资源。会导致程序发送SIGABRT信号给自身,这通常用于调试目的,以便在发生严重错误时立即停止程序。表示程序是非正常退出的。 - -`exit(0)` 更适合在程序正常结束时使用,而 `abort` 更适合在发生不可恢复的错误时使用。使用 `abort` 可以快速停止程序,但可能会导致资源泄漏等问题,因为它不会执行任何清理操作。 - - - -不过我们的逻辑是,在非 debug 模式才进行这样的检测。所以用 `#ifndef DEBUG` 包装 - -```objective-c -#ifndef DEBUG - timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0)); - dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 0 * NSEC_PER_SEC); - dispatch_source_set_event_handler(timer, ^{ - if (isInDebugMode()) { - NSLog(@"在调试模式"); - exit(0); - } else { - NSLog(@"不在调试模式"); - } - }); - dispatch_resume(timer); -#endif -``` - - - -## sysctl 安全吗 - -`sysctl ` 是系统函数,存在间接符号表,所以可以用 fishhook 进行 hook。 - -继续用动态库 + App 的形式验证能否 hook 成功。 - - - -第一步:注册一个函数指针,用来保存 sysctl 的函数地址 - -```c++ -// sysctl 函数指针,保存原始 sysctl 函数地址 -int (*sysctl_pointer)(int *, u_int, void *, size_t *, void *, size_t); -``` - -第二步:写替换后的 sysctl 函数实现 - -```c++ -int hooked_sysctl(int *name, u_int namelen, void *info, size_t *infosize, void *newInfo, size_t newInfoSize) { - int resultCode = sysctl_pointer(name, namelen, info, infosize, newInfo, newInfoSize); - if (namelen == 4 && name[0] == CTL_KERN && name[1] == KERN_PROC && name[2] == KERN_PROC_PID && info) { - struct kinfo_proc *myInfo = (struct kinfo_proc *)info; - if (myInfo->kp_proc.p_flag & P_TRACED) { - // 异或取反。设置调试判断位为0. - myInfo->kp_proc.p_flag ^= P_TRACED; - } - return resultCode; - } - return resultCode; -} -``` - -第三步:调用 fishhook rebind_symbols 完成系统符号 `sysctl` 的 hook - -第四步:验证 hook 是否成功。如果成功,则 App 运行起来,处于 debug 模式下,还是会输出 `不在调试模式` - -结果如下: - - - - - -可以看到 fishhook 也可以 hook sysctl。所以不安全。 - - - -## sysctl 安全性改进 - -修改思路参考上面的 ptrace,知道 fishhook 的原理,绕开懒加载符号表,绕开 dyld 修正符号和填充地址这个过程。 - -不再赘述,核心代码如下图所示: - - - - - -效果就是在 fishhook hook 的情况下,App 检测到处于 debug 模式下,调用 `exit(0)` 自动结束进程。 - - - -## syscall 简易版本 - -`int syscall(int, ...) `,`syscall`函数是一种用于调用系统调用的方法。系统调用是用户空间程序请求操作系统内核服务的一种机制。 - -在用户空间和内核空间之间,有一个叫做 Syscall (系统调用, system call )的中间层,是连接用户态和内核态的桥梁。这样即提高了内核的安全型,也便于移植,只需实现同一套接口即可。Linux系统,用户空间通过向内核空间发出 syscall,产生软中断,从而让程序陷入内核态,执行相应的操作。对于每个系统调用都会有一个对应的系统调用号,比很多操作系统要少很多。 - -引入头文件 `#import ` - -```c++ -syscall(26, 31, 0, 0); -// 等价于 syscall(SYS_ptrace, PT_DENY_ATTACH, 0, 0); -``` - - - -syscall 调用的时候第一个参数是调用函数的名称,后面的参数是调用函数的参数。 - - - -## syscall 安全吗 - -syscall 本质也是系统函数,最后还是躲不开 fishhook 的追杀。所以是不安全的。有没有什么办法可以解决? - -这里就不再去写一遍 fishhook 的代码了,很重复... - - - -## syscall 安全性改进 - -- 隐藏符号,还原符号 - -- 使用 dlopen、dlsym 的方式,找到 syscall 符号的地址 -- syscall 发起系统调用,调用 sysctl 能力 -- GCD 定时器检测,判断是否处于调试模式 -- 如果处于调试模式,调用汇编 quit_process 结束进程 - -可以看到:即使 fishhook hook 了 ptrace、sysctl 绕过 hook,但是这种方式还是可以对非法调试 App 的行为进行了保护,立马会结束进程。 - - - - - -## svc 调用 - -SVC指令在ARM体系中被归于异常处理类指令,该指令能允许用户程序调用内核,其格式如下: - -```c++ -SVC{cond} #imm // Supervisor call, allows application program to call the kernel (EL1) -``` - -传统 arm中使用 svc 0 表示中断,在 xnu 中使用的是 svc 0x80。具体的看下面的例子 - - - -## 更安全的版本 - -### 隐藏符号名称 - -iOS 中常量字符串可以在 Mach-O 文件的 `__TEXT` 段中找到。如果加密的 salt、一些支付的 key、地图的 key,直接明文存储很不安全,一个可能的方案是采用 c 字符脱符号,比如通过下面的方式获取字符串 - -```objective-c -char name[] = {'s', 'y', 's', 'c', 'a', 'l', 'l', '\0'}; -NSString *funcName = [NSString stringWithUTF8String:name]; -``` - - - - - -更安全的是不让分析者在 MachO 中显示的看到 ptrace、sysctl 符号名称。所以采用异或运算一个固定的 key,再根据指针指向字符串初始值,再次异或,得到原始字符串。 - -隐藏 ptrace 符号名称的方法,如下所示 - -```c++ -void disable_gdb_via_hidden_ptrace(void) { - // 使用一个 char 数组拼接一个 ptrace 字符串 (此拼接方式可以让逆向的人在使用工具查看汇编时无法直接看到此字符串) - unsigned char funcName[] = { - (KEY ^ 'p'), - (KEY ^ 't'), - (KEY ^ 'r'), - (KEY ^ 'a'), - (KEY ^ 'c'), - (KEY ^ 'e'), - (KEY ^ '\0'), - }; - unsigned char * p = funcName; - // 再次异或之后恢复原本的值 - while (((*p) ^= KEY) != '\0') p++; - - void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW); - ptrace_ptr_t ptrace_ptr = dlsym(handle, (const char *)funcName); - if (ptrace_ptr) { - ptrace_ptr(PT_DENY_ATTACH, 0, 0, 0); - } - dlclose(handle); -} -``` - -隐藏 sysctl 符号的方法如下 - -```c++ -bool isInDebugModeViaHiddenSysctl(void) { - // 使用一个 char 数组拼接一个 sysctl 字符串 (此拼接方式可以让逆向的人在使用工具查看汇编时无法直接看到此字符串) - unsigned char funcName[] = { - (KEY ^ 's'), - (KEY ^ 'y'), - (KEY ^ 's'), - (KEY ^ 'c'), - (KEY ^ 't'), - (KEY ^ 'l'), - (KEY ^ '\0'), - }; - unsigned char * p = funcName; - //再次异或之后恢复原本的值 - while (((*p) ^= KEY) != '\0') p++; - - int name[4]; - name[0] = CTL_KERN; // 内核 - name[1] = KERN_PROC; // 查询进程 - name[2] = KERN_PROC_PID; // 通过进程 ID 来查找 - name[3] = getpid(); // 当前进程 ID - - struct kinfo_proc info; // 接收查询信息,利用结构体传递引用 - size_t infoSize = sizeof(info); - void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW); - sysctl_ptr_t sysctl_ptr = dlsym(handle, (const char *)funcName); - - sysctl_ptr(name, sizeof(name)/sizeof(*name), &info, &infoSize, 0, 0); - dlclose(handle); - return info.kp_proc.p_flag & P_TRACED; -} -``` - -会发现隐藏符号后,可以实现防止 hook 效果的。骚操作来还原符号,保证安全。 - - - - - -### 利用汇编调用系统函数 - -函数调用都可以利用 `syscall`的方式调用。 - -```c++ -syscall(SYS_ptrace,PT_DENY_ATTACH,0,0); -// 等价于 -syscall(26,31,0,0,0); -``` - -`volatile` 代表不优化此汇编代码 - -```assembly -asm volatile( - "mov x0,#26\n" - "mov x1,#31\n" - "mov x2,#0\n" - "mov x3,#0\n" - "mov x16,#0\n" // 这里就是syscall的函数编号 - "svc #0x80\n" // 这条指令就是触发中断(系统级别的跳转) -); -``` - -`ptrace(PT_DENY_ATTACH, 0, 0, 0);` 等价于 - -```assembly -asm volatile( - "mov x0,#31\n" // 参数1 - "mov x1,#0\n" // 参数2 - "mov x2,#0\n" // 参数3 - "mov x3,#0\n" // 参数4 - "mov x16,#26\n"// 中断根据 x16 里面的值,跳转 ptrace - "svc #0x80\n" // 这条指令就是触发中断去找 x16 执行 -); -``` - -还可以对 `exit(0)` 进行汇编混合,自定义符号 `quit_process` - -```assembly -static __attribute__((always_inline)) void quit_process () { -#ifdef __arm64__ - asm( - "mov x0,#0\n" - "mov x16,#1\n" // 这里相当于 Sys_exit,调用exit函数 - "svc #0x80\n" - ); - return; -#endif -#ifdef __arm__ - asm( - "mov r0,#0\n" - "mov r16,#1\n" // 这里相当于 Sys_exit - "svc #80\n" - ); - return; -#endif - exit(0); -} -``` - -最后的效果: - - - - - -## 一些其他的思路 - -### 符号混淆 - -做 iOS 开发的同学,在类名、方法名等命名上,都会做到见名知意,这点在开发阶段、日常维护阶段是好事情。但是站在黑客和攻击者角度来看的话,这对他们来说也是一件好事,但对 App 的安全来讲就是一件坏事。所以需要做**符号混淆**。 - -在静态分析应用的时候,常常会使用 `class-dump` 导出应用的头文件,通过头文件中的函数名或变量名 猜测这些函数的功能,然后进行 `Hook` 动态分析窥探大概逻辑。如果让这些方法名、变量名、类名从名称上看没有任何意义,那么就能从一定程度干扰攻击者猜测,这叫做代码混淆 - - - -如果一个登陆注册如下所示(伪代码): - -```objective-c -@interface LoginViewController: UIViewController -- (void)handleLoginAction; -@end -``` - -这样的代码上传到 App Store 后,攻击者利用 class-dump 还原后,还是很清晰的,见名知意,一下子就可以判断这是登陆事件的处理函数。如果对符号进行混淆,如下所示 - -```objective-c -@interface $38wiewh81_Controller: UIViewController -- (void)0jjd1; -@end -``` - -攻击者看到这样的符号,无疑会增大破解难度,至少不会像以前的一样,代码做到裸奔。 - - - - - -### 动态库白名单检测 - -除了保护应用中的关键代码,还可以通过代码检测应用中动态库是否是合法的。无论是越狱环境还是非越狱环境,如果要入侵除了修改二进制就是注入动态库,所以可以写段逻辑判断 App 中除了我们自己项目中的动态库,是否还存在入侵的动态库。 - -通过 dyld API 函数获取应用中的动态库名称,把这些字符串名称合并作为一个白名单,如果发现动态库不在白名单中,则结束进程。 - -```objective-c -const char *whitstr= ""; -void checkWhiteStr(){ -    uint32_t count= _dyld_image_count(); -    for(int i=1;i= V2)这种基于组件的架构模式所取代。组件式影响到新诞生的一批框架 - -React/React Native 选择基于组件的架构模式,好处有3: - -- 第一,组件是内聚的,组件内既有逻辑,也有状态,又有视图。一个组件可以独立完成一个事情,这也使得 UI 模块复用变得简单; -- 第二,组件之间是可以组合的,多个组件可以组合成一个更大的页面或者一个更大的组件。当一个组件很大很臃肿、难以维护的时候也可以拆分优化成粒度更合理的组件 -- 第三,组件和组件之间的数据流动永远是确定的,从上到下单向流动 - -组件可组合、可复用的特性,和组件之间单向数据流的模式,是现代应用重交互重展示的情况下,更方便,这也是 React/React Native 采用组件式的核心原因。 - - - -## 二、热更新平台 - -热更新能力是大家选择 RN 的一个重要原因之一,有了热更新能力,就相当于在用户手机和公司业务之间铺设了一道直达的高速公路,公司新业务开发后不再受限于 App 发版审核下载更新这个流程了。那么如何设计一个热更新平台呢? - -业界主流的有: - -- Code Push:是微软 App Center 的服务之一。底层是微软自家的 Azure 云服务。由于国内网络环境的原因,访问国外云服务较慢,不推荐使用 -- Expo:是亚马逊的 AWS 和 Google Cloud 云服务。由于国内网络环境的原因,访问国外云服务较慢,不推荐使用 -- Pushy:是 React Native 中文网提供的热更新方案,使用的是国内的阿里云服务,且比前2者有更省流量的增量更新方案。也是国内可直接使用的开源热更新方案之一了。 -- 自研:灵活自由度高,可控 - -热更新方案主要包括2部分:打包服务 + 静态资源服务。 - -打包服务核心就是将 React Native 项目中的 JS 代码打包成一个 Bundle 文件。静态资源服务就是将 Bundle 文件分发给客户端的服务。客户端拿到 Bundle 文件后,就可以渲染展示了。 - -1. 通过 react-native bundle 命令,提前把 JS 代码打包成一个 Bundle 文件,本质上是一个可执行的 JavaScript 文件。 - - ```shell - npx react-native bundle --entry-file index.js --dev false --minify false --bundle-output ./build/index.bundle --assets-dest ./build - ``` - -2. 如果使用的是 Hermes,还需要把 Javascript 文件转成相应的字节码文件。Hermes 提供了方案 - - ``` - hermes -emit-binary -out ./build/index.hbc ./build/index.bundle - ``` - - 转换后会得到一个 `.hbc` 字节码包,hbc 也就是 hermes bytecode。 - -得到 Bundle 文件后就需要上传到 CDN 上了。但 CDN 存在一个问题,就是 CDN 资源地址是固定不变的,所以不够灵活,**存在几分钟的更新延迟问题**,假设线上出现一个重大故障,需要等几分钟才可以完全回滚,这对于公司形象、用户损失都会产生很大影响。 - - - -上图中旧版本的 JS Bundle 包是绿色的,新版本 JS Bundle 是蓝色的。在旧版本覆盖新版本的过程中:删除 CDN 缓存的旧版本资源,当 CDN 没有缓存了,这时候用户新的请求才不会命中缓存,而是到 OSS 拉取最新资源。 - -然而 CDN 不是单点计算机,而是分布在不同位置的网络节点,当使用 CDN 刷新能力时,实际上就是删除上千个节点的缓存。搞过 CDN 的人说,要把这成千个节点的缓存删除干净,最长需要5分钟,同时无法保证这5分钟的时效。 - -也就是说这5分钟内请求 JS Bundle 存在3种情况: - -- 命中老版缓存 -- 未命中缓存,从 OSS 拉取新的资源 -- 命中新版缓存 - -也就是说在享受 CDN 的同时,也要接受这5分钟渐进式更新的延迟,也就是说要有5分钟内用户可能全部访问有问题的业务 JS Bundle 的预见性。 - - - -有没有改进措施? - -在端上设备和 CDN 之间再架设一层版本服务。 - - - -具体步骤: - -1. 本地打包好的 JS Bundle 根据文件内容生成 MD5,然后命名对应的 JS Bundle 文件,格式为 `{MD5}.bundle` ,保证唯一性,然后上传 Bundle 到 OSS。 -2. 发布上线,比如一个可视化平台点击上线按钮,要做的事情就是告诉版本服务,当前业务 JS Bundle 版本为 0.01,JS Bundle 名为 `s2d8...j07.bundle` -3. 端上发起请求,请求版本服务,版本服务根据信息返回对应的 Bundle 名。`{uri: s2d8...j07.bundle}` -4. 端上再次发起真正的 CDN 资源请求。资源请求会先询问某个 CDN 边缘节点,如果边缘节点没有缓存,则去源站拉取资源;如果边缘节点有缓存,则直接返回 - - - -## 三、RN 启动速度优化 - -### 3.1 Hermes - -Hermes 是 FaceBook 2019 年中旬开源的一款 JS 引擎,从 **release[1]** 记录可以看出,这个是专为 React Native 打造的 JS 引擎,可以说从设计之初就是为 Hybrid UI 系统打造。 - -Hermes **支持直接加载字节码**,也就是说,`Babel`、`Minify`、`Parse` 和 `Compile` 这些流程全部都在开发者电脑上完成,直接下发字节码让 Hermes 运行就行,这样做可以**省去 JSEngine 解析编译 JavaScript 的流程**,JS 代码的加载速度将会大大加快,启动速度也会有非常大的提升。 - - - -### 3.2 减小 JS Bundle 体积 - -前面的优化其实都是 Native 层的优化,从这里开始就进入 Web 前端最熟悉的领域了。 - -其实谈到 JS Bundle 的优化,来来回回就是那么几条路: - -- **缩**:缩小 Bundle 的总体积,减少 JS 加载和解析的时间 -- **延**:动态导入(dynamic import),懒加载,按需加载,延迟执行 -- **拆**:拆分公共模块和业务模块,避免公共模块重复引入 - -如果有 webpack 打包优化经验的小伙伴,看到上面的优化方式,是不是脑海中已经浮现出 webpack 的一些配置项了?不过 React Native 的打包工具不是 webpack 而是 Facebook 自研的 Metro,虽然配置细节不一样,但道理是相通的,下面我就这几个点讲讲 React Native 如何优化 JS Bundle。 - - - -Metro 打包 JS 时,会把 ESM 模块转为 CommonJS 模块,这就导致现在比较火的依赖于 ESM 的 Tree Shaking 完全不起作用,而且根据官方回复,Metro 未来也不会支持 Tree Shaking - - - -说人话就是 Tree-Shaking 现在不搞了,现在在搞更有前途的方式来保证 bundle 体积的减少: - -#### 1. 使用 react-native-bundle-visualizer 查看包体积 - -使用 react-native-bundle-visualizer 查看包体积。优化 bundle 文件前,一定要知道 bundle 里有些什么,最好的方式就是用可视化的方式把所有的依赖包列出来。web 开发中,可以借助 Webpack 的 `webpack-bundle-analyzer` 插件查看 bundle 的依赖大小分布,React Native 也有类似的工具,可以借助 RN`react-native-bundle-visualizer` 查看依赖关系: - - - -#### 2. 对于同样的功能,优先选择体积更小的第三方库 - -这是一个非常经典的例子。同样是时间格式化的第三方库, moment.js 体积 200 KB,day.js 体积只有 2KB,而且 API 与 moment.js 保持一致。如果项目里用了 moment.js,替换为 day.js 后可以立马减少 JSBundle 的体积。 - -**利用 babel 插件,避免全量引用** - -lodash 基本上属于 Web 前端的工程标配了,但是对于大多数人来说,对于 lodash 封装的近 300 个函数,只会用常用的几个,例如 `get`、 `chunk`,为了这几个函数全量引用还是有些浪费的。 - -社区上面对这种场景,当然也有优化方案,比如说 `lodash-es`,以 ESM 的形式导出函数,再借助 Webpack 等工具的 Tree Sharking 优化,就可以只保留引用的文件。但是就如前面所说,React Native 的打包工具 Metro 不支持 Tree Shaking,所以对于 `lodash-es` 文件,其实还会全量引入,而且 `lodash-es` 的全量文件比 `lodash` 要大得多。 - -做了个简单的测试,对于一个刚刚初始化的 React Native 应用,全量引入 lodash 后,包体积增大了 71.23KB,全量引入 `lodash-es` 后,包体积会扩大 173.85KB。 - -`lodash-es ` 太大了,能不能在 lodash 上做文章? - -```js -// 全量 -import { join } from 'lodash' -// 局部 -import join from 'lodash/join' -``` - -这样打包就只会打 `lodash/join` 这一个文件,但这严格依赖于团队小伙伴的共识,不能严格保证。且使用 lodash 的七八个方法,就需要 import 七八次,这很低效。有个 `babel-plugin-lodash` 插件,可以在 JS 编译时操作 AST 做如下转换: - -```js -import { join, chunk } from 'lodash' -// 转换为 -import join from 'lodash/join' -import chunk from 'lodash/chunk' -``` - -终极大杀器:**`babel-plugin-import`** 基本可以解决所有按需引用的问题 - -如何使用? - -有个 ahooks 开源库,封装了很多常用的 React hooks,但问题是这个库是针对 Web 平台封装的,比如说 `useTitle` 这个 hook,是用来设置网页标题的,但是 React Native 平台是没有相关的 BOM API 的,所以这个 hooks 完全没有必要引入,RN 也永远用不到这个 API。 - -这时候我们就可以用 `babel-plugin-import` 实现按需引用了,假设我们只要用到 `useInterval` 这个 Hooks,我们现在业务代码中引入: - -```javascript -import { useInterval } from 'ahooks' -``` - -然后运行 `yarn add babel-plugin-import -D` 安装插件,在 `babel.config.js` 文件里启用插件: - -``` -// babel.config.js -module.exports = { - plugins: [ - [ - 'import', - { - libraryName: 'ahooks', - camel2DashComponentName: false, // 是否需要驼峰转短线 - camel2UnderlineComponentName: false, // 是否需要驼峰转下划线 - }, - ], - ], - presets: ['module:metro-react-native-babel-preset'], -}; -``` - -启用后就可以实现 ahooks 的按需引入 - -```js -import { useInterval } from 'ahooks' -// 等价于 -import useInterval from 'ahooks/lib/useInterval' -``` - -| 全量 ahooks | ahooks/lib/useInterval 单文件引用 | ahooks + babel-plugin-import | -| :---------- | :-------------------------------- | :--------------------------- | -| 111.41 KiB | 443 Bytes | 443 Bytes | - -#### 3. 制定编码规范,减少重复代码 - -##### 移除 console - -`babel-plugin-transform-remove-console` 插件,我们可以配置它在打包发布的时候移除 `console` 语句,减小包体积的同时还会加快 JS 运行速度,我们只要安装后再简单的配置一下就好了: - -```js -// babel.config.js -module.exports = { - presets: ['module:metro-react-native-babel-preset'], - env: { - production: { - plugins: ['transform-remove-console'], - }, - }, -}; -``` - -##### 制定良好的编码规范 - -- 代码的抽象和复用:代码中重复的逻辑根据可复用程度,尽量抽象为一个方法,不要用一次复制一次 -- 删除无效的逻辑:这个也很常见,随着业务的迭代,很多代码都不会用了,如果某个功能下线了,就直接删掉,哪天要用到再从 git 记录里找 -- 删除冗余的样式:例如引入 ESLint plugin for React Native,开启 `"react-native/no-unused-styles"` 选项,借助 ESLint 提示无效的样式文件 - -### 3.3 Inline Requires - -懒执行。一般情况下 RN 容器初始化之后就会加载全量的 JS Bundle 文件,而 Inline Requires 延迟运行,只有需要使用的时候才会执行 JS 代码,而不是启动的时候就执行,RN 0.64 版本,默认开启。需要在 `metro.config.js` 中进行修改 - -```js -const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); -const fs = require('fs'); -const path = require('path'); - -const config = { - transformer: { - getTransformOptions: async () => ({ - transform: { - experimentalImportSupport: false, - inlineRequires: true, - }, - }), - }, -}; - -module.exports = mergeConfig(getDefaultConfig(__dirname), config); -``` - -比如说我们写了个工具函数 `join` 放在 `utils.js` 文件里: - -```js -// utils.js -export function join(list, j) { - return list.join(j); -} -``` - -```js -// App.js -import { join } from 'my-module'; -const App = (props) => { - const result = join(['a', 'b', 'c'], '~'); - return {result}; -}; -``` - -被 Metro 编译后 - -```js -const App = (props) => { - const result = require('./utils').join(['a', 'b', 'c'], '~'); - return {result}; -}; -``` - - - -`r()` 代表 `require()` 函数,可以看到在顶部的 import 实际上被替换成在实际使用的位置 import 了。 - -然而 Metro 的 import 是不支持 export default 的。需要注意,可以具体查看 [RN 官方文档](https://reactnative.dev/docs/ram-bundles-inline-requires) - - - -### 3.4 RN 拆包 - -假设业务 A 和 B 的业务代码是通过 JS Bundle 动态下发的: - -- Business A JS Bundle:共300KB,其中 200KB 基础包(React、RN)、100KB 业务代码 -- Business B JS Bundle:共400KB,其中 200KB 基础包(React、RN)、200KB 业务代码 - -访问完业务 A 之后再去访问业务 B,下载好300KB 代码后需要继续下载400KB 代码。存在冗余,完全没有必要多次下载和加载,这时候一个想法自然而然就出来了: - -> 能否把一些共有库打包到一个 `common.bundle` 文件里,我们每次只要动态下发业务包 `businessA.bundle` 和 `businessB.bundle`,然后在客户端实现先加载 `common.bundle` 文件,再加载 `business.bundle` 文件就可以了 - -这样做的好处有几个: - -- `common.bundle` 可以直接放在本地,省去多业务线的多次下载,**节省流量和带宽** -- 可以在 RN 容器预初始化的时候就加载 `common.bundle` ,**二次加载的业务包体积更小,初始化速度更快** - -顺着上面的思路,上面问题就会转换为两个小问题: - -- 如何实现 JSBundle 的拆包? -- iOS/Android 的 RN 容器如何实现多 bundle 加载? - - - -RN 本地调试还是构建,底层都是用到了 Metro 打包工具的能力。然而 Facebook 的 Metro 本身没有拆包能力,只能将 JS 代码打包成一个 Bundle 文件,且 Metro 不支持三方插件。在查看 Metro 源代码的时候发现了一个令人眼前一亮的方法 `customSerializer` ,可以实现不侵入修改 Metro 源码,通过配置的方式给 Metro 写第三方插件的能力 - -#### 3.4.1 拆包原理 - -为什么选择基于模块拆包而不是基于文本?因为基于模块拆包,加载速度会更快。 - -为什么基于模块的拆包方式能够独立运行,而基于文本的拆包方式不能独立运行? - -来做一些说明,架设采用的是多 Bundle 基于文本的拆包方式,多个 Bundle 之间的公共代码是 React、React Native 库,用 `console.log('react')`、`console.log('react native')` 代替。多个 Bundle 不同的部分是业务代码,用 `console.log('foo')` 代表业务代码。 - -基于文本拆包一般采用 Google 开源的 [diff-match-patch](https://github.com/google/diff-match-patch?tab=readme-ov-file) 算法。repo 主页也提供了 Demo 入口,可以在线体验效果 - - - -实际上,在基于文本计算热更新包的场景下,我们会内置 Old version,这部分代码除了升级 RN 版本外不会改动,而 New Version 的字符串是本次热更新的目标代码,但为了传输效率不需要下载完成的 Bundle 文件,因为 Old Version 已经内置了,基于 diff-match-patch 计算出需要热更新的部分即可。 - -客户端拉取到需要 Patch 热更新包后,会和 Old Version 代表的内置包进行合并,合并的结果就是 New Version 所代表的完整的 Bundle - -很显然,Patch 热更新是一段记录修改位置、修改内容的文本,而不是一段可单独运行的代码。会导致内置包没法提前执行,只能等下在完成再合并,生成完整的 Bundle 文件后,作为整体才执行。这就是为什么基于文本的拆包方式不能独立运行的原因。 - - - -引入正题,基于模块的拆包方式,内置包和热更新包就可以分别独立运行了。 - - - -可以看到基于模块的拆包方案,拆出来热更新包是一个业务代码,单独可运行。所以可以在客户端先运行内置包,然后下载热更新包,等热更新包下载完成,再运行热更新包。 - - - -#### 3.4.2 Bundle 文件结构及内容说明 - -React Native打包形成的Bundle文件的内容从上到下依次是: - -- Polyfills:定义基本的JS环境(如:`__d()`函数、`__r()`函数、`__DEV__` 变量等) -- Module定义:使用`__d()`函数定义所有用到的模块,该函数为每个模块赋予了一个模块ID,模块之间的依赖关系都是通过这个ID进行关联的。 -- Require调用:使用`__r()`函数引用根模块。 - -业务不同的2个 Bundle 但是会在 Polyfills 部分和 Module 定义的部分有大量重复,因为都包含 React、React Native 2个模块代码,重复部分大约500K 左右 - - - -`-d` 函数实际上就是 `define()` 函数,3个参数分别为:factory 方法、moudleID、dependencyMap - -```js -function define(factory, moduleId, dependencyMap) { - if (moduleId in modules) { - // that are already loaded - return; - } - modules[moduleId] = { dependencyMap}; - // other code .... -}; -``` - - - -`_r` 函数实际上就是 `require()` 函数,这个方法首先判断所有要加载的模块是否已经存在并完成了初始化。如果是,则直接返回模块的 exports,如果不是则调用 `guardedLoadModule` 方法来完成模块的初始化 - -```js -function require(moduleId) { - const module = modules[moduleId]; - return module && module.isInitialized - ? module.exports - : guardedLoadModule(moduleIdReallyIsNumber, module); -} -function guardedLoadModule(moduleId, module) { - return loadModuleImplementation(moduleId, module); -} -function loadModuleImplementation(moduleId, module) { - module.isInitialized = true; - const exports = (module.exports = {}); - var _module = module; - const factory = _module.factory, - dependencyMap = _module.dependencyMap; - const moduleObject = { exports }; - factory(global, require, moduleObject, exports, dependencyMap); - return (module.exports = moduleObject.exports); -} -``` - - - -#### 3.4.3 公共资源包 - -随着 RN 版本迭代,官方已经逐步将bundle文件生成流程规范化,并为此设计了独立的打包模块 – Metro。Metro 通过输入一个需要打包的JS文件及几个配置参数,返回一个包含了所有依赖内容的JS文件。 - -Metro将打包的过程分为了3个依次执行的阶段: - -1. **解析(Resolution)**:计算得到所有的依赖模块,形成依赖树,该过程是多线程并行执行。 -2. **转义(Transformation)**:代码的编译转换,该过程是多线程并行执行。 -3. **序列化(Serialization)**:所有代码转换完毕后,打印转换后的代码,生成一个或者多个 bundle 文件 - -Metro工具提供了配置功能,开发人员可以通过配置RN项目中的**metro.config.js**文件修改bundle文件的生成流程。 - -可以看到,我们需要关注 Metro Serialization 阶段,只要借助 `Serialization` 暴露的各个方法就可以实现 bundle 分包了。主要是 `createModuleIdFactory(path)` 方法和 `processModuleFilter(module)` 。 - -`createModuleIdFactory(path)`是传入的模块绝对路径`path`,并为该模块返回一个唯一的`Id`。`processModuleFilter(module)`则可以实现对模块进行过滤,使其不被写入到最后的bundle文件中。 - -官方的 `createModuleIdFactory` 内部实现是返回一个数字,该数字在 require 方法中被调用,以此来实现模块的导入和初始化 - -```js -"use strict"; -function createModuleIdFactory() { - const fileToIdMap = new Map(); - let nextId = 0; - return path => { - let id = fileToIdMap.get(path); - if (typeof id !== "number") { - id = nextId++; - fileToIdMap.set(path, id); - } - return id; - }; -} -``` - -官方默认的实现存在一个问题,就是业务代码改动后,重新打包,由于 moduleID 是从0开始自增分配,可能会存在前后2次构建中 moduleID 发生改变。 - -针对官方实现,可以重新自定义 `createModuleIdFactory(path)` 方法,该方法根据当前模块文件的路径哈希值作为分配 moduleID 的依据,并建立哈希值和模块 ID 的对应关系保存到本地文件缓存中,每次编译 Bundle 先读取本地缓存文件来初始化内存缓存,当需要分配 ID 的时候,先从缓存内部查找,找不到则重新分配 ID 并存储变化。 - -```js -// metro.common.config.js -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); -const fs = require('fs'); -const path = require('path'); - -const config = { - transformer: { - getTransformOptions: async () => ({ - transform: { - experimentalImportSupport: false, - inlineRequires: true, - }, - }), - }, - serializer: { - createModuleIdFactory: function () { - //获取命令行执行的目录,__dirname是nodejs提供的变量 - const projectRootPath = __dirname; - return (path) => { - let name = ''; - // 如果需要去除react-native/Libraries路径去除可以放开下面代码 - // if (path.indexOf('node_modules' + pathSep + 'react-native' + pathSep + 'Libraries' + pathSep) > 0) { - // //这里是react native 自带的库,因其一般不会改变路径,所以可直接截取最后的文件名称 - // name = path.substr(path.lastIndexOf(pathSep) + 1); - // } - if (path.indexOf(projectRootPath) == 0) { - /* - 这里是react native 自带库以外的其他库,因是绝对路径,带有设备信息, - 为了避免重复名称,可以保留node_modules直至结尾 - 如/{User}/{username}/{userdir}/node_modules/xxx.js 需要将设备信息截掉 - */ - name = path.substr(projectRootPath.length + 1); - } - //js png字符串 文件的后缀名可以去掉 - // name = name.replace('.js', ''); - // name = name.replace('.png', ''); - //最后在将斜杠替换为下划线 - let regExp = pathSep == '\\' ? new RegExp('\\\\', "gm") : new RegExp(pathSep, "gm"); - name = name.replace(regExp, '_'); - //名称加密 - if (isEncrypt) { - name = md5(name); - } - fs.appendFileSync('./idList.txt', `${name}\n`); - return name; - }; - }, - }, -}; - -module.exports = mergeConfig(getDefaultConfig(__dirname), config); -``` - -同时,为了能够在`processModuleFilter(module)`方法中对模块进行过滤,需要在构建Common文件时,标记某个模块是否已包含在Common文件中。 - -打 common 包:`npx react-native bundle --platform ios --config metro.common.config.js --dev false --entry-file common.js --bundle-output='./ios/common.ios.bundle'` - -#### 3.4.4 业务资源包 - - - -```js -// metro.business.config.js -const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const fs = require('fs'); -const path = require('path'); -const idList = fs.readFileSync('./idList.txt', 'utf8').toString().split('\n'); - -function createModuleId(path) { - // 和上面生成 moduleID 方法一样 - return moduleId; -} - -const config = { - transformer: { - getTransformOptions: async () => ({ - transform: { - experimentalImportSupport: false, - inlineRequires: true, - }, - }), - }, - serializer: { - createModuleIdFactory: function () { - return function (path) { - return createModuleId(path); - }; - }, - processModuleFilter: function (modules) { - const mouduleId = createModuleId(modules.path); - // 通过 mouduleId 过滤在 common.bundle 里的数据 - if (idList.indexOf(mouduleId) < 0) { - console.log('createModuleIdFactory path', mouduleId); - return true; - } - return false; - }, - }, -}; - -module.exports = mergeConfig(getDefaultConfig(__dirname), config); -``` - -此时打业务包 `npx react-native bundle --platform ios --config metro.business.config.js --dev false --entry-file index.js --bundle-output='./ios/businessA.ios.bundle'` - -其中几个业务,就需要几个业务入口,也就需要打几次包。当然可以再次基础上包装一层,比如读取配置文件。 - - - -#### 3.4.5 客户端加载 - -以 iOS 为例,加载基础包 - -```objective-c -@implementation AppDelegate - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions -{ - RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; - RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge - moduleName:@"rnCodeSplitDemo" - initialProperties:nil]; - - if (@available(iOS 13.0, *)) { - rootView.backgroundColor = [UIColor systemBackgroundColor]; - } else { - rootView.backgroundColor = [UIColor whiteColor]; - } - - self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; - UIViewController *rootViewController = [UIViewController new]; - rootViewController.view = rootView; - self.window.rootViewController = rootViewController; - [self.window makeKeyAndVisible]; - return YES; -} - -- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge -{ - return [[NSBundle mainBundle] URLForResource:@"common.ios"" withExtension:@"bundle"]; -} -``` - -加载业务包 - -为 `RCTCxxBridge` 添加分类 `RCTCxxBridge+RunBundleJS` - -```objective-c -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTCxxBridge (RunBundleJS) -- (void)executeSourceCode:(NSData *)sourceCode withSourceURL:(NSURL *)url sync:(BOOL)sync; -@end - -NS_ASSUME_NONNULL_END -``` - -```objective-c - -NSString *businessBundle = [[NSBundle mainBundle] pathForResource:@"business.ios.jsbundle" ofType:nil]; -NSData *businessData = [NSData dataWithContentsOfFile:businessBundle options:NSDataReadingMappedIfSafe error:nil]; -[(RCTCxxBridge *)bridge.batchedBridge executeSourceCode:businessData sync:YES]; -``` - - - -## Fabric 新渲染器 - -React Native 开源之初的宏大愿景是:将现代 Web 技术引入移动端(Brighing modern web techniques to mobile) -想来也是,Web 开发历史悠久,沉淀了很多优秀的 UI 开发实践和基础设置。随着 React Web 的出现,将现代的 Web 中积累的开发理念、语言、框架、规范也带入到移动端, -但也有难点。如何打通 Web 和移动端 - -换言之,问题就是语言之间相互调用,也就是通信。本质上,JavaScript 如何与 OC/Java 通信。彼此可以互相调用方法、访问属性。 - -### 老架构渲染器 - -- 老渲染器的主要职责之一,就是将 JavaScript 侧声明的组件转换为 iOS/Android 侧的 Api 命令。 -``` -const App = () => Hello world -AppRegistry.registerComponent(appName, () => App) -``` -当声明一个包含 `Hello world` App 组件,并将该 App 组件传递给 `registerComponent` 方法后,通过渲染器,会将声明式的代码转换为原生指令。 -以上 Hello World 应用中会包括一个用于布局的 View 视图和显示文本的视图。在 iOS 端,会生成一个 UIView 用于布局,并会创建 NSAttributedString 用于显示文本。在 Objective-C 中调用相关以上创建视图的 API 后,操作系统就会将 Hello World 文字显示在屏幕上了。 - -- 老渲染器的另一个重要职责是实现 Flex 布局。 -开源的第一版 Flex 布局是直接用原生代码实现的,后来该功能独立了出来,作为一个 C++ 第三方库 Yoga 被 React Native 引入。 -假设想实现文字居中 -``` - - Hello world - -``` -渲染器会将 style 属性设置,转换为包裹 Hello World 视图容器的 x/y 轴坐标使其实现屏幕居中。 - -- 老版渲染器还有一个职责就是尽可能的提升渲染性能。 -RN 在第一版的时候就设置为双线程异步消息通信架构,后来 RN 团队又为 Yoga 布局引擎,新增了一个线程,专门用于处理布局。 -相较于单线程同步调用的架构,多线程异步消息架构更能大幅减少卡顿。一方面,渲染任务被分解到3个线程中(JS 线程、布局线程、UI 线程),所以 UI 线程的任务量会变少,UI 线程卡顿的几率也会减少。另一方面,采用异步通信,JS 线程任务的执行不会阻塞 UI 线程。 - - -### Fabric 新渲染器 -Fabric 新渲染器是基于老渲染器的重构升级,而重构升级过程中不变的是核心责任,是组件化 / 声明式、Flex 布局和多线程模型。升级的是开发者体验,以及性能提升带来的用户体验。 -Fabric 渲染器完成的主要任务还是将声明的组件转换为最终原生 Api 调用。转换过程涉及到3颗树: -- Element Tree -- Fiber Tree -- Shadow Tree - -#### Element Tree -Element Tree 是 Javascript 侧,由 React 通过开发者书写的 JSX 创建而成,由若干个 Element 组成。 -一般而言,根结点 `` 就是一个 Element,同时它也是 Element Tree。一个 Element 其实就是一个普通的 Object,该对象描述组件的实例或宿主视图实例。 -``` -const App = () => { - return ( - - Hello World - - ); -}; -// Element Tree - -``` -整个应用的根节点是 ``,`` 的子节点是 ``,`` 子节点是 ``,共同构成了一棵 Element Tree。 -Element Tree 的每个节点都是一个 Element,React Element 有2种类型:一种是通过函数或者自定义合成组件生成的、一种是宿主组件生成的。其中,宿主组件指的是框架通过 JS 引擎暴露给 JS 的原生组件(Native 组件) - -`` 根节点是自定义函数创建的,属于合成组件生成的节点,由 type、props、concurrentRoot 等属性组成的对象,type 属性是一个 function 函数,函数名 name 是 App。打印信息如下 - - -`` 节点是由框架暴露组件生成的节点,信息如下 - - -可知,一个 Element 也是一个普通的对象,该对象的 type 属性为 RCTText,style 属性值由设置透明属性 `opacity:0.9` 和设置居中布局的属性组成,子节点 children 属性值为 `Hello world` - -从 Hello World 应用中的 节点的构成,我们可以看出,一个 Element 常见的属性包括 type 、props、concurrentRoot、style、children 等属性。 -- type:type 代表该 Element 的类型。如果 type 的值是 RCTText、RCTView 之类的字符串,那么该 Element 对应着一个宿主视图。如果 type 的值是函数或类,那么该 Element 是由合成组件生成的,并且没有对应的宿主视图。 -- props:Element 初始化传入的属性,其中又包括当前根节点 concurrentRoot、样式 style、子节点 children,或者例如 Text 组件的 ellipsizeMode 文本省略属性等等。 - -在 React 层, Element Tree 会被映射为 Fiber Tree。 - -#### Fiber Tree -Fiber Tree 由若干个 FIber 节点组成。如果某个 Fiber 节点是通过用于描述宿主视图的 Element 生成的,那么该 Fiber 节点会对应一个同样的宿主视图。 - -Fiber 是 React16 之后引入的新能力,使得 React 每次可渲染的颗粒度变小了。由 React 16 之前的一次 render 所有节点,变成了一次 render 时可分批次对节点进行操作。因此,从渲染角度,我们还可以将 Fiber 节点当作每次 render 的最小渲染单位,让 Fabric 渲染器更快更智能。 - -在 React 内部,Fiber 节点是由 `function createFiberFromElement(element, mode, lanes)` 函数创建的。顾名思义,Fiber 节点是由 Element 节点生成的,换言之 Fiber Tree 可以看成是 Element Tree 的映射。 - -同样 Fiber Tree 分为2种,一种是由合成组件生成的 Element 所映射得到的 Fiber 节点,它没有对应的宿主组件实例;一种是由宿主组件生成的 Element 所映射得到的 Fiber 节点,它拥有对应的宿主组件实例。 - -以 Hello world 威力,打印 App 组件所创建的 Fiber 节点 - - -可以看到 App Fiber 也是一个 JS 对象,也拥有 type、child、props 等属性,这些属性在 Element 上也有。例如 App 组件创建的 Element、Fiber 的 Type 都是一个名为 App 的函数。Fiber 节点上的属性比 Element 多,比如 Fiber 节点拥有兄弟节点 sibling、父节点 return、状态节点 stateNode 等属性。 - -状态节点是一个特殊的属性,关联了渲染器在 C++ 层生成的 Shadow 节点。App Fiber 节点的 stateNode 为 null,代表的就是合成组件所对应的 Fiber 节点是没有关联的 Shadow 节点的,也就没有对应的宿主视图。 - -打印 Text 组件所创建的 Fiber 节点,如下: - -Text Fiber 节点和 App Fiber 节点属性都一样,比如:类型 type、子节点 child、兄弟节点 sibling、父节点 return、状态节点 stateNode 等。不同的是 Text Fiber 节点的 type 是 RCTText,App Fiber 的 type 是个函数。Text Fiber 的 stateNode 是有值的,node 属性值是一个 CallbackObject,而 App Fiber 的 node 是 null。该 CallbackObject 类型的值代表的是一个在 C++ 层的 shadow 节点。 - - -#### Shadow Tree -Shadow Tree 是 C++ 层创建的树,由若干个 Shadow 节点组成,这些 Shadow 节点是在创建对应的拥有 stateNode 值的 Fiber 节点时,同步创建的。 -``` -Hello world -``` -Xcode 打印如下: - -元素对应的 Shadow 节点是个 Native 对象,拥有属性 props、子节点 children、布局 layoutMetrics 等属性。 - -其中,Shadow 的 props 的透明度 opacity: 0.88 来自于 JSX 中的 style={{opacity: 0.88}} 的设置;子节点 children 的 text="Hello World" 来自于 JSX 标签括起来的内容 Hello World。而 x/y 轴坐标以及 width/height 视图大小是根据其自身 style 布局属性,以及父节点和其他节点 style 布局属性计算出来的。 - -Shadow Tree 不仅继承了由 JSX 所创建的 Element Tree 相关属性、父子节点关系,还新增了该视图如何在屏幕上进行布局的具体值。 - -最后 Fabric 渲染器的 C++ 层,通过 diff 算法对比更新前后的2颗 Shadow Tree,计算出更新视图的操作指令,完成最终的渲染。整个流程就是 Fabric 渲染器将 JSX 渲染成原生视图的完整流程。 - - -可以访问完整的[渲染、提交与挂载流程](https://reactnative.cn/architecture/render-pipeline) - -## 经验小结 - -最近在做企业内部工具,在提 MR 进行 Code review 的时候,reviewer 提了这样一个问题。将 `{this.renderOverview()}` 改为 `` 这种写法,界面上更加直观些。我觉得公用组件或者页面这样做是可以的,但是页面上的某个部分 UI,其他页面不需要用到,所以不需要抽取出来。所以这个 code review 我没接受,跟她讲了一番,我维持了现状。 - -背景是这样的,React + Redux 实现界面编写,Redux 负责状态的管理,页面一般是需要负责渲染和交互的,所以在代码编写上通过 Redux 的 `@connect` 将 state 绑定到当前页面的 `props` 上,因此界面的展示全部由 props 完成。页面的交互逻辑由各个组件内部 `dispatch` `action` 到 `reducer` 中进行运算。运算后的结果继续以 props 绑定到当前的页面上。代码如下 - - -```Javascript -@connect(({ skynet }) => ({ - ...skynet, -}), dispatch => ({ - getXXXLogDetail (***) { - return dispatch({ - type: ListPre('***'), - payload: *** - }) - } -})) - -export default class Detail extends Component { - state = { - showDispatchPanel: false - } - - get id() { - const { match } = this.props - return match.params.id - } - - componentDidMount() { - const { getSkyLogDetail } = this.props - getXXXLogDetail({ groupHash: this.id}) - } - - s() { - const { skyLogDetail: { groupDTO = {} } } = this.props - // ... - return ( -
- //*** -
- ) - } - - render() { - return ( -
- - {this.renderOverview()} - - {/* ... */} -
- ) - } -} - -``` - -和 Reviewer 聊过后,她好像自己对这些东西不是很熟悉,比如 `` 这种形式需要改造为纯函数,且如果纯函数不能通过这种小写的方式去写,不然 React 在内部渲染会认为是一个**html 标签**。效果如下 -![渲染为标签](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20200205-ReactPureComponent) - -最佳实践就是:自定义组件大写开头,小写开头会被认为是 html 标签。createElement(ComponentVariable),createElement('renderOverview')。只是渲染,不需要内部改变 state 的话,纯函数非常适合做 UI 渲染,状态的事情由 redux 解决,最后通过 props 处理。 - diff --git a/Chapter1 - iOS/1.7.md b/Chapter1 - iOS/1.7.md deleted file mode 100644 index 3713c3f..0000000 --- a/Chapter1 - iOS/1.7.md +++ /dev/null @@ -1,1347 +0,0 @@ -# 对象在内存中的存储底层原理 - -## 一、 栈、堆、BSS、数据段、代码段是什么? - -栈(stack):又称作堆栈,用来存储程序的局部变量(但不包括static声明的变量,static修饰的数据存放于数据段中)。除此之外,在函数被调用时,栈用来传递参数和返回值。 - -堆(heap):用于存储程序运行中被动态分配的内存段,它的大小并不固定,可动态的扩张和缩减。操作函数(malloc/free) - -BSS段(bss segment):通常用来存储程序中未被初始化的全局变量和静态变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段输入静态内存分配 - -数据段(data segment):通常用来存储程序中已被初始化的全局变量和静态变量和字符串的一块内存区域 - -代码段(code segment):通常是指用来存储程序可执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量。 - -![内存](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ram.png) - - - -## 二、 类的本质 - -Demo1 - -```objective-c -#import - -int main(int argc, const char * argv[]) { - @autoreleasepool { - NSObject *obj = [[NSObject alloc] init]; - } - return 0; -} -``` - -因为 OC 本质就是 c/c++,所以转成 c/c++ 来窥探下,采用指令 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp`(不要采用 `clang -rewrite-objc main.m -o main.cpp` 因为这样生成的 c++ 文件由于没有指明设备和对应的架构指令集,不够准确)。 - -产看生成的 `main-arm64.cpp` 其中有段代码是定义结构体。 - -```c++ -struct NSObject_IMPL { - Class isa; -}; -``` - -然后点击 NSObject 跳转官方的声明可以看到 - -```c++ -@interface NSObject { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wobjc-interface-ivars" - Class isa OBJC_ISA_AVAILABILITY; -#pragma clang diagnostic pop -} -// 剔除一些无效信息后 -@interface NSObject { - Class isa; -} -``` - -因此可以知道,OC 的类底层是由 c/c++ 的继承实现的。 - - - -由于 obj 对象没有任何属性和方法,只有一个 isa 指针,且类的本质就是结构体,所以当结构体只有1个成员时,该成员的地址值,就是该结构体的地址。 - -即 isa 指针值为 0x100200110,结构体地址为 0x100200110,obj 指针值为 0x100200110。 - - - -Demo2 - -```objective-c -@interface Student : NSObject -{ - @public - int _no; - int _age; -} -@end - -@implementation Student -@end - -int main(int argc, const char * argv[]) { - @autoreleasepool { - Student *st = [[Student alloc] init]; - st->_no = 1; - st->_age = 29; - } - return 0; -} -``` - -采用指令 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` 转换为 c++ 代码 - -```c++ -struct NSObject_IMPL { - Class isa; -}; - -struct Student_IMPL { - struct NSObject_IMPL NSObject_IVARS; // 父类的所有 ivars。由于 Student 父类是 NSObject,所以其实只有 isa - int _no; - int _age; -}; -``` - -`struct NSObject_IMPL NSObject_IVARS;` 代表父类的所有 ivars。由于 Student 父类是 NSObject,所以其实只有 isa - -由于 `NSObject_IMPL` 结构体只有1个 isa 成员,所以上面代码等价于 - -```c++ -struct Student_IMPL { - Class isa; - int _no; - int _age; -}; -``` - -类的本质是结构体,结构体成员内存紧挨着。内存布局如图所示: - - - - - - - -如果上述结论正确,那是不是可以声明一个 ` Student_IMPL` 类型的结构体指针,指向 st 指针指向的对象。然后通过结构体指针访问成员变量,看看取值是不是正确的 - - - -发现是可以正确访问的。 - -为什么 `class_getInstanceSize` 打印出16?因为本质是计算所有 ivars 的内存大小,1个 isa,2个 int 就是16. - -而 `malloc_size` 是系统真正分配的,由于最小分配16,所以刚好就是16. - - - -Demo3 - -```objective-c -@interface Person : NSObject -{ - @public - int _age; -} -@end - -@implementation Person -@end - -@interface Student : Person -{ - @public - int _no; -} -@end - -@implementation Student - -@end - -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p = [[Person alloc] init]; - NSLog(@"%zd", class_getInstanceSize([Person class])); // 16 - NSLog(@"%zd", malloc_size((__bridge const void *)p)); // 16 - Student *st = [[Student alloc] init]; - NSLog(@"%zd", class_getInstanceSize([Student class])); // 16 - NSLog(@"%zd", malloc_size((__bridge const void *)st)); // 16 - } - return 0; -} -``` - -采用指令 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` 转换为 c++ 代码 - -```c++ -struct NSObject_IMPL { - Class isa; -}; - -struct Person_IMPL { - struct NSObject_IMPL NSObject_IVARS; - int _age; -}; - -struct Student_IMPL { - struct Person_IMPL Person_IVARS; - int _no; -}; -``` - -为什么 `class_getInstanceSize([Person class])` 也是16,不是8+4吗?因为存在内存对齐,结构体的大小必须是最大成员大小的倍数(Person 中也就是8的倍数) - - - - - -Demo4 - -```objective-c -@interface Person : NSObject -{ - @public - int _age; - int _no; - int _height; -} -@end - -@implementation Person -@end - -int main(int argc, const char * argv[]) { - @autoreleasepool { - Person *p = [[Person alloc] init]; - NSLog(@"%zd", class_getInstanceSize([Person class])); // 24 - NSLog(@"%zd", malloc_size((__bridge const void *)p)); // 32 - } - return 0; -} -``` - -采用指令 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` 转换为 c++ 代码 - -```c++ -struct NSObject_IMPL { - Class isa; -}; -struct Person_IMPL { - struct NSObject_IMPL NSObject_IVARS; - int _age; - int _no; - int _height; -}; -// -> -struct Person_IMPL { - Class isa; - int _age; - int _no; - int _height; -}; -``` - -2个问题: - -- `class_getInstanceSize` 不是一个 isa 8 + 3个 Int 4 = 8 + 3 * 4 = 20吗?查看源码知道 `class_getInstanceSize` 返回的是内存对齐后的成员变量内存大小 -- `malloc_size` 为什么是32?只需要24字节,为什么分配32? - - - - - - - - - - - -## 三、研究下对象在内存中如何存储? - -```objective-c -Person *p1 = [Person new] -``` - -看这行代码,先来看几个注意点: - -new底层做的事情: - -* 在堆内存中申请1块合适大小的空间 -* 在这块内存上根据类模版创建对象。类模版中定义了什么属性就依次把这些属性声明在对象中;对象中还存在一个属性叫做 **isa**,是一个指针,指向对象所属的类在代码段中地址 -* 初始化对象的属性。这里初始化有几个原则: - * 如果属性的数据类型是基本数据类型则赋值为 0 - * 如果属性的数据类型是 C 语言的指针类型则赋值为 NULL - * 如果属性的数据类型为 OC 的指针类型则赋值为 nil - -* 返回堆空间上对象的地址 - - - -注意: - -* 对象只有属性,没有方法。包括类本身的属性和一个指向代码段中的类isa指针 -* 如何访问对象的属性?指针名->属性名;本质:根据指针名找到指针指向的对象,再根据属性名查找来访问对象的属性值 -* 如何调用方法?[指针名 方法];本质:根据指针名找到指针指向的对象,再发现对象需要调用方法,再通过对象的isa指针找到代码段中的类,再调用类里面方法 - -为什么不把方法存储在对象中? - -* 因为以类为模版创建的对象只有属性可能不相同,而方法相同,如果堆区的对象里面也保存方法的话就会很重复,浪费了堆空间,因此将方法存储与代码段 - -* 所以一个类创建的n个对象的isa指针的地址值都相同,都指向代码段中的类地址 - -做个小实验 - -```Objective-C -#import -@interface Person : NSObject{ - @public - int _age; - NSString *_name; - int *p; -} - --(void)sayHi; -@end - -@implementation Person - --(void)sayHi{ - NSLog(@"Hi, %@",_name); -} - -@end - -int main(int argc, const char * argv[]) { - Person *p1 = [Person new]; - Person *p2 = [Person new]; - Person *p3 = [Person new]; - p1->_age = 20; - p2->_age = 20; - - [p1 sayHi]; - [p2 sayHi]; - [p3 sayHi]; - - return 0; -} -``` - -`Person *p1 = [Person new];` 这句代码在内存分配原理如下图所示 - -![解析图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Untitled%20Diagram-2.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的值相同。** - - - -## 四、一个对象占用多少内存空间? - -Demo - -```objective-c -int main(int argc, const char * argv[]) { - @autoreleasepool { - NSObject *obj = [[NSObject alloc] init]; - // 获取类的实例对象的成员变量所占用内存大小 - NSLog(@"%zd", class_getInstanceSize([NSObject class])); // 8 - // 获取 obj 指针,所指向内存大小 - NSLog(@"%zd", malloc_size((__bridge const void *)obj)); // 16 - } - return 0; -} -``` - -为什么一个是8,一个是16?查看 objc 源代码可以看到: - -```objectivec -size_t class_getInstanceSize(Class cls) -{ - if (!cls) return 0; - return cls->alignedInstanceSize(); -} - // Class's ivar size rounded up to a pointer-size boundary. -uint32_t alignedInstanceSize() { - return word_align(unalignedInstanceSize()); -} -``` - -```objectivec -id class_createInstance(Class cls, size_t extraBytes) -{ - return _class_createInstanceFromZone(cls, extraBytes, nil); -} - -static __attribute__((always_inline)) -id -_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, - bool cxxConstruct = true, - size_t *outAllocatedSize = nil) -{ - if (!cls) return nil; - - assert(cls->isRealized()); - - // Read class's info bits all at once for performance - bool hasCxxCtor = cls->hasCxxCtor(); - bool hasCxxDtor = cls->hasCxxDtor(); - bool fast = cls->canAllocNonpointer(); - - size_t size = cls->instanceSize(extraBytes); - if (outAllocatedSize) *outAllocatedSize = size; - - id obj; - if (!zone && fast) { - obj = (id)calloc(1, size); - if (!obj) return nil; - obj->initInstanceIsa(cls, hasCxxDtor); - } - else { - if (zone) { - obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size); - } else { - obj = (id)calloc(1, size); - } - if (!obj) return nil; - - // Use raw pointer isa on the assumption that they might be - // doing something weird with the zone or RR. - obj->initIsa(cls); - } - - if (cxxConstruct && hasCxxCtor) { - obj = _objc_constructOrFree(obj, cls); - } - - return obj; -} - -size_t instanceSize(size_t extraBytes) { - size_t size = alignedInstanceSize() + extraBytes; - // CF requires all objects be at least 16 bytes. - if (size < 16) size = 16; - return size; -} -``` - -`alloc` 本质上调用的就是 `_objc_rootAllocWithZone` ,继续查看源码 - -```c++ -// NSObject.mm -id -_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone) -{ - id obj; - -#if __OBJC2__ - // allocWithZone under __OBJC2__ ignores the zone parameter - (void)zone; - obj = class_createInstance(cls, 0); -#else - if (!zone) { - obj = class_createInstance(cls, 0); - } - else { - obj = class_createInstanceFromZone(cls, 0, zone); - } -#endif - - if (slowpath(!obj)) obj = callBadAllocHandler(cls); - return obj; -} -``` - -继续调用的是 `class_createInstance` - -```c++ -// objc-runtime-new.mm -id -class_createInstance(Class cls, size_t extraBytes) -{ - return _class_createInstanceFromZone(cls, extraBytes, nil); -} - -static __attribute__((always_inline)) -id -_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, - bool cxxConstruct = true, - size_t *outAllocatedSize = nil) -{ - if (!cls) return nil; - - assert(cls->isRealized()); - - // Read class's info bits all at once for performance - bool hasCxxCtor = cls->hasCxxCtor(); - bool hasCxxDtor = cls->hasCxxDtor(); - bool fast = cls->canAllocNonpointer(); - - size_t size = cls->instanceSize(extraBytes); - if (outAllocatedSize) *outAllocatedSize = size; - - id obj; - if (!zone && fast) { - obj = (id)calloc(1, size); - if (!obj) return nil; - obj->initInstanceIsa(cls, hasCxxDtor); - } - else { - if (zone) { - obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size); - } else { - obj = (id)calloc(1, size); - } - if (!obj) return nil; - - // Use raw pointer isa on the assumption that they might be - // doing something weird with the zone or RR. - obj->initIsa(cls); - } - - if (cxxConstruct && hasCxxCtor) { - obj = _objc_constructOrFree(obj, cls); - } - - return obj; -} -``` - -可以看到调用的是 c 的 `obj = (id)calloc(1, size);`,其中 size 是前面计算好的,继续看看这个 size 的计算 `size_t size = cls->instanceSize(extraBytes);` - -```c++ -// objc-runtime-new.h -size_t instanceSize(size_t extraBytes) { - size_t size = alignedInstanceSize() + extraBytes; - // CF requires all objects be at least 16 bytes. - if (size < 16) size = 16; - return size; -} -``` - -可以看到计算好 size 之后有个判断,如果小于16,则赋值为16,也就是最小为16(`CF requires all objects be at least 16 bytes`)。 - -结论:我们用2种方式获取内存大小,其中 - -- `class_getInstanceSize([NSObject class])` :8,返回实例对象内存对齐后的的成员变量所占用的内存大小(即代码注释的 `Class's ivar size rounded up to a pointer-size boundary.` ),一个空对象,只有 isa 指针,所以只有8字节。可以理解为 **创建一个对象,至少需要多少内存** -- `malloc_size((__bridge const void *)obj)` :16,Apple 规定,对象至少16个字节。但是只有一个 isa,所以只占用8个字节。 - -- 内存对齐:结构体的最终大小必须是最大成员的倍数。可以理解为**创建一个对象,实际上分配了多少内存** - -- 系统分配了16个字节给 NSObject 对象(通过 malloc_size 函数获得) -- 但 NSObject 对象内部只使用了8个字节的空间(64位环境下,通过 class_getInstanceSize 函数获得) - - - - - -```objective-c -@interface Person : NSObject -@end - -Person *person = [[Person alloc] init]; -NSLog(@"%zd", class_getInstanceSize([person class])); // 8 -NSLog(@"%zd", malloc_size((__bridge const void *)person)); // 16 -``` - - - -为对象增加2个属性呢 - -```objective-c -@interface Person : NSObject -@property (nonatomic, assign) int age; -@property (nonatomic, assign) int height; -@end - -Person *person = [[Person alloc] init]; -NSLog(@"%zd", class_getInstanceSize([person class])); // 16 -NSLog(@"%zd", malloc_size((__bridge const void *)person)); // 16 - -``` - - - -创建一个实例对象,至少需要多少内存? - -```objective-c -#import -class_getInstanceSize([Person class]) -sizeOf() -``` - -创建一个实例对象,实际上分配了多少内存? - -```objective-c -#import -malloc_size((__bridge const Person *)obj) -``` - -占用内存,只需要遵循结构体内存对齐规则即可:即结构体成员变量内存最大的整数倍数 - -实际内存,除了考虑结构体内存对齐规则以外,还需要考虑系统为了内存访问速度而设计的内存分配 buckets 大小。 - - - -## 五、属性和方法 - -```objective-c -@interface Person : NSObject -{ - @public - int _age; -} -@property (nonatomic, assign) NSInteger height; -@end -``` - -采用指令 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` - -```c++ -struct NSObject_IMPL { - Class isa; -}; - -struct Person_IMPL { - struct NSObject_IMPL NSObject_IVARS; - int _age; - NSInteger _height; -}; -``` - -我们知道 `@property` 的本质是生成一个带下划线的 ivar,即 `_height`,还有 height 的 getter、setter 方法。 - -为什么在结构体里没有看到方法? - -1. 内存优化: - - 因为对象可以存在多个,这些方法的实现需要公用,没必要每个对象里都保存一份。如果方法存储在实例中,1000 个实例会有 1000 份相同的方法代码,导致内存浪费。 - - **共享方法实现是面向对象语言的通用设计**(如 C++、Java) -2. **动态性支持**: - - 方法存储在类对象中,使得 Objective-C 的**运行时方法替换**(Method Swizzling)成为可能。例如,可以通过 `class_replaceMethod` 动态修改类的方法实现,所有实例立即生效。 -3. **继承与多态**: - - 子类可以重写父类方法,方法查找会沿着类继承链进行,这种机制依赖于方法存储在类对象中。 - - - -## 五、类继承的本质 - -QA: 结构体计算大小为什么需要内存对齐? - -iOS 分配内存,为什么需要内存对齐?libmalloc 可以看到至少是16的倍数。 - -写一个最基础的 Person 类 - -```objectivec -@interface Person : NSObject -@end - -@implementation Person -@end -``` - -clang 转为 c 代码看看,因为同样的代码经过 clang 后转成 c/c++ 后,不同平台具有不同的实现,所以为了精确研究 iOS,最好指明 arm64 架构后再研究,具体指令为:`xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` - -```c -struct NSObject_IMPL { - Class isa; -} - -struct Person_IMPL { - struct NSObject_IMPL NSObject_IVARS; -}; -``` - -如果给 Person 增加属性 - -```objective-c -@interface Person : NSObject -@property (nonatomic, assign) double height; -@property (nonatomic, assign) double weight; -@property (nonatomic, assign) int salary; -- (void)test; -@end - -@implementation Person -@end -``` - -创建一个继承自 Person 的 Student 类 - -```objective-c -@interface Student : Person -@property (nonatomic, assign) NSInteger score; -- (void)test; -@end - -@implementation Student -@end -``` - -利用 `xcrun --sdk iphoneos clang -arch arm64 -rewrite-objc Person.m -o Person-arm64.cpp` 转换为 c++,将主要的摘出来 - -```c++ -struct NSObject_IMPL { - Class isa; -}; - -struct Person_IMPL { - struct NSObject_IMPL NSObject_IVARS; - int _salary; - double _height; - double _weight; -}; - -struct Student_IMPL { - struct Person_IMPL Person_IVARS; - NSInteger _score; -}; -``` - -结构体 `Person_IMPL` 等价于 - -```c++ -struct Person_IMPL { - Class isa; - int _salary; - double _height; - double _weight; -}; -``` - -结构体 `Student_IMPL` 等价于 - -```c++ -struct Student_IMPL { - Class isa; - int _salary; - double _height; - double _weight; - NSInteger _score; -}; -``` - -首先我们可以知道一个 OC 类的属性可以写多种数据类型,那么大概率是 C 结构体实现。用 clang 转为 c 可以得到印证。另外存在类的继承关系的时候:**子类结构体中第一个信息是父类结构体对象;其次是当前子类自己的信息;根节点一定是 NSObject_IMPL 结构体;且其中只有 `Class isa`**。也就是说,**一个实例对象,内部的第一个成员就是 isa 指针,其次是父类属性,最后的自己的属性。且 isa 指针地址就是当前实例对象的地址**。 - - - -观察 clang 转换后的 c 代码,发现 property 没有看到 setter、getter 方法。为什么这么设计? -**方法不会存储到实例对象中去的。因为方法在各个对象中是通用的,方法存储在类对象的方法列表中。** - -```objectivec -@interface Person:NSObject -{ - int _height; - int _age; - int _weight; -} -@end - -@implementation Person -@end - -struct NSObject_IMPL { - Class isa; -}; - -struct PersonIMPL { - struct NSObject_IMPL ivars; - int _height; - int _age; - int _weight; -}; - -struct PersonIMPL person = {}; -Person *p = [[Person alloc] init]; -NSLog(@"%zd", class_getInstanceSize([Person class])); // 24,这个数值代表我们这个类,这个结构体,创建出来至少只需要24字节. -NSLog(@"%zd", sizeof(person)); // 24,这个数值代表我们这个类,这个结构体只需要24字节就够 -NSLog(@"%zd", malloc_size((__bridge const void *)p)); // 32。iOS 系统会做优化,比如为了加速访问速度,会按照16的倍数进行分配。 -``` - -`class_getInstanceSize`这个数值代表我们这个类,这个结构体,创建出来至少只需要24字节,`malloc_size` iOS 系统会做优化,比如为了加速访问速度,会按照16的倍数进行分配。 - -iOS 中,系统分配内存,都是16的倍数。pageSize?系统在分配内存的时候也存在内存对齐。 -GUN 都存在内存对齐这个概念。 -`sizeof` 本质是运算符。在 Xcode 编译后就替换为真正的值。通过指令 `xcrun --sdk iphoneos clang -arch arm64 -S -emit-llvm ViewController.m -o ViewController.ll` 查看 IR。 - - - - - -实例对象: -类对象:isa、superclass、属性信息、对象方法信息、协议信息、成员变量信息... -元类对象:存储 isa、superclass、类方法信息... -一个实例对象只有一个类对象,一个实例对象只有一个元类对象。 `class_isMetaClass()`判断一个类是否为元类对象 - -`objc_getClass()` 如果传递 instance 实例对象,返回 class 类对象;传递 Class 类对象,返回 meta-class 元类对象;传递 meta-class 元对象,则返回 NSObject(基类)的 meta-class 对象 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/objc-isa.png) - -instance 的 isa 指向 Class。当调用方法时,通过 instance 的 isa 找到 Class,最后找到对象方法的实现进行调用 -class 的 isa 指向 meta-class。当调用类方法的时,通过 class 的 isa 找到 meta-class,最后找到类方法进行调用。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/objc-superclass.png) - -当 Student 实例对象调用 Person 的对象方法时,首先根据 Student 对象的 isa 找到 Stduent 的 Class 类对象,然后根据 Stduent Class 类对象中的 superClass 找到 Person 的 Class 类对象,在类对象的对象方法列表中找到方法实现并调用。 -当 Stduent 实例对象调用 init 方法时候,首先根据 Student 对象的 isa 找到 Stduent 的 Class 类对象,然后根据 Stduent Class 类对象中的 superClass 找到 Person 的 Class 类对象,找到 Person Claas 的 superClass 到 NSObject 类对象,在 NSObject 类对象的方法列表中找到 `init` 方法并调用。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/objc-metaclass-superclass.png) - -当 Stduent 对象调用类方法的时候,先根据 isa 找到 Student 的元类对象,然后在元类对象的 superclass 找到 Person 的元类对象,再根据 Person 元类对象的 superClass 找到 NSObject 的元类对象。最后找到元类对象的方法列表,调用到对象方法。 - - - -```objectivec -@interface Student : NSObject -@end - -@implementation Person -@end - -@interface NSObject(TestMessage) -@end - -@implementation NSObject(TestMessage) - -- (void)test -{ - NSLog(@"%s", __func__); -} -@end -``` - -奇怪的是,我们给 Student 类对象调用 test 方法,`[Student test]` 则调用成功。是不是很奇怪?站在面向对象的角度出发,Student 类对象根据 isa 找到元对象,此时元对象方法列表中没有类方法,所以再继续根据 superclass 找到 NSObject 元类对象,发现元类对象自身也没有类方法。但是为什么调用了 `-(void)test` 对象方法? - -因为NSObject 元类对象的 superClass 继承自 NSObject 的类对象,类对象是存储对象方法的,所以定义在 NSObject 分类中的 `-(void)test` 最终会被调用。 - -从64位开始,iOS 的 isa 需要与 ISA_MASK 进行与位运算。& ISA_MASK 才可以得到真正的类对象地址。 -为了打印和研究类对象中的 superclass、isa - -```objectivec -// 实例对象 -Person *p = [[Person alloc] init]; -Student *s = [[Student alloc] init]; -// 类对象 -class pclass = object_getClass(p); -class sclass = object_getClass(s); -// Mock 系统结构体 object_class -struct mock_object_class { - class isa; - class superclass; -}; -// 转换如下 -struct mock_object_class *person = (__bridge mock_object_class *)[[Person alloc] init]; -struct mock_object_class *student = (__bridge mock_object_class *)[[Student alloc] init]; -``` - -如何查看类真正的结构?在 Xcode 中打印出来 -思路:查看 Class 内部的数据,发现是 struct,所以我们查看源码,自己定义一个 struct,去承接类对象的元类对象信息 - -```c -#import - -#ifndef MockClassInfo_h -#define MockClassInfo_h - -# if __arm64__ -# define ISA_MASK 0x0000000ffffffff8ULL -# elif __x86_64__ -# define ISA_MASK 0x00007ffffffffff8ULL -# endif - -#if __LP64__ -typedef uint32_t mask_t; -#else -typedef uint16_t mask_t; -#endif -typedef uintptr_t cache_key_t; - -struct bucket_t { - cache_key_t _key; - IMP _imp; -}; - -struct cache_t { - bucket_t *_buckets; - mask_t _mask; - mask_t _occupied; -}; - -struct entsize_list_tt { - uint32_t entsizeAndFlags; - uint32_t count; -}; - -struct method_t { - SEL name; - const char *types; - IMP imp; -}; - -struct method_list_t : entsize_list_tt { - method_t first; -}; - -struct ivar_t { - int32_t *offset; - const char *name; - const char *type; - uint32_t alignment_raw; - uint32_t size; -}; - -struct ivar_list_t : entsize_list_tt { - ivar_t first; -}; - -struct property_t { - const char *name; - const char *attributes; -}; - -struct property_list_t : entsize_list_tt { - property_t first; -}; - -struct chained_property_list { - chained_property_list *next; - uint32_t count; - property_t list[0]; -}; - -typedef uintptr_t protocol_ref_t; -struct protocol_list_t { - uintptr_t count; - protocol_ref_t list[0]; -}; - -struct class_ro_t { - uint32_t flags; - uint32_t instanceStart; - uint32_t instanceSize; // instance对象占用的内存空间 -#ifdef __LP64__ - uint32_t reserved; -#endif - const uint8_t * ivarLayout; - const char * name; // 类名 - method_list_t * baseMethodList; - protocol_list_t * baseProtocols; - const ivar_list_t * ivars; // 成员变量列表 - const uint8_t * weakIvarLayout; - property_list_t *baseProperties; -}; - -struct class_rw_t { - uint32_t flags; - uint32_t version; - const class_ro_t *ro; - method_list_t * methods; // 方法列表 - property_list_t *properties; // 属性列表 - const protocol_list_t * protocols; // 协议列表 - Class firstSubclass; - Class nextSiblingClass; - char *demangledName; -}; - -#define FAST_DATA_MASK 0x00007ffffffffff8UL -struct class_data_bits_t { - uintptr_t bits; -public: - class_rw_t* data() { - return (class_rw_t *)(bits & FAST_DATA_MASK); - } -}; - -/* OC对象 */ -struct mock_objc_object { - void *isa; -}; - -/* 类对象 */ -struct mock_objc_class : mock_objc_object { - Class superclass; - cache_t cache; - class_data_bits_t bits; -public: - class_rw_t* data() { - return bits.data(); - } - - mock_objc_class* metaClass() { - return (mock_objc_class *)((long long)isa & ISA_MASK); - } -}; - -#endif /* MockClassInfo_h */ -``` - -使用用 - -```objective-c -Student *stu = [[Student alloc] init]; -stu->_weight = 10; - -mock_objc_class *studentClass = (__bridge mock_objc_class *)([Student class]); -mock_objc_class *personClass = (__bridge mock_objc_class *)([Person class]); - -class_rw_t *studentClassData = studentClass->data(); -class_rw_t *personClassData = personClass->data(); - -class_rw_t *studentMetaClassData = studentClass->metaClass()->data(); -class_rw_t *personMetaClassData = personClass->metaClass()->data(); -``` - -可以看到 Xcode 报错了,因为是 `main.m` 引入了 `MockClassInfo.h` ,会当作 OC 去编译。为了解决编译报错,将 `main.m` 改为 `main.mm` ,变成为 objective-c++ 文件,支持 OC 和 C++ 混编。 - - - - - -## 六、 内存对齐 - -内存对齐是指数据在内存中存储时按照一定规则对齐到特定的地址上。在 iOS 开发中,内存对齐是为了提高内存访问的效率和性能。内存对齐的原因主要包括以下几点: -1. 提高访问速度:内存对齐可以使数据在内存中的存储更加高效,因为大部分计算机体系结构都要求数据按照特定的边界对齐,这样可以减少内存访问的次数,提高访问速度。CPU访问非对齐的内存时需要进行多次拼接。 -如下图,比如需要读取从[2, 5]的内存,需要分别读取两次,然后还需要做位移的运算,最后才能得到需要的数据。这中间的损耗就会影响访问速度。 - - -2. 为了方便移植。CPU是一块块的进行进行内存访问。有一些硬件平台不允许随机访问,只能访问对齐后的内存地址,否则会报异常。 -很多 CPU(如基于 Alpha,IA-64,MIPS,和 SuperH 体系的)拒绝读取未对齐数据。当一个程序要求这些 CPU 读取未对齐数据时,这时 CPU 会进入异常处理状态并且通知程序不能继续执行。举个例子,在 ARM,MIPS,和 SH 硬件平台上,当操作系统被要求存取一个未对齐数据时会默认给应用程序抛出硬件异常。 -3. 硬件要求:某些硬件平台对于数据的访问有特定的要求,例如ARM架构的处理器对于某些数据类型的访问需要按照特定的对齐方式进行 -4. 数据结构优化:内存对齐也有助于优化数据结构的布局,使得数据在内存中的存储更加紧凑和高效。 - -Demo1 - -```objectivec -@interface Person : NSObject -{ - int _age; - int _height; -} -@end - -struct Person_IMPL { - Class isa; - int _age; - int _height; -}; - -Person *person = [[Person alloc] init]; -NSLog(@"%zd", malloc_size((__bridge const void *)person)); // 16 -NSLog(@"%zd", sizeof(struct Person_IMPL)); // 16 -``` - -`isa 指针 8字节` + `int _age 4字节` + `_hright 字节` = 16 字节 - - Demo2 - -```objectivec -@interface Person : NSObject -{ - int _age; - int _height; - int _no; -} -@end - -struct Person_IMPL { - Class isa; - int _age; - int _height; - int _no; -}; - -Person *person = [[Person alloc] init]; -NSLog(@"%zd", sizeof(struct Person_IMPL)); // 24 -NSLog(@"%zd", malloc_size((__bridge const void *)person)); // 32 -``` - -QA:`isa 指针8字节` + `int _age 4字节` + `_height 4字节` + `_no 4 字节` = 20 字节。 - -结构体内存对齐规则:结构体的总大小必须是**最大成员对齐基数**的倍数。也就是 isa 8个字节的整数倍。所以占据24个字节的内存。**结构体成员变量,内存对齐时,对齐基数必须是成员变量最大一个的字节数的倍数**。 - -比如 `Person_IMPL` 结构体的最大成员变量是 isa,大小为8,所以内存对齐时,结构体占用内存必须是8的倍数。内存占用为8的倍数,实际大小为20,但为了内存对齐,则为24 - -QA:结构体占据24字节,为什么运行起来后通过 `malloc_size` 得到32个字节? - -这个涉及到运行时内存对齐。规定 **iOS 中内存对齐以 16 的倍数为准**。内存分配器的底层策略: - -大多数系统的内存分配器(如 macOS/iOS 的 `malloc`)会按 **特定大小的块(Bucket)** 分配内存,以提高性能和减少碎片。例如: - -- 如果请求的内存大小在 **16~32 字节** 之间,分配器可能直接分配 **32 字节**。 -- 这种策略称为 **Size Class**,目的是减少内存碎片和管理 - -系统原理: - -- malloc 的实现:macOS/iOS 使用 `libmalloc`,其内存块按 **16 字节粒度** 分配。 -- **`malloc_size` 的行为**:返回系统实际分配的大小,而非用户请求的大小。 -- **内存对齐的硬件原因**:CPU 访问对齐的内存地址效率更高,未对齐可能导致性能损失或崩溃。 - - - -Demo - -```objectivec -void *temp = malloc(4); -NSLog(@"%zd", malloc_size(temp)); -// 16 -``` - -可以看到 malloc 申请了4个字节,但是打印却看到16个字节。 - -查看 libmalloc 源码也可以出来分配内存最小是以16的倍数为基准进行分配的。 - -```c -#define NANO_MAX_SIZE 256 /* Buckets sized {16, 32, 48, 64, 80, 96, 112, ...} */ -``` - -为什么系统是由16字节对齐的? - -成员变量占用8字节对齐,每个对象的第一个都是 isa 指针,必须要占用8字节。举例一个极端 case,假设 n 个对象,其中 m 个对象没有成员变量,只有 isa 指针占用8字节,其中的 n-m个对象既有 isa 指针,又有成员变量。每个类交错排列,那么 CPU 在访问对象的时候会耗费大量时间去识别具体的对象。很多时候会取舍,这个 case 就是时间换空间。以16字节对齐,会加快访问速度(参考链表和数组的设计) - -上述是 Apple 官方的角度出发探究的,其他系统,比如 Linux 也是存在内存对齐的。由于 Linux 也是采用 GNU 的东西,所以探索下 GNU 下 glibc malloc 的实现。从[这里](https://ftp.gnu.org/gnu/glibc/)下载 glibc 源码。然后拖到 Xcode 中查看 - - -可以看到 GNU 源码里面,内存对齐 `MALLOC_ALIGNMENT` -在 i386 里面是16,在非 i386 里面有个判断 - -```c -#define MALLOC_ALIGNMENT (2 * SIZE_SZ < __alignof__ (long double) \ - ? __alignof__ (long double) : 2 * SIZE_SZ) -``` -三目运算符的后2个结果分别是 `__alignof__ (long double)` 和 `2 * SIZE_SZ`。其中 `SIZE_SZ` 又是一个宏定义,等价于 `(sizeof (INTERNAL_SIZE_T))`,即 `2*sizeof(size_t)` -```c -#define SIZE_SZ (sizeof (INTERNAL_SIZE_T)) -# define INTERNAL_SIZE_T size_t -``` -在 Xcode 打印输出, `__alignof__ (long double)` 为16,`sizeof(size_t)` 为8,即 `2 * SIZE_SZ` = 16,所以不管怎么看,在 GUN 里面内存对齐一定都是16. - -Todo: 研究探索 libmalloc 源码 - - - -## 七、class 对象 - -```objective-c -// instance 对象,实例对象 -NSObject *obj1 = [[NSObject alloc] init]; -NSObject *obj2 = [[NSObject alloc] init]; -// class 对象,类对象 -Class cls1 = [obj1 class]; -Class cls2 = [obj2 class]; -Class cls3 = object_getClass(obj1); -Class cls4 = object_getClass(obj2); -Class cls5 = [NSObject class]; -NSLog(@"%p %p", obj1, obj2); // 0x600000004040 0x600000004050 -NSLog(@"%p %p %p %p %p", cls1, cls2, cls3, cls4, cls5); // 0x7ff85ca74270 0x7ff85ca74270 0x7ff85ca74270 0x7ff85ca74270 0x7ff85ca74270 -``` - -- cls1...cls5 都是 NSObject 的 class 对象,也就是类对象。 -- 它都是同一个对象,每个类在内存中有且只有一个 class 对象 - -```objective-c -Person *person = [[Person alloc] init]; -NSLog(@"%zd", class_getInstanceSize([person class])); // 24 -NSLog(@"%zd", malloc_size((__bridge const void *)person)); // 32 - -// 类对象 -Person *p = objc_getClass("Person"); -Class pCls0 = [person class]; -Class pCls1 = [Person class]; -Class pCls11 = [[[Person class] class] class]; -// 元类对象 -Class pcls2 = object_getClass([Person class]); -Class pcls22 = object_getClass(object_getClass(person)); - -NSLog(@"%p %p %p %p %p %p", p, pCls0, pCls1, pCls11, pcls2, pcls22); -// 0x100008268 0x100008268 0x100008268 0x100008268 0x100008240 0x100008240 -``` - - - -Class 对象在内存中存储的信息主要包括: - -- isa 指针 -- superclass 指针 -- 类的属性信息(@property)、类的对象方法信息(instance method) -- 类的协议信息(@protocol、)类的成员变量信息(ivars) - - - -## 八、元类对象 - - Objective-C 中对象分维三类: - -- instance 对象,实例对象。例如 `NSObject *obj1 = [[NSObject alloc] init];` -- class 对象,类对象。例如 `Class cls1 = [obj1 class];` -- 元类对象(meta-class)。例如 `Class metaClass = object_getClass(cls1);` - -如何获取元类对象? - -利用 runtime `object_getClass`API,传入类对象获取。例如 `Class objectMetaClass = object_getClass([NSObject class])` - -不可以通过2次调用 class 方法获取 meta-class 对象。调用 class 方法只可以获取到 class 对象。 - -- 每个类在 内存中有且只有一个 meta-class 对象 - -- meta-class 对象和 Class 对象的内存结构是一样的(都是 Class),但是用途不一样,在内存中存储的信息主要包括: - - - isa 指针 - - superclass 指针 - - 类的类方法信息(class method) - -如何判断是否是元类对象 `class_isMetaClass()` - -Demo: - -```objective-c -// instance 对象,实例对象 -NSObject *obj1 = [[NSObject alloc] init]; -NSObject *obj2 = [[NSObject alloc] init]; -// class 对象,类对象 -// class 方法返回的就是类对象 -Class cls1 = [obj1 class]; -Class cls2 = [obj2 class]; -Class cls3 = object_getClass(obj1); -Class cls4 = object_getClass(obj2); -Class cls5 = [NSObject class]; -NSLog(@"%p %p", obj1, obj2); -NSLog(@"%p %p %p %p %p", cls1, cls2, cls3, cls4, cls5); - -// 将类对象当作参数传入,获取元类对象(meta-class) -Class metaClass = object_getClass(cls1); -Class metaClass2 = [cls2 class]; -NSLog(@"%p %p", metaClass, metaClass2); - -BOOL isMetaClass = class_isMetaClass(metaClass); -NSLog(@"%@", isMetaClass ? @"是元类对象" : @"不是元类对象"); - -isMetaClass = class_isMetaClass(metaClass2); -NSLog(@"%@", isMetaClass ? @"是元类对象" : @"不是元类对象"); - -// console -0x60000000c030 0x60000000c040 -0x7ff85ec7c270 0x7ff85ec7c270 0x7ff85ec7c270 0x7ff85ec7c270 0x7ff85ec7c270 -0x7ff85ec7c220 0x7ff85ec7c270 -是元类对象 -不是元类对象 -``` - -objc 源代码中: - -```c++ -Class object_getClass(id obj) -{ - // 传入的如果是实例对象,则返回 class 类对象 - // 传入的如果是 class 类对象,则返回 meta-class 元类对象 - // 传入的如果是 meta-class 元类对象,返回 NSObject(基类)的 meta-class 元类对象 - if (obj) return obj->getIsa(); - else return Nil; -} - -Class objc_getClass(const char *aClassName) -{ - if (!aClassName) return Nil; - - // NO unconnected, YES class handler - return look_up_class(aClassName, NO, YES); -} -``` - -`object_getClass`: - -- 传入的如果是实例对象,则返回 class 类对象 -- 传入的如果是 class 类对象,则返回 meta-class 元类对象 -- 传入的如果是 meta-class 元类对象,返回 NSObject(基类)的 meta-class 元类对象 - -`-(Class)class`、`+(Class)class`:返回的是类对象 - - - - - -## QA - -### 对象的 isa 指向什么? -- Instance 对象的 isa 指向类对象(Class) -- Class 对象的 isa 指向元类对象(Meta-Class) -- Meta-Class 对象的 isa 指向基类的元类对象(Meta-Class) - -但注意: -- 实例对象的 isa 需要与 ISA_MASK 按位与之后才可以得到类对象的地址值。 -- 类对象的 isa 需要与 ISA_MASK 按位与之后才可以得元类对象的地址值。 - - -### OC 的类信息存放在哪? -- Instance 对象:成员变量具体的值,存放在实例对象中 - ``` - struct NSObject_IMPL { - class isa; - } - - struct Person_IMPL { - struct NSObject_IMPL NSObject_IAVRS; - int _age; - int _height; - } - - // - struct Person_IMPL { - class isa; - int _age; - int _height; - } - ``` -- Class 对象:属性信息、对象方法信息、成员变量信息、协议信息、superclass、isa 存放在类对象中。 -- Meta-Class 对象:类方法信息,存放在元类对象中。 - - - -## 总结 - - - -1. 实例对象的 isa 指针指向类对象 -2. 类对象的 isa 指针指向元类对象 -3. 元类对象的 isa 指针指向基类对象的元类对象 -4. 基类的元类对象的 isa 指针指向基类的类对象 -5. 同一个类的多个实例对象只有1个类对象 -6. 同一个类的多个实例对象只有1个元类对象 -7. 如果一个类美元父类,比如 NSObject 类,则 superclass 指针为 nil -8. 实例对象的 isa 地址,按位与 `ISA_MASK` 得到类对象的地址 -9. 类对象的 isa 地址,按位与 `ISA_MASK` 得到元类对象的地址 - -对象的 isa 指针指向哪里? - -- 实例对象(instance)的 isa 指针指向类对象(class 对象) -- 类对象(class 对象) 的 isa 指针指向元类对象(meta-class 对象) -- 元类对象(meta-class 对象) 的 isa 指针指向基类的元类对象(meta-class 对象) - -OC 的类信息存放在哪里? - -```objective-c -@interface Student : Person { - int _no; -} -@property (nonatomic, assign) int score; - - -- (void)study; - -+ (void)live; - -@end -``` - -- 对象方法、属性、成员变量、协议信息存储在类对象(class 对象)中 - - 比如 `-(void)study` 方法、score 属性、`_no` 成员变量,`NSCopying` 协议 - -- 类方法,存储在元类对象(meta-class 对象)中 - - 比如 `+(void)live` 方法 - -- 成员变量的具体值,存放在实例对象的内存中。比如 student1 的 score 为30,student2 的 score 为 90 diff --git a/Chapter1 - iOS/1.70.md b/Chapter1 - iOS/1.70.md deleted file mode 100644 index ffd1606..0000000 --- a/Chapter1 - iOS/1.70.md +++ /dev/null @@ -1,312 +0,0 @@ -# 不一样的动态化能力 - -> 对于热修复,对于大多数公司来说都是可望而不可及的技术手段。热修复对于线上问题是杀手锏级别项目。Android 热修复方案很多,典型的属微信的 `Tinker` 莫属,而苹果公司对于安全的要求非常高,所以一些动态调用的能力都会被封杀,这篇文章主要研究下 iOS 端的热修复技术方案。 - - -## 热修复方案 - -- 将下发的原生代码,通过自己实现的代码解析引擎,将代码转换为AST树,然后存储在相关的模型里面,在通过一个上下文注入到runtime里面,当runtime回调到当前函数的时候,上下文从存储的相关模型取出各个参数,然后放到当前堆栈里面去执行相关的逻辑,执行问之后,在返回之前调用的地方,这里跟腾讯的OCS有点像. - -- JSPatch:加加密,多混淆,关键词替换。(其实重要封杀的是respondsToSelector:, performSelector:, method_exchangeImplementations() 这些函数,然后现在aop、hook、jspatch 都是离不开这些函数的。解决方案将 动态能力的 API 替换名字:而是本地已经处理好,写到代码的静态变量里面,执行的时候去按照相应的解密方法去解密,然后得到 respondsToSelector:, 再去执行) - -- 几大app中的方案都是自己研发的,不过大同小异,有比较多的是从编译器层面出发,直接把写的代码编译好,然后自己再写解析器解析执行 - -- lua kit:https://github.com/alibaba/LuaViewSDK;https://alibaba.github.io/LuaViewSDK/guide.html - -其实重要封杀的是respondsToSelector:, performSelector:, method_exchangeImplementations() 这些函数,然后现在aop、hook、jspatch 都是离不开这些函数的。 - - -## 思路 - -`JavaScriptCore` 是苹果给开发者操作 Javascript 的一个库,因此使用 JavaScriptCore 基本不存在问题。另外做热修复的基本思路就是在某个类执行某个类方法、某个类的对象执行某个对象方法的时候做一些处理。所以这里涉及到几个因素:类、类对象、类方法、实例方法、方法执行前、方法执行后、方法完全替换。 Objective-C 有运行时特性,所以可以很容易实现上面的几个点,但是直接使用 Runtime 会比较麻烦,这时候就不得不提一下一个面向切面编程的开源库-[Aspects](https://github.com/steipete/Aspects)。 - - -所以剩下来的事情就是将 Aspects 的几个能力暴露给 JavascriptCore 对象。然后 App 在启动的时候去调用热修复接口,拿到修复的字符串,然后给 JavascriptCore 对象,然后 Javascript 对象去执行拿到的热修复的字符串,这样子整个流程下来,当我们去进入某个页面或者调用某个功能的时候,发现 A 类的 methodA 方法有问题,我们下发了热修复代码,就可以在 methodA 的前后加入逻辑,甚至是完全替换。 - - -## 代码实现 - -
-FixManager - -```Objective-C -#import -#import "Aspects.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface FixManager : NSObject - -+ (FixManager *)sharedInstance; -+ (void)fixIt; -+ (void)evalString:(NSString *)javascriptString; - -@end - -NS_ASSUME_NONNULL_END - - -#import "FixManager.h" - -@implementation FixManager - -+ (FixManager *)sharedInstance -{ - static FixManager *manager = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - manager = [[self alloc] init]; - }); - return manager; -} - - -+ (JSContext *)context -{ - static JSContext *_context; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _context = [[JSContext alloc] init]; - [_context setExceptionHandler:^(JSContext *context, JSValue *exception) { - NSLog(@"Ooops, %@", exception); - }]; - }); - return _context; -} - -+ (void)fixIt -{ - [self context][@"fixInstanceMethodBefore"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl){ - [self _fixWithMethod:NO - aspectionOptions:AspectPositionBefore instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"fixInstanceMethodReplace"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl) { - [self _fixWithMethod:NO aspectionOptions:AspectPositionInstead instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"fixInstanceMethodAfter"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl) { - [self _fixWithMethod:NO aspectionOptions:AspectPositionAfter instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"fixClassMethodBefore"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl) { - [self _fixWithMethod:YES aspectionOptions:AspectPositionBefore instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"fixClassMethodReplace"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl) { - [self _fixWithMethod:YES aspectionOptions:AspectPositionInstead instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"fixClassMethodAfter"] = ^(NSString *instanceName, NSString *selectorName, JSValue *fixImpl) { - [self _fixWithMethod:YES aspectionOptions:AspectPositionAfter instanceName:instanceName selectorName:selectorName fixImpl:fixImpl]; - }; - - [self context][@"runClassWithNoParamter"] = ^id(NSString *className, NSString *selectorName) { - return [self _runClassWithClassName:className selector:selectorName obj1:nil obj2:nil]; - }; - - [self context][@"runClassWith1Paramter"] = ^id(NSString *className, NSString *selectorName, id obj1) { - return [self _runClassWithClassName:className selector:selectorName obj1:obj1 obj2:nil]; - }; - - [self context][@"runClassWith2Paramters"] = ^id(NSString *className, NSString *selectorName, id obj1, id obj2) { - return [self _runClassWithClassName:className selector:selectorName obj1:obj1 obj2:obj2]; - }; - - [self context][@"runVoidClassWithNoParamter"] = ^(NSString *className, NSString *selectorName) { - [self _runClassWithClassName:className selector:selectorName obj1:nil obj2:nil]; - }; - - [self context][@"runVoidClassWith1Paramter"] = ^(NSString *className, NSString *selectorName, id obj1) { - [self _runClassWithClassName:className selector:selectorName obj1:obj1 obj2:nil]; - }; - - [self context][@"runVoidClassWith2Paramters"] = ^(NSString *className, NSString *selectorName, id obj1, id obj2) { - [self _runClassWithClassName:className selector:selectorName obj1:obj1 obj2:obj2]; - }; - - [self context][@"runInstanceWithNoParamter"] = ^id(id instance, NSString *selectorName) { - return [self _runInstanceWithInstance:instance selector:selectorName obj1:nil obj2:nil]; - }; - - [self context][@"runInstanceWith1Paramter"] = ^id(id instance, NSString *selectorName, id obj1) { - return [self _runInstanceWithInstance:instance selector:selectorName obj1:obj1 obj2:nil]; - }; - - [self context][@"runInstanceWith2Paramters"] = ^id(id instance, NSString *selectorName, id obj1, id obj2) { - return [self _runInstanceWithInstance:instance selector:selectorName obj1:obj1 obj2:obj2]; - }; - - [self context][@"runVoidInstanceWithNoParamter"] = ^(id instance, NSString *selectorName) { - [self _runInstanceWithInstance:instance selector:selectorName obj1:nil obj2:nil]; - }; - - [self context][@"runVoidInstanceWith1Paramter"] = ^(id instance, NSString *selectorName, id obj1) { - [self _runInstanceWithInstance:instance selector:selectorName obj1:obj1 obj2:nil]; - }; - - [self context][@"runVoidInstanceWith2Paramters"] = ^(id instance, NSString *selectorName, id obj1, id obj2) { - [self _runInstanceWithInstance:instance selector:selectorName obj1:obj1 obj2:obj2]; - }; - - [self context][@"runInvocation"] = ^(NSInvocation *invocation) { - [invocation invoke]; - }; - - // helper:将 JS 的 console.log 用 Native Log 替换 - [[self context] evaluateScript:@"var console = {}"]; - [self context][@"console"][@"log"] = ^(id message) { - NSLog(@"Javascript log: %@",message); - }; - -} - -+ (void)_fixWithMethod:(BOOL)isClassMethod aspectionOptions:(AspectOptions)option instanceName:(NSString *)instanceName selectorName:(NSString *)selectorName fixImpl:(JSValue *)fixImpl -{ - Class klass = NSClassFromString(instanceName); - if (isClassMethod) { - klass = object_getClass(klass); - } - SEL sel = NSSelectorFromString(selectorName); - [klass aspect_hookSelector:sel withOptions:option usingBlock:^(id aspectInfo){ - [fixImpl callWithArguments:@[aspectInfo.instance, aspectInfo.originalInvocation, aspectInfo.arguments]]; - } error:nil]; -} - -+ (id)_runClassWithClassName:(NSString *)className selector:(NSString *)selector obj1:(id)obj1 obj2:(id)obj2 -{ - Class klass = NSClassFromString(className); -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - return [klass performSelector:NSSelectorFromString(selector) withObject:obj1 withObject:obj2]; -#pragma clang diagnostic pop -} - - -+ (id)_runInstanceWithInstance:(id)instance selector:(NSString *)selector obj1:(id)obj1 obj2:(id)obj2 -{ -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - return [instance performSelector:NSSelectorFromString(selector) withObject:obj1 withObject:obj2]; -#pragma clang diagnostic pop -} - - -+ (void)evalString:(NSString *)javascriptString -{ - [[self context] evaluateScript:javascriptString]; -} - -@end -``` -
- - - - - - - - -
-BugProtector - -```Objective-C -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface BugProtector : NSObject - -+ (instancetype)sharedInstance; - -+ (void)getFixScript:(NSString *)scriptText; - -@end - -NS_ASSUME_NONNULL_END - -#import "BugProtector.h" -#import "FixManager.h" - -@interface BugProtector () - -@end - -@implementation BugProtector - -static BugProtector *_sharedInstance = nil; - -#pragma mark - life cycle -+ (instancetype)sharedInstance -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - //because has rewrited allocWithZone use NULL avoid endless loop lol. - _sharedInstance = [[super allocWithZone:NULL] init]; - [FixManager fixIt]; - }); - - return _sharedInstance; -} - -+ (FixManager *)fixManager -{ - static FixManager *_manager; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _manager = [FixManager sharedInstance]; - }); - return _manager; -} - -+ (id)allocWithZone:(struct _NSZone *)zone -{ - return [BugProtector sharedInstance]; -} - -+ (instancetype)alloc -{ - return [BugProtector sharedInstance]; -} - -- (id)copy -{ - return self; -} - -- (id)mutableCopy -{ - return self; -} - -- (id)copyWithZone:(struct _NSZone *)zone -{ - return self; -} - -#ifdef DEBUG -- (void)dealloc -{ - NSLog(@"%s",__func__); -} -#endif - - -#pragma mark - public Method -+ (void)getFixScript:(NSString *)scriptText -{ - [FixManager evalString:scriptText]; -} - -@end -``` -
- - -完整的 [Demo](https://github.com/FantasticLBP/BlogDemos/tree/master/HotFix) 可以点此查看链接. - - -## 未完,待续 \ No newline at end of file diff --git a/Chapter1 - iOS/1.71.md b/Chapter1 - iOS/1.71.md deleted file mode 100644 index 0f82497..0000000 --- a/Chapter1 - iOS/1.71.md +++ /dev/null @@ -1,102 +0,0 @@ - -# Flutter初体验-安装 - -> 多端融合能力是现在大前端研究的技术风向标之一,当前 Flutter 风头正盛,它的设计之初就是为了解决移动端的当今的诸多问题。 - - -Flutter 的设计的思想以及出发点不是本篇的重点,所以直奔主题,如何在 Mac 上安装 Flutter 的开发环境。 - -### 1. Homebrew - -Homebrew 是 Mac 是安装各种软件包的一个工具,所以你需要先安装好 Homebrew。 - -```shell -/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" -``` - -### 2. 安装 Flutter SDK - -#### 下载 SDK -SDK 下载方式有2种。一是使用 `git clone -b beta https://github.com/flutter/flutter.git` 下载,二是通过官网选择符合自己机器环境的文件。(实验后发现方式一特别慢,建议大家直接使用方式二) - -![Flutter文件夹位置](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-08-13-FlutterDirectory.png) - -下载好之后解压,安装到自己指定的位置。 - -#### 配置环境变量 - -Flutter 在运行的时候需要去官网下载一些需要的资源,所以需要设置镜像服务器。我使用的是 iterm2。所以打开 **.zshrc** 文件。如果使用的是系统的 Terminal,则需要打开 **.bash_profile**。 - -```shell -export PUB_HOSTED_URL=https://pub.flutter-io.cn -export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn -export PATH=/Users/liubinpeng/flutter/bin:$PATH -``` -`/Users/liubinpeng` 是我电脑环境下的 Flutter 路径,这里改成你自己的路径。之后执行 `source .zshrc`。 - -验证 Flutter 环境是否成功。 - -```shell -flutter -h -``` - -### 3. 配置 Android studio - -一般认为用户是 iOS 开发,电脑没有 Android 开发环境,首先去[官网](https://developer.android.google.cn/studio)下载。 - -然后通过 `flutter doctor` 来检查 flutter 的环境配置。一般可以看到多个 ✗ 。每个 ✗ 后面的描述内容都是我们需要解决的问题。 - -- 打开 Android studio。但是首次打开会报错,提示找不到 SDK。解决方案:在应用程序文件夹下面找到 Android studio,显示包内容。路径如下 - -`/Applications/Android\ Studio.app/Contents/bin/idea.properties`,用文本编辑器打开,在最下面添加如下代码 - -```shell -isable.android.first.run=true -``` - -- 设置 Android studio 的环境变量。 -```shell -export ANDROID_HOME=~/Library/Android/sdk -export PATH=${PATH}:${ANDROID_HOME}/emulator -export PATH=${PATH}:${ANDROID_HOME}/tools -export PATH=${PATH}:${ANDROID_HOME}/platform-tools -``` -分别是安卓 SDK 路径、安卓模拟器路径、安卓 tools 路径、安卓平台工具。 - -- 安装 Android studio Flutter 插件 -接下来使用 flutter doctor 检查,显示信息 - -```shell - ✗ Flutter plugin not installed; this adds Flutter specific functionality. - ✗ Dart plugin not installed; this adds Dart specific functionality. -``` -意思是缺少 Flutter 插件。步骤:Preferences -> Plugins -> 搜索栏输入 Flutter,找到第一个点击 install。此时会弹出对话框让你选择安装 Dart,点击 YES。之后重启 Android studio,看到在主界面会多出下图红色框的内容。至此我们可以创建 Flutter 工程了。 - -![Androidstudio](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-08-12-Flutter.png) - -- 给 Android studio 设置模拟器 - -点击右上方区域一个类似手机的按钮,选择手机(Pixel 2).下载对应的系统(pie)(需要开启翻墙模式)。 - -### 配置 iOS 环境 - -前提安装好 Xcode 和选择好对应的模拟器。并执行下面的脚本 - -```shell -brew link pkg-config -brew install --HEAD usbmuxd -brew unlink usbmuxd -brew link usbmuxd -brew install --HEAD libimobiledevice -brew install ideviceinstaller -``` - - -### 配置 VSCode - -安装好 VSCode,在插件的地方搜索 Flutter 和 Dart 对应的插件。 - -![验证](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-07-22-Flutter_Verify.png) -最后输入 flutter doctor 检测你的全部是否完毕,至此你可以展开 Flutter 之旅了。祝愉快 - - diff --git a/Chapter1 - iOS/1.72.md b/Chapter1 - iOS/1.72.md deleted file mode 100644 index 82030cf..0000000 --- a/Chapter1 - iOS/1.72.md +++ /dev/null @@ -1,99 +0,0 @@ -# 架构心得 - -> 2019-07月底跳槽,从事的工作内容是基础平台内容,主要是基础工具和 SDK 的封装;工程化 cli 落地、研发管理、静态代码扫描等。虽然以前写代码也是站在封装、复用、聚合等出发点写代码,但是还是和真正写 SDK 注意点有很多不同,这也是为什么写这篇文章总结的原因。 - - -## 一些注意点 - -- 当你开发某个功能的时候,轻易不要使用第三方的库。为什么?因为你难以确保业务方是否也在使用这个库,可能库在使用了,但是版本号不一致,就会造成 api 内部实现可能不一样,造成功能不符合预期或者一些神奇的 Bug。 -- 假如你遇到上面的情况,你出于某种原因不得不使用某个第三方,但是你又必须考虑调用者的工程可能也加入了该库。解决方案大体有3种。1、推进业务方不要使用离散的功能三方库,比如 AFNetWorkging 不要自己引入,而是引入基础平台方封装好的网络功能库;2、自己将引入的第三方网络库选取主要用到的功能去自己实现掉。我们首先要自己这个第三方做了什么事情,提供了哪些功能,其中哪些功能是我们会使用到的,那么我们可以借鉴源代码,自己去做类似的事情,然后一个精简版的 AFNetWorking 就出来了;3、将第三方库的类名称、方法名称、Block、宏...都给更换名称(一开始想到找到一定的规则用自动化脚本去做,发现这样子不可能处理全部的 case,程序员自己脑子都想不全所有的 case,所以代码实现根本不可能;)一番操作下来发现还是人工手动操作效果最好 -- 当你写某个功能的时候,你封装的 SDK 对于提供某个能力,项目以组件化的形式开展,所以你对外暴露的地方在于 Router 文件中, Router 负责解析 url,最后调用 [target performSelector withObject],然后在 target 对象内部真正去实现某个功能,Router 一定只做最简单的事情,也就是 url parse,寻找 target,执行 performSelector。target 暴露某个接口,也许接口内部实现也很复杂,需要依赖其他几个 api 或者其他几个类的 api。所以 api 也就是函数需要做到单一原则。可能某个大的能力需要几个能力的聚合,这个大的函数内部依靠几个单独的函数逻辑才实现某个能力。可能由于版本迭代,你需要将之前不对外暴露的能力也要暴露出去,所以做好函数的单一功能非常重要,可拓展性强、易测试。 -- 一定要写好 Unit Test。这样子不断版本迭代,对于 UT,输入恒定,输出恒定,这样内部实现如何变动不需要关心,只需要判断恒定输入,恒定输出就足够了。(针对每个函数单一原则的基础上也是满足 UT) -- 在做 SDK 的时候,对于一些方法或者函数的返回值,尽量要做到 iOS 和 Android 端的输出值的数据类型一致,除非某些特殊情况,无法保证一致的输出。 -- 当你想写宏定义的时候应该先判断下是否存在,因为工程中很可能已经存在一个同名的宏。 - ```Objective-C - #ifndef Hi - #define Hi @"Hello, nice to meet you" - #endif - ``` -- 避免重复宏定义 - 因为宏定义可以多次,但是一个工程中有可能因为命名太规范了,大家不小心会为一个功能起一个同名的宏定义,所以我们在宏定义的时候需要做判断,不然多个同名宏定义,最后的功能会根据文件编译顺序决定,最后的宏定义才生效。 - ```Objective-c - #ifndef CM_IS_CLASS - #define CM_IS_CLASS(obj,cls) [obj isKindOfClass:[cls class]] - #endif - ``` -- 对于你的某个 SDK,你在为某个方法、某个类、某个宏定义命名的时候需要注意选择合适的前缀 - 比如。你的某个项目是在做监控,SDK 的名字叫做 Hermes-Client。那么你的类名称、类方法名称、宏定义、分类名称、分类方法名称等都需要合适且统一的前缀,一般选取 `前3个字母组合`。当前的项目叫做 `HCT`。类前面加 HCT,类里面的方法不加前缀。分类名称加前缀 HCT,分类里面的方法前面加前缀,小写的 HCT。 - 普通类的方法不加前缀是因为普通类已经通过类名的唯一性确定了方法的唯一。 - 分类里面方法加前缀是因为分类的方法在工程里面这个类都可以访问。所以要在方法前面区分 - ```Objective-C - // 安全的数据获取方法 - #ifndef HCT_SAFE_STRING - #define HCT_SAFE_STRING(x) (x) != nil ? (x) : @"" - #endif - - NSData+HCTAES.h - - (NSData *)hct_AES128EncryptWithKey:(NSString *)key gIv:(NSString *)Iv; - - HCTRequestFactory.h - + (void)fetchUploadConfigurationWithRequestURL:(NSString *)requestUrlString - params:(NSDictionary *)params - success:(void (^)(PRCConfigurationModel*model))success - failure:(void (^)(NSError *error))failure; - ``` - -- 一般来说如果你的某个文件代码中高频率的使用宏,且宏里面是做一些运算,建议使用内联函数代替,因为内联函数效率高,且在编译阶段可以检查错误。函数的调用顺序底层是出入栈的过程,Frame Pointer、Stack Pointer。一个栈保存当前函数的局部变量、参数、返回地址。所以不同函数的调用会效率有影响,如果高频使用的函数建议用内联函数。 - 内联函数和宏的区别 - 优点相比于函数 - - - inline 函数避免了普通函数的,在汇编时必须调用 call 的缺点:取消了函数的参数压栈,减少了调用的开销,提高效率.所以执行速度确比一般函数的执行速度要快 - - 集成了宏的优点,使用时直接用代码替换(像宏一样) - 优点相比于宏 - - 避免了宏的缺点:需要预编译.因为 inline 内联函数也是函数,不需要预编译 - - 编译器在调用一个内联函数时,会首先检查它的参数的类型,保证调用正确。然后进行一系列的相关检查,就像对待任何一个真正的函数一样。这样就消除了它的隐患和局限性 - - 可以使用所在类的保护成员及私有成员。 - 注意事项 - - 内联函数只是我们向编译器提供的申请,编译器不一定采取inline形式调用函数 - - 内联函数不能承载大量的代码.如果内联函数的函数体过大,编译器会自动放弃内联 - - 内联函数内不允许使用循环语句或开关语句 - - 内联函数的定义须在调用之前 - - Objective-C 中内联函数用 NS_INLINE ,等价于 static inline。且内联函数的命名需要注意,在该模块内的内联函数需要加前缀。 - ```Objective-C - NS_INLINE NSString * HCTGetTableNameFromType(HCTLogTableType type){ - if (type == HCTLogTableTypeMeta) { - return PRC_LOG_TABLE_META; - } - if (type == HCTLogTableTypePayload) { - return PRC_LOG_TABLE_PAYLOAD; - } - return @""; - } - ``` -- 什么情况下用统跳(路由能力)? - 技术 SDK 的话,因为可能依赖非常多的其他技术 SDK 所以会比较难梳理出一个需要暴露的能力,非常难抽象 - 业务 SDK 很清楚需要暴露哪些能力。所以我们一般将业务 SDK 提供统跳能力,技术 SDK 不提供 - -- 基础平台组做什么?怎么做? - 业务线的同学一般做的事情就是在操作 UI,手机屏幕很小,要做的事情也会比较单一,可能就是单击某个按钮然后多线程异步去处理某个逻辑(网络、数据库、File等),然后异步回调里面回调主线程去更新 UI。所以做的事情的广度不一样。基础平台组做的事情一般来说脱离独立的 UI,换句话说就是焦点不在于 UI,而在于整个的架构逻辑,比如一个数据上报 SDK。它考虑的事情不是 UI 怎么用,而是数据来源是什么,我设计的接口需要暴露什么信息,数据如何高效存储、数据如何校验、数据如何高效及时上报。 - - 假如我做的数据上报 SDK 可以上报 APM 监控数据、同时也开放能力给业务线使用,业务线自己将感兴趣的数据并写入保存,保证不丢失的情况下如何高效上报。因为数据实时上报,所以需要考虑上传的网络环境、Wi-Fi 环境和 4G 环境下的逻辑不一样的、数据聚合组装成自定义报文并上报、一个自然天内数据上传需要做流量限制等等、App 版本升级一些数据可能会失去意义、当然存储的数据也存在时效性。种种这些东西就是在开发前需要考虑清楚的。所以基础平台做事情基本是 **设计思考时间:编码时间 = 7:3** - - 为什么?假设你一个需求,预期10天时间;前期架构设计、类的设计、Uint Test 设计估计7天,到时候编码开发2天完成。 - - 这么做的好处很多,比如: - 1. 除非是非常优秀,不然脑子想的再前面到真正开发的时候发现有出入,coding 完发现和前期方案设计不一样。所以建议用流程图、UML图、技术架构图、UT 也一样,设计个表格,这样等到时候编码也就是 coding 的工作了,将图翻译成代码 - 2. 后期和别人讨论或者沟通或者 CTO 进行 code review 的时候不需要一行行看代码。你将相关的架构图、流程图、UML 图给他看看。他再看看一些关键逻辑的 UT,保证输入输出正确,一般来说这样就够了 - 3. 软件项目管理也一样,制定进度表、确定干系人、kick-of meeting 等、定期碰头 - -- 一般来说不要在 load 方法里面做非本类的事情。 - 一般来说,不应该在当前类的 `load` 方法里面写和其他类有关系的代码,除非非做不可。 - ```Objective-C - + (void)load - { - NSLog(@"%zd", [AFNetworkReachabilityManager sharedManager].networkReachabilityStatus); - } - ``` - 之前在做一个类 `HCTRequestFactory` 用来管理网络相关的逻辑。需要判断网络状态,我们都知道 AFNetWorking 第一次判断网络状态得到的是 AFNetworkReachabilityStatusUnknown。而我的逻辑需要 SDK 启动的时候判断网络状态,然后去上报数据。所以刚开始 AFNetworkReachabilityStatusUnknown 显然不能上报 Crash 数据,所以想着是将第一次的网络状态获取放到 **load** 方法里。这样是没问题的,可以拿到网络状态,但是我们知道 load 是类加载的时候调用的,打开 Xcode 看到 Build Phases 里面 `Link BiBinary With Libraries` 这个里面的库的顺序决定了里面的类加载顺序。我们知道 Pod 的原理是在 Podfile 里面描述的 pod 库依赖,然后会按照字典序(首字母排序去)引入,所以 AFNetWorking 这个肯定早,所以会成功的。但是万一是人工手动去引入或者修改库的位置,则在 HCTRequestFactory 里面的 load 方法执行的时候不一定可以保证 AFNetworkReachabilityManager 已经加载好。所以将 load 逻辑移动到 init 里面。 - - 另外,load 方法一般只做和本类有关系的逻辑,比如 hook 方法。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.73.md b/Chapter1 - iOS/1.73.md deleted file mode 100644 index 4df013b..0000000 --- a/Chapter1 - iOS/1.73.md +++ /dev/null @@ -1,775 +0,0 @@ - -# Ruby - -> 为了 iOS 工程化开展,自己最近开始了 Ruby 的学习,本篇博文就用来记录 Ruby 的学习心得和体验。本文作为切入点,展开聊聊原理、组件、脚本 - - -## 一. Ruby VS Python -- Python 的解析器实现更成熟,第三方库质量高。但是 Ruby 包管理更简单、方便。 -- Python 的应用领域广泛。而Ruby目前主要局限在在 Web 领域与精致项目。 -- Python语法简单,Ruby更强大、灵活 - - - -## 二. Ruby 语法 - -### 1. 注释 -单行注释 -``` -# 单行注释 -puts "Hello, ruby!" -``` - -多行注释 -```Ruby -=begin -多行注释:第1行 -多行注释:第2行 -多行注释:第3行 -=end -print("Hello world!\n") -``` - -### 2. 打印 -- puts:打印后自动换行 -- print:打印后不会自动换行 - -- 另外如果打印内容携带变量格式的话,必须用双引号。比如 - ```Ruby - name = "@FantasticLBP" - puts "hello, #{name}!" - puts 'hello,#{name}!' - - # 输出 - hello, @FantasticLBP! - hello,#{name}! - ``` -- 如果要直接 shell,则需要用 `` - ```Ruby - puts `ruby --version` - # 输出 - ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.x86_64-darwin21] - ``` - -### 3. 万物皆对象 -```Ruby -puts 3.class -puts 'FantasticLBP'.class -puts nil.class -puts true.class -# 输出 -Integer -String -NilClass -TrueClass -``` - - -### 4. Symbol -- Ruby 是一个强大的面向对象脚本语言,一切皆是对象 -- 在 Ruby 中 Symbol 表示“名字”,比如字符串的名字,标识符的名字。 -- 创建一个 Symbol 对象的方法是在名字或者字符串前面加上冒号: -- 在 Ruby 中每一个对象都有唯一的对象标识符(Object Identifier) -- 对于 Symbol 对象,一个名字唯一确定一个 Symbol 对象 -- Ruby 内部一直在使用 Symbol,Ruby内部也存在符号表 -- Symbol 本质上是一个数字,这个数字和创建 Symbol 的名字形成一对一的映射;而String 对象是一个重量级的 用C结构体表示的家伙,因此使用 Symbol 和 String 的开销相差很大。 -- 符号表是一个全局数据结构,它存放了所有 Symbol 的(数字ID,名字)。 Ruby 不会从中删除 Symbol ,因此 当你创建一个 Symbol 对象后,它将一直存在,直到程序结束。 - -## 三. 安装篇 - -### 1. rvm & rbenv - -- rvm & rbenv 是一种命令行工具,可让您轻松地安装,管理和使用多个Ruby环境。 -- 这两个工具本质都是 PATH 上做手脚,一个在执行前,一个在执行中 -- 如果你不需要维护特定版本的 Ruby 项目,那么只需要装一个比较新的 Ruby 版本 就行了。`brew install ruby` - -### 2. gem - -- 与大多数的编程语言一样,Ruby 也受益于海量的第三方代码库 -- 这些代码库大部分都以 Gem 形式发布。 RubyGems 是设计用来帮助创建,分享和安装 这些代码库的 - - `gem search -r/-f ` - - `gem install --version ` - - `gem list` - 有没有发现 gem search、install、list 和 cocoapods 的 pod 指令一样 - -### 3. Bundler - -Bundler 能够跟踪并安装所需的特定版本的 gem,以此来为 Ruby 项目提供一致的运行环境 - - -``` -source 'https://rubygems.org' gem 'rails', '4.1.0.rc2' -gem ‘rack-cache' -gem 'nokogiri', '~> 1.6.1' -``` - -- 读取 Gemfile:Bundler 首先会读取当前目录下的 Gemfile 文件,解析其中声明的所有依赖项及其版本约束 -- 解析依赖关系: - - 分析每个 gem 的版本要求,确定满足所有约束的最佳版本组合 - - 处理依赖的依赖(传递依赖),确保整个依赖树的兼容性 -- 检查本地缓存: - - 查看本地是否已缓存所需版本的 gem - - 如果有,直接使用本地缓存,跳过下载步骤 -- 从源下载 gem: - - 对于本地没有的 gem,从 source 'https://rubygems.org' 指定的源下载 - - 默认源是 RubyGems 官方仓库,国内用户可能需要切换到镜像源以提高速度 -- 安装 gem 到项目目录: - - 默认情况下,gem 会被安装到项目根目录下的 vendor/bundle 目录 - - 这种方式可以避免污染系统级的 gem 安装,实现项目间的依赖隔离 -- 生成 Gemfile.lock: - - 安装完成后,Bundler 会生成 Gemfile.lock 文件 - - 该文件记录了实际安装的每个 gem 的精确版本,确保团队协作或部署时使用完全相同的依赖版本 - -思考:怎么样,是不是发现 Ruby Bundler 工作流程和 iOS Cocoapods 的工作过程一致?是的,iOS Cocoapods 的设计就是参考 Ruby Bundler 的设计。甚至连 pod search、install、list API 设计也和 gem 一致 - - - -## 四. Cocoapods - -### 1. cocoapods-binary -- cocoapods-binary 通过开关,在 pod insatll 的过程中进行 library 的预编译,生成 framework,并自动集成到项目中。 -- 整个预编译工作分成了三个阶段来完成: - - binary pod 的安装 - - binary pod 的预编译 - - binary pod 的集成 - -### 2. Hook -- pre_install:Pod 下载之后,但在安装之前可以有时机对 Pod 进行任何更改 -- post_install:当所需依赖安装完成,但此时生成的 XcodeProj 项目在写入磁盘前,我们可以对其进行最后的更改 - - -### 3. Cocoapods 工程拆解 -#### 1. cocoapods.gemspec 文件 -```Ruby -# encoding: UTF-8 -require File.expand_path('../lib/cocoapods/gem_version', __FILE__) -require 'date' - -Gem::Specification.new do |s| - s.name = "cocoapods" - s.version = Pod::VERSION - s.date = Date.today - s.license = "MIT" - s.email = ["eloy.de.enige@gmail.com", "fabiopelosin@gmail.com", "kyle@fuller.li", "segiddins@segiddins.me"] - s.homepage = "https://github.com/CocoaPods/CocoaPods" - s.authors = ["Eloy Duran", "Fabio Pelosin", "Kyle Fuller", "Samuel Giddins"] - - s.summary = "The Cocoa library package manager." - s.description = "CocoaPods manages library dependencies for your Xcode project.\n\n" \ - "You specify the dependencies for your project in one easy text file. " \ - "CocoaPods resolves dependencies between libraries, fetches source " \ - "code for the dependencies, and creates and maintains an Xcode " \ - "workspace to build your project.\n\n" \ - "Ultimately, the goal is to improve discoverability of, and engagement " \ - "in, third party open-source libraries, by creating a more centralized " \ - "ecosystem." - - s.files = Dir["lib/**/*.rb"] + %w{ bin/pod bin/sandbox-pod README.md LICENSE CHANGELOG.md } - - s.executables = %w{ pod sandbox-pod } - s.require_paths = %w{ lib } - - # Link with the version of CocoaPods-Core - s.add_runtime_dependency 'cocoapods-core', "= #{Pod::VERSION}" - - s.add_runtime_dependency 'claide', '>= 1.0.2', '< 2.0' - s.add_runtime_dependency 'cocoapods-deintegrate', '>= 1.0.3', '< 2.0' - s.add_runtime_dependency 'cocoapods-downloader', '>= 2.1', '< 3.0' - s.add_runtime_dependency 'cocoapods-plugins', '>= 1.0.0', '< 2.0' - s.add_runtime_dependency 'cocoapods-search', '>= 1.0.0', '< 2.0' - s.add_runtime_dependency 'cocoapods-trunk', '>= 1.6.0', '< 2.0' - s.add_runtime_dependency 'cocoapods-try', '>= 1.1.0', '< 2.0' - s.add_runtime_dependency 'molinillo', '~> 0.8.0' - s.add_runtime_dependency 'xcodeproj', '>= 1.27.0', '< 2.0' - - s.add_runtime_dependency 'colored2', '~> 3.1' - s.add_runtime_dependency 'escape', '~> 0.0.4' - s.add_runtime_dependency 'fourflusher', '>= 2.3.0', '< 3.0' - s.add_runtime_dependency 'gh_inspector', '~> 1.0' - s.add_runtime_dependency 'nap', '~> 1.0' - s.add_runtime_dependency 'ruby-macho', '~> 4.1.0' - - s.add_runtime_dependency 'addressable', '~> 2.8' - - s.add_development_dependency 'bacon', '~> 1.1' - s.add_development_dependency 'bundler', '~> 2.0' - s.add_development_dependency 'rake', '~> 12.3' - - s.required_ruby_version = '>= 2.6' -end - -``` - -`cocoapods.gemspec` 作为 Cocoapods 工程的配置文件,类似 iOS 组件库的 Podspec 文件一样。 -Cocospods 工程本身就是一个 Ruby gem,所以 `cocoapods.gemspec` 用于描述这个 gem 包的元数据,包括作者、版本、描述信息,包括一些导入的文件。 - -也声明了该 gem 包含的源代码文件、资源文件,以及它所依赖的其他 Ruby gem(比如 xcodeProj 等)和版本要求,确保安装时能正确解析依赖关系。 - - - -#### 2. cocoapods-core - -1. CocoaPods 核心模块,用来支持: - - - Pod::specification(podspec) - - - Pod::Podfile(Podfile) - - - Pod::Source(Spec repo) - -2. cocoapods-deintergrate: 用于从项目中删除和取消集成 CocoaPods,指令为 `pod deintegrate` - -3. Xcodeproj: - - 来操作 Xcode 项目的创建和编辑等。同时支持 Xcode 项目的脚本管理和 libraries 构建,以及 Xcode 工作空间(.xcworkspace) 和配置文件 .xcconfig 的管理 - -4. cocospods-downloader:用于下载和管理引入的源码 - -5. cocoapods-plugins: 插件管理功能 - -6. cocoapods-try:可以快速体验该 pod 的 Demo 项目 - -7. CLAide:命令行解释器 - -8. ruby-macho:一个用于检查和修改 Mach-O 文件的 Ruby 库 - - - -#### 3. Podfile - -Podfile 是一个文件,以 DSL 来描述依赖关系,用于描述项目所需要的第三方库。 - - - -### 4. VSCode 调试 Cocoapods - -1. 新创建文件夹 `RubyDemos` -2. 从 git clone Cocoapods 源码到本地目录 -3. 进入到 Cocoapods 文件夹,将分支切换到和本机安全的 pod 版本一致的分支,指令为: **git checkout `pod --version`** -4. `RubyDemos` 根目录下创建一个 Xcode iOS 工程,并为其编写 Podfile 文件。目的是为了调试 Cocoapods -5. `RubyDemos` 根目录下创建一个 **Gemfile** 文件。内容如下: - ```Ruby - source 'https://rubygems.org' - - gem 'cocoapods', path: './Cocoapods' # 指向本地源码 - gem 'debug', '~> 1.9.0' # 调试用 - ``` -6. 终端执行 `bundle install` 指令 -7. 此时,项目文件夹为: - ``` - . - ├── CocoaPods - ├── Demos - ├── Gemfile - ├── Gemfile.lock - └── StaticLibConflictsDemo - ``` -8. 用 VSCode 打开工程。进入 Run and Debug 面板 → 点击 create a launch.json file → 选择 Ruby → 选 Debug Local File。 -9. 修改 launch.json 内容。 - ```json - { - "version": "0.2.0", - "configurations": [ - { - "name": "Debug CocoaPods", - "type": "rdbg", // 必须为 rdbg(debug 工具类型) - "request": "launch", - "script": "${workspaceFolder}/CocoaPods/bin/pod", // 源码中的 pod 入口 - "args": ["install", "--verbose"], // 执行 pod install - "cwd": "${workspaceFolder}/StaticLibConflictsDemo", // 测试工程目录(Podfile 所在目录) - "useBundler": true, // 强制通过 bundle 执行 - "askParameters": false // 关闭参数询问(避免干扰) - } - ] - } - ``` -10. VSCode 插件市场安装:VSCode rdbg Ruby Debugger、Ruby LSP -11. VSCode 面板中,点击左侧的调试按钮。便可调试。要是看到下面的图,说明可以正常 Debug 了 - - -接下来就可以愉快的调试了。 -说明: -- pod 的每个指令,分别对应 Cocoapods 工程中一个代码文件 - -- 同时根据观察,发现 `target do` 的代码比 pre_install、post_install 执行更早。所以我们可以做一些脚本化的操作。 -比如下面,增加了一段自定义的脚本 - - - - -## 五、体验核心依赖库能力 - -### 1. ruby-macho - -#### 1. 操作 Mach-O 文件 - -读取 Mach-O 文件,并用操作对象的方式去读区、增加、删除信息。 - -分别对 Mach-O 增加了一个 `LC_RPATH` 类型的 Load Command - -```ruby -lc_rpath = MachO::LoadCommands::LoadCommand.create(:LC_RPATH, 'test_rpath') -file_exec.add_command lc_rpath -``` - - - -对 Mach-O 文件中删除了类型为 `LC_LINKER_OPTION` 的 Load Command - - - -#### 2. 操作动态库 - -`ruby-macho` 还可以读取动态库信息,下面演示几个能力: - -- 读取动态库所依赖的动态库信息 - - ```ruby - # 打印出当前 Mach-O 文件使用的所有动态库 - macho_dylibs = MachO::Tools.dylibs(macho_filepath) - macho_dylibs.each do | dylib | - puts dylib - end - ``` - -- 修改动态库的 id - - ```ruby - # 修改动态库的 id - MachO::Tools.change_dylib_id(macho_copy_filepath, 'test_selfdefined_dylib') - ``` - - 修改后在终端用指令 **objdump --macho --private-headers ./macho/libAFNetworking_copy.dylib | grep 'LC_ID_DYLIB' -A 5** 验证效果,如下图所示: - - - - 也可以直接查看动态库的 id - - ```ruby - original_dylib_id = MachO::MachOFile.new(macho_filepath) - copyed_dylib_id = MachO::MachOFile.new(macho_copy_filepath) - - # 也可以直接查看动态库的 id - puts "before change dylib id: #{original_dylib_id.dylib_id}" - puts "after change dylib id: #{copyed_dylib_id.dylib_id}" - ``` - -- 修改动态库 rpath - - ```ruby - MachO::Tools.change_rpath(macho_copy_filepath, '@loader_path/Frameworks', '@loader_path/Frameworks/FantasicLBP') - ``` - - - -#### 3. 合并动态库到胖二进制 - -ruby-macho 有很多丰富的 API,基本上开发阶段所遇到的问题,都有现成的 API 解决。比如二进制指令集的合并 - -```ruby -filenames = [dylib_merged_filepath, dylib_arm_filepath] -# # 第一个参数为合并之后的动态库名称,第二个参数为需要合并的一堆动态库 -MachO::Tools.merge_machos(dylib_merged_filepath, *filenames) -``` - -合并后用 `otool -f ./macho/libAFNetworking_merged.dylib` 指令查看指令集 - - - - - -### 2. Xcodeproj - -通过 xcodeproject 路径构建 xcodeproj 对象 `app_project = Xcodeproj::Project.new(app_project_path)` - -通过 xcworkspace 路径构建 xcworkspace 对象 `app_workspace = Xcodeproj::Workspace.new_from_xcworkspace(app_workspace_project_path)` - -然后通过对象的方式访问 xcworkspace 的信息。比如 schemes - -```ruby -app_workspace.schemes.each do | scheme | - puts scheme -end -``` - - - -也可以针对特定的 target 修改 xcconfig - -```ruby -# 修改 targets 中第一个对应 configurations 为指定的 xcconfig 文件 -configuration_path = File.dirname(__FILE__) + '/xcodeproject/AFNetworkingMock/xcodeproj-testing.debug.xcconfig' -# 转换为 xcode 的 file -xc_file = app_project.new_file(configuration_path) - -app_project.targets.first.build_configurations.first.base_configuration_reference = xc_file -``` - -效果如下: - - - -也可以对特定的 target 修改 buildSetting 中的信息,比如 bundle id - -```ruby -# 修改 target 的 bundle id -app_project.targets.each do | target | - target.build_configurations.each do | config | - config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.github.FantasticLBP.AFNetworkingMock' if config.name == 'Debug' - end -end -``` - - - - - -## 六、自定义 cocoapods 插件 - -### 1. 自定义 gem 库 - - - -#### 1. 初始化创建 - -输入指令 `bundle gem cocoapods-hmap` 自定义一个名为 `cocoapods-hmap` 的 gem 库 - -#### 2. 工程结构说明 - -得到的工程结构是: - -```shell -. -├── CHANGELOG.md -├── CODE_OF_CONDUCT.md -├── Gemfile -├── LICENSE.txt -├── README.md -├── Rakefile -├── bin -│   ├── console -│   └── setup -├── cocoapods-hmap.gemspec -├── lib -│   └── cocoapods -│   ├── hmap -│   │   └── version.rb -│   └── hmap.rb -└── sig - └── cocoapods - └── hmap.rbs -``` - -说明: - -- **cocoapods-hmap.gemspec**: - - - gem 的核心配置文件,定义了 gem 的名称、版本、作者、依赖、描述、文件包含规则等关键信息。 - - 用于打包和发布 gem 到 RubyGems 仓库,是 gem 工程的 “身份证 - -- **Rakefile**: - - - 定义自动化任务(如测试、打包、发布等),通过 `rake <任务名>` 执行(类似 `Makefile`)。 - - 常见任务:`rake spec`(运行测试)、`rake build`(打包 gem)、`rake release`(发布到 RubyGems) - -- **Gemfile**: - - - 定义 gem 开发 / 测试阶段的依赖(如测试框架 `rspec`、打包工具等),类似前端的 `package.json`。 - - 通过 `bundle install` 安装依赖,依赖版本由 `Gemfile.lock`(自动生成)锁定 - -- **源代码目录:lib/ ** - - - gem 的核心功能代码存放目录,Ruby 会自动加载该目录下的文件 - - - `lib/cocoapods/hmap.rb`:gem 的主入口文件,定义了 `CocoaPods::Hmap` 模块的核心逻辑,是功能实现的主要载体(如与 CocoaPods 集成的逻辑、头文件映射相关功能等) - - `lib/cocoapods/hmap/version.rb`: 单独存放版本号的文件,通常定义 `CocoaPods::Hmap::VERSION` 常量,便于统一管理版本(在 `gemspec`中会引用该常量)。 - - - -#### 3. 改造并 run 起来 - -- 修改 gemspec 文件中带有 url、uri 的字段,开发阶段可以修改为任何一个 url 字符串 - -- 修改自带的工程结构,比如 `cocoapods/hmap` 改为 `cocoapods-hmap`,将对应的文件也移动位置 - -- 我们预期的效果是:在终端输入 **pod hmap** 就可以将工程中的静态库 Header Search Path 传统查找模式改为 hmap 文件配置模式。所以需要的的步骤就是在 **bin 目录下创建 hmap.rb**,然后通过 **bin 目录下的代码调用 lib 目录下的 HMap.rb** 能力。 - -- 为此,需要: - - - 在 lib 目录下创建 HMap.rb 文件 - - ```ruby - require_relative "version" - - module CocoapodsHmap - class HMap - def initialize - puts "Cocoapods HMap initialized" - end - - def self.run - puts "Running Cocoapods HMap..." - end - - end - end - - ``` - - - 在 bin 目录下创建 hmap.rb 文件 - - ```ruby - #!/usr/bin/env ruby - require "bundler/setup" - require "cocoapods-hmap/hmap" - - # 打印携带的参数 - puts ARGV - - CocoapodsHmap::HMap.run - ``` - -- 为了在 VSCode 中测试,需要在 Gemfile 中添加一行 **gem 'debug', '~> 1.9.0' # 调试用** - -- 工程根目录创建 `.vscode` 文件夹,创建 launch.json 文件。内容如下: - - ```json - { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "rdbg", - "name": "Debug current file with rdbg", - "request": "launch", - "script": "${workspaceFolder}/bin/hmap", - "args": ["hmap"], // pod 命令的参数 - "askParameters": true, - "cwd": "${workspaceFolder}", // pod 执行命令的路径 - }, - { - "type": "rdbg", - "name": "Attach with rdbg", - "request": "attach" - } - ] - } - ``` - -VSCode 中运行效果如下: - - - -#### 4. 如何将自定义的指令加入到 cocoapods 中 - -- 在 Command 目录下创建 `hmap` 文件,不带任何拓展名。rake 处理后,最后会变为 `/Users/unix_kernel/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/cocoapods-hmap-0.1.0/bin/hmap: Ruby script text executable, ASCII text` - -- 在 lib 目录下,创建 `cocoapods-hmap` 文件夹。 - - 在 `cocoapods-hmap` 文件夹内创建 `command` 文件夹,在 `command` 文件夹内创建 `hmap.rb` 文件 - - class 继承自 Pod::Command,并实现相关方法。比如 initialize、validate!、run -- 在 lib 目录下,创建 `cocoapods-plugin.rb` 文件 - - 暴露继承自 Pod::Command 的 hmap command - -调试运行后的效果如下: - - - -类名小写,和类文件关联起来 - -比如 install.rb 中,类名为 `class Install` ,内部会记录为 **{"install": "install.rb"}** - - - -#### 5. 打包安装到本地 - -在终端项目目录下,执行指令 **rake install:local** ,主要用于**在本地构建并安装当前开发的 gem 包**,方便开发者进行本地测试和调试。 - -一开始有报错,如下图所示。按照提示修改 `cocoapods-hmap.gemspec` 中的配置,然后就可以成功安装了。然后输入 **gem list** 查看: - - - -输入: **gem info cocoapods-hmap** 查看安装信息 - - - - - -就目前的功能进行测试: - -| 条件 | 预期 | 结果 | -| ----------------------------------- | --------------------------------------- | -------- | -| 随便一个工程目录,没有 Podfile 文件 | 执行 `pod hmap` 会报错 | 符合预期 | -| 存在 Podfile 文件的目录 | 正常执行 run 方法里面的逻辑(打印逻辑) | 符合预期 | - - - - - -#### 6. Hook 能力 - -##### 1. post_install - -- 修改插件入口文件 (cocoapods_plugin.rb) - - 先在 `lib/cocoapods-plugin.rb` 中注册插件和对应的 hook 能力。利用 API:**Pod::HooksManager.register('cocoapods-hmap', :post_install)**,其文档说明如下: - - >register(plugin_name, hook_name, &block) - > - >**Definitions**: [hooks_manager.rb](vscode-file://vscode-app/Applications/Visual Studio Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) - > - >Registers a block for the hook with the given name. - > - >@param [String] plugin_name The name of the plugin the hook comes from. - > - >@param [Symbol] hook_name The name of the notification. - > - >@param [Proc] block The block. - - ```ruby - Pod::HooksManager.register('cocoapods-hmap', :post_install) do |context, options| - argv = CLAide::ARGV.new([]) # 创建一个空的参数数组 - command = Pod::Command::HMap.new(argv) - command.run_post_install(context, options) - end - ``` - -- 完善 HMap 命令类 - - 修改 `lib/cocoapods-hmap/command/hmap.rb`,增加 `run_post_install` 方法 - - ```ruby - module Pod - class Command - class HMap < Command - // ... - - # Post-install 钩子执行的方法 - def run_post_install(context, options = {}) - puts "[Cocoapods hmap] Running HMap command in post_install hook..." - end - end - end - end - ``` - -- 测试配置 - - 修改测试项目的 Podfile 文件,声明 **plugin 'cocoapods-hmap'** - - ```ruby - platform :ios, '9.0' - plugin 'cocoapods-hmap' - - post_install do | installer | - puts "Self defined post_install hook" - end - - target 'StaticLibConflictsDemo' do - # Comment the next line if you don't want to use dynamic frameworks - # AFNetworking 以静态库的形式被依赖 - pod 'AFNetworking' - # 脚本化 - script_phase :name => 'Run Self-defined Script', - :script => "echo 'This is a self-defined script phase'", - :input_files => [], - :execution_position => :after_compile - end - ``` - -- 修改 `cocoapods-hmap` 工程的 VSCode 的 launch.json 文件 - - 因为 cocoapods-hmap 工程和 iOS Pods 工程不在一个目录,所以可以在 args 的第二个参数设置为测试工程路径 - - ```json - { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "rdbg", - "name": "Debug current file with rdbg", - "request": "launch", - // "script": "${workspaceFolder}/bin/hmap", - "script": " /Users/unix_kernel/.rbenv/versions/3.2.2/bin/pod", - "args": [ - "install", - "--project-directory=${workspaceFolder}/../StaticLibConflictsDemo" - ], // pod 命令的参数 - "askParameters": true, - "cwd": "${workspaceFolder}", // pod 执行命令的路径 - }, - { - "type": "rdbg", - "name": "Attach with rdbg", - "request": "attach" - } - ] - } - ``` - -- 测试:存在2种方法 - - - 第一种:在 cocospods-hmap 工程中测试,如下图 - - - - - 第二种: - - - 在终端 cocoapods-hmap 目录下执行 **rake install:local** ,将插件安装到本地 - - 然后切换到 iOS 被测工程目录下,执行 `pod install` - - 效果如下: - - - - - -##### 2. pre_install - - - - - - - -### 2. CocoaPods 插件系统设计 - -CocoaPods 通过严格的目录结构约定来加载插件: - -```shell -lib/ -├── cocoapods-plugin.rb # 插件主入口文件(必需) -└── cocoapods-hmap/ # 插件命名空间目录 - └── command/ # 命令目录 - └── hmap.rb # 命令实现文件(必需) -``` - -#### 1. 自动加载机制 - -CocoaPods 启动时会自动执行以下操作: - -1. 扫描已安装的 gem -2. 查找所有以 `cocoapods-` 为前缀的 gem -3. 加载这些 gem 中的 `lib/cocoapods-plugin.rb` 文件 -4. 通过该文件加载插件功能 - -#### 2. 关键文件 - -- 插件入口文件 (`lib/cocoapods-plugin.rb`) - - ```ruby - require 'cocoapods-hmap/command/hmap' - ``` - -- 命令实现文件 (lib/cocoapods-hmap/command/hmap.rb) - -- - - - - - diff --git a/Chapter1 - iOS/1.74.md b/Chapter1 - iOS/1.74.md deleted file mode 100644 index 29b7450..0000000 --- a/Chapter1 - iOS/1.74.md +++ /dev/null @@ -1,8316 +0,0 @@ -## 带你打造一套 APM 监控系统 - -> APM 是 Application Performance Monitoring 的缩写,监视和管理软件应用程序的性能和可用性。应用性能管理对一个应用的持续稳定运行至关重要。所以这篇文章就从一个 iOS App 的性能管理的维度谈谈如何精确监控以及数据如何上报等技术点 - -App 的性能问题是影响用户体验的重要因素之一。性能问题主要包含:卡顿、Crash、网络请求错误或者超时、UI 响应速度慢、主线程卡顿、CPU 和内存使用率高、耗电量大、Weex/RN/Flutter 页面白屏等等。大多数的问题原因在于开发者错误地使用了线程锁、系统函数、编程规范问题、数据结构等等。解决问题的关键在于尽早的发现和定位问题。 - -本篇文章着重总结了 APM 的原因以及如何收集数据。APM 数据收集后结合数据上报机制,按照一定策略上传数据到服务端。服务端消费这些信息并产出报告。请结合[姊妹篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md), 总结了如何打造一款灵活可配置、功能强大的数据上报组件。 - -## 一、卡顿监控 - -卡顿问题,程度较低就是掉帧,比如用户在滑动某个列表的时候会有不流畅的体验,但是应用程序还是可以相应的。严重些就是 ANR,就是短时间在主线程上无法响应用户交互的问题。影响着用户的直接体验,所以针对 App 的卡顿监控是 APM 里面重要的一环。 - -FPS(frame per second)每秒钟的帧刷新次数,iPhone 手机以 60 为最佳,iPad 某些型号是 120,也是作为卡顿监控的一项参考参数,为什么说是参考参数?因为它不准确。先说说怎么获取到 FPS。CADisplayLink 是一个系统定时器,会以帧刷新频率一样的速率来刷新视图。 `[CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]`。至于为什么不准我们来看看下面的示例代码 - -```Objective-C -_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)]; -[_displayLink setPaused:YES]; -[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; -``` - -代码所示,CADisplayLink 对象是被添加到指定的 RunLoop 的某个 Mode 下。所以还是 CPU 层面的操作,卡顿的体验是整个图像渲染的结果:CPU + GPU。请继续往下看 - -### 1. 屏幕绘制原理 - -![老式 CRT 显示器原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_screen_scan.png) - -讲讲老式的 CRT 显示器的原理。 CRT 电子枪按照上面方式,从上到下一行行扫描,扫面完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;当一帧画面绘制完成后,电子枪恢复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(Vertical synchronization),简称 VSync。显示器通常以固定的频率进行刷新,这个固定的刷新频率就是 VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏,但是原理保持不变。 - -![显示器和 CPU、GPU 关系](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-02-screen_display_gpu.png) - -通常,屏幕上一张画面的显示是由 CPU、GPU 和显示器是按照上图的方式协同工作的。CPU 根据工程师写的代码计算好需要显实的内容(比如视图创建、布局计算、图片解码、文本绘制等),然后把计算结果提交到 GPU,GPU 负责图层合成、纹理渲染,随后 GPU 将渲染结果提交到帧缓冲区。随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过数模转换传递给显示器显示。 - -在帧缓冲区只有一个的情况下,帧缓冲区的读取和刷新都存在效率问题,为了解决效率问题,显示系统会引入 2 个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入帧缓冲区,让视频控制器来读取,当下一帧渲染好后,GPU 直接把视频控制器的指针指向第二个缓冲区。提升了效率。 - -目前来看,双缓冲区提高了效率,但是带来了新的问题:当视频控制器还未读取完成时,即屏幕内容显示了部分,GPU 将新渲染好的一帧提交到另一个帧缓冲区并把视频控制器的指针指向新的帧缓冲区,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂的情况。 - -为了解决这个问题,GPU 通常有一个机制叫垂直同步信号(V-Sync),当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新。这样的几个机制解决了画面撕裂的情况,也增加了画面流畅度。但需要更多的计算资源 - -![IPC唤醒 RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-ios_vsync_runloop.png) - -答疑 - -可能有些人会看到「当开启垂直同步信号后,GPU 会等到视频控制器发送 V-Sync 信号后,才进行新的一帧的渲染和帧缓冲区的更新」这里会想,GPU 收到 V-Sync 才进行新的一帧渲染和帧缓冲区的更新,那是不是双缓冲区就失去意义了? - -设想一个显示器显示第一帧图像和第二帧图像的过程。首先在双缓冲区的情况下,GPU 首先渲染好一帧图像存入到帧缓冲区,然后让视频控制器的指针直接直接这个缓冲区,显示第一帧图像。第一帧图像的内容显示完成后,视频控制器发送 V-Sync 信号,GPU 收到 V-Sync 信号后渲染第二帧图像并将视频控制器的指针指向第二个帧缓冲区。 - -**看上去第二帧图像是在等第一帧显示后的视频控制器发送 V-Sync 信号。是吗?真是这样的吗? 当然不是,不然双缓冲区就没有存在的意义了** - -揭秘。请看下图 - -![多缓冲区显示原理](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-Comparison_double_triple_buffering.png) - -当第一次 V-Sync 信号到来时,先渲染好一帧图像放到帧缓冲区,但是不展示,当收到第二个 V-Sync 信号后读取第一次渲染好的结果(视频控制器的指针指向第一个帧缓冲区),并同时渲染新的一帧图像并将结果存入第二个帧缓冲区,等收到第三个 V-Sync 信号后,读取第二个帧缓冲区的内容(视频控制器的指针指向第二个帧缓冲区),并开始第三帧图像的渲染并送入第一个帧缓冲区,依次不断循环往复。 - -请查看资料,需要梯子:[Multiple buffering](https://en.m.wikipedia.org/wiki/Multiple_buffering) - -### 2. 卡顿产生的原因 - -![卡顿原因](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-04-ios_frame_drop.png) - -VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容(视图创建、布局计算、图片解码、文本绘制等)。然后将计算的内容提交到 GPU,GPU 经过图层的变换、合成、渲染,随后 GPU 把渲染结果提交到帧缓冲区,等待下一次 VSync 信号到来再显示之前渲染好的结果。在垂直同步机制的情况下,如果在一个 VSync 时间周期内,CPU 或者 GPU 没有完成内容的提交,就会造成该帧的丢弃(也叫掉帧),等待下一次机会再显示,这时候屏幕上还是之前渲染的图像,所以这就是 CPU、GPU 层面界面卡顿的原因。 - -目前 iOS 设备有双缓存机制,也有三缓冲机制,Android 现在主流是三缓冲机制,在早期是单缓冲机制。 -[iOS 三缓冲机制例子](https://ios.developreference.com/article/12261072/Metal+newBufferWithBytes+usage) - -CPU 和 GPU 资源消耗原因很多,比如对象的频繁创建、属性调整、文件读取、视图层级的调整、布局的计算(AutoLayout 视图个数多了就是线性方程求解难度变大)、图片解码(大图的读取优化)、图像绘制、文本渲染、数据库读取(多读还是多写乐观锁、悲观锁的场景)、锁的使用(举例:自旋锁使用不当会浪费 CPU)等方面。开发者根据自身经验寻找最优解(这里不是本文重点)。 - -### 3. APM 如何监控卡顿并上报 - -CADisplayLink 肯定不用了,这个 FPS 仅作为参考。一般来讲,卡顿的监测有 2 种方案:**监听 RunLoop 状态回调、子线程 ping 主线程** - -#### 3.1 RunLoop 状态监听的方式 - -RunLoop 负责监听输入源进行调度处理。比如网络、输入设备、周期性或者延迟事件、异步回调等。RunLoop 会接收 2 种类型的输入源:一种是来自另一个线程或者来自不同应用的异步消息(source0 事件)、另一种是来自预定或者重复间隔的事件。 - -RunLoop 状态如下图 - -![RunLoop](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/4.png) - -第一步:通知 Observers,RunLoop 要开始进入 loop,紧接着进入 loop - -```Objective-c -if (currentMode->_observerMask & kCFRunLoopEntry ) - // 通知 Observers: RunLoop 即将进入 loop - __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry); -// 进入loop -result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode); -``` - -第二步:开启 do while 循环保活线程,通知 Observers,RunLoop 触发 Timer 回调、Source0 回调,接着执行被加入的 block - -```Objective-c - if (rlm->_observerMask & kCFRunLoopBeforeTimers) - // 通知 Observers: RunLoop 即将触发 Timer 回调 - __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); -if (rlm->_observerMask & kCFRunLoopBeforeSources) - // 通知 Observers: RunLoop 即将触发 Source 回调 - __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); -// 执行被加入的block -__CFRunLoopDoBlocks(rl, rlm); -``` - -第三步:RunLoop 在触发 Source0 回调后,如果 Source1 是 ready 状态,就会跳转到 handle_msg 去处理消息。 - -```Objective-c -// 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息 -if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) { -#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - msg = (mach_msg_header_t *)msg_buffer; - - if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) { - goto handle_msg; - } -#elif DEPLOYMENT_TARGET_WINDOWS - if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) { - goto handle_msg; - } -#endif -} -``` - -第四步:回调触发后,通知 Observers 即将进入休眠状态 - -```Objective-c -Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR); -// 通知 Observers: RunLoop 的线程即将进入休眠(sleep) -if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting); - __CFRunLoopSetSleeping(rl); -``` - -第五步:进入休眠后,会等待 mach_port 消息,以便再次唤醒。只有以下 4 种情况才可以被再次唤醒。 - -- 基于 port 的 source 事件 -- Timer 时间到 -- RunLoop 超时 -- 被调用者唤醒 - -```Objective-c -do { - if (kCFUseCollectableAllocator) { - // objc_clear_stack(0); - // - memset(msg_buffer, 0, sizeof(msg_buffer)); - } - msg = (mach_msg_header_t *)msg_buffer; - - __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy); - - if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) { - // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer. - while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue)); - if (rlm->_timerFired) { - // Leave livePort as the queue port, and service timers below - rlm->_timerFired = false; - break; - } else { - if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg); - } - } else { - // Go ahead and leave the inner loop. - break; - } -} while (1); -``` - -第六步:唤醒时通知 Observer,RunLoop 的线程刚刚被唤醒了 - -```Objective-C -// 通知 Observers: RunLoop 的线程刚刚被唤醒了 -if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting); - // 处理消息 - handle_msg:; - __CFRunLoopSetIgnoreWakeUps(rl); -``` - -第七步:RunLoop 唤醒后,处理唤醒时收到的消息 - -- 如果是 Timer 时间到,则触发 Timer 的回调 -- 如果是 dispatch,则执行 block -- 如果是 source1 事件,则处理这个事件 - -```Objective-C -#if USE_MK_TIMER_TOO - // 如果一个 Timer 到时间了,触发这个Timer的回调 - else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) { - CFRUNLOOP_WAKEUP_FOR_TIMER(); - // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled. - // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754 - if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) { - // Re-arm the next timer - __CFArmNextTimerInMode(rlm, rl); - } - } -#endif - // 如果有dispatch到main_queue的block,执行block - else if (livePort == dispatchPort) { - CFRUNLOOP_WAKEUP_FOR_DISPATCH(); - __CFRunLoopModeUnlock(rlm); - __CFRunLoopUnlock(rl); - _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL); -#if DEPLOYMENT_TARGET_WINDOWS - void *msg = 0; -#endif - __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); - _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL); - __CFRunLoopLock(rl); - __CFRunLoopModeLock(rlm); - sourceHandledThisLoop = true; - didDispatchPortLastTime = true; - } - // 如果一个 Source1 (基于port) 发出事件了,处理这个事件 - else { - CFRUNLOOP_WAKEUP_FOR_SOURCE(); - - // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again. - voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release); - - CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort); - if (rls) { -#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI - mach_msg_header_t *reply = NULL; - sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop; - if (NULL != reply) { - (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL); - CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply); - } -#elif DEPLOYMENT_TARGET_WINDOWS - sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop; -#endif -``` - -第八步:根据当前 RunLoop 状态判断是否需要进入下一个 loop。当被外部强制停止或者 loop 超时,就不继续下一个 loop,否则进入下一个 loop - -```Objective-C -if (sourceHandledThisLoop && stopAfterHandle) { - // 进入loop时参数说处理完事件就返回 - retVal = kCFRunLoopRunHandledSource; - } else if (timeout_context->termTSR < mach_absolute_time()) { - // 超出传入参数标记的超时时间了 - retVal = kCFRunLoopRunTimedOut; -} else if (__CFRunLoopIsStopped(rl)) { - __CFRunLoopUnsetStopped(rl); - // 被外部调用者强制停止了 - retVal = kCFRunLoopRunStopped; -} else if (rlm->_stopped) { - rlm->_stopped = false; - retVal = kCFRunLoopRunStopped; -} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) { - // source/timer一个都没有 - retVal = kCFRunLoopRunFinished; -} -``` - -完整且带有注释的 RunLoop 代码见[此处](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CFRunLoop.c)。 Source1 是 RunLoop 用来处理 Mach port 传来的系统事件的,Source0 是用来处理用户事件的。收到 Source1 的系统事件后本质还是调用 Source0 事件的处理函数。 - -![RunLoop 状态](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-05-RunLoop.png) -RunLoop 6 个状态 - -```Objective-C -typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { - kCFRunLoopEntry , // 进入 loop - kCFRunLoopBeforeTimers , // 触发 Timer 回调 - kCFRunLoopBeforeSources , // 触发 Source0 回调 - kCFRunLoopBeforeWaiting , // 等待 mach_port 消息 - kCFRunLoopAfterWaiting, // 接收 mach_port 消息 - kCFRunLoopExit, // 退出 loop - kCFRunLoopAllActivities // loop 所有状态改变 -} -``` - -RunLoop 在进入睡眠前的方法执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步,都会阻塞线程。如果是主线程,则表现为卡顿。 - -一旦发现进入睡眠前的 KCFRunLoopBeforeSources 状态,或者唤醒后 KCFRunLoopAfterWaiting,在设置的时间阈值内没有变化,则可判断为卡顿,此时 dump 堆栈信息,还原案发现场,进而解决卡顿问题。 - -开启一个子线程,不断进行循环监测是否卡顿了。在 n 次都超过卡顿阈值后则认为卡顿了。卡顿之后进行堆栈 dump 并上报(具有一定的机制,数据处理在下一 part 讲)。 - -WatchDog 在不同状态下具有不同的值。 - -- 启动(Launch):20s -- 恢复(Resume):10s -- 挂起(Suspend):10s -- 退出(Quit):6s -- 后台(Background):3min(在 iOS7 之前可以申请 10min;之后改为 3min;可连续申请,最多到 10min) - -卡顿阈值的设置的依据是 WatchDog 的机制。APM 系统里面的阈值需要小于 WatchDog 的值,所以取值范围在 [1, 6] 之间,业界通常选择 3 秒。 - -通过 `long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)` 方法判断是否阻塞主线程,`Returns zero on success, or non-zero if the timeout occurred.` 返回非 0 则代表超时阻塞了主线程。 - -![RunLoop-ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-APM-RunLoopANR.jpg) - -可能很多人纳闷 RunLoop 状态那么多,为什么选择 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?因为大部分卡顿都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之间。比如 Source0 类型的 App 内部事件等 - -Runloop 检测卡顿流程图如下: - -![RunLoop ANR](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-ANRRunloop.png) - -关键代码如下: - -```Objective-c -// 设置Runloop observer的运行环境 -CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL}; -// 创建Runloop observer对象 -_observer = CFRunLoopObserverCreate(kCFAllocatorDefault, - kCFRunLoopAllActivities, - YES, - 0, - &runLoopObserverCallBack, - &context); -// 将新建的observer加入到当前thread的runloop -CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes); -// 创建信号 -_semaphore = dispatch_semaphore_create(0); - -__weak __typeof(self) weakSelf = self; -// 在子线程监控时长 -dispatch_async(dispatch_get_global_queue(0, 0), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) { - return; - } - while (YES) { - if (strongSelf.isCancel) { - return; - } - // N次卡顿超过阈值T记录为一次卡顿 - long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC)); - if (semaphoreWait != 0) { - if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) { - if (++strongSelf.countTime < strongSelf.standstillCount){ - continue; - } - // 堆栈信息 dump 并结合数据上报机制,按照一定策略上传数据到服务器。堆栈 dump 会在下面讲解。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - } - } - strongSelf.countTime = 0; - } -}); -``` - -#### 3.2 子线程 ping 主线程监听的方式 - -开启一个子线程,创建一个初始值为 0 的信号量、一个初始值为 YES 的布尔值类型标志位。将设置标志位为 NO 的任务派发到主线程中去,子线程休眠阈值时间,时间到后判断标志位是否被主线程成功(值为 NO),如果没成功则认为主线程发生了卡顿情况,此时 dump 堆栈信息并结合数据上报机制,按照一定策略上传数据到服务器。数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - -```Objective-c -while (self.isCancelled == NO) { - @autoreleasepool { - __block BOOL isMainThreadNoRespond = YES; - dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - - dispatch_async(dispatch_get_main_queue(), ^{ - isMainThreadNoRespond = NO; - dispatch_semaphore_signal(semaphore); - }); - - [NSThread sleepForTimeInterval:self.threshold]; - - if (isMainThreadNoRespond) { - if (self.handlerBlock) { - self.handlerBlock(); // 外部在 block 内部 dump 堆栈(下面会讲),数据上报 - } - } - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); - } - } -``` - -### 4. 堆栈 dump - -方法堆栈的获取是一个麻烦事。理一下思路。`[NSThread callStackSymbols]` 可以获取当前线程的调用栈。但是当监控到卡顿发生,需要拿到主线程的堆栈信息就无能为力了。从任何线程回到主线程这条路走不通。先做个知识回顾。 - -在计算机科学中,调用堆栈是一种栈类型的数据结构,用于存储有关计算机程序的线程信息。这种栈也叫做执行堆栈、程序堆栈、控制堆栈、运行时堆栈、机器堆栈等。调用堆栈用于跟踪每个活动的子例程在完成执行后应该返回控制的点。 - -维基百科搜索到 “Call Stack” 的一张图和例子,如下 -![函数调用栈](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-08-StackFrame.png) -上图表示为一个栈。分为若干个栈帧(Frame),每个栈帧对应一个函数调用。下面蓝色部分表示 `DrawSquare` 函数,它在执行的过程中调用了 `DrawLine` 函数,用绿色部分表示。 - -可以看到栈帧由三部分组成:函数参数、返回地址、局部变量。比如在 DrawSquare 内部调用了 DrawLine 函数:第一先把 DrawLine 函数需要的参数入栈;第二把返回地址(控制信息。举例:函数 A 内调用函数 B,调用函数 B 的下一行代码的地址就是返回地址)入栈;第三函数内部的局部变量也在该栈中存储。 - -栈指针 Stack Pointer 表示当前栈的顶部,大多部分操作系统都是栈向下生长,所以栈指针是最小值。帧指针 Frame Pointer 指向的地址中,存储了上一次 Stack Pointer 的值,也就是返回地址。 - -大多数操作系统中,每个栈帧还保存了上一个栈帧的帧指针。因此知道当前栈帧的 Stack Pointer 和 Frame Pointer 就可以不断回溯,递归获取栈底的帧。 - -接下来的步骤就是拿到所有线程的 Stack Pointer 和 Frame Pointer。然后不断回溯,还原案发现场。 - -### 5. Mach Task 知识 - -**Mach task:** - -App 在运行的时候,会对应一个 Mach Task,而 Task 下可能有多条线程同时执行任务。《OS X and iOS Kernel Programming》 中描述 Mach Task 为:任务(Task)是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的,这些资源包括设备和其他句柄。简单概括为:Mack task 是一个机器无关的 thread 的执行环境抽象。 - -作用: task 可以理解为一个进程,包含它的线程列表。 - -结构体:task_threads,将 target_task 任务下的所有线程保存在 act_list 数组中,数组个数为 act_listCnt - -```c++ -kern_return_t task_threads -( - task_t traget_task, - thread_act_array_t *act_list, //线程指针列表 - mach_msg_type_number_t *act_listCnt //线程个数 -) -``` - -thread_info: - -```c++ -kern_return_t thread_info -( - thread_act_t target_act, - thread_flavor_t flavor, - thread_info_t thread_info_out, - mach_msg_type_number_t *thread_info_outCnt -); -``` - -如何获取线程的堆栈数据: - -系统方法 `kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt);` 可以获取到所有的线程,不过这种方法获取到的线程信息是最底层的 **mach 线程**。 - -对于每个线程,可以用 `kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt);` 方法获取它的所有信息,信息填充在 `_STRUCT_MCONTEXT` 类型的参数中,这个方法中有 2 个参数随着 CPU 架构不同而不同。所以需要定义宏屏蔽不同 CPU 之间的区别。 - -`_STRUCT_MCONTEXT` 结构体中,存储了当前线程的 Stack Pointer 和最顶部栈帧的 Frame pointer,进而回溯整个线程调用堆栈。 - -但是上述方法拿到的是内核线程,我们需要的信息是 NSThread,所以需要将内核线程转换为 NSThread。 - -pthread 的 p 是 **POSIX** 的缩写,表示「可移植操作系统接口」(Portable Operating System Interface)。设计初衷是每个系统都有自己独特的线程模型,且不同系统对于线程操作的 API 都不一样。所以 POSIX 的目的就是提供抽象的 pthread 以及相关 API。这些 API 在不同的操作系统中有不同的实现,但是完成的功能一致。 - -Unix 系统提供的 `task_threads` 和 `thread_get_state` 操作的都是内核系统,每个内核线程由 thread_t 类型的 id 唯一标识。pthread 的唯一标识是 pthread_t 类型。其中内核线程和 pthread 的转换(即 thread_t 和 pthread_t)很容易,因为 pthread 设计初衷就是「抽象内核线程」。 - -`memorystatus_action_neededpthread_create` 方法创建线程的回调函数为 **nsthreadLauncher**。 - -```Objective-c -static void *nsthreadLauncher(void* thread) -{ - NSThread *t = (NSThread*)thread; - [nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil]; - [t _setName: [t name]]; - [t main]; - [NSThread exit]; - return NULL; -} -``` - -NSThreadDidStartNotification 其实就是字符串 @"\_NSThreadDidStartNotification"。 - -```Objective-c -{number = 1, name = main} -``` - -为了 NSThread 和内核线程对应起来,只能通过 name 一一对应。 pthread 的 API `pthread_getname_np` 也可获取内核线程名字。np 代表 not POSIX,所以不能跨平台使用。 - -思路概括为:将 NSThread 的原始名字存储起来,再将名字改为某个随机数(时间戳),然后遍历内核线程 pthread 的名字,名字匹配则 NSThread 和内核线程对应了起来。找到后将线程的名字还原成原本的名字。对于主线程,由于不能使用 `pthread_getname_np`,所以在当前代码的 load 方法中获取到 thread_t,然后匹配名字。 - -```Objective-c -static mach_port_t main_thread_id; -+ (void)load { - main_thread_id = mach_thread_self(); -} -``` - -### 6. 精确堆栈如何还原 - -当检测到卡顿的时候再去 dump 堆栈,可能已经错过第一案发现场了,所以我们按照"悲观策略",每50ms抓取一次堆栈,维护最近30组堆栈。 - -这里有个策略,卡顿是由于主线程某个或某组方法执行过长而造成的卡顿,所以卡顿堆栈只需要分析主线程即可,但是此时是未符号化的方法(完成流程如下) - - - -测试过,单次抓取主线程符号耗时大概1ms左右。因此连续抓取堆栈是可取方案。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/callstackCostTime.png) - -按照栈顶函数地址和当前堆栈的深度作为判断依据,如果某个堆栈栈顶地址和深度相同,则出现次数最多的一个堆栈,可以认为是一个卡顿堆栈,因为认为当发生卡顿的时候,函数堆栈大概率不会变化。 - -``` -1 func1 - func2 - func3 - func4 - func5 -2 func1 - func2 - func3 - func4 - func6 -3 func1 - func2 - func3 - func4 - func6 -4 func1 - func2 - func3 - func4 - func7 -5 func1 - func2 - func3 - func4 -6 func1 - func2 - func3 - func4 -7 func1 - func2 - func3 - func4 -8 func1 - func2 - func3 - func4 - func8 -``` - -这个情况下,卡顿堆栈为:func1 - func2 - func3 - func4 - -另外,卡顿堆栈和 Crash 堆栈还原不太一样。Crash 堆栈是一个完整的文件,`symbolicatecrash` 可以对整个文件进行符号化。但是卡顿只记录了地址符号,所以是不能利用 symbolicatecrash 能力。需要使用 `atos` 实现。 - -因为 iOS 侧存在 ASLR 技术,所以仅凭借当前的符号地址是无法符号化的,所以还需要一些基础信息,各个 DYLD 的信息,binary image 加载的地址和名称等。 - -``` -{ - arch = "arm64 v8"; - "dyld_images" = ( - { - "addr_slide" = 0x5c18000; - "base_addr" = 0x1e9ead000; - name = "/usr/lib/libBacktraceRecording.dylib"; - uuid = 31239ad326ce32dfb01b3eb5703fac81; - }, - // ... -} -``` - -上传这些信息到服务端后,APM 后端调用符号化服务的能力,符号化机器找到对应的 DSYM 文件,调用 atos 的指令进行符号化,如下效果。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LagStackSymbolicate.png) - -系统堆栈符号化的问题,也就是获取系统符号文件的问题。可以通过 ipsw,也就是刷机所需要的固件信息。 - -因为目前最低兼容 iOS10, iOS 10 以后我们只需要支持 arm64 和 arm64e 两个架构,也就是我们只要下载同一个版本下任意 arm64 的固件和任意一个 arm64e 的一个固件即可。 - -另外一种策略就是火焰图 thread_profile,类似 instruments 上看到的树型调用栈,比如50ms 抓取1次堆栈,维护最近的30个方法堆栈。其中堆栈信息为主线程的符号地址. - -深度遍历,每一层找到该层方法出现次数最多的方法符号。最后拼接组成完整方法堆栈。 - -服务端聚合策略 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CallStackGroupHash.png) - -找到最接近栈顶的符合占比条件的业务代码方法,作为卡顿堆栈归类 key。 - -key 的判定条件为: 当前节点方法耗时大于等于父节点耗时的 1.1/n,且子节点方法耗时大于父节点总耗时的20%。其中,n为当前深度的总节点个数。 - -```java -curNode.costTime > (parentNode.costTime * 1.1/n) && curNode.costTime > parentNode.costTime * 0.2 -``` - -裁剪策略:count=1~60,每次 filterCostTime =5ms,第一次裁剪1*5ms的节点,第二次裁剪2*5ms的节点,每次遍历时判断剩余节点数满足上传的阈值上限,则不再继续裁剪 - -上报堆栈数据为:depth 代表方法深度,count 代表方法访问次数;methodId 是Android 方法插桩的方法标记,该节点 iOS 是没有值的。另外 methodAddress 是 iOS 才有的符号地址。 - -``` -[ - { - "depth": 0, - "count": 1, - "costTime": 90, - "methodId": 181, - "methodAddress": "0x104d0824f" - } -] -``` - -方法调用的深度约定为100,现在方法调用层级不会那么深,如果真有那么多节点都是很小的子节点,排查意义不大,所以阈值没必要设置太大。 - -### 7. 卡顿优化 - -#### CPU - -- 尽量使用轻量级的对象,比如用不到事件处理的地方,就考虑用 CALayer 取代 UIView - -- 不要频繁的调用 UIView 相关属性,比如 frame、bounds、transform 等属性,尽量减少不必要的修改 - -- 尽量提前计算好布局,在有需要的时候一次性调整对应的属性,不要多次修改属性 - -- Autolayout 会比直接设置 frame 消耗更多的 CPU 资源 - - AutoLayout 的机制与开销 - - - **约束解析**:AutoLayout 基于约束(Constraints)构建线性方程组,通过 **Cassowary 算法** 求解视图的最终布局。这个过程涉及较多的数学计算(尤其是视图层级复杂时)。 - - **布局传递**:当视图层级变化时,AutoLayout 会触发 **布局传递(Layout Pass)**,包括 `updateConstraints`、`layoutSubviews`、`drawRect` 等阶段,可能多次递归调用。 - - **性能瓶颈**: - - 复杂层级:视图嵌套层级越深,约束数量越多,计算量呈指数级增长。 - - 动态修改约束:频繁添加/删除约束(如动画中)会导致重复布局计算。 - - 苹果对 AutoLayout 的性能进行了持续优化(如 iOS 12 引入的 **Cassandra 引擎**),显著减少了约束解析的开销 - - 所以,可以创建 DataModel.frameModel 中计算控件布局信息。 - - 再比如 `FDTemplateLayoutCell` 库。 - -- 图片的 size 最好跟 UIImageView 的 size 一致,否则会拉伸或者压缩 - -- 控制线程的最大并发数量。 - -- 尽量把耗时操作放到子线程中,完成后在主线程更新 UI。比如 Texture 框架 - - - 文本处理(尺寸计算、绘制) - - 图片处理(解码、绘制 ) - -#### GPU - -- 尽量减少视图的层次和数量 - -- 尽量避免短时间内大量图片的显示,尽可能将多张图片合并成一张进行展示 - -- GPU 能处理的最大纹理尺寸是 4096*4096,一旦超过这个尺寸,就会占用 CPU 资源进行处理,所以纹理尽量不要超过这个尺寸 - -- 减少透明的视图(alpha < 1),不透明就设置 opaque 为 YES - - - 因为 alpha 透明度导致 GPU 需要实时混合多个图层,增加计算负担 - - Demo:两个透明视图叠加的混合计算 - - 假设有一个蓝色半透明视图(`alpha = 0.5`)覆盖在一个红色视图上: - - 1. 底层红色视图:颜色值为 `RGB(1.0, 0, 0)`。 - 2. 上层蓝色视图:颜色值为 `RGB(0, 0, 1.0)`,`alpha = 0.5`。 - 3. 混合计算: - - **最终颜色 = 上层颜色 × alpha + 下层颜色 × (1 - alpha)** - - 结果 = `(0, 0, 1.0) * 0.5 + (1.0, 0, 0) * 0.5 = RGB(0.5, 0, 0.5)`。 - - **关键点**:每个像素都需要实时计算,这对 GPU 来说是额外的负担。 - - - 在视觉效果和性能之间权衡,优先保证用户交互流畅性。比如 - - - 优先使用不透明颜色替代透明度:用 `RGB(0.9, 0.9, 0.9)` 代替 `white.withAlphaComponent(0.9)`。让设计师提供不透明的近似色值 - - 如果视图完全不透明,设置 `view.isOpaque = true`,这会提示系统跳过混合计算。 - - 合并视图层级,避免多层透明视图嵌套。用单个 `UILabel` 代替 `UIView(背景半透明) + UILabel` - -- 尽量避免出现离屏渲染 - - - 需要创建新的缓冲区 - - 离屏渲染的整个过程,需要多次切换上下文环境,先从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束后,再将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕 - - 离屏渲染的代价是普通渲染的 2 倍以上(读写两次缓冲区)。 - - - - - -## 二、 App 启动时间监控 - -### 0. Xcode 查看简易启动时间 - -Xcode 增加环境变量,添加 `DYLD_PRINT_STATISTICS` 设为1,来查看 pre-main 各个阶段的耗时情况 - -如果需要更详细的信息,可以增加 `DYLD_PRINT_STATISTICS_DETAILS` 为1来查看数据。 - -### 1. 简易版App 启动时间的监控 - -应用启动时间是影响用户体验的重要因素之一,所以我们需要量化去衡量一个 App 的启动速度到底有多快。启动分为冷启动和热启动。 - -![App 启动时间](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-03-30-APMAppLaunch.png) - -冷启动:App 尚未运行,必须加载并构建整个应用。完成应用的初始化。冷启动存在较大优化空间。冷启动时间从 `application: didFinishLaunchingWithOptions:` 方法开始计算,App 一般在这里进行各种 SDK 和 App 的基础初始化工作。 - -热启动:应用已经在后台运行(常见场景:比如用户使用 App 过程中点击 Home 键,再打开 App),由于某些事件将应用唤醒到前台,App 会在 `applicationWillEnterForeground:` 方法接受应用进入前台的事件 - -思路比较简单。如下 - -- 在监控类的 `load` 方法中先拿到当前的时间值 -- 监听 App 启动完成后的通知 `UIApplicationDidFinishLaunchingNotification` -- 收到通知后拿到当前的时间 -- 步骤 1 和 3 的时间差就是 App 启动时间。 - -`mach_absolute_time` 是一个 CPU/总线依赖函数,返回一个 CPU 时钟周期数。系统休眠时不会增加。是一个纳秒级别的数字。获取前后 2 个纳秒后需要转换到秒。需要基于系统时间的基准,通过 `mach_timebase_info` 获得。 - -```Objective-c -mach_timebase_info_data_t g_apmmStartupMonitorTimebaseInfoData = 0; -mach_timebase_info(&g_apmmStartupMonitorTimebaseInfoData); -uint64_t timelapse = mach_absolute_time() - g_apmmLoadTime; -double timeSpan = (timelapse * g_apmmStartupMonitorTimebaseInfoData.numer) / (g_apmmStartupMonitorTimebaseInfoData.denom * 1e9); -``` - - - -### 2. 线上监控启动时间就好,但是在开发阶段需要对启动时间做优化。 - -要优化启动时间,就先得知道在启动阶段到底做了什么事情,针对现状作出方案。 - -pre-main 阶段定义为 App 开始启动到系统调用 main 函数这个阶段;main 阶段定义为 main 函数入口到主 UI 框架的 viewDidAppear。 - -App 启动过程: - -- 解析 Info.plist:加载相关信息例如闪屏;沙盒建立、权限检查; -- Mach-O 加载:如果是胖二进制文件,寻找合适当前 CPU 架构的部分;加载所有依赖的 Mach-O 文件(递归调用 Mach-O 加载的方法);定义内部、外部指针引用,例如字符串、函数等;加载分类中的方法;c++ 静态对象加载、调用 Objc 的 `+load()` 函数;执行声明为 \__attribute_((constructor)) 的 c 函数; -- 程序执行:调用 main();调用 UIApplicationMain();调用 applicationWillFinishLaunching(); - -Pre-Main 阶段 -![Pre-Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-PreMain.png) - -Main 阶段 -![Main 阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-10-AppSpeed-Main.png) - -#### 2.1 加载 Dylib - -每个动态库的加载,dyld 需要 - -- 分析所依赖的动态库 -- 找到动态库的 Mach-O 文件 -- 打开文件 -- 验证文件 -- 在系统核心注册文件签名 -- 对动态库的每一个 segment 调用 mmap() - -优化: - -- 减少非系统库的依赖 -- 使用静态库而不是动态库 -- 合并非系统动态库为一个动态库 - -#### 2.2 Rebase && Binding - -优化: - -- 减少 Objc 类数量,减少 selector 数量,把未使用的类和函数都可以删掉 -- 减少 c++ 虚函数数量 -- 转而使用 Swift struct(本质就是减少符号的数量) - -#### 2.3 Initializers - -优化: - -- 用 `+initialize` 方法和 `dispatch_once` 取代所有的 `attribute *((constructor))`、c++ 静态构造器、Objc 的 `+load` 方法 -- 不要使用过 `attribute *((constructor))` 将方法显示标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用时才初始化,推迟了一部分工作耗时也尽量不要使用 c++ 的静态对象 - -QA:为什么 `+ initialize` 需要搭配 `dispatch_once`?因为 `+initialize` 可能执行多次 - -#### 2.4 pre-main 阶段影响因素 - -- 动态库加载越多,启动越慢。 -- ObjC 类越多,函数越多,启动越慢。 -- 可执行文件越大启动越慢。 -- C 的 constructor 函数越多,启动越慢。 -- C++ 静态对象越多,启动越慢。 -- ObjC 的 +load 越多,启动越慢。 - -优化手段: - -- 减少依赖不必要的库,不管是动态库还是静态库;如果可以的话,把动态库改造成静态库;如果必须依赖动态库,则把多个非系统的动态库合并成一个动态库 -- 检查下 framework 应当设为 optional 和 required,如果该 framework 在当前 App 支持的所有 iOS 系统版本都存在,那么就设为 required,否则就设为 optional,因为 optional 会有些额外的检查 -- 合并或者删减一些 OC 类和函数。关于清理项目中没用到的类,使用工具 AppCode 代码检查功能,查到当前项目中没有用到的类(也可以用根据 linkmap 文件来分析,但是准确度不算很高) - 有一个叫做[FUI](https://github.com/dblock/fui)的开源项目能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了 C++的类模板 -- 删减一些无用的静态变量 -- 删减没有被调用到或者已经废弃的方法 -- 将不必须在 +load 方法中做的事情延迟到 +initialize 中,尽量不要用 C++ 虚函数(创建虚函数表有开销) -- 类和方法名不要太长:iOS 每个类和方法名都在 \_\_cstring 段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的 - 因还是 Object-c 的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,Object-c 对象模型会把类/方法名字符串都保存下来; -- 用 dispatch_once() 代替所有的 attribute((constructor)) 函数、C++ 静态对象初始化、ObjC 的 +load 函数; -- 在设计师可接受的范围内压缩图片的大小,会有意外收获。 - 压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的, - 图片小了,IO 操作量就小了,启动当然就会快了,比较靠谱的压缩算法是 TinyPNG。 - -#### 2.5 main 阶段优化 - -在不影响用户体验的情况下,尽可能将一些逻辑延迟,不要全部放在 `finishLaunching` 方法中。 - -- 减少启动初始化的流程。能懒加载就懒加载,能放后台初始化就放后台初始化,能延迟初始化的就延迟初始化,不要卡主线程的启动时间,已经下线的业务代码直接删除 -- 优化代码逻辑。去除一些非必要的逻辑和代码,减小每个流程所消耗的时间 -- 启动阶段使用多线程来进行初始化,把 CPU 性能发挥最大 -- 使用纯代码而不是 xib 或者 storyboard 来描述 UI,尤其是主 UI 框架,比如 TabBarController。因为 xib 和 storyboard 还是需要解析成代码来渲染页面,多了一步。 - -### 3. 启动时间加速 - -内存缺页异常?在使用中,访问虚拟内存的一个 page 而对应的物理内存缺不存在(没有被加载到物理内存中),则发生缺页异常。影响耗时,在几毫秒之内。 - -什么时候发生大量的缺页异常?一个应用程序刚启动的时候。 - -启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。 - -二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。 - -一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。 - -其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以 hook 肯定不行、fishhook 也不行,用 clang 插桩可以满足需求。 - -启动优化具体可以查看这篇 [App 启动时间优化与二进制重排](./1.61.md) - -### 4. 精确版启动时间监控 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AppStarupPipeline.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/APMStartup.png) - -进程创建:通过 sysctl 可以拿到 - -第一个 +load 时刻:通过 `AAA` 为前缀给 Pod 命名,则可以让 +load 第一个被执行,从而记录时间 - -didFinishLaunching:通过 UIApplicationDidFinishLaunchingNotification 拿到 - -首屏渲染时间怎么拿?能获取到 `CA::Transaction::commit()` 时刻即可。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CATransactionCommit.png) - -对 UI 进行修改后会在主线程休眠前触发 `CA::Transaction::commit()`,其实就是打包 Render Tree,通过 IPC 发送给 Render Server。 - -对 View 的各个方法加断点,查看和 Core Animation Commit 的时间顺序: - -在`CA::Transaction::commit()` 会依次执行以下步骤: - -- setNeedsDisplay - -- Layout 布局:调用 `layout` 等与布局相关的 API - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline1.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline2.png) - -- Display 绘制:调用 `drawRect` 等与绘制相关方法 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline3.png) - -- Prepare:图片解码 - -- Commit:递归打包 Render Tree,通过 IPC 计数发送给 Render Server - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline4.png) -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline5.png) -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationPipeline6.png) - -断点调试完,`[BSXPCServiceConnectionProxy invokeMethod:onTarget:withMessage:forConnection:]` 底层调用的是 send 方法。send 方法调用 `BSXPCServiceConnectionMessageReply send` 方法。 - -我们 hook `BSXPCServiceConnectionMessageReply` 的 send 便可监控启动耗时监控。 - -```objectivec - Class aClass = NSClassFromString(@"BSXPCServiceConnectionMessageReply"); - Class class = aClass; - SEL originalSelector = NSSelectorFromString(@"send"); - SEL swizzledSelector = @selector(mockSend); - - Method originalMethod = class_getInstanceMethod(aClass, originalSelector); - Method swizzledMethod = class_getInstanceMethod([KKMonitor class], swizzledSelector); - - BOOL didAddMethod = - class_addMethod(class, - originalSelector, - method_getImplementation(swizzledMethod), - method_getTypeEncoding(swizzledMethod)); - if (didAddMethod) { - class_replaceMethod(class, - swizzledSelector, - method_getImplementation(originalMethod), - method_getTypeEncoding(originalMethod)); - } else { - method_exchangeImplementations(originalMethod, swizzledMethod); - } -} - -- (void)mockSend -{ - // 时间计算 -} -``` - - -### 5. RN 启动时间监控 -跨端类的统计口径一般不会像 Native 那么严格,不会从 main 函数冷启动开始计算,启动还区分那么多 t1(进程创建到 main函数执行)、t2(main 函数执行到 didFinishLaunching 完成)、t3(didFinishLaunching 完成到首屏渲染完成) - -开始时间点:一般需要 Native 配合,容器页面创建好就是开始时间点。比如 iOS 的 VC `viewDidLoad`、Android 的 `onActivityCreated` -结束时间点:结束时间一般在跨端侧,比如 RN 中的组件挂载完成 componentDidMount 回调的时刻。 - - - -### 6. 工单跟进 - -数据采集上报后,产生工单,自动分配到人、通知负责人和对应的群。 - -这些在其他篇章会讲。比如启动时间的工单信息类似: - -| 阶段 | 耗时 | 方法 | 业务 | 负责人 | 操作 | -| ---- | ---- | ----------------------------- | ----- | ------ | -------------- | -| T1 | 30ms | [Appdeledate handleDBUpgrade] | Goods | @张三 | 更改状态、详情 | - - - - - - - - - -## 三、 CPU 使用率监控 - -### 1. CPU 架构 - -CPU(Central Processing Unit)中央处理器,市场上主流的架构有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。区别在于**不同的 CPU 设计理念和方法**。 - -早期 CPU 全部是 CISC 架构,设计目的是**用最少的机器语言指令来完成所需的计算任务**。比如对于乘法运算,在 CISC 架构的 CPU 上。一条指令 `MUL ADDRA, ADDRB` 就可以将内存 ADDRA 和内存 ADDRB 中的数香乘,并将结果存储在 ADDRA 中。做的事情就是:将 ADDRA、ADDRB 中的数据读入到寄存器,相乘的结果写入到内存的操作依赖于 CPU 设计,所以 **CISC 架构会增加 CPU 的复杂性和对 CPU 工艺的要求。** - -RISC 架构要求软件来指定各个操作步骤。比如上面的乘法,指令实现为 `MOVE A, ADDRA; MOVE B, ADDRB; MUL A, B; STR ADDRA, A;`。这种架构可以降低 CPU 的复杂性以及允许在同样的工艺水平下生产出功能更加强大的 CPU,但是对于编译器的设计要求更高。 - -目前市场是大部分的 iPhone 都是基于 arm64 架构的。且 arm 架构能耗低。 - -### 2. 获取线程信息 - -讲完了区别来讲下如何做 CPU 使用率的监控 - -- 开启定时器,按照设定的周期不断执行下面的逻辑 -- 获取当前任务 task。从当前 task 中获取所有的线程信息(线程个数、线程数组) -- 遍历所有的线程信息,判断是否有线程的 CPU 使用率超过设置的阈值 -- 假如有线程使用率超过阈值,则 dump 堆栈 -- 组装数据,上报数据 - -线程信息结构体 - -```Objective-c -struct thread_basic_info { - time_value_t user_time; /* user run time(用户运行时长) */ - time_value_t system_time; /* system run time(系统运行时长) */ - integer_t cpu_usage; /* scaled cpu usage percentage(CPU使用率,上限1000) */ - policy_t policy; /* scheduling policy in effect(有效调度策略) */ - integer_t run_state; /* run state (运行状态,见下) */ - integer_t flags; /* various flags (各种各样的标记) */ - integer_t suspend_count; /* suspend count for thread(线程挂起次数) */ - integer_t sleep_time; /* number of seconds that thread - * has been sleeping(休眠时间) */ -}; -``` - -代码在讲堆栈还原的时候讲过,忘记的看一下上面的分析 - -```Objective-C -thread_act_array_t threads; -mach_msg_type_number_t threadCount = 0; -const task_t thisTask = mach_task_self(); -kern_return_t kr = task_threads(thisTask, &threads, &threadCount); -if (kr != KERN_SUCCESS) { - return ; -} -for (int i = 0; i < threadCount; i++) { - thread_info_data_t threadInfo; - thread_basic_info_t threadBaseInfo; - mach_msg_type_number_t threadInfoCount; - - kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount); - - if (kr == KERN_SUCCESS) { - - threadBaseInfo = (thread_basic_info_t)threadInfo; - // todo:条件判断,看不明白 - if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) { - integer_t cpuUsage = threadBaseInfo->cpu_usage / 10; - if (cpuUsage > CPUMONITORRATE) { - - NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary]; - NSData *CPUPayloadData = [NSData data]; - - NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread]; - // 1. 组装卡顿的 Meta 信息 - CPUMetaDictionary[@"MONITOR_TYPE"] = APMMonitorCPUType; - - // 2. 组装卡顿的 Payload 信息(一个JSON对象,对象的 Key 为约定好的 STACK_TRACE, value 为 base64 后的堆栈信息) - NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding]; - NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0]; - NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)}; - - NSError *error; - // NSJSONWritingOptions 参数一定要传0,因为服务端需要根据 \n 处理逻辑,传递 0 则生成的 json 串不带 \n - NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error]; - if (error) { - APMMLog(@"%@", error); - return; - } - CPUPayloadData = [parsedData copy]; - - // 3. 数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - [[HermesClient sharedInstance] sendWithType:APMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; - } - } - } -} -``` - -## 四、 OOM 问题 - -大多数情况下 OOM 问题比 crash 问题更严重,线上稳定性问题主要是由于 OOM 造成的,因为线下可以利用 Xcode 的一些工具解决并定位 crash。线上也有类似 KSCrash 这样的优秀监控工具。但是 OOM 方面线下只有一些三方的工具,这些工具大多是基于 OC 对象引用关心实现的,所以只能判断 OC 对象。另外比较耗费性能,所以线上 OOM 问题还是比较多的。 - -### 1. 基础知识准备 - -硬盘:也叫做磁盘,用于存储数据。你存储的歌曲、图片、视频都是在硬盘里。 - -内存:由于硬盘读取速度较慢,如果 CPU 运行程序期间,所有的数据都直接从硬盘中读取,则非常影响效率。所以 CPU 会将程序运行所需要的数据从硬盘中读取到内存中。然后 CPU 与内存中的数据进行计算、交换。内存是易失性存储器(断电后,数据消失)。内存条区是计算机内部(在主板上)的一些存储器,用来保存 CPU 运算的中间数据和结果。内存是程序与 CPU 之间的桥梁。从硬盘读取出数据或者运行程序提供给 CPU。 - -**虚拟内存** 是计算机系统内存管理的一种技术。它使得程序认为它拥有连续的可用内存,而实际上,它通常被分割成多个物理内存碎片,可能部分暂时存储在外部磁盘(硬盘)存储器上(当需要使用时则用硬盘中数据交换到内存中)。Windows 系统中称为 “虚拟内存”,Linux/Unix 系统中称为 ”交换空间“。 - -iOS 不支持交换空间?不只是 iOS 不支持交换空间,大多数手机系统都不支持。因为移动设备的大量存储器是**闪存**,它的读写速度远远小电脑所使用的硬盘,也就是说手机即使使用了**交换空间**技术,也因为闪存慢的问题,不能提升性能,所以索性就没有交换空间技术。 - -### 2. iOS 内存知识 - -内存(RAM)与 CPU 一样都是系统中最稀少的资源,也很容易发生竞争,应用内存与性能直接相关。iOS 没有交换空间作为备选资源,所以内存资源尤为重要。 - -什么是 OOM?是 out-of-memory 的缩写,字面意思是超过了内存限制。分为 FOOM(Foreground Out Of Memory)应用在前台运行的过程中崩溃。用户在使用的过程中产生的,这样的崩溃会使得活跃用户流失,业务上是非常不愿意看到的和 BOOM(Background Out Of Memory)应用在后台运行的过程崩溃。它是由 iOS 的 `Jetsam` 机制造成的一种非主流 Crash,它不能通过 Signal 这种监控方案所捕获。 - -什么是 Jetsam 机制?Jetsam 机制可以理解为系统为了控制内存资源过度使用而采用的一种管理机制。Jetsam 机制是运行在一个独立的进程中,每个进程都有一个内存阈值,一旦超过这个内存阈值,Jetsam 会立即杀掉这个进程。 - -为什么设计 Jetsam 机制?因为设备的内存是有限的,所以内存资源非常重要。系统进程以及其他使用的 App 都会抢占这个资源。由于 iOS 不支持交换空间,一旦触发低内存事件,Jetsam 就会尽可能多的释放 App 所在内存,这样 iOS 系统上出现内存不足时,App 就会被系统杀掉,变现为 crash。 - -2 种情况触发 OOM:系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;当前 App 达到了 "**highg water mark**" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)。 - -读了源码(xnu/bsd/kern/kern_memorystatus.c)会发现内存被杀也有 2 种机制,如下 - -highwater 处理 -> 我们的 App 占用内存不能超过单个限制 - -1. 从优先级列表里循环寻找线程 -2. 判断是否满足 p_memstat_memlimit 的限制条件 -3. DiagonoseActive、FREEZE 过滤 -4. 杀进程,成功则 exit,否则循环 - -memorystatus_act_aggressive 处理 -> 内存占用高,按照优先级杀死 - -1. 根据 policy 家在 jld_bucket_count,用来判断是否被杀 -2. 从 JETSAM_PRIORITY_ELEVATED_INACTIVE 开始杀 -3. Old_bucket_count 和 memorystatus_jld_eval_period_msecs 判断是否开杀 -4. 根据优先级从低到高开始杀,直到 memorystatus_avail_pages_below_pressure - -内存过大的几种情况 - -- App 内存消耗较低,同时其他 App 内存管理也很棒,那么即使切换到其他 App,我们自己的 App 依旧是“活着”的,保留了用户状态。体验好 -- App 内存消耗较低,但其他 App 内存消耗太大(可能是内存管理糟糕,也可能是本身就耗费资源,比如游戏),那么除了在前台的线程,其他 App 都会被系统杀死,回收内存资源,用来给活跃的进程提供内存。 -- App 内存消耗较大,切换到其他 App 后,即使其他 App 向系统申请的内存不大,系统也会因为内存资源紧张,优先把内存消耗大的 App 杀死。表现为用户将 App 退出到后台,过会儿再次打开会发现 App 重新加载启动。 -- App 内存消耗非常大,在前台运行时就被系统杀死,造成闪退。 - -App 内存不足时,系统会按照一定策略来腾出更多的空间供使用。比较常见的做法是将一部分优先级低的数据挪到磁盘上,该操作为称为 **page out**。之后再次访问这块数据的时候,系统会负责将它重新搬回到内存中,该操作被称为 **page in**。 - -Memory page\*\* 是内存管理中的最小单位,是系统分配的,可能一个 page 持有多个对象,也可能一个大的对象跨越多个 page。通常它是 16KB 大小,且有 3 种类型的 page。 - -![内存page种类](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryType.png) - -- Clean Memory - Clean memory 包括 3 类:可以 `page out` 的内存、内存映射文件、App 使用到的 framework(每个 framework 都有 \_DATA_CONST 段,通常都是 clean 状态,但使用 runtime swizling,那么变为 dirty)。 - - 一开始分配的 page 都是干净的(堆里面的对象分配除外),我们 App 数据写入时候变为 dirty。从硬盘读进内存的文件,也是只读的、clean page。 - - ![Clean memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeClean.png) - -- Dirty Memory - - Dirty memory 包括 4 类:被 App 写入过数据的内存、所有堆区分配的对象、图像解码缓冲区、framework(framework 都有 \_DATA 段和 \_DATA_DIRTY 段,它们的内存都是 dirty)。 - - 在使用 framework 的过程中会产生 Dirty memory,使用单例或者全局初始化方法有助于帮助减少 Dirty memory(因为单例一旦创建就不销毁,一直在内存中,系统不认为是 Dirty memory)。 - - ![Dirty memory](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryTypeDirty.png) - -- Compressed Memory - - 由于闪存容量和读写限制,iOS 没有交换空间机制,而是在 iOS7 引入了 **memory compressor**。它是在内存紧张时候能够将最近一段时间未使用过的内存对象,内存压缩器会把对象压缩,释放出更多的 page。在需要时内存压缩器对其解压复用。在节省内存的同时提高了响应速度。 - - 比如 App 使用某 Framework,内部有个 NSDictionary 属性存储数据,使用了 3 pages 内存,在近期未被访问的时候 memory compressor 将其压缩为 1 page,再次使用的时候还原为 3 pages。 - -App 运行内存 = pageNumbers \* pageSize。因为 Compressed Memory 属于 Dirty memory。所以 Memory footprint = dirtySize + CompressedSize - -设备不同,内存占用上限不同,App 上限较高,extension 上限较低,超过上限 crash 到 `EXC_RESOURCE_EXCEPTION`。 -![Memory footprint](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-28-iOSMemoryFootprint.png) - -接下来谈一下如何获取内存上限,以及如何监控 App 因为占用内存过大而被强杀。 - -### 3. 获取内存信息 - -#### 3.1 通过 JetsamEvent 日志计算内存限制值 - -当 App 被 Jetsam 机制杀死时,手机会生成系统日志。查看路径:Settings-Privacy-Analytics & Improvements- Analytics Data(设置-隐私- 分析与改进-分析数据),可以看到 `JetsamEvent-2020-03-14-161828.ips` 形式的日志,以 JetsamEvent 开头。这些 JetsamEvent 日志都是 iOS 系统内核强杀掉那些优先级不高(idle、frontmost、suspended)且占用内存超过系统内存限制的 App 留下的。 - -日志包含了 App 的内存信息。可以查看到 日志最顶部有 `pageSize` 字段,查找到 per-process-limit,该节点所在结构里的 `rpages` ,将 rpages \* pageSize 即可得到 OOM 的阈值。 - -日志中 largestProcess 字段代表 App 名称;reason 字段代表内存原因;states 字段代表奔溃时 App 的状态( idle、suspended、frontmost...)。 - -为了测试数据的准确性,我将测试 2 台设备(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。 循环申请内存,ViewController 代码如下 - -```objective-c -- (void)viewDidLoad { - [super viewDidLoad]; - NSMutableArray *array = [NSMutableArray array]; - for (NSInteger index = 0; index < 10000000; index++) { - UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIImage *image = [UIImage imageNamed:@"AppIcon"]; - imageView.image = image; - [array addObject:imageView]; - } -} -``` - -iPhone 6s plus/13.3.1 数据如下: - -```shell -{"bug_type":"298","timestamp":"2020-03-19 17:23:45.94 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"DA8AF66D-24E8-458C-8734-981866942168"} -{ - "crashReporterKey" : "fc9b659ce486df1ed1b8062d5c7c977a7eb8c851", - "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:10:44 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_S8000", - "product" : "iPhone8,2", - "incident" : "DA8AF66D-24E8-458C-8734-981866942168", - "date" : "2020-03-19 17:23:45.93 +0800", - "build" : "iPhone OS 13.3.1 (17D50)", - "timeDelta" : 332, - "memoryStatus" : { - "compressorSize" : 48499, - "compressions" : 7458651, - "decompressions" : 5190200, - "zoneMapCap" : 744407040, - "largestZone" : "APFS_4K_OBJS", - "largestZoneSize" : 41402368, - "pageSize" : 16384, - "uncompressed" : 104065, - "zoneMapSize" : 141606912, - "memoryPages" : { - "active" : 26214, - "throttled" : 0, - "fileBacked" : 14903, - "wired" : 20019, - "anonymous" : 37140, - "purgeable" : 142, - "inactive" : 23669, - "free" : 2967, - "speculative" : 2160 - } -}, - "largestProcess" : "Test", - "genCounter" : 0, - "processes" : [ - { - "uuid" : "39c5738b-b321-3865-a731-68064c4f7a6f", - "states" : [ - "daemon", - "idle" - ], - "lifetimeMax" : 188, - "age" : 948223699030, - "purgeable" : 0, - "fds" : 25, - "coalition" : 422, - "rpages" : 177, - "pid" : 282, - "idleDelta" : 824711280, - "name" : "com.apple.Safari.SafeBrowsing.Se", - "cpuTime" : 10.275422000000001 - }, - // ... - { - "uuid" : "83dbf121-7c0c-3ab5-9b66-77ee926e1561", - "states" : [ - "frontmost" - ], - "killDelta" : 2592, - "genCount" : 0, - "age" : 1531004794, - "purgeable" : 0, - "fds" : 50, - "coalition" : 1047, - "rpages" : 92806, - "reason" : "per-process-limit", - "pid" : 2384, - "cpuTime" : 59.464373999999999, - "name" : "Test", - "lifetimeMax" : 92806 - }, - // ... - ] -} -``` - -iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024\*1024)=1450.09375M - -iPhone 11 Pro/13.3.1 数据如下: - -```shell -{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"} -{ - "crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276", - "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan 9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030", - "product" : "iPhone12,3", - "incident" : "7F111601-BC7A-4BD7-A468-CE3370053057", - "date" : "2020-03-19 17:30:28.39 +0800", - "build" : "iPhone OS 13.3.1 (17D50)", - "timeDelta" : 189, - "memoryStatus" : { - "compressorSize" : 66443, - "compressions" : 25498129, - "decompressions" : 15532621, - "zoneMapCap" : 1395015680, - "largestZone" : "APFS_4K_OBJS", - "largestZoneSize" : 41222144, - "pageSize" : 16384, - "uncompressed" : 127027, - "zoneMapSize" : 169639936, - "memoryPages" : { - "active" : 58652, - "throttled" : 0, - "fileBacked" : 20291, - "wired" : 45838, - "anonymous" : 96445, - "purgeable" : 4, - "inactive" : 54368, - "free" : 5461, - "speculative" : 3716 - } -}, - "largestProcess" : "杭城小刘", - "genCounter" : 0, - "processes" : [ - { - "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7", - "states" : [ - "daemon", - "idle" - ], - "lifetimeMax" : 171, - "age" : 5151034269954, - "purgeable" : 0, - "fds" : 50, - "coalition" : 66, - "rpages" : 164, - "pid" : 11276, - "idleDelta" : 3801132318, - "name" : "wcd", - "cpuTime" : 3.430787 - }, - // ... - { - "uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0", - "states" : [ - "frontmost" - ], - "killDelta" : 4345, - "genCount" : 0, - "age" : 654480778, - "purgeable" : 0, - "fds" : 50, - "coalition" : 1718, - "rpages" : 134278, - "reason" : "per-process-limit", - "pid" : 14206, - "cpuTime" : 23.955463999999999, - "name" : "杭城小刘", - "lifetimeMax" : 134278 - }, - // ... - ] -} -``` - -iPhone 11 Pro/13.3.1 手机 OOM 临界值为:(16384\*134278)/(1024\*1024)=2098.09375M - -**iOS 系统如何发现 Jetsam ?** - -MacOS/iOS 是一个 BSD 衍生而来的系统,其内核是 Mach,但是对于上层暴露的接口一般是基于 BSD 层对 Mach 的包装后的。Mach 是一个微内核的架构,真正的虚拟内存管理也是在其中进行的,BSD 对内存管理提供了上层接口。Jetsam 事件也是由 BSD 产生的。`bsd_init` 函数是入口,其中基本都是在初始化各个子系统,比如虚拟内存管理等。 - -```c++ -// 1. Initialize the kernel memory allocator, 初始化 BSD 内存 Zone,这个 Zone 是基于 Mach 内核的zone 构建 -kmeminit(); - -// 2. Initialise background freezing, iOS 上独有的特性,内存和进程的休眠的常驻监控线程 -#if CONFIG_FREEZE -#ifndef CONFIG_MEMORYSTATUS - #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS" -#endif - /* Initialise background freezing */ - bsd_init_kprintf("calling memorystatus_freeze_init\n"); - memorystatus_freeze_init(); -#endif> - -// 3. iOS 独有,JetSAM(即低内存事件的常驻监控线程) -#if CONFIG_MEMORYSTATUS - /* Initialize kernel memory status notifications */ - bsd_init_kprintf("calling memorystatus_init\n"); - memorystatus_init(); -#endif /* CONFIG_MEMORYSTATUS */ -``` - -**主要作用就是开启了 2 个优先级最高的线程,来监控整个系统的内存情况。** - -CONFIG_FREEZE 开启时,内核对进程进行冷冻而不是杀死。冷冻功能是由内核中启动一个 `memorystatus_freeze_thread` 进行,这个进程在收到信号后调用 `memorystatus_freeze_top_process` 进行冷冻。 - -iOS 系统会开启优先级最高的线程 `vm_pressure_monitor` 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 进程。iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。有关 Jetsam 也就是 memorystatus 相关的逻辑,可以在 XNU 项目中的 **kern_memorystatus.h** 和 **kern_memorystatus.c **源码中查看。 - -iOS 系统因内存占用过高会强杀 App 前,至少有 6 秒钟可以用来做优先级判断,JetsamEvent 日志也是在这 6 秒内生成的。 - -上文提到了 iOS 系统没有交换空间,于是引入了 **MemoryStatus 机制(也称为 Jetsam)**。也就是说在 iOS 系统上释放尽可能多的内存供当前 App 使用。这个机制表现在优先级上,就是先强杀后台应用;如果内存还是不够多,就强杀掉当前应用。在 MacOS 中,MemoryStatus 只会强杀掉标记为空闲退出的进程。 - -MemoryStatus 机制会开启一个 memorystatus_jetsam_thread 的线程,它负责强杀 App 和记录日志,不会发送消息,所以内存压力检测线程无法获取到强杀 App 的消息。 - -当监控线程发现某 App 有内存压力时,就发出通知,此时有内存的 App 就去执行 `didReceiveMemoryWarning` 代理方法。在这个时机,我们还有机会做一些内存资源释放的逻辑,也许会避免 App 被系统杀死。 - - - -**源码角度查看问题** - -iOS 系统内核有一个数组,专门维护线程的优先级。数组的每一项是一个包含进程链表的结构体。结构体如下: - -```objective-c -#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1) - -typedef struct memstat_bucket { - TAILQ_HEAD(, proc) list; - int count; -} memstat_bucket_t; - -memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT]; -``` - -在 kern_memorystatus.h 中可以看到进行优先级信息 - -```objective-c -#define JETSAM_PRIORITY_IDLE_HEAD -2 -/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */ -#define JETSAM_PRIORITY_IDLE 0 -#define JETSAM_PRIORITY_IDLE_DEFERRED 1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/ -#define JETSAM_PRIORITY_AGING_BAND1 JETSAM_PRIORITY_IDLE_DEFERRED -#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC 2 -#define JETSAM_PRIORITY_AGING_BAND2 JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC -#define JETSAM_PRIORITY_BACKGROUND 3 -#define JETSAM_PRIORITY_ELEVATED_INACTIVE JETSAM_PRIORITY_BACKGROUND -#define JETSAM_PRIORITY_MAIL 4 -#define JETSAM_PRIORITY_PHONE 5 -#define JETSAM_PRIORITY_UI_SUPPORT 8 -#define JETSAM_PRIORITY_FOREGROUND_SUPPORT 9 -#define JETSAM_PRIORITY_FOREGROUND 10 -#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY 12 -#define JETSAM_PRIORITY_CONDUCTOR 13 -#define JETSAM_PRIORITY_HOME 16 -#define JETSAM_PRIORITY_EXECUTIVE 17 -#define JETSAM_PRIORITY_IMPORTANT 18 -#define JETSAM_PRIORITY_CRITICAL 19 - -#define JETSAM_PRIORITY_MAX 21 -``` - -可以明显的看到,后台 App 优先级 JETSAM_PRIORITY_BACKGROUND 为 3,前台 App 优先级 JETSAM_PRIORITY_FOREGROUND 为 10。 - -优先级规则是:内核线程优先级 > 操作系统优先级 > App 优先级。且前台 App 优先级高于后台运行的 App;当线程的优先级相同时, CPU 占用多的线程的优先级会被降低。 - -在 kern_memorystatus.c 中可以看到 OOM 可能的原因: - -```shell -/* For logging clarity */ -static const char *memorystatus_kill_cause_name[] = { - "" , /* kMemorystatusInvalid */ - "jettisoned" , /* kMemorystatusKilled */ - "highwater" , /* kMemorystatusKilledHiwat */ - "vnode-limit" , /* kMemorystatusKilledVnodes */ - "vm-pageshortage" , /* kMemorystatusKilledVMPageShortage */ - "proc-thrashing" , /* kMemorystatusKilledProcThrashing */ - "fc-thrashing" , /* kMemorystatusKilledFCThrashing */ - "per-process-limit" , /* kMemorystatusKilledPerProcessLimit */ - "disk-space-shortage" , /* kMemorystatusKilledDiskSpaceShortage */ - "idle-exit" , /* kMemorystatusKilledIdleExit */ - "zone-map-exhaustion" , /* kMemorystatusKilledZoneMapExhaustion */ - "vm-compressor-thrashing" , /* kMemorystatusKilledVMCompressorThrashing */ - "vm-compressor-space-shortage" , /* kMemorystatusKilledVMCompressorSpaceShortage */ -}; -``` - -查看 memorystatus_init 这个函数中初始化 Jetsam 线程的关键代码 - -```c++ -__private_extern__ void -memorystatus_init(void) -{ - // ... - /* Initialize the jetsam_threads state array */ - jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads); - - /* Initialize all the jetsam threads */ - for (i = 0; i < max_jetsam_threads; i++) { - - result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread); - if (result == KERN_SUCCESS) { - jetsam_threads[i].inited = FALSE; - jetsam_threads[i].index = i; - thread_deallocate(jetsam_threads[i].thread); - } else { - panic("Could not create memorystatus_thread %d", i); - } - } -} -``` - -```shell -/* - * High-level priority assignments - * - ************************************************************************* - * 127 Reserved (real-time) - * A - * + - * (32 levels) - * + - * V - * 96 Reserved (real-time) - * 95 Kernel mode only - * A - * + - * (16 levels) - * + - * V - * 80 Kernel mode only - * 79 System high priority - * A - * + - * (16 levels) - * + - * V - * 64 System high priority - * 63 Elevated priorities - * A - * + - * (12 levels) - * + - * V - * 52 Elevated priorities - * 51 Elevated priorities (incl. BSD +nice) - * A - * + - * (20 levels) - * + - * V - * 32 Elevated priorities (incl. BSD +nice) - * 31 Default (default base for threads) - * 30 Lowered priorities (incl. BSD -nice) - * A - * + - * (20 levels) - * + - * V - * 11 Lowered priorities (incl. BSD -nice) - * 10 Lowered priorities (aged pri's) - * A - * + - * (11 levels) - * + - * V - * 0 Lowered priorities (aged pri's / idle) - ************************************************************************* - */ -``` - -可以看出:用户态的应用程序的线程不可能高于操作系统和内核。而且,用户态的应用程序间的线程优先级分配也有区别,比如处于前台的应用程序优先级高于处于后台的应用程序优先级。iOS 上应用程序优先级最高的是 SpringBoard;此外线程的优先级不是一成不变的。Mach 会根据线程的利用率和系统整体负载动态调整线程优先级。如果耗费 CPU 太多就降低线程优先级,如果线程过度挨饿,则会提升线程优先级。但是无论怎么变,程序都不能超过其所在线程的优先级区间范围。 - -可以看出,系统会根据内核启动参数和设备性能,开启 max_jetsam_threads 个(一般情况为 1,特殊情况下可能为 3)jetsam 线程,且这些线程的优先级为 95,也就是 MAXPRI_KERNEL(注意这里的 95 是线程的优先级,XNU 的线程优先级区间为:0 ~ 127。上文的宏定义是进程优先级,区间为:-2~19)。 - -紧接着,分析下 memorystatus_thread 函数,主要负责线程启动的初始化 - -```c++ -static void -memorystatus_thread(void *param __unused, wait_result_t wr __unused) -{ - //... - while (memorystatus_action_needed()) { - boolean_t killed; - int32_t priority; - uint32_t cause; - uint64_t jetsam_reason_code = JETSAM_REASON_INVALID; - os_reason_t jetsam_reason = OS_REASON_NULL; - - cause = kill_under_pressure_cause; - switch (cause) { - case kMemorystatusKilledFCThrashing: - jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING; - break; - case kMemorystatusKilledVMCompressorThrashing: - jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING; - break; - case kMemorystatusKilledVMCompressorSpaceShortage: - jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE; - break; - case kMemorystatusKilledZoneMapExhaustion: - jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION; - break; - case kMemorystatusKilledVMPageShortage: - /* falls through */ - default: - jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE; - cause = kMemorystatusKilledVMPageShortage; - break; - } - - /* Highwater */ - boolean_t is_critical = TRUE; - if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) { - if (is_critical == FALSE) { - /* - * For now, don't kill any other processes. - */ - break; - } else { - goto done; - } - } - - jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code); - if (jetsam_reason == OS_REASON_NULL) { - printf("memorystatus_thread: failed to allocate jetsam reason\n"); - } - - if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) { - goto done; - } - - /* - * memorystatus_kill_top_process() drops a reference, - * so take another one so we can continue to use this exit reason - * even after it returns - */ - os_reason_ref(jetsam_reason); - - /* LRU */ - killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors); - sort_flag = FALSE; - - if (killed) { - if (memorystatus_post_snapshot(priority, cause) == TRUE) { - - post_snapshot = TRUE; - } - - /* Jetsam Loop Detection */ - if (memorystatus_jld_enabled == TRUE) { - if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) { - jld_idle_kills++; - } else { - /* - * We've reached into bands beyond idle deferred. - * We make no attempt to monitor them - */ - } - } - - if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) { - /* - * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT - * then we attempt to relieve pressure by purging corpse memory. - */ - task_purge_all_corpses(); - corpse_list_purged = TRUE; - } - goto done; - } - - if (memorystatus_avail_pages_below_critical()) { - /* - * Still under pressure and unable to kill a process - purge corpse memory - */ - if (total_corpses_count() > 0) { - task_purge_all_corpses(); - corpse_list_purged = TRUE; - } - - if (memorystatus_avail_pages_below_critical()) { - /* - * Still under pressure and unable to kill a process - panic - */ - panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages); - } - } - -done: - -} -``` - -可以看到它开启了一个 循环,memorystatus_action_needed() 来作为循环条件,持续释放内存。 - -```c++ -static boolean_t -memorystatus_action_needed(void) -{ -#if CONFIG_EMBEDDED - return (is_reason_thrashing(kill_under_pressure_cause) || - is_reason_zone_map_exhaustion(kill_under_pressure_cause) || - memorystatus_available_pages <= memorystatus_available_pages_pressure); -#else /* CONFIG_EMBEDDED */ - return (is_reason_thrashing(kill_under_pressure_cause) || - is_reason_zone_map_exhaustion(kill_under_pressure_cause)); -#endif /* CONFIG_EMBEDDED */ -} -``` - -它通过 vm_pagepout 发送的内存压力来判断当前内存资源是否紧张。几种情况:频繁的页面换出换进 is_reason_thrashing, Mach Zone 耗尽了 is_reason_zone_map_exhaustion、以及可用的页低于了 memory status_available_pages 这个门槛。 - -继续看 memorystatus_thread,会发现内存紧张时,将先触发 High-water 类型的 OOM,也就是说假如某个进程使用过程中超过了其使用内存的最高限制 hight water mark 时会发生 OOM。在 memorystatus_act_on_hiwat_processes() 中,通过 memorystatus_kill_hiwat_proc() 在优先级数组 memstat_bucket 中查找优先级最低的进程,如果进程的内存小于阈值(footprint_in_bytes <= memlimit_in_bytes)则继续寻找次优先级较低的进程,直到找到占用内存超过阈值的进程并杀死。 - -通常来说单个 App 很难触碰到 high water mark,如果不能结束任何进程,最终走到 memorystatus_act_aggressive,也就是大多数 OOM 发生的地方。 - -```c++ -static boolean_t -memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) -{ - // ... - if ( (jld_bucket_count == 0) || - (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) { - - /* - * Refresh evaluation parameters - */ - jld_timestamp_msecs = jld_now_msecs; - jld_idle_kill_candidates = jld_bucket_count; - *jld_idle_kills = 0; - jld_eval_aggressive_count = 0; - jld_priority_band_max = JETSAM_PRIORITY_UI_SUPPORT; - } - //... -} -``` - -上述代码看到,判断要不要真正执行 kill 是根据一定的时间间判断的,条件是 `jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs`。 也就是在 memorystatus_jld_eval_period_msecs 后才发生条件里面的 kill。 - -```C -/* Jetsam Loop Detection */ -if (max_mem <= (512 * 1024 * 1024)) { - /* 512 MB devices */ -memorystatus_jld_eval_period_msecs = 8000; /* 8000 msecs == 8 second window */ -} else { - /* 1GB and larger devices */ -memorystatus_jld_eval_period_msecs = 6000; /* 6000 msecs == 6 second window */ -} -``` - -其中 memorystatus_jld_eval_period_msecs 取值最小 6 秒。所以我们可以在 6 秒内做些处理。 - -#### 3.2 开发者们整理所得 - -[stackoverflow](https://stackoverflow.com/questions/5887248/ios-app-maximum-memory-budget/15200855#15200855) 上有一份数据,整理了各种设备的 OOM 临界值 - -| device | crash amount:MB | total amount:MB | percentage of total | -|:----------------------------------:|:---------------:|:---------------:|:-------------------:| -| iPad1 | 127 | 256 | 49% | -| iPad2 | 275 | 512 | 53% | -| iPad3 | 645 | 1024 | 62% | -| iPad4(iOS 8.1) | 585 | 1024 | 57% | -| Pad Mini 1st Generation | 297 | 512 | 58% | -| iPad Mini retina(iOS 7.1) | 696 | 1024 | 68% | -| iPad Air | 697 | 1024 | 68% | -| iPad Air 2(iOS 10.2.1) | 1383 | 2048 | 68% | -| iPad Pro 9.7"(iOS 10.0.2 (14A456)) | 1395 | 1971 | 71% | -| iPad Pro 10.5”(iOS 11 beta4) | 3057 | 4000 | 76% | -| iPad Pro 12.9” (2015)(iOS 11.2.1) | 3058 | 3999 | 76% | -| iPad 10.2(iOS 13.2.3) | 1844 | 2998 | 62% | -| iPod touch 4th gen(iOS 6.1.1) | 130 | 256 | 51% | -| iPod touch 5th gen | 286 | 512 | 56% | -| iPhone4 | 325 | 512 | 63% | -| iPhone4s | 286 | 512 | 56% | -| iPhone5 | 645 | 1024 | 62% | -| iPhone5s | 646 | 1024 | 63% | -| iPhone6(iOS 8.x) | 645 | 1024 | 62% | -| iPhone6 Plus(iOS 8.x) | 645 | 1024 | 62% | -| iPhone6s(iOS 9.2) | 1396 | 2048 | 68% | -| iPhone6s Plus(iOS 10.2.1) | 1396 | 2048 | 68% | -| iPhoneSE(iOS 9.3) | 1395 | 2048 | 68% | -| iPhone7(iOS 10.2) | 1395 | 2048 | 68% | -| iPhone7 Plus(iOS 10.2.1) | 2040 | 3072 | 66% | -| iPhone8(iOS 12.1) | 1364 | 1990 | 70% | -| iPhoneX(iOS 11.2.1) | 1392 | 2785 | 50% | -| iPhoneXS(iOS 12.1) | 2040 | 3754 | 54% | -| iPhoneXS Max(iOS 12.1) | 2039 | 3735 | 55% | -| iPhoneXR(iOS 12.1) | 1792 | 2813 | 63% | -| iPhone11(iOS 13.1.3) | 2068 | 3844 | 54% | -| iPhone11 Pro Max(iOS 13.2.3) | 2067 | 3740 | 55% | - -#### 3.3 触发当前 App 的 high water mark - -我们可以写定时器,不断的申请内存,之后再通过 `phys_footprint` 打印当前占用内存,按道理来说不断申请内存即可触发 Jetsam 机制,强杀 App,那么**最后一次打印的内存占用也就是当前设备的内存上限值**。 - -```objective-c -timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES]; - -- (void)allocateMemory { - UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIImage *image = [UIImage imageNamed:@"AppIcon"]; - imageView.image = image; - [array addObject:imageView]; - - memoryLimitSizeMB = [self usedSizeOfMemory]; - if (memoryWarningSizeMB && memoryLimitSizeMB) { - NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB); - } -} - -- (int)usedSizeOfMemory { - task_vm_info_data_t taskInfo; - mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; - kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); - - if (kernReturn != KERN_SUCCESS) { - return 0; - } - return (int)(taskInfo.phys_footprint/1024.0/1024.0); -} -``` - -#### 3.4 适用于 iOS13 系统的获取方式 - -iOS13 开始 中 `size_t os_proc_available_memory(void); ` 可以查看当前可用内存。 - -> Return Value -> -> The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns `0`. -> -> Discussion -> -> Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device. -> -> Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently. -> -> Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs. -> -> If you need more detailed information about the available memory resources, you can call [`task_info`](apple-reference-documentation://hcPGvbcfam). However, be aware that `task_info` is an expensive call, whereas this function is much more efficient. - -```objective-c -if (@available(iOS 13.0, *)) { - return os_proc_available_memory() / 1024.0 / 1024.0; -} -``` - -App 内存信息的 API 可以在 Mach 层找到,`mach_task_basic_info` 结构体存储了 Mach task 的内存使用信息,其中 phys_footprint 就是应用使用的物理内存大小,virtual_size 是虚拟内存大小。 - -```Objective-c -#define MACH_TASK_BASIC_INFO 20 /* always 64-bit basic info */ -struct mach_task_basic_info { - mach_vm_size_t virtual_size; /* virtual memory size (bytes) */ - mach_vm_size_t resident_size; /* resident memory size (bytes) */ - mach_vm_size_t resident_size_max; /* maximum resident memory size (bytes) */ - time_value_t user_time; /* total user run time for - terminated threads */ - time_value_t system_time; /* total system run time for - terminated threads */ - policy_t policy; /* default policy for new threads */ - integer_t suspend_count; /* suspend count for task */ -}; -``` - -所以获取代码为 - -```Objective-c -task_vm_info_data_t vmInfo; -mach_msg_type_number_t count = TASK_VM_INFO_COUNT; -kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count); - -if (kr != KERN_SUCCESS) { - return ; -} -CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0); -``` - -可能有人好奇不应该是 `resident_size` 这个字段获取内存的使用情况吗?一开始测试后发现 resident_size 和 Xcode 测量结果差距较大。而使用 phys_footprint 则接近于 Xcode 给出的结果。且可以从 [WebKit 源码](https://github.com/WebKit/webkit/blob/52bc6f0a96a062cb0eb76e9a81497183dc87c268/Source/WTF/wtf/cocoa/MemoryFootprintCocoa.cpp)中得到印证。 - -所以在 iOS13 上,我们可以通过 `os_proc_available_memory` 获取到当前可以用内存,通过 `phys_footprint` 获取到当前 App 占用内存,2 者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。 - -```objective-c -- (CGFloat)limitSizeOfMemory { - if (@available(iOS 13.0, *)) { - task_vm_info_data_t taskInfo; - mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT; - kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount); - - if (kernReturn != KERN_SUCCESS) { - return 0; - } - return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0); - } - return 0; -} -``` - -当前可以使用内存:1435.936752MB;当前 App 已占用内存:14.5MB,临界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中获取到的内存临界值一样「iPhone 6s plus/13.3.1 手机 OOM 临界值为:(16384\*92806)/(1024\*1024)=1450.09375M」。 - -#### 3.5 通过 XNU 获取内存限制值 - -在 XNU 中,有专门用于获取内存上限值的函数和宏,可以通过 `memorystatus_priority_entry` 这个结构体得到所有进程的优先级和内存限制值。 - -```objective-c -typedef struct memorystatus_priority_entry { - pid_t pid; - int32_t priority; - uint64_t user_data; - int32_t limit; - uint32_t state; -} memorystatus_priority_entry_t; -``` - -其中,priority 代表进程优先级,limit 代表进程的内存限制值。但是这种方式需要 root 权限,由于没有越狱设备,我没有尝试过。 - -相关代码可查阅 `kern_memorystatus.h` 文件。需要用到函数 `int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);` - -```c -/* Commands */ -#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1 -#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2 -#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3 -#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4 -#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */ -#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */ -#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */ -#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */ -#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */ -#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */ -#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE 11 /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */ -#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12 /* Disable the 'lenient' mode for aggressive jetsam. */ -#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS 13 /* Compute how much a process's phys_footprint exceeds inactive memory limit */ -#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */ -#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 15 /* Reset the inactive jetsam band for a process to the default band (0)*/ -#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED 16 /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */ -#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED 17 /* Return the 'managed' status of a process */ -#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., -``` - -伪代码 - -```objective-c -struct memorystatus_priority_entry memStatus[NUM_ENTRIES]; -size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES; -int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count); -if (rc < 0) { - NSLog(@"memorystatus_control"); - return ; -} - -int entry = 0; -for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){ - printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n", - memstatus[entry].pid, - memstatus[entry].priority, - memstatus[entry].user_data, - memstatus[entry].limit, - state_to_text(memstatus[entry].state)); - entry++; -} -``` - -for 循环打印出每个进程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。从 log 中找出优先级为 10 的进程,即我们前台运行的 App。为什么是 10? 因为 `#define JETSAM_PRIORITY_FOREGROUND 10` 我们的目的就是获取前台 App 的内存上限值。 - -### 4. 如何判定发生了 OOM - -首先我们无法通过正常手段监控 OOM,因为 XNU 源码显示发生 OOM 发送的信号为 SIGKILL,该信号无法监控。 - -```c -/* - * The jetsam no frills kill call - * Return: 0 on success - * error code on failure (EINVAL...) - */ -static int jetsam_do_kill(proc_t p, int jetsam_flags, os_reason_t jetsam_reason) { - int error = 0; - error = exit_with_reason(p, W_EXITCODE(0, SIGKILL), (int *)NULL, FALSE, FALSE, jetsam_flags, jetsam_reason); - return error; -} -``` - -FacekBook 提出排除法监控 OOM。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Facebook-OOM.jpeg) - -- App 更新了版本 - -- App 发生了崩溃 - -- 用户手动退出 - -- 操作系统更新了版本 - -- App 切换到后台之后进程终止 - -其实不够全,存在误判,增加下面几种 case - -- 覆盖安装 - -- WatchDog 崩溃 - -- 后台启动 - -- XCTest/UITest 等自动化测试框架驱动 - -- 应用 exit 主动退出 - -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`。也可以注册 `UIApplicationDidReceiveMemoryWarningNotification` 通知,此时获取一下内存情况一般就是 high Water 线。 - -结论: - -**收到低内存警告不一定会 Crash,因为有 6 秒钟的系统判断时间,6 秒内内存下降了则不会 crash。发生 OOM 也不一定会收到低内存警告。** - -### 5. 内存信息收集 - -要想精确的定位问题,就需要 dump 所有对象及其内存信息。当内存接近系统内存上限的时候,收集并记录所需信息,结合一定的数据上报机制,上传到服务器,分析并修复。 - -还需要知道每个对象具体是在哪个函数里创建出来的,以便还原“案发现场”。 - -源代码(libmalloc/malloc),内存分配函数 malloc 和 calloc 等默认使用 nano_zone,nano_zone 是小于 256B 以下的内存分配,大于 256B 则使用 scalable_zone 来分配。 - -主要针对大内存的分配监控。malloc 函数用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。 - -使用 scalable_zone 分配内存的函数都会调用 malloc_logger 函数,因为系统为了有个地方专门统计并管理内存分配情况。这样的设计也满足「收口原则」。 - -```c++ -void * -malloc(size_t size) -{ - void *retval; - retval = malloc_zone_malloc(default_zone, size); - if (retval == NULL) { - errno = ENOMEM; - } - return retval; -} - -void * -calloc(size_t num_items, size_t size) -{ - void *retval; - retval = malloc_zone_calloc(default_zone, num_items, size); - if (retval == NULL) { - errno = ENOMEM; - } - return retval; -} -``` - -首先来看看这个 `default_zone` 是什么东西, 代码如下 - -```c++ -typedef struct { - malloc_zone_t malloc_zone; - uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)]; -} virtual_default_zone_t; - -static virtual_default_zone_t virtual_default_zone -__attribute__((section("__DATA,__v_zone"))) -__attribute__((aligned(PAGE_MAX_SIZE))) = { - NULL, - NULL, - default_zone_size, - default_zone_malloc, - default_zone_calloc, - default_zone_valloc, - default_zone_free, - default_zone_realloc, - default_zone_destroy, - DEFAULT_MALLOC_ZONE_STRING, - default_zone_batch_malloc, - default_zone_batch_free, - &default_zone_introspect, - 10, - default_zone_memalign, - default_zone_free_definite_size, - default_zone_pressure_relief, - default_zone_malloc_claimed_address, -}; - -static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone; - -static void * -default_zone_malloc(malloc_zone_t *zone, size_t size) -{ - zone = runtime_default_zone(); - - return zone->malloc(zone, size); -} - - -MALLOC_ALWAYS_INLINE -static inline malloc_zone_t * -runtime_default_zone() { - return (lite_zone) ? lite_zone : inline_malloc_default_zone(); -} -``` - -可以看到 `default_zone` 通过这种方式来初始化 - -```c++ -static inline malloc_zone_t * -inline_malloc_default_zone(void) -{ - _malloc_initialize_once(); - // malloc_report(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone); - return malloc_zones[0]; -} -``` - -**随后的调用如下** -`_malloc_initialize` -> `create_scalable_zone` -> `create_scalable_szone` 最终我们创建了 szone_t 类型的对象,通过类型转换,得到了我们的 default_zone。 - -```c++ -malloc_zone_t * -create_scalable_zone(size_t initial_size, unsigned debug_flags) { - return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags); -} -``` - -```c++ -void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) -{ - MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0); - void *ptr; - if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) { - internal_check(); - } - if (size > MALLOC_ABSOLUTE_MAX_SIZE) { - return NULL; - } - ptr = zone->malloc(zone, size); - // 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录 - if (malloc_logger) { - malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0); - } - MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0); - return ptr; -} -``` - -其分配实现是 `zone->malloc` 根据之前的分析,就是 szone_t 结构体对象中对应的 malloc 实现。 - -在创建 szone 之后,做了一系列如下的初始化操作。 - -```c++ -// Initialize the security token. -szone->cookie = (uintptr_t)malloc_entropy[0]; - -szone->basic_zone.version = 12; -szone->basic_zone.size = (void *)szone_size; -szone->basic_zone.malloc = (void *)szone_malloc; -szone->basic_zone.calloc = (void *)szone_calloc; -szone->basic_zone.valloc = (void *)szone_valloc; -szone->basic_zone.free = (void *)szone_free; -szone->basic_zone.realloc = (void *)szone_realloc; -szone->basic_zone.destroy = (void *)szone_destroy; -szone->basic_zone.batch_malloc = (void *)szone_batch_malloc; -szone->basic_zone.batch_free = (void *)szone_batch_free; -szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect; -szone->basic_zone.memalign = (void *)szone_memalign; -szone->basic_zone.free_definite_size = (void *)szone_free_definite_size; -szone->basic_zone.pressure_relief = (void *)szone_pressure_relief; -szone->basic_zone.claimed_address = (void *)szone_claimed_address; -``` - -其他使用 scalable_zone 分配内存的函数的方法也类似,所以大内存的分配,不管外部函数如何封装,最终都会调用到 malloc_logger 函数。所以我们可以用 fishhook 去 hook 这个函数,然后记录内存分配情况,结合一定的数据上报机制,上传到服务器,分析并修复。 - -``` -// For logging VM allocation and deallocation, arg1 here -// is the mach_port_name_t of the target task in which the -// alloc or dealloc is occurring. For example, for mmap() -// that would be mach_task_self(), but for a cross-task-capable -// call such as mach_vm_map(), it is the target task. - -typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip); - -extern malloc_logger_t *__syscall_logger; -``` - -当 malloc_logger 和 \_\_syscall_logger 函数指针不为空时,malloc/free、vm_allocate/vm_deallocate 等内存分配/释放通过这两个指针通知上层,这也是内存调试工具 malloc stack 的实现原理。有了这两个函数指针,我们很容易记录当前存活对象的内存分配信息(包括分配大小和分配堆栈)。分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 DSYM 解析符号。所以还要记录每个 image 加载时的偏移 slide,这样 **符号表地址 = 堆栈地址 - slide。** - -小 tips: - -ASLR(Address space layout randomization):常见称呼为位址空间随机载入、位址空间配置随机化、位址空间布局随机化,是一种防止内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的定址空间来放置攻击者能可靠地跳转到内存的特定位置来操作函数。现代作业系统一般都具备该机制。 - -函数地址 add: 函数真实的实现地址; - -函数虚拟地址:`vm_add`; - -ASLR: `slide` 函数虚拟地址加载到进程内存的随机偏移量,每个 mach-o 的 slide 各不相同。`vm_add + slide = add`。也就是:`*(base +offset)= imp`。 - -由于腾讯也开源了自己的 OOM 定位方案- [OOMDetector](https://github.com/Tencent/OOMDetector) ,有了现成的轮子,那么用好就可以了,所以对于内存的监控思路就是找到系统给 App 的内存上限,然后当接近内存上限值的时候,dump 内存情况,组装基础数据信息成一个合格的上报数据,经过一定的数据上报策略到服务端,服务端消费数据,分析产生报表,客户端工程师根据报表分析问题。不同工程的数据以邮件、短信、企业微信等形式通知到该项目的 owner、开发者。(情况严重的会直接电话给开发者,并给主管跟进每一步的处理结果)。 -问题分析处理后要么发布新版本,要么 hot fix。 - -### 6. 开发阶段针对内存我们能做些什么 - -1. 图片缩放 - - WWDC 2018 Session 416 - iOS Memory Deep Dive,处理图片缩放的时候直接使用 UIImage 会在解码时读取文件而占用一部分内存,还会生成中间位图 bitmap 消耗大量内存。而 **ImageIO** 不存在上述 2 种弊端,只会占用最终图片大小的内存 - - 做了 2 组对比实验:给 App 显示一张图片 - - ```objective-c - // 方法1: 19.6M - UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"] newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)]; - self.imageView.image = imageResult; - - // 方法2: 14M - NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]); - UIImage *imageResult = [self scaledImageWithData:data withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp]; - self.imageView.image = imageResult; - - - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize - { - UIGraphicsBeginImageContextWithOptions(newSize, NO, 0); - [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; - UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - return newImage; - } - - - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation - { - CGFloat maxPixelSize = MAX(size.width, size.height); - CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil); - NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue, - (__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]}; - CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options); - UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation]; - CGImageRelease(imageRef); - CFRelease(sourceRef); - return resultImage; - } - ``` - - 可以看出使用 ImageIO 比使用 UIImage 直接缩放占用内存更低。 - -2. 合理使用 autoreleasepool - -我们知道 autoreleasepool 对象是在 RunLoop 结束时才释放。在 ARC 下,我们如果在不断申请内存,比如各种循环,那么我们就需要手动添加 autoreleasepool,避免短时间内内存猛涨发生 OOM。 - -对比实验 - -```objective-c -// 实验1 -NSMutableArray *array = [NSMutableArray array]; -for (NSInteger index = 0; index < 10000000; index++) { - NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; - NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; - [array addObject:resultString]; -} - -// 实验2 -NSMutableArray *array = [NSMutableArray array]; -for (NSInteger index = 0; index < 10000000; index++) { - @autoreleasepool { - NSString *indexStrng = [NSString stringWithFormat:@"%zd", index]; - NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng]; - [array addObject:resultString]; - } -} -``` - -实验 1 消耗内存 739.6M,实验 2 消耗内存 587M。 - -3. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必须成双出现,不然会造成 context 泄漏。另外 XCode 的 Analyze 也能扫出这类问题。 - -4. 不管是打开网页,还是执行 js,都应该使用 WKWebView。UIWebView 会占用大量内存,从而导致 App 发生 OOM 的几率增加,而 WKWebView 是一个多进程组件,Network Loading 以及 UI Rendering 在其它进程中执行,比 UIWebView 占用更低的内存开销。 - -5. 在做 SDK 或者 App,如果场景是缓存相关,尽量使用 NSCache 而不是 NSMutableDictionary。它是系统提供的专门处理缓存的类,NSCache 分配的内存是 `Purgeable Memory`,可以由系统自动释放。NSCache 与 NSPureableData 的结合使用可以让系统根据情况回收内存,也可以在内存清理时移除对象。 - - 其他的开发习惯就不一一描述了,良好的开发习惯和代码意识是需要平时注意修炼的。 - -### 7. Memory Graph - -在使用了一波业界优秀的的内存监控工具后发现了一些问题,比如 `MLeaksFinder`、`OOMDetector`、`FBRetainCycleDetector`等都有一些问题。比如 `MLeaksFinder` 因为单纯通过 VC 的 push、pop 等检测内存泄露的情况,会存在误报的情况。`FBRetainCycleDetector` 则因为对象深度优先遍历,会有一些性能问题,影响 App 性能。`OOMDetector` 因为没有合适的触发时机。 - -思路有 2 种: - -- `MLeaksFinder` + `FBRetainCycleDetector` 结合提高准确性 -- 借鉴头条的实现方案:基于内存快照技术的线上方案,我们称之为——线上 Memory Graph。(引用如下) - -> - 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。 -> - 基于分配堆栈信息聚类的方案需要常驻运行,对内存、CPU 等资源存在较大消耗,无法针对有内存问题的用户进行监控,只能广撒网,用户体验影响较大。同时,通过某些比较通用的堆栈分配的内存无法定位出实际的内存使用场景,对于循环引用等常见泄漏也无法分析。 - -核心原理是: 扫描进程中所有的 Dirty 内存,通过内存节点中保存的其他内存节点的地址值,建立起内存节点之间的引用关系的有向图。 - -全部的讲解可以看[这里](<(https://mp.weixin.qq.com/s/4-4M9E8NziAgshlwB7Sc6g)>)。对于 Memory Graph 的实现细节感兴趣的可以看这篇[文章](https://juejin.cn/post/6895583288451465230) - - - -## 五、野指针/内存泄漏 - -### 1. 概念定义 - -#### 内存泄漏会导致 OOM,那什么是内存泄漏? - -定义:程序中已经动态分配的堆内存,由于某些原因,导致程序无法释放或者未释放内存,造成系统内存的浪费,导致程序的运行速度减慢甚至是系统奔溃等严重后果。 - -一般来说导致内存泄漏的原因:对象没有释放(Core Foundation 对象需要手动调用 release 方法)、循环引用 - -#### 什么是野指针? - -C 语言中:声明一个指针变量,但是没有初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,指向一个随机的内存空间。所以指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存 - -OC 语言:指针指向的内存对象已经被释放或回收了,但是指针没有置为 nil,还是指向已经回收的内存空间。 - -#### 什么是空指针? - -空指针就是没有存储任何内存地址(也就是没有指向任何对象的指针),即 nil、Nil、NULL、NSNULL、0 - -- nil:OC 中的对象的空指针 - -- Nil:OC 中类的空指针 - -- NULL:C 类型的空指针 - -- NSNull:数值类的空对象 - -#### 内存回收的本质 - -申请一块内存空间,其本质是向系统申请一块别人不再使用的内存空间,即已经回收的内存空间 - -释放一块内存空间,其本质是这个内存空间不再使用,可以由系统分配给别的对象使用。此时内存空间虽然回收了,但是原本的数据依赖的是存在的,可以理解为垃圾数据。 - -- OC 对象释放后,内存回收,表示这一块内存可以分配给别的对象了 -- 这块内存在分配给别的对象之前,仍然保留着已经释放对象的数据 - -#### 什么是僵尸对象? - -僵尸对象就是指一个 OC 对象释放后所占用的内存还没被覆写(重新分配给其他对象)前被称为僵尸对象。此时僵尸对象内存很不稳定,内存随时可能被系统分配给其他对象所使用。所以此时僵尸对象不应该访问和使用(调用对象的方法等) - -#### 为什么 OC 野指针 Crash 很多? - -App 上线前经过自测、UI 自动化、精准测试、高铁回归包测试等,但是野指针具有随机性,所以很多野指针偷偷跑到线上了。 - -野指针随机性表现为: - -- 出错分支比较难进,执行不到出错的 case,所以能做的就是提高测试覆盖率 - -- 即使跑进有问题的分支,但是野指针指向的地址并不一定会导致 crash。因为野指针本质是一个指向已删除的对象或受限内存区域的指针。这里 OC 野指针指的是 OC 对象释放后但指针未置空导致的野指针。dealloc 执行后只是告诉系统这篇内存我不用,但是系统并没有让这块内存不能访问。访问的话,暂时是安全的,过了一会儿,由于内存紧张,可能被系统分配到其他对象去了。被填充了一个新的对象的信息。比如成员变量、方法等。再去访问就会 crash - -#### 野指针可能存在的问题 - - - -### 2. Zombie Object - -`Zombie Object` 是 Xcode 提供的一种用来检测内存问题的工具(`EXC_BAD_ACCESS`),它可以捕获任何尝试访问坏内存的调用。 - -一个对象解除了它的引用,已经被释放掉,但仍可以接收消息,就叫 zombie object 。 - -如果给僵尸对象发送消息,那么系统会在运行期间奔溃并在 Xcode 输出错误日志,通过日志定位到野指针对象调用的方法和类名。 - -- 当野指针指向的僵尸对象所占用的内存还未被复写(未分配),此时是可以访问的 - -- 当野指针指向的僵尸对象所占用的内存被分配后,此时访问会奔溃,比如 objc_msgSend 失败、SIGBUS、SIGFPE、SIGILL 等异常。 - -开启僵尸对象时,不回收真正内存,而是将其 isa 变为僵尸对象,僵尸对象在内存中无法复用,所以偶现的 crash 变为必现 crash - - - -### 3. 探索 Xcode Zombie Object 实现原理 - -Xcode 默认设置不检查指针指向的对象是否是僵尸对象。为什么这么设计?因为开启会影响开发效率,所以线上就会报错。 - -线下阶段做内存优化时,有些僵尸对象访问是不会报错的,可以在 Xcode 中开启。路径为:Edit Scheme - Diagnostics - Memory Management - Zombie Objects - -Demo:MRC + Xcode 开启 Zombie Object - -```objectivec -- (void)viewDidLoad { - [super viewDidLoad]; - Person *person = [[Person alloc] init]; - printClassInfo(person); - [person release]; - printClassInfo(person); -    [person description]; -} -// console -self:Person - superClass:NSObject -self:_NSZombie_Person - superClass:nil -*** -[Person description]: message sent to deallocated instance 0x6000024f1030 -``` - -(前提是开启了 Zombie Object)可以看到系统在回收对象时,不是真正的回收,而是先将其转为僵尸对象,僵尸对象所在内存无法被重用,所以让不稳定复现的内存奔溃变为稳定崩溃(更好的复现问题)。 - - - -利用 Instruments 下的 Zombies 工具检测,看看野指针的情况,系统是怎么捕获的。 - - - -切换到 `Call Trees` 模式下可以看到系调用了 `_dealloc_zombie` 方法。底层到底做了什么?加个符号断点查看下 - - - -通过符号名称大概可以猜系统会在调用 dealloc 方法时,类似 KVO,派生出一个新的僵尸类,将当前类的 isa 指针指向僵尸类。 - -查看 Runtime 源码 - -```objectivec -/*********************************************************************** -* object_dispose -* fixme -* Locking: none -**********************************************************************/ -id object_dispose(id obj) { - if (!obj) return nil; - objc_destructInstance(obj); - free(obj); - return nil; -} -/*********************************************************************** -* objc_destructInstance -* Destroys an instance without freeing memory. -* Calls C++ destructors. -* Calls ARC ivar cleanup. -* Removes associative references. -* Returns `obj`. Does nothing if `obj` is nil. -**********************************************************************/ -void *objc_destructInstance(id obj) { - if (obj) { - // Read all of the flags at once for performance. - bool cxx = obj->hasCxxDtor(); - bool assoc = obj->hasAssociatedObjects(); - - // This order is important. - if (cxx) object_cxxDestruct(obj); - if (assoc) _object_remove_assocations(obj, /*deallocating*/true); - obj->clearDeallocating(); - } - return obj; -} -``` - -dealloc 方法最终调用到 `object_dispose`,但是如果开启 Zombie Object 检测则不会执行 free。其中 `objc_destructInstance` 方法会清理对象的关联对象、c++ 的析构函数、对象的 ivar 清理。 - -另一方面,从 GUN 源码中窥探下 (NSObject.m 文件) - -```objectivec -- (void) dealloc{ - NSDeallocateObject(self); -} - -inline void NSDeallocateObject(id anObject){ - Class aClass = object_getClass(anObject); - if ((anObject != nil) && !class_isMetaClass(aClass)) { -#ifndef OBJC_CAP_ARC - obj o = &((obj)anObject)[-1]; - NSZone *z = NSZoneFromPointer(o); -#endif - /* Call the default finalizer to handle C++ destructors. - */ - // c++ 对象调用默认的析构函数 - (*finalize_imp)(anObject, finalize_sel); - - AREM(aClass, (id)anObject); - // Xcode 环境变量。调试僵尸对象,对象 -dealloc 时并没有真正释放,而是将 isa 指向NSZombie 类,从而在向它发消息时能打印出相关信息 - if (NSZombieEnabled == YES){ -#ifdef OBJC_CAP_ARC - if (0 != zombieMap){ - pthread_mutex_lock(&allocationLock); - if (0 != zombieMap) { - NSMapInsert(zombieMap, (void*)anObject, (void*)aClass); - } - pthread_mutex_unlock(&allocationLock); - } - // Xcode 环境变量。上面变量开启时对象内存不会释放,同时开启这个会释放僵尸对象 - if (NSDeallocateZombies == YES) { - object_dispose(anObject); - } else { - // 设置 isa 指针 - object_setClass(anObject, zombieClass); - } -#else - // 调用设置僵尸对象方法 - GSMakeZombie(anObject, aClass); - if (NSDeallocateZombies == YES) { - NSZoneFree(z, o); - } -#endif - } - else { -#ifdef OBJC_CAP_ARC - // ARC: runtime 释放对象方法 - object_dispose(anObject); -#else - // MRC:设置对象 isa - object_setClass((id)anObject, (Class)(void*)0xdeadface); - NSZoneFree(z, o); -#endif - } - } - return; -} - - -@class NSZombie; -static Class zombieClass = Nil; -static NSMapTable *zombieMap = 0; - -#ifndef OBJC_CAP_ARC -static void GSMakeZombie(NSObject *o, Class c) { - // 将 dealloc 对象的 isa 修改为 zombieClass。zombieClass 在 NSObject 的 initialize 方法中初始化 - object_setClass(o, zombieClass); - if (0 != zombieMap) { - pthread_mutex_lock(&allocationLock); - if (0 != zombieMap) { - NSMapInsert(zombieMap, (void*)o, (void*)c); - } - pthread_mutex_unlock(&allocationLock); - } -} -#endif -// 后续针对 Zombie Objects 对象的任何消息都会经过 runtime 调用到 GSLogZombie,内部会打印日志 -static void GSLogZombie(id o, SEL sel){ - Class c = 0; - if (0 != zombieMap) { - pthread_mutex_lock(&allocationLock); - if (0 != zombieMap) { - c = NSMapGet(zombieMap, (void*)o); - } - pthread_mutex_unlock(&allocationLock); - } - if (c == 0) { - NSLog(@"*** -[??? %@]: message sent to deallocated instance %p", - NSStringFromSelector(sel), o); - } else { - // 按照固定格式打印日志 - NSLog(@"*** -[%@ %@]: message sent to deallocated instance %p", - c, NSStringFromSelector(sel), o); - } - // 最后根据环境变量判断,调用系统底层 abort 来奔溃 - if (GSPrivateEnvironmentFlag("CRASH_ON_ZOMBIE", NO) == YES) { - abort(); - } -} - -@implementation NSZombie -- (Class) class{ - return object_getClass(self); -} -- (Class) originalClass{ - Class c = Nil; - if (0 != zombieMap) { - pthread_mutex_lock(&allocationLock); - if (0 != zombieMap) { - c = NSMapGet(zombieMap, (void*)self); - } - pthread_mutex_unlock(&allocationLock); - } - return c; -} -// 针对僵尸对象的任何方法调用都会走 runtime forwarding 进行调用 GSLogZombie -- (void) forwardInvocation: (NSInvocation*)anInvocation { - NSUInteger size = [[anInvocation methodSignature] methodReturnLength]; - unsigned char v[size]; - memset(v, '\0', size); - GSLogZombie(self, [anInvocation selector]); - [anInvocation setReturnValue: (void*)v]; - return; -} -- (NSMethodSignature*) methodSignatureForSelector: (SEL)aSelector { - Class c; - if (0 == aSelector) { - return nil; - } - pthread_mutex_lock(&allocationLock); - c = zombieMap ? NSMapGet(zombieMap, (void*)self) : Nil; - pthread_mutex_unlock(&allocationLock); - return [c instanceMethodSignatureForSelector: aSelector]; -} -@end - -// OSObject -+ (void) initialize { - if (self == [NSObject class]) { - // ... - /* Determine zombie management flags and set up a map to store - * information about zombie objects. - */ - NSZombieEnabled = GSPrivateEnvironmentFlag("NSZombieEnabled", NO); - NSDeallocateZombies = GSPrivateEnvironmentFlag("NSDeallocateZombies", NO); - zombieMap = NSCreateMapTable(NSNonOwnedPointerMapKeyCallBacks, - NSNonOwnedPointerMapValueCallBacks, 0); - - /* We need to cache the zombie class. - * We can't call +class because NSZombie doesn't have that method. - * We can't use NSClassFromString() because that would use an NSString - * object, and that class hasn't been initialized yet ... - */ - zombieClass = objc_lookUpClass("NSZombie"); - } - return; -} -``` - -可以从 GUN 中看到调用对象 dealloc 方法时,内部实现通过调用 `GSMakeZombie` 方法,将类的 isa 指向为 NSZombie 类,也就是 zombieClass,其中 zombieClass 在 NSObject `initialize` 方法中初始化。后续针对僵尸对象的所有方法调用,都会走 Runtime forwarding 这个机制,内部会调用 `GSLogZombie` 方法,方法会按照 `NSLog(@"*** -[%@ %@]: message sent to deallocated instance %p", c, NSStringFromSelector(sel), o);` 格式打印日志,最后根据环境变量判断,调用系统底层 `abort` 来奔溃 - - - -开启僵尸对象检测后,系统会修改对象 isa 指针,指向新生成的僵尸类,僵尸类可以响应所有的消息,但表现打印格式为 `*** -[类 sel]: message sent to deallocated instance 地址` 的日志,然后结束进程。 - -神奇,为什么打印出 `_NSZombie_Person` 。 - -```objectivec -// Replaced by NSZombies -- (void)dealloc { - _objc_rootDealloc(self); -} -``` - -objc 源码中 dealloc 方法注释说了 dealloc 的实现会被僵尸对象所替换。 - - - -### 4. Malloc Scribble - -申请内存 alloc 时在内存上填 `0xAA`,释放内存 dealloc 时在内存上填 `0x55`。此种方案也会让内存泄漏偶现的 crash 变为必现。 - - - -### 5. 野指针监控工具 - -##### 类 Zombie Object 方案 - -可以模拟系统工作原理,做到类似的野指针监控。 - -其中的僵尸对象检测做了这么几件事: - -- 获取有问题内存对象的类名 -- 字符串拼接,类似 `_NSZombie_${ClassName}` 的形式 -- 按照上面得到的类名,创建一个继承自 NSProxy 的子类。因为需要响应各种原始对象的任何方法,所以需要是 NSProxy 的子类 -- hook NSObject 和 NSProxy 2个基类的 `dealloc` 方法,新的 dealloc 方法实现里修改当前类的 isa 为新创建的类 -- 为了避免内存空间释放后被重写,造成野指针问题。通过字典存储被方式的对象,同时设置在 30s 后调用 dealloc 方法将字典中存储的对象释放,避免 OOM -- 新创建的类除了处理消息转发外,还需要实现 NSObject 的基础方法,比如 copy、zone、description 方法 -- hook 之后,给僵尸对象发消息,最后都会调用 NSProxy 的 `forwardInvocation` 方法,其内部打印方法名称、对象地址、调用堆栈信息。最后调用 abort 方法,crash 掉。 -- 奔溃的时候应该将调用堆栈等案发信息保存下来,走 APM 问题跟进流程 - -```objective-c -// ZombiePoxy -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ZombieProxy : NSProxy -@property (nonatomic, assign) Class originClass; -@end - -NS_ASSUME_NONNULL_END - -#import "ZombieProxy.h" -#define MockZombieObjectsDetector [self mockSystemBehaviorOfZombieObjects: _cmd] - -@implementation ZombieProxy - -- (BOOL)respondsToSelector: (SEL)aSelector -{ - return [self.originClass instancesRespondToSelector:aSelector]; -} - -- (NSMethodSignature *)methodSignatureForSelector: (SEL)sel -{ - return [self.originClass instanceMethodSignatureForSelector:sel]; -} - -- (void)forwardInvocation: (NSInvocation *)invocation -{ - [self mockSystemBehaviorOfZombieObjects:invocation.selector]; -} - - -- (Class)class -{ - MockZombieObjectsDetector; - return nil; -} - -- (BOOL)isEqual:(id)object -{ - MockZombieObjectsDetector; - return NO; -} - -- (NSUInteger)hash -{ - MockZombieObjectsDetector; - return 0; -} - -- (id)self -{ - MockZombieObjectsDetector; - return nil; -} - -- (BOOL)isKindOfClass:(Class)aClass -{ - MockZombieObjectsDetector; - return NO; -} - -- (BOOL)isMemberOfClass:(Class)aClass -{ - MockZombieObjectsDetector; - return NO; -} - -- (BOOL)conformsToProtocol:(Protocol *)aProtocol -{ - MockZombieObjectsDetector; - return NO; -} - -- (BOOL)isProxy -{ - MockZombieObjectsDetector; - return NO; -} - -- (void)dealloc -{ - MockZombieObjectsDetector; - [super dealloc]; -} - -- (NSZone *)zone -{ - MockZombieObjectsDetector; - return nil; -} - -- (NSString *)description -{ - MockZombieObjectsDetector; - return nil; -} - -#pragma mark - Private -- (void)mockSystemBehaviorOfZombieObjects:(SEL)selector -{ - NSString *msg = [NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass), NSStringFromSelector(selector), self]; - NSLog(@"%@", msg); - NSLog(@"堆栈:\n%@",[NSThread callStackSymbols]); - abort(); -} -@end - -// ZombieSniffer.h -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ZombieSniffer : NSObject - -/*! - * @method installSniffer - * 启动zombie检测 - */ -+ (void)installSniffer; - -/*! - * @method uninstallSnifier - * 停止zombie检测 - */ -+ (void)uninstallSnifier; - -/*! - * @method appendIgnoreClass - * 添加白名单类 - */ -+ (void)appendIgnoreClass: (Class)cls; - -@end - -NS_ASSUME_NONNULL_END - - -#import "ZombieSniffer.h" -#import -#import "ZombieProxy.h" - -typedef void (*MIDeallocPointer) (id objc); -//野指针探测器是否开启 -static BOOL _enabled = NO; -//根类 -static NSArray *_rootClasses = nil; -//用于存储被释放的对象 -static NSDictionary *_rootClassDeallocImps = nil; - -//白名单 -static inline NSMutableSet *__mi_sniffer_white_lists() -{ - //创建白名单集合 - static NSMutableSet *mi_sniffer_white_lists; - //单例初始化白名单集合 - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - mi_sniffer_white_lists = [[NSMutableSet alloc] init]; - }); - return mi_sniffer_white_lists; -} - -static inline void __mi_dealloc(__unsafe_unretained id obj) -{ - //获取对象的类 - Class currentCls = [obj class]; - Class rootCls = currentCls; - - //获取非NSObject和NSProxy的类 - while (rootCls != [NSObject class] && rootCls != [NSProxy class]) { - //获取rootCls的父类,并赋值 - rootCls = class_getSuperclass(rootCls); - } - //获取类名 - NSString *clsName = NSStringFromClass(rootCls); - //根据类名获取dealloc的imp指针 - MIDeallocPointer deallocImp = NULL; - [[_rootClassDeallocImps objectForKey:clsName] getValue:&deallocImp]; - - if (deallocImp != NULL) { - deallocImp(obj); - } -} - -static inline IMP __mi_swizzleMethodWithBlock(Method method, void *block) -{ - IMP blockImp = imp_implementationWithBlock((__bridge id _Nonnull)(block)); - return method_setImplementation(method, blockImp); -} - -@implementation ZombieSniffer - - -+ (void)initialize -{ - _rootClasses = [@[[NSObject class], [NSProxy class]] retain]; -} - -#pragma mark - public -+ (void)installSniffer -{ - @synchronized (self) { - if (!_enabled) { - [self _swizzleDealloc]; - _enabled = YES; - } - } -} - -+ (void)uninstallSnifier -{ - @synchronized (self) { - if (_enabled) { - [self _unswizzleDealloc]; - _enabled = NO; - } - } -} - -+ (void)appendIgnoreClass:(Class)cls -{ - @synchronized (self) { - NSMutableSet *whiteList = __mi_sniffer_white_lists(); - NSString *clsName = NSStringFromClass(cls); - [clsName retain]; - [whiteList addObject:clsName]; - } -} - -#pragma mark - private -+ (void)_swizzleDealloc -{ - static void *swizzledDeallocBlock = NULL; - //定义block,作为方法的IMP - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - swizzledDeallocBlock = (__bridge void *)[^void(id obj) { - //获取对象的类 - Class currentClass = [obj class]; - //获取类名 - NSString *clsName = NSStringFromClass(currentClass); - //判断该类是否在白名单类 - if ([__mi_sniffer_white_lists() containsObject: clsName]) { - //如果在白名单内,则直接释放对象 - __mi_dealloc(obj); - } else { - NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))]; - //为obj设置指定的类 - object_setClass(obj, [ZombieProxy class]); - //保留对象原本的类 - ((ZombieProxy *)obj).originClass = currentClass; - - //设置在30s后调用dealloc将存储的对象释放,避免内存空间的增大 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __unsafe_unretained id deallocObj = nil; - //获取需要dealloc的对象 - [objVal getValue: &deallocObj]; - //设置对象的类为原本的类 - object_setClass(deallocObj, currentClass); - //释放 - __mi_dealloc(deallocObj); - }); - } - } copy]; - }); - - //交换了根类NSObject和NSProxy的dealloc方法为originalDeallocImp - NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary]; - //遍历根类 - for (Class rootClass in _rootClasses) { - //获取指定类中dealloc方法 - Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc")); - //hook - 交换dealloc方法的IMP实现 - IMP originalDeallocImp = __mi_swizzleMethodWithBlock(oriMethod, swizzledDeallocBlock); - //设置IMP的具体实现 - [deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)]; - } - //_rootClassDeallocImps字典存储交换后的IMP实现 - _rootClassDeallocImps = [deallocImps copy]; -} - -+ (void)_unswizzleDealloc -{ - //还原dealloc交换的IMP - [_rootClasses enumerateObjectsUsingBlock:^(Class rootClass, NSUInteger idx, BOOL * _Nonnull stop) { - IMP originDeallocImp = NULL; - //获取根类类名 - NSString *clsName = NSStringFromClass(rootClass); - //获取hook后的dealloc实现 - [[_rootClassDeallocImps objectForKey:clsName] getValue:&originDeallocImp]; - - NSParameterAssert(originDeallocImp); - //获取原本的dealloc实现 - Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc")); - //还原dealloc的实现 - method_setImplementation(oriMethod, originDeallocImp); - }]; - //释放 - [_rootClassDeallocImps release]; - _rootClassDeallocImps = nil; -} - -@end - -2022-05-14 12:29:06.879970+0800 DDD[86210:3208547] self:Person - superClass:NSObject -2022-05-14 12:29:06.880239+0800 DDD[86210:3208547] self:ZombieProxy - superClass:NSProxy -2022-05-14 12:29:06.880451+0800 DDD[86210:3208547] (-[Person description]) was sent to a zombie object at address: 0x6000013c03e0 -2022-05-14 12:29:06.885690+0800 DDD[86210:3208547] 堆栈: -( - 0 DDD 0x00000001010968d3 -[ZombieProxy mockSystemBehaviorOfZombieObjects:] + 163 - 1 DDD 0x0000000101096825 -[ZombieProxy description] + 37 - 2 DDD 0x0000000101092fe4 -[ViewController viewDidLoad] + 132 - 3 UIKitCore 0x00000001061a29f0 -[UIViewController _sendViewDidLoadWithAppearanceProxyObjectTaggingEnabled] + 88 - 4 UIKitCore 0x00000001061a73f3 -[UIViewController loadViewIfRequired] + 1193 - 5 UIKitCore 0x00000001060d077a -[UINavigationController _updateScrollViewFromViewController:toViewController:] + 162 - 6 UIKitCore 0x00000001060d0a7d -[UINavigationController _startTransition:fromViewController:toViewController:] + 162 - 7 UIKitCore 0x00000001060d1aa3 -[UINavigationController _startDeferredTransitionIfNeeded:] + 863 - 8 UIKitCore 0 -``` - -注意:ZombieProxy、ZombieSinffer 2个类要在 Build Phases 设置为非 ARC,加 `-fno-objc-arc` - -可以看到可以实现野指针的稳定复现,并打印调用信息和堆栈。真实环境这里一般是案发现场数据的保存,走数据上报策略,APM 问题状态跟进流程。 - -##### 类 Malloc Scribble 方案 - -主要步骤: - -- fishhook hook c 函数 free 方法为 safeFree - -- safeFree 内对将要释放的对象内存填充 `0x55`,使该块内存不能继续访问,从而后续发消息就必现 crash - -- 为了防止 `0x55` 这块内存被系统重用,使“必现 crash” 这个目的达不到,在 safeFree 内部不释放内存(也就是不调用之前的 free 方法,代码表现为 safeFree 内不调用 safeFree) - -- 因为不释放,为了防止系统内存消耗过快,需要在保留的内存大于某个临界值的时候,释放一部分,防止出现 OOM。同时收到系统内存警告,也要释放一部分内存 - -- 奔溃的时候应该将调用堆栈等案发信息保存下来,走 APM 问题跟进流程 - -关键代码实现 - -```objectivec -// ZombieObjectDetector -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ZombieObjectDetector : NSObject - -@end - -NS_ASSUME_NONNULL_END - - -#import "ZombieObjectDetector.h" -#import -#include -#include -#import "queue.h" -#import "fishhook.h" -#import "ZombieProxy.h" - -#define MAX_STEAL_MEM_SIZE 1024*1024*100 // 监控对象的临界值,防止系统因为大内存造成 OOM -#define MAX_STEAL_MEM_NUM 1024*1024*10 // 最多保留这么多个指针,再多就释放一部分 -#define BATCH_FREE_NUM 100// 每次释放的时候释放指针数量 - -static Class mockIsa; -static size_t DetectObjectSize; - -static void(* orig_free)(void *p); -static CFMutableSetRef registeredClasses = nil; -struct DSQueue* _unfreeQueue = NULL;//用来保存自己偷偷保留的内存:1这个队列要线程安全或者自己加锁;2这个队列内部应该尽量少申请和释放堆内存。 -int unfreeSize = 0;//用来记录我们偷偷保存的内存的大小 - - -@implementation ZombieObjectDetector - -#pragma mark - life cycle -+ (void)load -{ -#ifdef DEBUG - loadCatchProxyClass(); - init_safe_free(); -#endif -} - - -#pragma mark -------------------------- Public Methods -//系统内存警告的时候调用这个函数释放一些内存 -void freeMemoryWhenMemoryWarning(size_t freeNum) -{ -#ifdef DEBUG - size_t count = ds_queue_length(_unfreeQueue); - freeNum= freeNum > count ? count:freeNum; - for (int i=0; i MAX_STEAL_MEM_NUM*0.9 || unfreeSize>MAX_STEAL_MEM_SIZE) { - freeMemoryWhenMemoryWarning(BATCH_FREE_NUM); - } else { - size_t memSiziee = malloc_size(p); - if (memSiziee > DetectObjectSize) {//有足够的空间才覆盖 - id obj=(id)p; - Class origClass= object_getClass(obj); - // 判断是不是objc对象 - char *type = @encode(typeof(obj)); - if (strcmp("@", type) == 0 && - CFSetContainsValue(registeredClasses, origClass)) { - memset(obj, 0x55, memSiziee); - memcpy(obj, &mockIsa, sizeof(void*)); //更改 isa - object_setClass(obj, [ZombieProxy class]); - ((ZombieProxy *)obj).originClass = origClass; - __sync_fetch_and_add(&unfreeSize, (int)memSiziee);// 多线程下int的原子加操作,多线程对全局变量进行自加,不用理线程锁了 - ds_queue_put(_unfreeQueue, p); - }else{ - orig_free(p); - } - }else{ - orig_free(p); - } - } -} - -void loadCatchProxyClass(void) -{ - registeredClasses = CFSetCreateMutable(NULL, 0, NULL); - unsigned int count = 0; - Class *classes = objc_copyClassList(&count); - for (unsigned int i = 0; i < count; i++) { - CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i])); - } - free(classes); - classes = NULL; - mockIsa = objc_getClass("ZombieProxy"); - DetectObjectSize = class_getInstanceSize(mockIsa); -} - - -bool init_safe_free(void) -{ - _unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM); - orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free"); - rebind_symbols((struct rebinding[]){{"free", (void*)safeFree}}, 1); - return true; -} -@end -``` - -注意:ZombieProxy、ZombieObjectDetector 2个类要在 Build Phases 设置为非 ARC,加 `-fno-objc-arc` - - - -### 6. 方案对比 - -- 僵尸对象相比 `Malloc Scribble`,不需要考虑会不会崩溃的问题,只需要野指针指向僵尸对象,那么再次访问野指针就一定会奔溃 -- 僵尸对象的方比如 `Malloc Scribble` 覆盖面广,可以通过 fishhook hook free 方法将 c 函数也包含在其中。 - - - -## 六、 App 网络监控 - -移动网络环境一直很复杂,WIFI、2G、3G、4G、5G 等,用户使用 App 的过程中可能在这几种类型之间切换,这也是移动网络和传统网络间的一个区别,被称为「Connection Migration」。此外还存在 DNS 解析缓慢、失败率高、运营商劫持等问题。用户在使用 App 时因为某些原因导致体验很差,要想针对网络情况进行改善,必须有清晰的监控手段。 - -### 1. App 网络请求过程 - -![网络请求各阶段](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-03-NetworkTime.png) - -App 发送一次网络请求一般会经历下面几个关键步骤: - -- DNS 解析 - - Domain Name system,网络域名名称系统,本质上就是将`域名`和`IP 地址` 相互映射的一个分布式数据库,使人们更方便的访问互联网。首先会查询本地的 DNS 缓存,查找失败就去 DNS 服务器查询,这其中可能会经过非常多的节点,涉及到**递归查询和迭代查询**的过程。运营商可能不干人事:一种情况就是出现运营商劫持的现象,表现为你在 App 内访问某个网页的时候会看到和内容不相关的广告;另一种可能的情况就是把你的请求丢给非常远的基站去做 DNS 解析,导致我们 App 的 DNS 解析时间较长,App 网络效率低。一般做 HTTPDNS 方案去自行解决 DNS 的问题。 - -- TCP 3 次握手 - - 关于 TCP 握手过程中为什么是 3 次握手而不是 2 次、4 次,可以查看这篇[文章](https://draveness.me/whys-the-design-tcp-three-way-handshake/)。 - -- TLS 握手 - - 对于 HTTPS 请求还需要做 TLS 握手,也就是密钥协商的过程。 - -- 发送请求 - - 连接建立好之后就可以发送 request,此时可以记录下 request start 时间 - -- 等待回应 - - 等待服务器返回响应。这个时间主要取决于资源大小,也是网络请求过程中最为耗时的一个阶段。 - -- 返回响应 - - 服务端返回响应给客户端,根据 HTTP header 信息中的状态码判断本次请求是否成功、是否走缓存、是否需要重定向。 - -### 2. 监控原理 - -| 名称 | 说明 | -|:---------------:|:----------------:| -| NSURLConnection | 已经被废弃。用法简单 | -| NSURLSession | iOS7.0 推出,功能更强大 | -| CFNetwork | NSURL 的底层,纯 C 实现 | - -iOS 网络框架层级关系如下: - -![Network Level](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-05-NetworkLevel.png) - -iOS 网络现状是由 4 层组成的:最底层的 BSD Sockets、SecureTransport;次级底层是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 实现的,且调用 CFNetwork;应用层框架 AFNetworking 基于 NSURLSession、NSURLConnection 实现。 - -目前业界对于网络监控主要有 2 种:一种是通过 NSURLProtocol 监控、一种是通过 Hook 来监控。下面介绍几种办法来监控网络请求,各有优缺点。 - -#### 2.1 方案一:NSURLProtocol 监控 App 网络请求 - -NSURLProtocol 作为上层接口,使用较为简单,但 NSURLProtocol 属于 URL Loading System 体系中。应用协议的支持程度有限,支持 FTP、HTTP、HTTPS 等几个应用层协议,对于其他的协议则无法监控,存在一定的局限性。如果监控底层网络库 CFNetwork 则没有这个限制。 - -对于 NSURLProtocol 的具体做法在[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.83.md)中讲过,继承抽象类并实现相应的方法,自定义去发起网络请求来实现监控的目的。 - -iOS 10 之后,NSURLSessionTaskDelegate 中增加了一个新的代理方法: - -```objective-c -/* - * Sent when complete statistics information has been collected for the task. - */ -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)); -``` - -可以从 `NSURLSessionTaskMetrics` 中获取到网络情况的各项指标。各项参数如下 - -```objective-c -@interface NSURLSessionTaskMetrics : NSObject - -/* - * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution. - */ -@property (copy, readonly) NSArray *transactionMetrics; - -/* - * Interval from the task creation time to the task completion time. - * Task creation time is the time when the task was instantiated. - * Task completion time is the time when the task is about to change its internal state to completed. - */ -@property (copy, readonly) NSDateInterval *taskInterval; - -/* - * redirectCount is the number of redirects that were recorded. - */ -@property (assign, readonly) NSUInteger redirectCount; - -- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); -+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); - -@end -``` - -其中:`taskInterval` 表示任务从创建到完成话费的总时间,任务的创建时间是任务被实例化时的时间,任务完成时间是任务的内部状态将要变为完成的时间;`redirectCount` 表示被重定向的次数;`transactionMetrics` 数组包含了任务执行过程中每个请求/响应事务中收集的指标,各项参数如下: - -```objective-c -/* - * This class defines the performance metrics collected for a request/response transaction during the task execution. - */ -API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) -@interface NSURLSessionTaskTransactionMetrics : NSObject - -/* - * Represents the transaction request. 请求事务 - */ -@property (copy, readonly) NSURLRequest *request; - -/* - * Represents the transaction response. Can be nil if error occurred and no response was generated. 响应事务 - */ -@property (nullable, copy, readonly) NSURLResponse *response; - -/* - * For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding “EndDate” metric will be nil. - * For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics. - */ - -/* - * 客户端开始请求的时间,无论是从服务器还是从本地缓存中获取 - * fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources. - * - * The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources: - * - * domainLookupStartDate - * domainLookupEndDate - * connectStartDate - * connectEndDate - * secureConnectionStartDate - * secureConnectionEndDate - */ -@property (nullable, copy, readonly) NSDate *fetchStartDate; - -/* - * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 开始解析的时间 - */ -@property (nullable, copy, readonly) NSDate *domainLookupStartDate; - -/* - * domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的时间 - */ -@property (nullable, copy, readonly) NSDate *domainLookupEndDate; - -/* - * connectStartDate is the time immediately before the user agent started establishing the connection to the server. - * - * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客户端与服务端开始建立 TCP 连接的时间 - */ -@property (nullable, copy, readonly) NSDate *connectStartDate; - -/* - * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手开始的时间 - * - * For example, this would correspond to the time immediately before the user agent started the TLS handshake. - * - * If an encrypted connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSDate *secureConnectionStartDate; - -/* - * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手结束的时间 - * - * If an encrypted connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSDate *secureConnectionEndDate; - -/* - * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客户端与服务器建立 TCP 连接完成的时间,包括 TLS 握手时间 - */ -@property (nullable, copy, readonly) NSDate *connectEndDate; - -/* - * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources. - 客户端请求开始的时间,可以理解为开始传输 HTTP 请求的 header 的第一个字节时间 - * - * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request. - */ -@property (nullable, copy, readonly) NSDate *requestStartDate; - -/* - * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources. - 客户端请求结束的时间,可以理解为 HTTP 请求的最后一个字节传输完成的时间 - * - * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request. - */ -@property (nullable, copy, readonly) NSDate *requestEndDate; - -/* - * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources. - 客户端从服务端接收响应的第一个字节的时间 - * - * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response. - */ -@property (nullable, copy, readonly) NSDate *responseStartDate; - -/* - * responseEndDate is the time immediately after the user agent received the last byte of the resource. 客户端从服务端接收到最后一个请求的时间 - */ -@property (nullable, copy, readonly) NSDate *responseEndDate; - -/* - * The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301]. - * E.g., h2, http/1.1, spdy/3.1. - 网络协议名,比如 http/1.1, spdy/3.1 - * - * When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol. - * - * For example: - * If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned. - * If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned. - * If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned. - * - */ -@property (nullable, copy, readonly) NSString *networkProtocolName; - -/* - * This property is set to YES if a proxy connection was used to fetch the resource. - 该连接是否使用了代理 - */ -@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection; - -/* - * This property is set to YES if a persistent connection was used to fetch the resource. - 是否复用了现有连接 - */ -@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection; - -/* - * Indicates whether the resource was loaded, pushed or retrieved from the local cache. - 获取资源来源 - */ -@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType; - -/* - * countOfRequestHeaderBytesSent is the number of bytes transferred for request header. - 请求头的字节数 - */ -@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * countOfRequestBodyBytesSent is the number of bytes transferred for request body. - 请求体的字节数 - * It includes protocol-specific framing, transfer encoding, and content encoding. - */ -@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream. - 上传体数据、文件、流的大小 - */ -@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * countOfResponseHeaderBytesReceived is the number of bytes transferred for response header. - 响应头的字节数 - */ -@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * countOfResponseBodyBytesReceived is the number of bytes transferred for response body. - 响应体的字节数 - * It includes protocol-specific framing, transfer encoding, and content encoding. - */ -@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler. -给代理方法或者完成后处理的回调的数据大小 - - */ -@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * localAddress is the IP address string of the local interface for the connection. - 当前连接下的本地接口 IP 地址 - * - * For multipath protocols, this is the local address of the initial flow. - * - * If a connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * localPort is the port number of the local interface for the connection. - 当前连接下的本地端口号 - - * - * For multipath protocols, this is the local port of the initial flow. - * - * If a connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * remoteAddress is the IP address string of the remote interface for the connection. - 当前连接下的远端 IP 地址 - * - * For multipath protocols, this is the remote address of the initial flow. - * - * If a connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * remotePort is the port number of the remote interface for the connection. - 当前连接下的远端端口号 - * - * For multipath protocols, this is the remote port of the initial flow. - * - * If a connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection. - 连接协商用的 TLS 协议版本号 - * It is a 2-byte sequence in host byte order. - * - * Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h - * - * If an encrypted connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection. - 连接协商用的 TLS 密码套件 - * It is a 2-byte sequence in host byte order. - * - * Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h - * - * If an encrypted connection was not used, this attribute is set to nil. - */ -@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * Whether the connection is established over a cellular interface. - 是否是通过蜂窝网络建立的连接 - */ -@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * Whether the connection is established over an expensive interface. - 是否通过昂贵的接口建立的连接 - */ -@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * Whether the connection is established over a constrained interface. - 是否通过受限接口建立的连接 - */ -@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - -/* - * Whether a multipath protocol is successfully negotiated for the connection. - 是否为了连接成功协商了多路径协议 - */ -@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0)); - - -- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); -+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0)); - -@end -``` - -网络监控简单代码 - -```objective-c -// 监控基础信息 -@interface NetworkMonitorBaseDataModel : NSObject -// 请求的 URL 地址 -@property (nonatomic, strong) NSString *requestUrl; -//请求头 -@property (nonatomic, strong) NSArray *requestHeaders; -//响应头 -@property (nonatomic, strong) NSArray *responseHeaders; -//GET方法 的请求参数 -@property (nonatomic, strong) NSString *getRequestParams; -//HTTP 方法, 比如 POST -@property (nonatomic, strong) NSString *httpMethod; -//协议名,如http1.0 / http1.1 / http2.0 -@property (nonatomic, strong) NSString *httpProtocol; -//是否使用代理 -@property (nonatomic, assign) BOOL useProxy; -//DNS解析后的 IP 地址 -@property (nonatomic, strong) NSString *ip; -@end - -// 监控信息模型 -@interface NetworkMonitorDataModel : NetworkMonitorBaseDataModel -//客户端发起请求的时间 -@property (nonatomic, assign) UInt64 requestDate; -//客户端开始请求到开始dns解析的等待时间,单位ms -@property (nonatomic, assign) int waitDNSTime; -//DNS 解析耗时 -@property (nonatomic, assign) int dnsLookupTime; -//tcp 三次握手耗时,单位ms -@property (nonatomic, assign) int tcpTime; -//ssl 握手耗时 -@property (nonatomic, assign) int sslTime; -//一个完整请求的耗时,单位ms -@property (nonatomic, assign) int requestTime; -//http 响应码 -@property (nonatomic, assign) NSUInteger httpCode; -//发送的字节数 -@property (nonatomic, assign) UInt64 sendBytes; -//接收的字节数 -@property (nonatomic, assign) UInt64 receiveBytes; - - -// 错误信息模型 -@interface NetworkMonitorErrorModel : NetworkMonitorBaseDataModel -//错误码 -@property (nonatomic, assign) NSInteger errorCode; -//错误次数 -@property (nonatomic, assign) NSUInteger errCount; -//异常名 -@property (nonatomic, strong) NSString *exceptionName; -//异常详情 -@property (nonatomic, strong) NSString *exceptionDetail; -//异常堆栈 -@property (nonatomic, strong) NSString *stackTrace; -@end - - -// 继承自 NSURLProtocol 抽象类,实现响应方法,代理网络请求 -@interface CustomURLProtocol () - -@property (nonatomic, strong) NSURLSessionDataTask *dataTask; -@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue; -@property (nonatomic, strong) NetworkMonitorDataModel *dataModel; -@property (nonatomic, strong) NetworkMonitorErrorModel *errModel; - -@end - -//使用NSURLSessionDataTask请求网络 -- (void)startLoading { - NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; - NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration - delegate:self - delegateQueue:nil]; - NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil]; - self.sessionDelegateQueue = [[NSOperationQueue alloc] init]; - self.sessionDelegateQueue.maxConcurrentOperationCount = 1; - self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue"; - self.dataTask = [session dataTaskWithRequest:self.request]; - [self.dataTask resume]; -} - -#pragma mark - NSURLSessionTaskDelegate -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { - if (error) { - [self.client URLProtocol:self didFailWithError:error]; - } else { - [self.client URLProtocolDidFinishLoading:self]; - } - if (error) { - NSURLRequest *request = task.currentRequest; - if (request) { - self.errModel.requestUrl = request.URL.absoluteString; - self.errModel.httpMethod = request.HTTPMethod; - self.errModel.requestParams = request.URL.query; - } - self.errModel.errorCode = error.code; - self.errModel.exceptionName = error.domain; - self.errModel.exceptionDetail = error.description; - // 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - } - self.dataTask = nil; -} - - -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics { - if (@available(iOS 10.0, *) && [metrics.transactionMetrics count] > 0) { - [metrics.transactionMetrics enumerateObjectsUsingBlock:^(NSURLSessionTaskTransactionMetrics *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if (obj.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad) { - if (obj.fetchStartDate) { - self.dataModel.requestDate = [obj.fetchStartDate timeIntervalSince1970] * 1000; - } - if (obj.domainLookupStartDate && obj.domainLookupEndDate) { - self.dataModel. waitDNSTime = ceil([obj.domainLookupStartDate timeIntervalSinceDate:obj.fetchStartDate] * 1000); - self.dataModel. dnsLookupTime = ceil([obj.domainLookupEndDate timeIntervalSinceDate:obj.domainLookupStartDate] * 1000); - } - if (obj.connectStartDate) { - if (obj.secureConnectionStartDate) { - self.dataModel. waitDNSTime = ceil([obj.secureConnectionStartDate timeIntervalSinceDate:obj.connectStartDate] * 1000); - } else if (obj.connectEndDate) { - self.dataModel.tcpTime = ceil([obj.connectEndDate timeIntervalSinceDate:obj.connectStartDate] * 1000); - } - } - if (obj.secureConnectionEndDate && obj.secureConnectionStartDate) { - self.dataModel.sslTime = ceil([obj.secureConnectionEndDate timeIntervalSinceDate:obj.secureConnectionStartDate] * 1000); - } - - if (obj.fetchStartDate && obj.responseEndDate) { - self.dataModel.requestTime = ceil([obj.responseEndDate timeIntervalSinceDate:obj.fetchStartDate] * 1000); - } - - self.dataModel.httpProtocol = obj.networkProtocolName; - - NSHTTPURLResponse *response = (NSHTTPURLResponse *)obj.response; - if ([response isKindOfClass:NSHTTPURLResponse.class]) { - self.dataModel.receiveBytes = response.expectedContentLength; - } - - if ([obj respondsToSelector:@selector(_remoteAddressAndPort)]) { - self.dataModel.ip = [obj valueForKey:@"_remoteAddressAndPort"]; - } - - if ([obj respondsToSelector:@selector(_requestHeaderBytesSent)]) { - self.dataModel.sendBytes = [[obj valueForKey:@"_requestHeaderBytesSent"] unsignedIntegerValue]; - } - if ([obj respondsToSelector:@selector(_responseHeaderBytesReceived)]) { - self.dataModel.receiveBytes = [[obj valueForKey:@"_responseHeaderBytesReceived"] unsignedIntegerValue]; - } - - self.dataModel.requestUrl = [obj.request.URL absoluteString]; - self.dataModel.httpMethod = obj.request.HTTPMethod; - self.dataModel.useProxy = obj.isProxyConnection; - } - }]; - // 上传 Network 数据到数据上报组件,数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - } -} -``` - -#### 2.2 方案二:NSURLProtocol 监控 App 网络请求之黑魔法篇 - -文章上面 [2.1 ](#network-2.1)分析到了 NSURLSessionTaskMetrics 由于兼容性问题,对于网络监控来说似乎不太完美,但是自后在搜资料的时候看到了一篇[文章](https://www.jianshu.com/p/1c34147030d1)。文章在分析 WebView 的网络监控的时候分析 Webkit 源码的时候发现了下面代码 - -```objective-c -#if !HAVE(TIMINGDATAOPTIONS) -void setCollectsTimingData() -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [NSURLConnection _setCollectsTimingData:YES]; - ... - }); -} -#endif -``` - -也就是说明 NSURLConnection 本身有一套 `TimingData` 的收集 API,只是没有暴露给开发者,苹果自己在用而已。在 runtime header 中找到了 NSURLConnection 的 `_setCollectsTimingData:` 、`_timingData` 2 个 api(iOS8 以后可以使用)。 - -NSURLSession 在 iOS9 之前使用 `_setCollectsTimingData:` 就可以使用 TimingData 了。 - -注意: - -- 因为是私有 API,所以在使用的时候注意混淆。比如 `[[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]`。 -- 不推荐私有 API,一般做 APM 的属于公共团队,你想想看虽然你做的 SDK 达到网络监控的目的了,但是万一给业务线的 App 上架造成了问题,那就得不偿失了。一般这种投机取巧,不是百分百确定的事情可以在玩具阶段使用。 - -```objective-c -@interface _NSURLConnectionProxy : DelegateProxy - -@end - -@implementation _NSURLConnectionProxy - -- (BOOL)respondsToSelector:(SEL)aSelector -{ - if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) { - return YES; - } - return [self.target respondsToSelector:aSelector]; -} - -- (void)forwardInvocation:(NSInvocation *)invocation -{ - [super forwardInvocation:invocation]; - if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) { - __unsafe_unretained NSURLConnection *conn; - [invocation getArgument:&conn atIndex:2]; - SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]); - NSDictionary *timingData = [conn performSelector:selector]; - [[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest]; - } -} - -@end - -@implementation NSURLConnection(tracker) - -+ (void)load -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Class class = [self class]; - - SEL originalSelector = @selector(initWithRequest:delegate:); - SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:); - - Method originalMethod = class_getInstanceMethod(class, originalSelector); - Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); - method_exchangeImplementations(originalMethod, swizzledMethod); - - NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]; - SEL selector = NSSelectorFromString(selectorName); - [NSURLConnection performSelector:selector withObject:@(YES)]; - }); -} - -- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id)delegate -{ - if (delegate) { - _NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate]; - objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - return [self swizzledInitWithRequest:request delegate:(id)proxy]; - }else{ - return [self swizzledInitWithRequest:request delegate:delegate]; - } -} - -@end -``` - -#### 2.3 方案三:Hook - -iOS 中 hook 技术有 2 类,一种是 NSProxy,一种是 method swizzling(isa swizzling) - -##### 2.3.1 方法一 - -写 SDK 肯定不可能手动侵入业务代码(你没那个权限提交到线上代码 😂),所以不管是 APM 还是无痕埋点都是通过 Hook 的方式。 - -面向切面程序设计(Aspect-oriented Programming,AOP)是计算机科学中的一种程序设计范型,将**横切关注点**与业务主体进一步分离,以提高程序代码的模块化程度。在不修改源代码的情况下给程序动态增加功能。其核心思想是将业务逻辑(核心关注点,系统主要功能)与公共功能(横切关注点,比如日志系统)进行分离,降低复杂性,保持系统模块化程度、可维护性、可重用性。常被用在日志系统、性能统计、安全控制、事务处理、异常处理等场景下。 - -在 iOS 中 AOP 的实现是基于 Runtime 机制,目前由 3 种方式:Method Swizzling、NSProxy、FishHook(主要用用于 hook c 代码)。 - -文章上面 [2.1 ](#network-2.1)讨论了满足大多数的需求的场景,NSURLProtocol 监控了 NSURLConnection、NSURLSession 的网络请求,自身代理后可以发起网络请求并得到诸如请求开始时间、请求结束时间、header 信息等,但是无法得到非常详细的网络性能数据,比如 DNS 开始解析时间、DNS 解析用了多久、reponse 开始返回的时间、返回了多久等。 iOS10 之后 NSURLSessionTaskDelegate 增加了一个代理方法 `- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));`,可以获取到精确的各项网络数据。但是具有兼容性。文章上面 [2.2 ](#network-2.2)讨论了从 Webkit 源码中得到的信息,通过私有方法 `_setCollectsTimingData:` 、`_timingData` 可以获取到 TimingData。 - -但是如果需要监全部的网络请求就不能满足需求了,查阅资料后发现了阿里百川有 APM 的解决方案,于是有了方案 3,对于网络监控需要做如下的处理 - -![network hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-network_monitor.png) - -可能对于 CFNetwork 比较陌生,可以看一下 CFNetwork 的层级和简单用法 - -![CFNetwork Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-04-CFNetworkStructure.png) - -CFNetwork 的基础是 CFSocket 和 CFStream。 - -CFSocket:Socket 是网络通信的底层基础,可以让 2 个 socket 端口互发数据,iOS 中最常用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包装,几乎实现了所有的 BSD 功能,此外加入了 RunLoop。 - -CFStream:提供了与设备无关的读写数据方法,使用它可以为内存、文件、网络(使用 socket)的数据建立流,使用 stream 可以不必将所有数据写入到内存中。CFStream 提供 API 对 2 种 CFType 对象提供抽象:CFReadStream、CFWriteStream。同时也是 CFHTTP、CFFTP 的基础。 - -简单 Demo - -```objective-c -- (void)testCFNetwork -{ - CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL); - CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1); - CFRelease(urlRef); - - CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef); - CFRelease(httpMessageRef); - - CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); - - CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered); - CFStreamClientContext context = { - 0, - NULL, - NULL, - NULL, - NULL - } ; - // Assigns a client to a stream, which receives callbacks when certain events occur. - CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context); - // Opens a stream for reading. - CFReadStreamOpen(readStream); -} -// callback -void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) { - CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0); - CFIndex numberOfBytesRead = 0; - do { - UInt8 buffer[2014]; - numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer)); - if (numberOfBytesRead > 0) { - CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead); - } - } while (numberOfBytesRead > 0); - - - CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader); - if (responseBytes) { - if (response) { - CFHTTPMessageSetBody(response, responseBytes); - } - CFRelease(responseBytes); - } - - // close and cleanup - CFReadStreamClose(stream); - CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes); - CFRelease(stream); - - // print response - if (response) { - CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response); - CFRelease(response); - - printResponseData(reponseBodyData); - CFRelease(reponseBodyData); - } -} - -void printResponseData (CFDataRef responseData) { - CFIndex dataLength = CFDataGetLength(responseData); - UInt8 *bytes = (UInt8 *)malloc(dataLength); - CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes); - CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE); - CFShow(responseString); - CFRelease(responseString); - free(bytes); -} -// console -{ - "args": {}, - "headers": { - "Host": "httpbin.org", - "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", - "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564" - }, - "origin": "183.159.122.102", - "url": "https://httpbin.org/get" -} -``` - -我们知道 NSURLSession、NSURLConnection、CFNetwork 的使用都需要调用一堆方法进行设置然后需要设置代理对象,实现代理方法。所以针对这种情况进行监控首先想到的是使用 runtime hook 掉方法层级。但是针对设置的代理对象的代理方法没办法 hook,因为不知道代理对象是哪个类。所以想办法可以 hook 设置代理对象这个步骤,将代理对象替换成我们设计好的某个类,然后让这个类去实现 NSURLConnection、NSURLSession、CFNetwork 相关的代理方法。然后在这些方法的内部都去调用一下原代理对象的方法实现。所以我们的需求得以满足,我们在相应的方法里面可以拿到监控数据,比如请求开始时间、结束时间、状态码、内容大小等。 - -NSURLSession、NSURLConnection hook 如下。 - -![NSURLSession Hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-NSURLSessionHook.jpeg) - -![NSURLConnection Hook](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-13-NSURLConnectionHook.jpeg) - -业界有 APM 针对 CFNetwork 的方案,整理描述下: - -CFNetwork 是 c 语言实现的,要对 c 代码进行 hook 需要使用 Dynamic Loader Hook 库 - [fishhook](https://github.com/facebook/fishhook)。 - -> **Dynamic Loader**(dyld)通过更新 **Mach-O** 文件中保存的指针的方法来绑定符号。借用它可以在 **Runtime** 修改 **C** 函数调用的函数指针。**fishhook** 的实现原理:遍历 `__DATA segment` 里面 `__nl_symbol_ptr` 、`__la_symbol_ptr` 两个 section 里面的符号,通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到自己要替换的函数,达到 hook 的目的。 - -> /\* Returns the number of bytes read, or -1 if an error occurs preventing any -> -> bytes from being read, or 0 if the stream's end was encountered. -> -> It is an error to try and read from a stream that hasn't been opened first. -> -> This call will block until at least one byte is available; it will NOT block -> -> until the entire buffer can be filled. To avoid blocking, either poll using -> -> CFReadStreamHasBytesAvailable() or use the run loop and listen for the -> -> kCFStreamEventHasBytesAvailable event for notification of data available. \*/ -> -> CF_EXPORT -> -> CFIndex CFReadStreamRead(CFReadStreamRef **\_Null_unspecified** stream, UInt8 \* **\_Null_unspecified** buffer, CFIndex bufferLength); - -CFNetwork 使用 CFReadStreamRef 来传递数据,使用回调函数的形式来接受服务器的响应。当回调函数受到 - -具体步骤及其关键代码如下,以 NSURLConnection 举例 - -- 因为要 Hook 挺多地方,所以写一个 method swizzling 的工具类 - - ```objective-c - #import - - NS_ASSUME_NONNULL_BEGIN - - @interface NSObject (hook) - - /** - hook对象方法 - - @param originalSelector 需要hook的原始对象方法 - @param swizzledSelector 需要替换的对象方法 - */ - + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; - - /** - hook类方法 - - @param originalSelector 需要hook的原始类方法 - @param swizzledSelector 需要替换的类方法 - */ - + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector; - - @end - - NS_ASSUME_NONNULL_END - - + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector - { - class_swizzleInstanceMethod(self, originalSelector, swizzledSelector); - } - - + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector - { - //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 - Class class2 = object_getClass(self); - class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); - } - - void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) - { - Method originMethod = class_getInstanceMethod(class, originalSEL); - Method replaceMethod = class_getInstanceMethod(class, replacementSEL); - - if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod))) - { - class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod)); - }else { - method_exchangeImplementations(originMethod, replaceMethod); - } - } - ``` - -- 建立一个继承自 NSProxy 抽象类的类,实现相应方法。 - - ```objective-c - #import - - NS_ASSUME_NONNULL_BEGIN - - // 为 NSURLConnection、NSURLSession、CFNetwork 代理设置代理转发 - @interface NetworkDelegateProxy : NSProxy - - + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate; - - @end - - NS_ASSUME_NONNULL_END - - // .m - @interface NetworkDelegateProxy () { - id _originalTarget; - id _NewDelegate; - } - - @end - - @implementation NetworkDelegateProxy - - #pragma mark - life cycle - - (instancetype)sharedInstance { - static NetworkDelegateProxy *_sharedInstance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _sharedInstance = [NetworkDelegateProxy alloc]; - }); - return _sharedInstance; - } - - #pragma mark - public Method - - - (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate { - NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance]; - instance->_originalTarget = originalTarget; - instance->_NewDelegate = newDelegate; - return instance; - } - - - (void)forwardInvocation:(NSInvocation *)invocation { - if ([_originalTarget respondsToSelector:invocation.selector]) { - [invocation invokeWithTarget:_originalTarget]; - [((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation]; - } - } - - - (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel{ - return [_originalTarget methodSignatureForSelector:sel]; - } - @end - ``` - -- 创建一个对象,实现 NSURLConnection、NSURLSession、NSIuputStream 代理方法 - - ```objective-c - // NetworkImplementor.m - - #pragma mark-NSURLConnectionDelegate - - (void)connection:(NSURLConnection *)connection didFailWithErrorbo:(NSError *)error { - NSLog(@"%s", __func__); - } - - - (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response { - NSLog(@"%s", __func__); - return request; - } - - #pragma mark-NSURLConnectionDataDelegate - - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { - NSLog(@"%s", __func__); - } - - - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { - NSLog(@"%s", __func__); - } - - - (void)connection:(NSURLConnection *)connection didSendBodyData:(NSInteger)bytesWritten - totalBytesWritten:(NSInteger)totalBytesWritten - totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite { - NSLog(@"%s", __func__); - } - - - (void)connectionDidFinishLoading:(NSURLConnection *)connection { - NSLog(@"%s", __func__); - } - - #pragma mark-NSURLConnectionDownloadDelegate - - (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { - NSLog(@"%s", __func__); - } - - - (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes { - NSLog(@"%s", __func__); - } - - - (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL { - NSLog(@"%s", __func__); - } - // 根据需求自己去写需要监控的数据项 - ``` - -- 给 NSURLConnection 添加 Category,专门设置 hook 代理对象、hook NSURLConnection 对象方法 - - ```objective-c - // NSURLConnection+Monitor.m - @implementation NSURLConnection (Monitor) - - + (void)load - { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - @autoreleasepool { - [[self class] apm_swizzleMethod:@selector(apm_initWithRequest:delegate:) swizzledSelector:@selector(initWithRequest: delegate:)]; - } - }); - } - - - (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate - { - /* - 1. 在设置 Delegate 的时候替换 delegate。 - 2. 因为要在每个代理方法里面,监控数据,所以需要将代理方法都 hook 下 - 3. 在原代理方法执行的时候,让新的代理对象里面,去执行方法的转发, - */ - NSString *traceId = @"traceId"; - NSMutableURLRequest *rq = [request mutableCopy]; - NSString *preTraceId = [request.allHTTPHeaderFields valueForKey:@"head_key_traceid"]; - if (preTraceId) { - // 调用 hook 之前的初始化方法,返回 NSURLConnection - return [self apm_initWithRequest:rq delegate:delegate]; - } else { - [rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"]; - - NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new]; - [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - - [self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - [self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"]; - - [self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"]; - [self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"]; - delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate]; - - // 调用 hook 之前的初始化方法,返回 NSURLConnection - return [self apm_initWithRequest:rq delegate:delegate]; - } - } - - - (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag - { - if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) { - IMP originalMethodImp = class_getMethodImplementation([originalDelegate class], NSSelectorFromString(methodName)); - IMP newMethodImp = class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)); - if (originalMethodImp != newMethodImp) { - [newDelegate registerSelector: methodName]; - NSLog(@""); - } - } else { - class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag); - } - } - - @end - ``` - -这样下来就是可以监控到网络信息了,然后将数据交给数据上报 SDK,按照下发的数据上报策略去上报数据。 - -##### 2.3.2 方法二 - -其实,针对上述的需求还有另一种方法一样可以达到目的,那就是 **isa swizzling**。 - -顺道说一句,上面针对 NSURLConnection、NSURLSession、NSInputStream 代理对象的 hook 之后,利用 NSProxy 实现代理对象方法的转发,有另一种方法可以实现,那就是 **isa swizzling**。 - -- Method swizzling 原理 - - ```objective-c - struct old_method { - SEL method_name; - char *method_types; - IMP method_imp; - }; - ``` - - ![method swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-09-methodSwizzling.png) - - method swizzling 改进版如下 - - ```objective-c - Method originalMethod = class_getInstanceMethod(aClass, aSEL); - IMP originalIMP = method_getImplementation(originalMethod); - char *cd = method_getTypeEncoding(originalMethod); - IMP newIMP = imp_implementationWithBlock(^(id self) { - void (*tmp)(id self, SEL _cmd) = originalIMP; - tmp(self, aSEL); - }); - class_replaceMethod(aClass, aSEL, newIMP, cd); - ``` - -- isa swizzling - - ```objective-c - /// Represents an instance of a class. - struct objc_object { - Class _Nonnull isa OBJC_ISA_AVAILABILITY; - }; - - /// A pointer to an instance of a class. - typedef struct objc_object *id; - ``` - - ![isa swizzling](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-04-13-isaSwizzling.png) - -我们来分析一下为什么修改 `isa` 可以实现目的呢? - -1. 写 APM 监控的人没办法确定业务代码 -2. 不可能为了方便监控 APM,写某些类,让业务线开发者别使用系统 NSURLSession、NSURLConnection 类 - -想想 KVO 的实现原理?结合上面的图 - -- 创建监控对象子类 -- 重写子类中属性的 getter、setter -- 将监控对象的 isa 指针指向新创建的子类 -- 在子类的 getter、setter 中拦截值的变化,通知监控对象值的变化 -- 监控完之后将监控对象的 isa 还原回去 - -按照这个思路,我们也可以对 NSURLConnection、NSURLSession 的 load 方法中动态创建子类,在子类中重写方法,比如 `- (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately;` ,然后将 NSURLSession、NSURLConnection 的 isa 指向动态创建的子类。在这些方法处理完之后还原本身的 isa 指针。 - -不过 isa swizzling 针对的还是 method swizzling,代理对象不确定,还是需要 NSProxy 进行动态处理。 - -至于如何修改 isa,我写一个简单的 Demo 来模拟 KVO - -```objective-c -- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context { - //生成自定义的名称 - NSString *className = NSStringFromClass(self.class); - NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className]; - //1. runtime 生成类 - Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0); - // 生成后不能马上使用,必须先注册 - objc_registerClassPair(myclass); - - //2. 重写 setter 方法 - class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@"); - -// class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@"); - //3. 修改 isa - object_setClass(self, myclass); - - //4. 将观察者保存到当前对象里面 - objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN); - - //5. 将传递的上下文绑定到当前对象里面 - objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN); -} - - -void say(id self, SEL _cmd) -{ - // 调用父类方法一 - struct objc_super superclass = {self, [self superclass]}; - ((void(*)(struct objc_super *,SEL))objc_msgSendSuper)(&superclass,@selector(say)); - NSLog(@"%s", __func__); -// 调用父类方法二 -// Class class = [self class]; -// object_setClass(self, class_getSuperclass(class)); -// objc_msgSend(self, @selector(say)); -} - -void setName (id self, SEL _cmd, NSString *name) { - NSLog(@"come here"); - //先切换到当前类的父类,然后发送消息 setName,然后切换当前子类 - //1. 切换到父类 - Class class = [self class]; - object_setClass(self, class_getSuperclass(class)); - //2. 调用父类的 setName 方法 - objc_msgSend(self, @selector(setName:), name); - - //3. 调用观察 - id observer = objc_getAssociatedObject(self, "observer"); - id context = objc_getAssociatedObject(self, "context"); - if (observer) { - objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context); - } - //4. 改回子类 - object_setClass(self, class); -} - -@end -``` - -#### 2.4 方案四:监控 App 常见网络请求 - -本着成本的原因,由于现在大多数的项目的网络能力都是通过 [AFNetworking](https://github.com/AFNetworking/AFNetworking) 完成的,所以本文的网络监控可以快速完成。 - -AFNetworking 在发起网络的时候会有相应的通知。`AFNetworkingTaskDidResumeNotification` 和 `AFNetworkingTaskDidCompleteNotification`。通过监听通知携带的参数获取网络情况信息。 - -```Objective-c - self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { - // 开始 - __strong __typeof(weakSelf)strongSelf = weakSelf; - NSURLSessionTask *task = note.object; - NSString *requestId = [[NSUUID UUID] UUIDString]; - task.apm_requestId = requestId; - [strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task]; -}]; - -self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) { - - __strong __typeof(weakSelf)strongSelf = weakSelf; - - NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey]; - NSURLSessionTask *task = note.object; - if (!error) { - // 成功 - [strongSelf.networkRecoder recordFinishRequestWithRequestID:task.apmn_requestId task:task]; - } else { - // 失败 - [strongSelf.networkRecoder recordResponseErrorWithRequestID:task.apmn_requestId task:task error:error]; - } -}]; -``` - -在 networkRecoder 的方法里面去组装数据,交给数据上报组件,等到合适的时机策略去上报。 - -因为网络是一个异步的过程,所以当网络请求开始的时候需要为每个网络设置唯一标识,等到网络请求完成后再根据每个请求的标识,判断该网络耗时多久、是否成功等。所以措施是为 **NSURLSessionTask** 添加分类,通过 runtime 增加一个属性,也就是唯一标识。 - -这里插一嘴,为 Category 命名、以及内部的属性和方法命名的时候需要注意下。假如不注意会怎么样呢?假如你要为 NSString 类增加身份证号码中间位数隐藏的功能,那么写代码久了的老司机 A,为 NSString 增加了一个方法名,叫做 getMaskedIdCardNumber,但是他的需求是从 [9, 12] 这 4 位字符串隐藏掉。过了几天同事 B 也遇到了类似的需求,他也是一位老司机,为 NSString 增加了一个也叫 getMaskedIdCardNumber 的方法,但是他的需求是从 [8, 11] 这 4 位字符串隐藏,但是他引入工程后发现输出并不符合预期,为该方法写的单测没通过,他以为自己写错了截取方法,检查了几遍才发现工程引入了另一个 NSString 分类,里面的方法同名 😂 真坑。 - -下面的例子是 SDK,但是日常开发也是一样。 - -- Category 类名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加当前分类的功能,也就是`类名+SDK名称简写_功能名称`。比如当前 SDK 叫 JuhuaSuanAPM,那么该 NSURLSessionTask Category 名称就叫做 `NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h` -- Category 属性名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加属性名,也就是`SDK名称简写_属性名称`。比如 JuhuaSuanAPM_requestId` -- Category 方法名:建议按照当前 SDK 名称的简写作为前缀,再加下划线,再加方法名,也就是`SDK名称简写_方法名称`。比如 `-(BOOL)JuhuaSuanAPM__isGzippedData` - -例子如下: - -```Objective-c -#import - -@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor) - -@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId; - -@end - -#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h" -#import - -@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor) - -- (NSString*)JuhuaSuanAPM_requestId -{ - return objc_getAssociatedObject(self, _cmd); -} - -- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId -{ - objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC); -} -@end -``` - -#### 2.5 iOS 流量监控 - -##### 2.5.1 HTTP 请求、响应数据结构 - -HTTP 请求报文结构 - -![请求报文结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-16-HTTPRequestStructure.png) - -响应报文的结构 - -![响应报文结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-16-HTTPResponseStructure.png) - -1. HTTP 报文是格式化的数据块,每条报文由三部分组成:对报文进行描述的起始行、包含属性的首部块、以及可选的包含数据的主体部分。 -2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一个由 2 个字符组成的行终止序列作为结束(包括一个回车符、一个换行符) -3. 实体的主体或者报文的主体是一个可选的数据块。与起始行和首部不同的是,主体中可以包含文本或者二进制数据,也可以为空。 -4. HTTP 首部(也就是 Headers)总是应该以一个空行结束,即使没有实体部分。浏览器发送了一个空白行来通知服务器,它已经结束了该头信息的发送。 - -请求报文的格式 - -```powershell - - - - -``` - -响应报文的格式 - -```shell - - - - -``` - -下图是打开 Chrome 查看极课时间网页的请求信息。包括响应行、响应头、响应体等信息。 - -![请求数据结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-13-HTTPDataStructure.png) - -下图是在终端使用 `curl` 查看一个完整的请求和响应数据 - -![curl查看HTTP响应](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-14-HTTPRequestStructure.png) - -我们都知道在 HTTP 通信中,响应数据会使用 gzip 或其他压缩方式压缩,用 NSURLProtocol 等方案监听,用 NSData 类型去计算分析流量等会造成数据的不精确,因为正常一个 HTTP 响应体的内容是使用 gzip 或其他压缩方式压缩的,所以使用 NSData 会偏大。 - -##### 2.5.2 问题 - -1. Request 和 Response 不一定成对存在 - - 比如网络断开、App 突然 Crash 等,所以 Request 和 Response 监控后不应该记录在一条记录里 - -2. 请求流量计算方式不精确 - - 主要原因有: - - - 监控技术方案忽略了请求头和请求行部分的数据大小 - - 监控技术方案忽略了 Cookie 部分的数据大小 - - 监控技术方案在对请求体大小计算的时候直接使用 `HTTPBody.length`,导致不够精确 - -3. 响应流量计算方式不精确 - - 主要原因有: - - - 监控技术方案忽略了响应头和响应行部分的数据大小 - - 监控技术方案在对 body 部分的字节大小计算,因采用 `exceptedContentLength` 导致不够准确 - - 监控技术方案忽略了响应体使用 gzip 压缩。真正的网络通信过程中,客户端在发起请求的请求头中 `Accept-Encoding` 字段代表客户端支持的数据压缩方式(表明客户端可以正常使用数据时支持的压缩方法),同样服务端根据客户端想要的压缩方式、服务端当前支持的压缩方式,最后处理数据,在响应头中`Content-Encoding` 字段表示当前服务器采用了什么压缩方式。 - -##### 2.5.3 技术实现 - -第五部分讲了网络拦截的各种原理和技术方案,这里拿 NSURLProtocol 来说实现流量监控(Hook 的方式)。从上述知道了我们需要什么样的,那么就逐步实现吧。 - -###### 2.5.3.1 Request 部分 - -1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 - -2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层) - - ```objective-c - @property(nonatomic, strong) NSURLConnection *internalConnection; - @property(nonatomic, strong) NSURLResponse *internalResponse; - @property(nonatomic, strong) NSMutableData *responseData; - @property (nonatomic, strong) NSURLRequest *internalRequest; - ``` - - ```objective-c - - (void)startLoading - { - NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; - self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self]; - self.internalRequest = self.request; - } - - - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response - { - [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; - self.internalResponse = response; - } - - - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data - { - [self.responseData appendData:data]; - [self.client URLProtocol:self didLoadData:data]; - } - ``` - -3. Status Line 部分 - -NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,所以要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 可以实现。 - -**思路:将 NSURLResponse 通过 `_CFURLResponse` 转换为 `CFTypeRef`,然后再将 `CFTypeRef` 转换为 `CFHTTPMessageRef`,再通过 `CFHTTPMessageCopyResponseStatusLine` 获取 `CFHTTPMessageRef` 的 Status Line 信息。** - -将读取 Status Line 的功能添加一个 NSURLResponse 的分类。 - -```objective-c -// NSURLResponse+apm_FetchStatusLineFromCFNetwork.h -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSURLResponse (apm_FetchStatusLineFromCFNetwork) - -- (NSString *)apm_fetchStatusLineFromCFNetwork; - -@end - -NS_ASSUME_NONNULL_END - -// NSURLResponse+apm_FetchStatusLineFromCFNetwork.m -#import "NSURLResponse+apm_FetchStatusLineFromCFNetwork.h" -#import - - -#define SuppressPerformSelectorLeakWarning(Stuff) \ -do { \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \ - Stuff; \ - _Pragma("clang diagnostic pop") \ -} while (0) - -typedef CFHTTPMessageRef (*APMURLResponseFetchHTTPResponse)(CFURLRef response); - -@implementation NSURLResponse (apm_FetchStatusLineFromCFNetwork) - -- (NSString *)apm_fetchStatusLineFromCFNetwork -{ - NSString *statusLine = @""; - NSString *funcName = @"CFURLResponseGetHTTPResponse"; - APMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]); - - SEL getSelector = NSSelectorFromString(@"_CFURLResponse"); - if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) { - CFTypeRef cfResponse; - SuppressPerformSelectorLeakWarning( - cfResponse = CFBridgingRetain([self performSelector:getSelector]); - ); - if (NULL != cfResponse) { - CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse); - statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef); - CFRelease(cfResponse); - } - } - return statusLine; -} - -@end -``` - -4. 将获取到的 Status Line 转换为 NSData,再计算大小 - - ```objective-c - - (NSUInteger)apm_getLineLength { - NSString *statusLineString = @""; - if ([self isKindOfClass:[NSHTTPURLResponse class]]) { - NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self; - statusLineString = [self apm_fetchStatusLineFromCFNetwork]; - } - NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding]; - return lineData.length; - } - ``` - -5. Header 部分 - - `allHeaderFields` 获取到 NSDictionary,然后按照 `key: value` 拼接成字符串,然后转换成 NSData 计算大小 - - 注意:`key: value` key 后是有空格的,curl 或者 chrome Network 面板可以查看印证下。 - - ```objective-c - - (NSUInteger)apm_getHeadersLength - { - NSUInteger headersLength = 0; - if ([self isKindOfClass:[NSHTTPURLResponse class]]) { - NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self; - NSDictionary *headerFields = httpResponse.allHeaderFields; - NSString *headerString = @""; - for (NSString *key in headerFields.allKeys) { - headerString = [headerStr stringByAppendingString:key]; - headheaderStringerStr = [headerString stringByAppendingString:@": "]; - if ([headerFields objectForKey:key]) { - headerString = [headerString stringByAppendingString:headerFields[key]]; - } - headerString = [headerString stringByAppendingString:@"\n"]; - } - NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding]; - headersLength = headerData.length; - } - return headersLength; - } - ``` - -6. Body 部分 - - Body 大小的计算不能直接使用 excepectedContentLength,官方文档说明了其不准确性,只可以作为参考。或者 `allHeaderFields` 中的 `Content-Length` 值也是不够准确的。 - - > /\*! - > - > **@abstract** Returns the expected content length of the receiver. - > - > **@discussion** Some protocol implementations report a content length - > - > as part of delivering load metadata, but not all protocols - > - > guarantee the amount of data that will be delivered in actuality. - > - > Hence, this method returns an expected amount. Clients should use - > - > this value as an advisory, and should be prepared to deal with - > - > either more or less data. - > - > **@result** The expected content length of the receiver, or -1 if - > - > there is no expectation that can be arrived at regarding expected - > - > content length. - > - > \*/ - > - > **@property** (**readonly**) **long** **long** expectedContentLength; - - - HTTP 1.1 版本规定,如果存在 `Transfer-Encoding: chunked`,则在 header 中不能有 `Content-Length`,有也会被忽视。 - - 在 HTTP 1.0 及之前版本中,`content-length` 字段可有可无 - - 在 HTTP 1.1 及之后版本。如果是 `keep alive`,则 `Content-Length` 和 `chunked` 必然是二选一。若是非`keep alive`,则和 HTTP 1.0 一样。`Content-Length` 可有可无。 - - 什么是 `Transfer-Encoding: chunked` - - 数据以一系列分块的形式进行发送 `Content-Length` 首部在这种情况下不被发送. 在每一个分块的开头需要添加当前分块的长度, 以十六进制的形式表示,后面紧跟着 `\r\n` , 之后是分块本身, 后面也是 `\r\n` ,终止块是一个常规的分块, 不同之处在于其长度为 0. - - 我们之前拿 NSMutableData 记录了数据,所以我们可以在 `stopLoading `方法中计算出 Body 大小。步骤如下: - - - 在 `didReceiveData` 中不断添加 data - - ```objective-c - - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data - { - [self.responseData appendData:data]; - [self.client URLProtocol:self didLoadData:data]; - } - ``` - - - 在 `stopLoading` 方法中拿到 `allHeaderFields` 字典,获取 `Content-Encoding` key 的值,如果是 **gzip**,则在 `stopLoading` 中将 NSData 处理为 gzip 压缩后的数据,再计算大小。(gzip 相关功能可以使用这个[工具](https://github.com/nicklockwood/GZIP)) - - 需要额外计算一个空白行的长度 - - ```objective-c - - (void)stopLoadi - { - [self.internalConnection cancel]; - - HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; - model.path = self.request.URL.path; - model.host = self.request.URL.host; - model.type = DMNetworkTrafficDataTypeResponse; - model.lineLength = [self.internalResponse apm_getStatusLineLength]; - model.headerLength = [self.internalResponse apm_getHeadersLength]; - model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength]; - if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) { - NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response; - NSData *data = self.dm_data; - if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) { - data = [self.dm_data gzippedData]; - } - model.bodyLength = data.length; - } - model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; - NSDictionary *networkTrafficDictionary = [model convertToDictionary]; - [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; - } - ``` - -###### 2.5.3.2 Resquest 部分 - -1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求 - -2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层) - - ```objective-c - @property(nonatomic, strong) NSURLConnection *internalConnection; - @property(nonatomic, strong) NSURLResponse *internalResponse; - @property(nonatomic, strong) NSMutableData *responseData; - @property (nonatomic, strong) NSURLRequest *internalRequest; - ``` - - ```objective-c - - (void)startLoading - { - NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; - self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self]; - self.internalRequest = self.request; - } - - - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response - { - [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; - self.internalResponse = response; - } - - - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data - { - [self.responseData appendData:data]; - [self.client URLProtocol:self didLoadData:data]; - } - ``` - -3. Status Line 部分 - - 对于 NSURLRequest 没有像 NSURLResponse 一样的方法找到 StatusLine。所以兜底方案是自己根据 Status Line 的结构,自己手动构造一个。结构为:`协议版本号+空格+状态码+空格+状态文本+换行` - - 为 NSURLRequest 添加一个专门获取 Status Line 的分类。 - - ```objective-c - // NSURLResquest+apm_FetchStatusLineFromCFNetwork.m - - (NSUInteger)apm_fetchStatusLineLength - { - NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"]; - NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding]; - return statusLineData.length; - } - ``` - -4. Header 部分 - - 一个 HTTP 请求会先构建判断是否存在缓存,然后进行 DNS 域名解析以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。 - - 所以一个网络监控不考虑 cookie 😂,借用王多鱼的一句话「那不完犊子了吗」。 - - 看过一些文章说 NSURLRequest 不能完整获取到请求头信息。其实问题不大, 几个信息获取不完全也没办法。衡量监控方案本身就是看接口在不同版本或者某些情况下数据消耗是否异常,WebView 资源请求是否过大,类似于控制变量法的思想。 - - 所以获取到 NSURLRequest 的 `allHeaderFields` 后,加上 cookie 信息,计算完整的 Header 大小 - - ```objective-c - // NSURLResquest+apm_FetchHeaderWithCookies.m - - (NSUInteger)apm_fetchHeaderLengthWithCookie - { - NSDictionary *headerFields = self.allHTTPHeaderFields; - NSDictionary *cookiesHeader = [self apm_fetchCookies]; - - if (cookiesHeader.count) { - NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields]; - [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader]; - headerFields = [headerDictionaryWithCookies copy]; - } - - NSString *headerString = @""; - - for (NSString *key in headerFields.allKeys) { - headerString = [headerString stringByAppendingString:key]; - headerString = [headerString stringByAppendingString:@": "]; - if ([headerFields objectForKey:key]) { - headerString = [headerString stringByAppendingString:headerFields[key]]; - } - headerString = [headerString stringByAppendingString:@"\n"]; - } - NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding]; - headersLength = headerData.length; - return headerString; - } - - - (NSDictionary *)apm_fetchCookies - { - NSDictionary *cookiesHeaderDictionary; - NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage]; - NSArray *cookies = [cookieStorage cookiesForURL:self.URL]; - if (cookies.count) { - cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; - } - return cookiesHeaderDictionary; - } - ``` - -5. Body 部分 - - NSURLConnection 的 `HTTPBody` 有可能获取不到,问题类似于 WebView 上 ajax 等情况。所以可以通过 `HTTPBodyStream` 读取 stream 来计算 body 大小. - - ```objective-c - - (NSUInteger)apm_fetchRequestBody - { - NSDictionary *headerFields = self.allHTTPHeaderFields; - NSUInteger bodyLength = [self.HTTPBody length]; - - if ([headerFields objectForKey:@"Content-Encoding"]) { - NSData *bodyData; - if (self.HTTPBody == nil) { - uint8_t d[1024] = {0}; - NSInputStream *stream = self.HTTPBodyStream; - NSMutableData *data = [[NSMutableData alloc] init]; - [stream open]; - while ([stream hasBytesAvailable]) { - NSInteger len = [stream read:d maxLength:1024]; - if (len > 0 && stream.streamError == nil) { - [data appendBytes:(void *)d length:len]; - } - } - bodyData = [data copy]; - [stream close]; - } else { - bodyData = self.HTTPBody; - } - bodyLength = [[bodyData gzippedData] length]; - } - return bodyLength; - } - ``` - -6. 在 `- (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response` 方法中将数据上报会在 [打造功能强大、灵活可配置的数据上报组件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 讲 - - ```objective-c - -(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response - { - if (response != nil) { - self.internalResponse = response; - [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; - } - - HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init]; - model.path = request.URL.path; - model.host = request.URL.host; - model.type = DMNetworkTrafficDataTypeRequest; - model.lineLength = [connection.currentRequest dgm_getLineLength]; - model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie]; - model.bodyLength = [connection.currentRequest dgm_getBodyLength]; - model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength]; - model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength; - - NSDictionary *networkTrafficDictionary = [model convertToDictionary]; - [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil]; - return request; - } - ``` - -### 3. RN 网络监控 -RN 中的 fetch 或者 axios 请求都是基于 XMLHttpRequest 包装的,所以要统计请求耗时,就可以统一收口在 XMLHttpRequest 侧,监听 open 事件、onreadystatechange 事件。open 事件记录请求开始的时间点,在 onreadystatechange 事件且 xht.readyState == 4 的时候记录结束点 -``` -let startTime = 0 -const originalOpen = XMLHttpRequest.prototype.open -XMLHttpRequest.prototype.open(function(...args) { - startTime = Date.now(); - - const xhr = this; - const originalOnReady = xhr.prototype.onreadystatechange; - xhr.prototype.onreadystatechange = function(...readyStateArgs) { - if (xhr.readyState === 4) { - const totalTimeSpan = Date.now() - startTime; - EventUtil.save(totalTimeSpan, EventType.Request); - } - originalOnReady(...readyStateArgs); - } - originalOpen.apply(xhr, args); -}) - -``` -这是切面处理,所以为了保证监控代码不影响原有逻辑,用一个变量记录原本的函数地址,内部处理完之后再调用原始方法 - - - -## 七、 电量消耗 - -移动设备上电量一直是比较敏感的问题,如果用户在某款 App 的时候发现耗电量严重、手机发热严重,那么用户很大可能会马上卸载这款 App。所以需要在开发阶段关心耗电量问题。 - -一般来说遇到耗电量较大,我们立马会想到是不是使用了定位、是不是使用了频繁网络请求、是不是不断循环做某件事情? - -开发阶段基本没啥问题,我们可以结合 `Instrucments` 里的 `Energy Log` 工具来定位问题。但是线上问题就需要代码去监控耗电量,可以作为 APM 的能力之一。 - -### 1. 如何获取电量 - -在 iOS 中,`IOKit` 是一个私有框架,用来获取硬件和设备的详细信息,也是硬件和内核服务通信的底层框架。所以我们可以通过 `IOKit `来获取硬件信息,从而获取到电量信息。步骤如下: - -- 首先在苹果开放源代码 opensource 中找到 [IOPowerSources.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPowerSources.h.auto.html)、[IOPSKeys.h](https://opensource.apple.com/source/IOKitUser/IOKitUser-647.6/ps.subproj/IOPSKeys.h)。在 Xcode 的 `Package Contents` 里面找到 `IOKit.framework`。 路径为 `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework` -- 然后将 IOPowerSources.h、IOPSKeys.h、IOKit.framework 导入项目工程 -- 设置 UIDevice 的 batteryMonitoringEnabled 为 true -- 获取到的耗电量精确度为 1% - -### 2. 定位问题 - -通常我们通过 Instrucments 里的 Energy Log 解决了很多问题后,App 上线了,线上的耗电量解决就需要使用 APM 来解决了。耗电地方可能是二方库、三方库,也可能是某个同事的代码。 - -思路是:在检测到耗电后,先找到有问题的线程,然后堆栈 dump,还原案发现场。 - -在上面部分我们知道了线程信息的结构, `thread_basic_info` 中有个记录 CPU 使用率百分比的字段 `cpu_usage`。所以我们可以通过遍历当前线程,判断哪个线程的 CPU 使用率较高,从而找出有问题的线程。然后再 dump 堆栈,从而定位到发生耗电量的代码。详细请看 [3.2](#threadInfo) 部分。 - -```objective-c -- (double)fetchBatteryCostUsage -{ - // returns a blob of power source information in an opaque CFTypeRef - CFTypeRef blob = IOPSCopyPowerSourcesInfo(); - // returns a CFArray of power source handles, each of type CFTypeRef - CFArrayRef sources = IOPSCopyPowerSourcesList(blob); - CFDictionaryRef pSource = NULL; - const void *psValue; - // returns the number of values currently in an array - int numOfSources = CFArrayGetCount(sources); - // error in CFArrayGetCount - if (numOfSources == 0) { - NSLog(@"Error in CFArrayGetCount"); - return -1.0f; - } - - // calculating the remaining energy - for (int i=0; i, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)()` 并指定 队列的 qos 为 `QOS_CLASS_UTILITY`。将任务提交到这个队列的 block 中,在 QOS_CLASS_UTILITY 模式下,系统针对大量数据的计算,做了电量优化 - - - - 除了 CPU 大量运算,I/O 操作也是耗电主要原因。业界常见方案都是将「碎片化的数据写入磁盘存储」这个操作延后,先在内存中聚合吗,然后再进行磁盘存储。碎片化数据先聚合,在内存中进行存储的机制,iOS 提供 `NSCache` 这个对象。 - - - - NSCache 是线程安全的,NSCache 会在达到达预设的缓存空间的条件时清理缓存,此时会触发 `- (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj;` 方法回调,在该方法内部对数据进行 I/O 操作,达到将聚合的数据 I/O 延后的目的。I/O 次数少了,对电量的消耗也就减少了。 - - NSCache 的使用可以查看 SDWebImage 这个图片加载框架。在图片读取缓存处理时,没直接读取硬盘文件(I/O),而是使用系统的 NSCache。 - - ```objective-c - - (nullable UIImage *)imageFromMemoryCacheForKey:(nullable NSString *)key { - return [self.memoryCache objectForKey:key]; - } - - - (nullable UIImage *)imageFromDiskCacheForKey:(nullable NSString *)key { - UIImage *diskImage = [self diskImageForKey:key]; - if (diskImage && self.config.shouldCacheImagesInMemory) { - NSUInteger cost = diskImage.sd_memoryCost; - [self.memoryCache setObject:diskImage forKey:key cost:cost]; - } - - return diskImage; - } - ``` - - 可以看到主要逻辑是先从磁盘中读取图片,如果配置允许开启内存缓存,则将图片保存到 NSCache 中,使用的时候也是从 NSCache 中读取图片。NSCache 的 `totalCostLimit、countLimit` 属性, - - `- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;` 方法用来设置缓存条件。所以我们写磁盘、内存的文件操作时可以借鉴该策略,以优化耗电量。 - - - -- 少用定时器 - -- 优化 I/O 的操作 - - - 尽量不要频繁写入小数据,最好批量一次性写入 - - 读写大量重要数据的时候,考虑用 `dispatch_io`,其提供了基于 GCD 的异步操作文件的 I/O 的 API,用 dispatch_io 系统会优化磁盘访问 - - 数据量较大的时候,建议使用数据库技术(SQLite、CoreData) - -- 网络优化 - - - 减少、压缩网络数据 - - 如果多次请求的结果是相同的,尽量使用缓存 - - 断点续传,否则网络不稳定的时候,可能多次传输相同内容 - - 网络不可用时候,不要尝试执行网络请求 - - 让用户可以取消长时间运行或者速度很慢的网络请求,设置合适的超时时间 - - 批量下载。比如下载电子邮件的时候,一次下载多份,不要一份一份的下载,会开启多个网络请求。 - -- 耗电优化 - - - 定位优化 - - - 如果只是需要快速确定用户位置信息,最好用 `CLLocationManager requestLocation` 方法,定位完成后,则会让定位硬件自动断电 - - - 如果不是导航应用,尽量不要实时更新位置,定位关闭就关掉定位服务 - - - 尽量降低定位精度,比如尽量不要使用精度最高的 `KCLLocationAccuracyBest` - - - 需要后台定位的时候,尽量设置 `manager.pausesLocationUpdatesAutomatically = YES` 。如果用户不太可能移动的时候,系统会自动暂停位置更新 - - - 尽量不要使用 `manager.startMonitoringSignificantLocationChanges`,优先考虑 `startMonitoringForRegion:` - - - **理围栏(`startMonitoringForRegion:`)** - 仅在设备**进入或离开预设的特定区域边界时触发**。系统通过低功耗传感器(如蜂窝基站、Wi-Fi)监测位置,仅在跨越边界时唤醒应用,触发频率极低。 - - 地理围栏通过精准的边界条件限制触发次数,最大程度减少位置更新频率,显著降低功耗 - - 需在特定地点(如商店、家)触发行为的应用(如推送通知、签到功能)。例如:“当用户接近咖啡店时发送优惠券”。 - - - **显著位置变化(`startMonitoringSignificantLocationChanges`)** - 在设备移动约500米或切换蜂窝基站时触发,**无论是否涉及特定区域**。虽然比持续GPS追踪省电,但触发频率较高(尤其在移动频繁时),导致更多后台唤醒。 - - 显著位置变化依赖大范围位置变动,可能在用户移动较多时频繁触发,增加电池消耗。 - - 需持续记录大致位置轨迹的应用(如运动追踪、天气更新)。但此类场景较少,且应谨慎使用以避免过度耗电。 - - - 硬件检测优化 - - - 用户移动、摇晃、倾斜设备时,都会产生动作(motion)事件,这些事件由加速度计、陀螺仪、磁力计等硬件检测,在不需要检测的场合,应该及时关闭这些硬件。 - - 硬件检测优化的核心是**精准控制传感器的启用时机**,结合生命周期管理与需求分析,避免后台或非必要场景下的持续耗电。通过合理使用Core Motion API、优化采样频率及严格测试,可显著提升应用的能效表现。 - - 比如: - - - 绑定生命周期管理。 - - - 视图控制器:在`viewDidDisappear`或`deinit`中停止传感器。 - - - 应用状态:监听`UIApplication.didEnterBackgroundNotification`,在后台时暂停传感器 - - - 优化采样频率 - - - 根据需求选择最低有效频率(如`0.1秒`代替`0.01秒`),减少数据处理的能耗 - - - -## 八、 Crash 监控 - -### 1. 异常相关知识回顾 - -#### 1.1 Mach 层对异常的处理 - -Mach 在消息传递基础上实现了一套独特的异常处理方法。Mach 异常处理在设计时考虑到: - -- 带有一致的语义的单一异常处理设施:Mach 只提供一个异常处理机制用于处理所有类型的异常(包括用户定义的异常、平台无关的异常以及平台特定的异常)。根据异常类型进行分组,具体的平台可以定义具体的子类型。 -- 清晰和简洁:异常处理的接口依赖于 Mach 已有的具有良好定义的消息和端口架构,因此非常优雅(不会影响效率)。这就允许调试器和外部处理程序的拓展-甚至在理论上还支持拓展基于网络的异常处理。 - -在 Mach 中,异常是通过内核中的基础设施-消息传递机制处理的。一个异常并不比一条消息复杂多少,异常由出错的线程或者任务(通过 msg_send()) 抛出,然后由一个处理程序通过 msg_recv())捕捉。处理程序可以处理异常,也可以清楚异常(将异常标记为已完成并继续),还可以决定终止线程。 - -Mach 的异常处理模型和其他的异常处理模型不同,其他模型的异常处理程序运行在出错的线程上下文中,而 Mach 的异常处理程序在不同的上下文中运行异常处理程序,出错的线程向预先指定好的异常端口发送消息,然后等待应答。每一个任务都可以注册一个异常处理端口,这个异常处理端口会对该任务中的所有线程生效。此外,每个线程都可以通过 `thread_set_exception_ports(<#thread_act_t thread#>, <#exception_mask_t exception_mask#>, <#mach_port_t new_port#>, <#exception_behavior_t behavior#>, <#thread_state_flavor_t new_flavor#>)` 注册自己的异常处理端口。通常情况下,任务和线程的异常端口都是 NULL,也就是异常不会被处理,而一旦创建异常端口,这些端口就像系统中的其他端口一样,可以转交给其他任务或者其他主机。(有了端口,就可以使用 UDP 协议,通过网络能力让其他的主机上应用程序处理异常)。 - -发生异常时,首先尝试将异常抛给线程的异常端口,然后尝试抛给任务的异常端口,最后再抛给主机的异常端口(即主机注册的默认端口)。如果没有一个端口返回 `KERN_SUCCESS`,那么整个任务将被终止。也就是 Mach 不提供异常处理逻辑,只提供传递异常通知的框架。 - -异常首先是由处理器陷阱引发的。为了处理陷阱,每一个现代的内核都会安插陷阱处理程序。这些底层函数是由内核的汇编部分安插的。 - -#### 1.2 BSD 层对异常的处理 - -BSD 层是用户态主要使用的 XUN 接口,这一层展示了一个符合 POSIX 标准的接口。开发者可以使用 UNIX 系统的一切功能,但不需要了解 Mach 层的细节实现。 - -Mach 已经通过异常机制提供了底层的陷进处理,而 BSD 则在异常机制之上构建了信号处理机制。硬件产生的信号被 Mach 层捕捉,然后转换为对应的 UNIX 信号,为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为 Mach 异常,然后再转换为信号。 - -Mach 异常都在 host 层被 `ux_exception` 转换为相应的 unix 信号,并通过 `threadsignal` 将信号投递到出错的线程。 - -![Mach 异常处理以及转换为 Unix 信号的流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-19-BSDCatchSignal.png) - -### 2. Crash 收集方式 - -iOS 系统自带的 Apples`s Crash Reporter 在设置中记录 Crash 日志,我们先观察下 Crash 日志 - -```shell -Incident Identifier: 7FA6736D-09E8-47A1-95EC-76C4522BDE1A -CrashReporter Key: 4e2d36419259f14413c3229e8b7235bcc74847f3 -Hardware Model: iPhone7,1 -Process: APMMonitorExample [3608] -Path: /var/containers/Bundle/Application/9518A4F4-59B7-44E9-BDDA-9FBEE8CA18E5/APMMonitorExample.app/APMMonitorExample -Identifier: com.Wacai.APMMonitorExample -Version: 1.0 (1) -Code Type: ARM-64 -Parent Process: ? [1] - -Date/Time: 2017-01-03 11:43:03.000 +0800 -OS Version: iOS 10.2 (14C92) -Report Version: 104 - -Exception Type: EXC_CRASH (SIGABRT) -Exception Codes: 0x00000000 at 0x0000000000000000 -Crashed Thread: 0 - -Application Specific Information: -*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSSingleObjectArrayI objectForKey:]: unrecognized selector sent to instance 0x174015060' - -Thread 0 Crashed: -0 CoreFoundation 0x0000000188f291b8 0x188df9000 + 1245624 ( + 124) -1 libobjc.A.dylib 0x000000018796055c 0x187958000 + 34140 (objc_exception_throw + 56) -2 CoreFoundation 0x0000000188f30268 0x188df9000 + 1274472 ( + 140) -3 CoreFoundation 0x0000000188f2d270 0x188df9000 + 1262192 ( + 916) -4 CoreFoundation 0x0000000188e2680c 0x188df9000 + 186380 (_CF_forwarding_prep_0 + 92) -5 APMMonitorExample 0x000000010004c618 0x100044000 + 34328 (-[MakeCrashHandler throwUncaughtNSException] + 80) -``` - -会发现,Crash 日志中 `Exception Type` 项由 2 部分组成:Mach 异常 + Unix 信号。 - -所以 `Exception Type: EXC_CRASH (SIGABRT)` 表示:Mach 层发生了 `EXC_CRASH` 异常,在 host 层被转换为 `SIGABRT` 信号投递到出错的线程。 - -**问题:** 捕获 Mach 层异常、注册 Unix 信号处理都可以捕获 Crash,这两种方式如何选择? - -**答:** 优选 Mach 层异常拦截。根据上面 1.2 中的描述我们知道 Mach 层异常处理时机更早些,假如 Mach 层异常处理程序让进程退出,这样 Unix 信号永远不会发生了。 - -业界关于崩溃日志的收集开源项目很多,著名的有: KSCrash、plcrashreporter,提供一条龙服务的 Bugly、友盟等。我们一般使用开源项目在此基础上开发成符合公司内部需求的 bug 收集工具。一番对比后选择 KSCrash。为什么选择 KSCrash 不在本文重点。 - -KSCrash 功能齐全,可以捕获如下类型的 Crash - -- Mach kernel exceptions -- Fatal signals -- C++ exceptions -- Objective-C exceptions -- Main thread deadlock (experimental) -- Custom crashes (e.g. from scripting languages) - -所以分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 监控实现原理。 - -#### 2.1. Mach 层异常处理 - -大体思路是:先创建一个异常处理端口,为该端口申请权限,再设置异常端口、新建一个内核线程,在该线程内循环等待异常。但是为了防止自己注册的 Mach 层异常处理抢占了其他 SDK、或者业务线开发者设置的逻辑,我们需要在最开始保存其他的异常处理端口,等逻辑执行完后将异常处理交给其他的端口内的逻辑处理。收集到 Crash 信息后组装数据,写入 json 文件。 - -流程图如下: - -![KSCrash流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-20-KSCrashStructure.png) - -对于 Mach 异常捕获,可以注册一个异常端口,该端口负责对当前任务的所有线程进行监听。 - -下面来看看关键代码: - -注册 Mach 层异常监听代码 - -```objective-c -static bool installExceptionHandler() -{ - KSLOG_DEBUG("Installing mach exception handler."); - - bool attributes_created = false; - pthread_attr_t attr; - - kern_return_t kr; - int error; - // 拿到当前进程 - const task_t thisTask = mach_task_self(); - exception_mask_t mask = EXC_MASK_BAD_ACCESS | - EXC_MASK_BAD_INSTRUCTION | - EXC_MASK_ARITHMETIC | - EXC_MASK_SOFTWARE | - EXC_MASK_BREAKPOINT; - - KSLOG_DEBUG("Backing up original exception ports."); - // 获取该 Task 上的注册好的异常端口 - kr = task_get_exception_ports(thisTask, - mask, - g_previousExceptionPorts.masks, - &g_previousExceptionPorts.count, - g_previousExceptionPorts.ports, - g_previousExceptionPorts.behaviors, - g_previousExceptionPorts.flavors); - // 获取失败走 failed 逻辑 - if(kr != KERN_SUCCESS) - { - KSLOG_ERROR("task_get_exception_ports: %s", mach_error_string(kr)); - goto failed; - } - // KSCrash 的异常为空则走执行逻辑 - if(g_exceptionPort == MACH_PORT_NULL) - { - KSLOG_DEBUG("Allocating new port with receive rights."); - // 申请异常处理端口 - kr = mach_port_allocate(thisTask, - MACH_PORT_RIGHT_RECEIVE, - &g_exceptionPort); - if(kr != KERN_SUCCESS) - { - KSLOG_ERROR("mach_port_allocate: %s", mach_error_string(kr)); - goto failed; - } - - KSLOG_DEBUG("Adding send rights to port."); - // 为异常处理端口申请权限:MACH_MSG_TYPE_MAKE_SEND - kr = mach_port_insert_right(thisTask, - g_exceptionPort, - g_exceptionPort, - MACH_MSG_TYPE_MAKE_SEND); - if(kr != KERN_SUCCESS) - { - KSLOG_ERROR("mach_port_insert_right: %s", mach_error_string(kr)); - goto failed; - } - } - - KSLOG_DEBUG("Installing port as exception handler."); - // 为该 Task 设置异常处理端口 - kr = task_set_exception_ports(thisTask, - mask, - g_exceptionPort, - EXCEPTION_DEFAULT, - THREAD_STATE_NONE); - if(kr != KERN_SUCCESS) - { - KSLOG_ERROR("task_set_exception_ports: %s", mach_error_string(kr)); - goto failed; - } - - KSLOG_DEBUG("Creating secondary exception thread (suspended)."); - pthread_attr_init(&attr); - attributes_created = true; - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - // 设置监控线程 - error = pthread_create(&g_secondaryPThread, - &attr, - &handleExceptions, - kThreadSecondary); - if(error != 0) - { - KSLOG_ERROR("pthread_create_suspended_np: %s", strerror(error)); - goto failed; - } - // 转换为 Mach 内核线程 - g_secondaryMachThread = pthread_mach_thread_np(g_secondaryPThread); - ksmc_addReservedThread(g_secondaryMachThread); - - KSLOG_DEBUG("Creating primary exception thread."); - error = pthread_create(&g_primaryPThread, - &attr, - &handleExceptions, - kThreadPrimary); - if(error != 0) - { - KSLOG_ERROR("pthread_create: %s", strerror(error)); - goto failed; - } - pthread_attr_destroy(&attr); - g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread); - ksmc_addReservedThread(g_primaryMachThread); - - KSLOG_DEBUG("Mach exception handler installed."); - return true; - - -failed: - KSLOG_DEBUG("Failed to install mach exception handler."); - if(attributes_created) - { - pthread_attr_destroy(&attr); - } - // 还原之前的异常注册端口,将控制权还原 - uninstallExceptionHandler(); - return false; -} -``` - -处理异常的逻辑、组装崩溃信息 - -```objective-c -/** Our exception handler thread routine. - * Wait for an exception message, uninstall our exception port, record the - * exception information, and write a report. - */ -static void* handleExceptions(void* const userData) -{ - MachExceptionMessage exceptionMessage = {{0}}; - MachReplyMessage replyMessage = {{0}}; - char* eventID = g_primaryEventID; - - const char* threadName = (const char*) userData; - pthread_setname_np(threadName); - if(threadName == kThreadSecondary) - { - KSLOG_DEBUG("This is the secondary thread. Suspending."); - thread_suspend((thread_t)ksthread_self()); - eventID = g_secondaryEventID; - } - // 循环读取注册好的异常端口信息 - for(;;) - { - KSLOG_DEBUG("Waiting for mach exception"); - - // Wait for a message. - kern_return_t kr = mach_msg(&exceptionMessage.header, - MACH_RCV_MSG, - 0, - sizeof(exceptionMessage), - g_exceptionPort, - MACH_MSG_TIMEOUT_NONE, - MACH_PORT_NULL); - // 获取到信息后则代表发生了 Mach 层异常,跳出 for 循环,组装数据 - if(kr == KERN_SUCCESS) - { - break; - } - - // Loop and try again on failure. - KSLOG_ERROR("mach_msg: %s", mach_error_string(kr)); - } - - KSLOG_DEBUG("Trapped mach exception code 0x%x, subcode 0x%x", - exceptionMessage.code[0], exceptionMessage.code[1]); - if(g_isEnabled) - { - // 挂起所有线程 - ksmc_suspendEnvironment(); - g_isHandlingCrash = true; - // 通知发生了异常 - kscm_notifyFatalExceptionCaptured(true); - - KSLOG_DEBUG("Exception handler is installed. Continuing exception handling."); - - - // Switch to the secondary thread if necessary, or uninstall the handler - // to avoid a death loop. - if(ksthread_self() == g_primaryMachThread) - { - KSLOG_DEBUG("This is the primary exception thread. Activating secondary thread."); -// TODO: This was put here to avoid a freeze. Does secondary thread ever fire? - restoreExceptionPorts(); - if(thread_resume(g_secondaryMachThread) != KERN_SUCCESS) - { - KSLOG_DEBUG("Could not activate secondary thread. Restoring original exception ports."); - } - } - else - { - KSLOG_DEBUG("This is the secondary exception thread. Restoring original exception ports."); -// restoreExceptionPorts(); - } - - // Fill out crash information - // 组装异常所需要的方案现场信息 - KSLOG_DEBUG("Fetching machine state."); - KSMC_NEW_CONTEXT(machineContext); - KSCrash_MonitorContext* crashContext = &g_monitorContext; - crashContext->offendingMachineContext = machineContext; - kssc_initCursor(&g_stackCursor, NULL, NULL); - if(ksmc_getContextForThread(exceptionMessage.thread.name, machineContext, true)) - { - kssc_initWithMachineContext(&g_stackCursor, 100, machineContext); - KSLOG_TRACE("Fault address 0x%x, instruction address 0x%x", kscpu_faultAddress(machineContext), kscpu_instructionAddress(machineContext)); - if(exceptionMessage.exception == EXC_BAD_ACCESS) - { - crashContext->faultAddress = kscpu_faultAddress(machineContext); - } - else - { - crashContext->faultAddress = kscpu_instructionAddress(machineContext); - } - } - - KSLOG_DEBUG("Filling out context."); - crashContext->crashType = KSCrashMonitorTypeMachException; - crashContext->eventID = eventID; - crashContext->registersAreValid = true; - crashContext->mach.type = exceptionMessage.exception; - crashContext->mach.code = exceptionMessage.code[0]; - crashContext->mach.subcode = exceptionMessage.code[1]; - if(crashContext->mach.code == KERN_PROTECTION_FAILURE && crashContext->isStackOverflow) - { - // A stack overflow should return KERN_INVALID_ADDRESS, but - // when a stack blasts through the guard pages at the top of the stack, - // it generates KERN_PROTECTION_FAILURE. Correct for this. - crashContext->mach.code = KERN_INVALID_ADDRESS; - } - crashContext->signal.signum = signalForMachException(crashContext->mach.type, crashContext->mach.code); - crashContext->stackCursor = &g_stackCursor; - - kscm_handleException(crashContext); - - KSLOG_DEBUG("Crash handling complete. Restoring original handlers."); - g_isHandlingCrash = false; - ksmc_resumeEnvironment(); - } - - KSLOG_DEBUG("Replying to mach exception message."); - // Send a reply saying "I didn't handle this exception". - replyMessage.header = exceptionMessage.header; - replyMessage.NDR = exceptionMessage.NDR; - replyMessage.returnCode = KERN_FAILURE; - - mach_msg(&replyMessage.header, - MACH_SEND_MSG, - sizeof(replyMessage), - 0, - MACH_PORT_NULL, - MACH_MSG_TIMEOUT_NONE, - MACH_PORT_NULL); - - return NULL; -} -``` - -还原异常处理端口,转移控制权 - -```objective-c -/** Restore the original mach exception ports. - */ -static void restoreExceptionPorts(void) -{ - KSLOG_DEBUG("Restoring original exception ports."); - if(g_previousExceptionPorts.count == 0) - { - KSLOG_DEBUG("Original exception ports were already restored."); - return; - } - - const task_t thisTask = mach_task_self(); - kern_return_t kr; - - // Reinstall old exception ports. - // for 循环去除保存好的在 KSCrash 之前注册好的异常端口,将每个端口注册回去 - for(mach_msg_type_number_t i = 0; i < g_previousExceptionPorts.count; i++) - { - KSLOG_TRACE("Restoring port index %d", i); - kr = task_set_exception_ports(thisTask, - g_previousExceptionPorts.masks[i], - g_previousExceptionPorts.ports[i], - g_previousExceptionPorts.behaviors[i], - g_previousExceptionPorts.flavors[i]); - if(kr != KERN_SUCCESS) - { - KSLOG_ERROR("task_set_exception_ports: %s", - mach_error_string(kr)); - } - } - KSLOG_DEBUG("Exception ports restored."); - g_previousExceptionPorts.count = 0; -} -``` - -#### 2.2. Signal 异常处理 - -对于 Mach 异常,操作系统会将其转换为对应的 `Unix 信号`,所以开发者可以通过注册 `signanHandler` 的方式来处理。 - -KSCrash 在这里的处理逻辑如下图: - -![signal 处理步骤](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-20-signalCrash.png) - -看一下关键代码: - -设置信号处理函数 - -```objective-c -static bool installSignalHandler() -{ - KSLOG_DEBUG("Installing signal handler."); - -#if KSCRASH_HAS_SIGNAL_STACK - // 在堆上分配一块内存, - if(g_signalStack.ss_size == 0) - { - KSLOG_DEBUG("Allocating signal stack area."); - g_signalStack.ss_size = SIGSTKSZ; - g_signalStack.ss_sp = malloc(g_signalStack.ss_size); - } - // 信号处理函数的栈挪到堆中,而不和进程共用一块栈区 - // sigaltstack() 函数,该函数的第 1 个参数 sigstack 是一个 stack_t 结构的指针,该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数 old_sigstack 也是一个 stack_t 类型指针,它用来返回上一次建立的“可替换信号栈”的信息(如果有的话) - KSLOG_DEBUG("Setting signal stack area."); - // sigaltstack 第一个参数为创建的新的可替换信号栈,第二个参数可以设置为NULL,如果不为NULL的话,将会将旧的可替换信号栈的信息保存在里面。函数成功返回0,失败返回-1. - if(sigaltstack(&g_signalStack, NULL) != 0) - { - KSLOG_ERROR("signalstack: %s", strerror(errno)); - goto failed; - } -#endif - - const int* fatalSignals = kssignal_fatalSignals(); - int fatalSignalsCount = kssignal_numFatalSignals(); - - if(g_previousSignalHandlers == NULL) - { - KSLOG_DEBUG("Allocating memory to store previous signal handlers."); - g_previousSignalHandlers = malloc(sizeof(*g_previousSignalHandlers) - * (unsigned)fatalSignalsCount); - } - - // 设置信号处理函数 sigaction 的第二个参数,类型为 sigaction 结构体 - struct sigaction action = {{0}}; - // sa_flags 成员设立 SA_ONSTACK 标志,该标志告诉内核信号处理函数的栈帧就在“可替换信号栈”上建立。 - action.sa_flags = SA_SIGINFO | SA_ONSTACK; -#if KSCRASH_HOST_APPLE && defined(__LP64__) - action.sa_flags |= SA_64REGSET; -#endif - sigemptyset(&action.sa_mask); - action.sa_sigaction = &handleSignal; - - // 遍历需要处理的信号数组 - for(int i = 0; i < fatalSignalsCount; i++) - { - // 将每个信号的处理函数绑定到上面声明的 action 去,另外用 g_previousSignalHandlers 保存当前信号的处理函数 - KSLOG_DEBUG("Assigning handler for signal %d", fatalSignals[i]); - if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0) - { - char sigNameBuff[30]; - const char* sigName = kssignal_signalName(fatalSignals[i]); - if(sigName == NULL) - { - snprintf(sigNameBuff, sizeof(sigNameBuff), "%d", fatalSignals[i]); - sigName = sigNameBuff; - } - KSLOG_ERROR("sigaction (%s): %s", sigName, strerror(errno)); - // Try to reverse the damage - for(i--;i >= 0; i--) - { - sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL); - } - goto failed; - } - } - KSLOG_DEBUG("Signal handlers installed."); - return true; - -failed: - KSLOG_DEBUG("Failed to install signal handlers."); - return false; -} -``` - -信号处理时记录线程等上下文信息 - -```objective-c -static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext) -{ - KSLOG_DEBUG("Trapped signal %d", sigNum); - if(g_isEnabled) - { - ksmc_suspendEnvironment(); - kscm_notifyFatalExceptionCaptured(false); - - KSLOG_DEBUG("Filling out context."); - KSMC_NEW_CONTEXT(machineContext); - ksmc_getContextForSignal(userContext, machineContext); - kssc_initWithMachineContext(&g_stackCursor, 100, machineContext); - // 记录信号处理时的上下文信息 - KSCrash_MonitorContext* crashContext = &g_monitorContext; - memset(crashContext, 0, sizeof(*crashContext)); - crashContext->crashType = KSCrashMonitorTypeSignal; - crashContext->eventID = g_eventID; - crashContext->offendingMachineContext = machineContext; - crashContext->registersAreValid = true; - crashContext->faultAddress = (uintptr_t)signalInfo->si_addr; - crashContext->signal.userContext = userContext; - crashContext->signal.signum = signalInfo->si_signo; - crashContext->signal.sigcode = signalInfo->si_code; - crashContext->stackCursor = &g_stackCursor; - - kscm_handleException(crashContext); - ksmc_resumeEnvironment(); - } - - KSLOG_DEBUG("Re-raising signal for regular handlers to catch."); - // This is technically not allowed, but it works in OSX and iOS. - raise(sigNum); -} -``` - -KSCrash 信号处理后还原之前的信号处理权限 - -```objective-c -static void uninstallSignalHandler(void) -{ - KSLOG_DEBUG("Uninstalling signal handlers."); - - const int* fatalSignals = kssignal_fatalSignals(); - int fatalSignalsCount = kssignal_numFatalSignals(); - // 遍历需要处理信号数组,将之前的信号处理函数还原 - for(int i = 0; i < fatalSignalsCount; i++) - { - KSLOG_DEBUG("Restoring original handler for signal %d", fatalSignals[i]); - sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL); - } - - KSLOG_DEBUG("Signal handlers uninstalled."); -} -``` - -说明: - -1. 先从堆上分配一块内存区域,被称为“可替换信号栈”,目的是将信号处理函数的栈干掉,用堆上的内存区域代替,而不和进程共用一块栈区。 - - 为什么这么做?一个进程可能有 n 个线程,每个线程都有自己的任务,假如某个线程执行出错,这样就会导致整个进程的崩溃。所以为了信号处理函数正常运行,需要为信号处理函数设置单独的运行空间。另一种情况是递归函数将系统默认的栈空间用尽了,但是信号处理函数使用的栈是它实现在堆中分配的空间,而不是系统默认的栈,所以它仍旧可以正常工作。 - -2. `int sigaltstack(const stack_t * __restrict, stack_t * __restrict)` 函数的二个参数都是 `stack_t` 结构的指针,存储了可替换信号栈的信息(栈的起始地址、栈的长度、状态)。第 1 个参数该结构存储了一个“可替换信号栈” 的位置及属性信息。第 2 个参数用来返回上一次建立的“可替换信号栈”的信息(如果有的话)。 - - ```c - _STRUCT_SIGALTSTACK - { - void *ss_sp; /* signal stack base */ - __darwin_size_t ss_size; /* signal stack length */ - int ss_flags; /* SA_DISABLE and/or SA_ONSTACK */ - }; - typedef _STRUCT_SIGALTSTACK stack_t; /* [???] signal stack */ - ``` - - 新创建的可替换信号栈,`ss_flags` 必须设置为 0。系统定义了 `SIGSTKSZ` 常量,可满足绝大多可替换信号栈的需求。 - - ```c - /* - * Structure used in sigaltstack call. - */ - - #define SS_ONSTACK 0x0001 /* take signal on signal stack */ - #define SS_DISABLE 0x0004 /* disable taking signals on alternate stack */ - #define MINSIGSTKSZ 32768 /* (32K)minimum allowable stack */ - #define SIGSTKSZ 131072 /* (128K)recommended stack size */ - ``` - - `sigaltstack` 系统调用通知内核“可替换信号栈”已经建立。 - - `ss_flags` 为 `SS_ONSTACK` 时,表示进程当前正在“可替换信号栈”中执行,如果此时试图去建立一个新的“可替换信号栈”,那么会遇到 `EPERM` (禁止该动作) 的错误;为 `SS_DISABLE` 说明当前没有已建立的“可替换信号栈”,禁止建立“可替换信号栈”。 - -3. `int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);` - - 第一个函数表示需要处理的信号值,但不能是 `SIGKILL` 和 `SIGSTOP` ,这两个信号的处理函数不允许用户重写,因为它们给超级用户提供了终止程序的方法( `SIGKILL` and `SIGSTOP` cannot be caught, blocked, or ignored); - - 第二个和第三个参数是一个 `sigaction` 结构体。如果第二个参数不为空则代表将其指向信号处理函数,第三个参数不为空,则将之前的信号处理函数保存到该指针中。如果第二个参数为空,第三个参数不为空,则可以获取当前的信号处理函数。 - - ```c - /* - * Signal vector "template" used in sigaction call. - */ - struct sigaction { - union __sigaction_u __sigaction_u; /* signal handler */ - sigset_t sa_mask; /* signal mask to apply */ - int sa_flags; /* see signal options below */ - }; - ``` - - `sigaction` 函数的 `sa_flags` 参数需要设置 `SA_ONSTACK` 标志,告诉内核信号处理函数的栈帧就在“可替换信号栈”上建立。 - -#### 2.3. C++ 异常处理 - -c++ 异常处理的实现是依靠了标准库的 `std::set_terminate(CPPExceptionTerminate)` 函数。 - -iOS 工程中某些功能的实现可能使用了 C、C++等。假如抛出 C++ 异常,如果该异常可以被转换为 NSException,则走 OC 异常捕获机制,如果不能转换,则继续走 C++ 异常流程,也就是 `default_terminate_handler`。这个 C++ 异常的默认 terminate 函数内部调用 `abort_message` 函数,最后触发了一个 `abort` 调用,系统产生一个 `SIGABRT` 信号。 - -在系统抛出 C++ 异常后,加一层 `try...catch...` 来判断该异常是否可以转换为 `NSException`,再重新抛出的 C++异常。此时异常的现场堆栈已经消失,所以上层通过捕获 `SIGABRT` 信号是无法还原发生异常时的场景,即异常堆栈缺失。 - -为什么?`try...catch...` 语句内部会调用 `__cxa_rethrow()` 抛出异常,`__cxa_rethrow()` 内部又会调用 `unwind`,`unwind` 可以简单理解为函数调用的逆调用,主要用来清理函数调用过程中每个函数生成的局部变量,一直到最外层的 catch 语句所在的函数,并把控制移交给 catch 语句,这就是 C++异常的堆栈消失原因。 - -```c++ -static void setEnabled(bool isEnabled) -{ - if(isEnabled != g_isEnabled) - { - g_isEnabled = isEnabled; - if(isEnabled) - { - initialize(); - - ksid_generate(g_eventID); - g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate); - } - else - { - std::set_terminate(g_originalTerminateHandler); - } - g_captureNextStackTrace = isEnabled; - } -} - -static void initialize() -{ - static bool isInitialized = false; - if(!isInitialized) - { - isInitialized = true; - kssc_initCursor(&g_stackCursor, NULL, NULL); - } -} - -void kssc_initCursor(KSStackCursor *cursor, - void (*resetCursor)(KSStackCursor*), - bool (*advanceCursor)(KSStackCursor*)) -{ - cursor->symbolicate = kssymbolicator_symbolicate; - cursor->advanceCursor = advanceCursor != NULL ? advanceCursor : g_advanceCursor; - cursor->resetCursor = resetCursor != NULL ? resetCursor : kssc_resetCursor; - cursor->resetCursor(cursor); -} -``` - -```c++ -static void CPPExceptionTerminate(void) -{ - ksmc_suspendEnvironment(); - KSLOG_DEBUG("Trapped c++ exception"); - const char* name = NULL; - std::type_info* tinfo = __cxxabiv1::__cxa_current_exception_type(); - if(tinfo != NULL) - { - name = tinfo->name(); - } - - if(name == NULL || strcmp(name, "NSException") != 0) - { - kscm_notifyFatalExceptionCaptured(false); - KSCrash_MonitorContext* crashContext = &g_monitorContext; - memset(crashContext, 0, sizeof(*crashContext)); - - char descriptionBuff[DESCRIPTION_BUFFER_LENGTH]; - const char* description = descriptionBuff; - descriptionBuff[0] = 0; - - KSLOG_DEBUG("Discovering what kind of exception was thrown."); - g_captureNextStackTrace = false; - try - { - throw; - } - catch(std::exception& exc) - { - strncpy(descriptionBuff, exc.what(), sizeof(descriptionBuff)); - } -#define CATCH_VALUE(TYPE, PRINTFTYPE) \ -catch(TYPE value)\ -{ \ - snprintf(descriptionBuff, sizeof(descriptionBuff), "%" #PRINTFTYPE, value); \ -} - CATCH_VALUE(char, d) - CATCH_VALUE(short, d) - CATCH_VALUE(int, d) - CATCH_VALUE(long, ld) - CATCH_VALUE(long long, lld) - CATCH_VALUE(unsigned char, u) - CATCH_VALUE(unsigned short, u) - CATCH_VALUE(unsigned int, u) - CATCH_VALUE(unsigned long, lu) - CATCH_VALUE(unsigned long long, llu) - CATCH_VALUE(float, f) - CATCH_VALUE(double, f) - CATCH_VALUE(long double, Lf) - CATCH_VALUE(char*, s) - catch(...) - { - description = NULL; - } - g_captureNextStackTrace = g_isEnabled; - - // TODO: Should this be done here? Maybe better in the exception handler? - KSMC_NEW_CONTEXT(machineContext); - ksmc_getContextForThread(ksthread_self(), machineContext, true); - - KSLOG_DEBUG("Filling out context."); - crashContext->crashType = KSCrashMonitorTypeCPPException; - crashContext->eventID = g_eventID; - crashContext->registersAreValid = false; - crashContext->stackCursor = &g_stackCursor; - crashContext->CPPException.name = name; - crashContext->exceptionName = name; - crashContext->crashReason = description; - crashContext->offendingMachineContext = machineContext; - - kscm_handleException(crashContext); - } - else - { - KSLOG_DEBUG("Detected NSException. Letting the current NSException handler deal with it."); - } - ksmc_resumeEnvironment(); - - KSLOG_DEBUG("Calling original terminate handler."); - g_originalTerminateHandler(); -} -``` - -#### 2.4. Objective-C 异常处理 - -对于 OC 层面的 NSException 异常处理较为容易,可以通过注册 `NSUncaughtExceptionHandler` 来捕获异常信息,通过 NSException 参数来做 Crash 信息的收集,交给数据上报组件。 - -```c++ -static void setEnabled(bool isEnabled) -{ - if(isEnabled != g_isEnabled) - { - g_isEnabled = isEnabled; - if(isEnabled) - { - KSLOG_DEBUG(@"Backing up original handler."); - // 记录之前的 OC 异常处理函数 - g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler(); - - KSLOG_DEBUG(@"Setting new handler."); - // 设置新的 OC 异常处理函数 - NSSetUncaughtExceptionHandler(&handleException); - KSCrash.sharedInstance.uncaughtExceptionHandler = &handleException; - } - else - { - KSLOG_DEBUG(@"Restoring original handler."); - NSSetUncaughtExceptionHandler(g_previousUncaughtExceptionHandler); - } - } -} -``` - -阅读下源码,看看为什么 `NSUncaughtExceptionHandler` 可以收集 crash 信息。查看 objc 源码 - - - - - -发现入口函数 `_objc_init` 中调用可设置异常处理的 `exception_init` 方法。内部通过底层 API `std::set_terminate(&_objc_terminate)` 来设置异常处理的 handler。 - - - -#### 2.5. 主线程死锁 - -主线程死锁的检测和 ANR 的检测有些类似 - -- 创建一个线程,在线程运行方法中用 `do...while...` 循环处理逻辑,加了 autorelease 避免内存过高 - -- 有一个 `awaitingResponse` 属性和 `watchdogPulse` 方法。watchdogPulse 主要逻辑为设置 `awaitingResponse` 为 YES,切换到主线程中,设置 `awaitingResponse` 为 NO, - - ```objective-c - - (void) watchdogPulse - { - __block id blockSelf = self; - self.awaitingResponse = YES; - dispatch_async(dispatch_get_main_queue(), ^ - { - [blockSelf watchdogAnswer]; - }); - } - ``` - -- 线程的执行方法里面不断循环,等待设置的 `g_watchdogInterval` 后判断 `awaitingResponse` 的属性值是不是初始状态的值,否则判断为死锁 - - ```objective-c - - (void) runMonitor - { - BOOL cancelled = NO; - do - { - // Only do a watchdog check if the watchdog interval is > 0. - // If the interval is <= 0, just idle until the user changes it. - @autoreleasepool { - NSTimeInterval sleepInterval = g_watchdogInterval; - BOOL runWatchdogCheck = sleepInterval > 0; - if(!runWatchdogCheck) - { - sleepInterval = kIdleInterval; - } - [NSThread sleepForTimeInterval:sleepInterval]; - cancelled = self.monitorThread.isCancelled; - if(!cancelled && runWatchdogCheck) - { - if(self.awaitingResponse) - { - [self handleDeadlock]; - } - else - { - [self watchdogPulse]; - } - } - } - } while (!cancelled); - } - ``` - -#### 2.6 Crash 的生成与保存 - -##### 2.6.1 Crash 日志的生成逻辑 - -上面的部分讲过了 iOS 应用开发中的各种 crash 监控逻辑,接下来就应该分析下 crash 捕获后如何将 crash 信息记录下来,也就是保存到应用沙盒中。 - -拿主线程死锁这种 crash 举例子,看看 KSCrash 是如何记录 crash 信息的。 - -```c -// KSCrashMonitor_Deadlock.m -- (void) handleDeadlock -{ - ksmc_suspendEnvironment(); - kscm_notifyFatalExceptionCaptured(false); - - KSMC_NEW_CONTEXT(machineContext); - ksmc_getContextForThread(g_mainQueueThread, machineContext, false); - KSStackCursor stackCursor; - kssc_initWithMachineContext(&stackCursor, 100, machineContext); - char eventID[37]; - ksid_generate(eventID); - - KSLOG_DEBUG(@"Filling out context."); - KSCrash_MonitorContext* crashContext = &g_monitorContext; - memset(crashContext, 0, sizeof(*crashContext)); - crashContext->crashType = KSCrashMonitorTypeMainThreadDeadlock; - crashContext->eventID = eventID; - crashContext->registersAreValid = false; - crashContext->offendingMachineContext = machineContext; - crashContext->stackCursor = &stackCursor; - - kscm_handleException(crashContext); - ksmc_resumeEnvironment(); - - KSLOG_DEBUG(@"Calling abort()"); - abort(); -} -``` - -其他几个 crash 也是一样,异常信息经过包装交给 `kscm_handleException()` 函数处理。可以看到这个函数被其他几种 crash 捕获后所调用。 - -![caller](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-03-KSCrashCaller.png) - -```c -/** Start general exception processing. - * - * @oaram context Contextual information about the exception. - */ -void kscm_handleException(struct KSCrash_MonitorContext* context) -{ - context->requiresAsyncSafety = g_requiresAsyncSafety; - if(g_crashedDuringExceptionHandling) - { - context->crashedDuringCrashHandling = true; - } - for(int i = 0; i < g_monitorsCount; i++) - { - Monitor* monitor = &g_monitors[i]; - // 判断当前的 crash 监控是开启状态 - if(isMonitorEnabled(monitor)) - { - // 针对每种 crash 类型做一些额外的补充信息 - addContextualInfoToEvent(monitor, context); - } - } - // 真正处理 crash 信息,保存 json 格式的 crash 信息 - g_onExceptionEvent(context); - - - if(g_handlingFatalException && !g_crashedDuringExceptionHandling) - { - KSLOG_DEBUG("Exception is fatal. Restoring original handlers."); - kscm_setActiveMonitors(KSCrashMonitorTypeNone); - } -} -``` - -`g_onExceptionEvent` 是一个 block,声明为 `static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);` 在 `KSCrashMonitor.c` 中被赋值 - -```c -void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monitorContext)) -{ - g_onExceptionEvent = onEvent; -} -``` - -`kscm_setEventCallback()` 函数在 `KSCrashC.c` 文件中被调用 - -```c -KSCrashMonitorType kscrash_install(const char* appName, const char* const installPath) -{ - KSLOG_DEBUG("Installing crash reporter."); - - if(g_installed) - { - KSLOG_DEBUG("Crash reporter already installed."); - return g_monitoring; - } - g_installed = 1; - - char path[KSFU_MAX_PATH_LENGTH]; - snprintf(path, sizeof(path), "%s/Reports", installPath); - ksfu_makePath(path); - kscrs_initialize(appName, path); - - snprintf(path, sizeof(path), "%s/Data", installPath); - ksfu_makePath(path); - snprintf(path, sizeof(path), "%s/Data/CrashState.json", installPath); - kscrashstate_initialize(path); - - snprintf(g_consoleLogPath, sizeof(g_consoleLogPath), "%s/Data/ConsoleLog.txt", installPath); - if(g_shouldPrintPreviousLog) - { - printPreviousLog(g_consoleLogPath); - } - kslog_setLogFilename(g_consoleLogPath, true); - - ksccd_init(60); - // 设置 crash 发生时的 callback 函数 - kscm_setEventCallback(onCrash); - KSCrashMonitorType monitors = kscrash_setMonitoring(g_monitoring); - - KSLOG_DEBUG("Installation complete."); - return monitors; -} - -/** Called when a crash occurs. - * - * This function gets passed as a callback to a crash handler. - */ -static void onCrash(struct KSCrash_MonitorContext* monitorContext) -{ - KSLOG_DEBUG("Updating application state to note crash."); - kscrashstate_notifyAppCrash(); - monitorContext->consoleLogPath = g_shouldAddConsoleLogToReport ? g_consoleLogPath : NULL; - - // 正在处理 crash 的时候,发生了再次 crash - if(monitorContext->crashedDuringCrashHandling) - { - kscrashreport_writeRecrashReport(monitorContext, g_lastCrashReportFilePath); - } - else - { - // 1. 先根据当前时间创建新的 crash 的文件路径 - char crashReportFilePath[KSFU_MAX_PATH_LENGTH]; - kscrs_getNextCrashReportPath(crashReportFilePath); - // 2. 将新生成的文件路径保存到 g_lastCrashReportFilePath - strncpy(g_lastCrashReportFilePath, crashReportFilePath, sizeof(g_lastCrashReportFilePath)); - // 3. 将新生成的文件路径传入函数进行 crash 写入 - kscrashreport_writeStandardReport(monitorContext, crashReportFilePath); - } -} -``` - -接下来的函数就是具体的日志写入文件的实现。2 个函数做的事情相似,都是格式化为 json 形式并写入文件。区别在于 crash 写入时如果再次发生 crash, 则走简易版的写入逻辑 `kscrashreport_writeRecrashReport()`,否则走标准的写入逻辑 `kscrashreport_writeStandardReport()`。 - -```c -bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, char* writeBuffer, int writeBufferLength) -{ - writer->buffer = writeBuffer; - writer->bufferLength = writeBufferLength; - writer->position = 0; - /* - open() 的第二个参数描述的是文件操作的权限 - #define O_RDONLY 0x0000 open for reading only - #define O_WRONLY 0x0001 open for writing only - #define O_RDWR 0x0002 open for reading and writing - #define O_ACCMODE 0x0003 mask for above mode - - #define O_CREAT 0x0200 create if nonexistant - #define O_TRUNC 0x0400 truncate to zero length - #define O_EXCL 0x0800 error if already exists - - 0755:即用户具有读/写/执行权限,组用户和其它用户具有读写权限; - 0644:即用户具有读写权限,组用户和其它用户具有只读权限; - 成功则返回文件描述符,若出现则返回 -1 - */ - writer->fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0644); - if(writer->fd < 0) - { - KSLOG_ERROR("Could not open crash report file %s: %s", path, strerror(errno)); - return false; - } - return true; -} -``` - -```c -/** - * Write a standard crash report to a file. - * - * @param monitorContext Contextual information about the crash and environment. - * The caller must fill this out before passing it in. - * - * @param path The file to write to. - */ -void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* const monitorContext, - const char* path) -{ - KSLOG_INFO("Writing crash report to %s", path); - char writeBuffer[1024]; - KSBufferedWriter bufferedWriter; - - if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer))) - { - return; - } - - ksccd_freeze(); - - KSJSONEncodeContext jsonContext; - jsonContext.userData = &bufferedWriter; - KSCrashReportWriter concreteWriter; - KSCrashReportWriter* writer = &concreteWriter; - prepareReportWriter(writer, &jsonContext); - - ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter); - - writer->beginObject(writer, KSCrashField_Report); - { - writeReportInfo(writer, - KSCrashField_Report, - KSCrashReportType_Standard, - monitorContext->eventID, - monitorContext->System.processName); - ksfu_flushBufferedWriter(&bufferedWriter); - - writeBinaryImages(writer, KSCrashField_BinaryImages); - ksfu_flushBufferedWriter(&bufferedWriter); - - writeProcessState(writer, KSCrashField_ProcessState, monitorContext); - ksfu_flushBufferedWriter(&bufferedWriter); - - writeSystemInfo(writer, KSCrashField_System, monitorContext); - ksfu_flushBufferedWriter(&bufferedWriter); - - writer->beginObject(writer, KSCrashField_Crash); - { - writeError(writer, KSCrashField_Error, monitorContext); - ksfu_flushBufferedWriter(&bufferedWriter); - writeAllThreads(writer, - KSCrashField_Threads, - monitorContext, - g_introspectionRules.enabled); - ksfu_flushBufferedWriter(&bufferedWriter); - } - writer->endContainer(writer); - - if(g_userInfoJSON != NULL) - { - addJSONElement(writer, KSCrashField_User, g_userInfoJSON, false); - ksfu_flushBufferedWriter(&bufferedWriter); - } - else - { - writer->beginObject(writer, KSCrashField_User); - } - if(g_userSectionWriteCallback != NULL) - { - ksfu_flushBufferedWriter(&bufferedWriter); - g_userSectionWriteCallback(writer); - } - writer->endContainer(writer); - ksfu_flushBufferedWriter(&bufferedWriter); - - writeDebugInfo(writer, KSCrashField_Debug, monitorContext); - } - writer->endContainer(writer); - - ksjson_endEncode(getJsonContext(writer)); - ksfu_closeBufferedWriter(&bufferedWriter); - ksccd_unfreeze(); -} - -/** Write a minimal crash report to a file. - * - * @param monitorContext Contextual information about the crash and environment. - * The caller must fill this out before passing it in. - * - * @param path The file to write to. - */ -void kscrashreport_writeRecrashReport(const struct KSCrash_MonitorContext* const monitorContext, - const char* path) -{ - char writeBuffer[1024]; - KSBufferedWriter bufferedWriter; - static char tempPath[KSFU_MAX_PATH_LENGTH]; - // 将传递过来的上份 crash report 文件名路径(/var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.json)修改为去掉 .json ,加上 .old 成为新的文件路径 /var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.old - - strncpy(tempPath, path, sizeof(tempPath) - 10); - strncpy(tempPath + strlen(tempPath) - 5, ".old", 5); - KSLOG_INFO("Writing recrash report to %s", path); - - if(rename(path, tempPath) < 0) - { - KSLOG_ERROR("Could not rename %s to %s: %s", path, tempPath, strerror(errno)); - } - // 根据传入路径来打开内存写入需要的文件 - if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer))) - { - return; - } - - ksccd_freeze(); - // json 解析的 c 代码 - KSJSONEncodeContext jsonContext; - jsonContext.userData = &bufferedWriter; - KSCrashReportWriter concreteWriter; - KSCrashReportWriter* writer = &concreteWriter; - prepareReportWriter(writer, &jsonContext); - - ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter); - - writer->beginObject(writer, KSCrashField_Report); - { - writeRecrash(writer, KSCrashField_RecrashReport, tempPath); - ksfu_flushBufferedWriter(&bufferedWriter); - if(remove(tempPath) < 0) - { - KSLOG_ERROR("Could not remove %s: %s", tempPath, strerror(errno)); - } - writeReportInfo(writer, - KSCrashField_Report, - KSCrashReportType_Minimal, - monitorContext->eventID, - monitorContext->System.processName); - ksfu_flushBufferedWriter(&bufferedWriter); - - writer->beginObject(writer, KSCrashField_Crash); - { - writeError(writer, KSCrashField_Error, monitorContext); - ksfu_flushBufferedWriter(&bufferedWriter); - int threadIndex = ksmc_indexOfThread(monitorContext->offendingMachineContext, - ksmc_getThreadFromContext(monitorContext->offendingMachineContext)); - writeThread(writer, - KSCrashField_CrashedThread, - monitorContext, - monitorContext->offendingMachineContext, - threadIndex, - false); - ksfu_flushBufferedWriter(&bufferedWriter); - } - writer->endContainer(writer); - } - writer->endContainer(writer); - - ksjson_endEncode(getJsonContext(writer)); - ksfu_closeBufferedWriter(&bufferedWriter); - ksccd_unfreeze(); -} -``` - -##### 2.6.2 Crash 日志的读取逻辑 - -当前 App 在 Crash 之后,KSCrash 将数据保存到 App 沙盒目录下,App 下次启动后我们读取存储的 crash 文件,然后处理数据并上传。 - -App 启动后函数调用: - -` [KSCrashInstallation sendAllReportsWithCompletion:]` -> `[KSCrash sendAllReportsWithCompletion:]` -> `[KSCrash allReports]` -> `[KSCrash reportWithIntID:]` ->`[KSCrash loadCrashReportJSONWithID:]` -> `kscrs_readReport ` - -在 `sendAllReportsWithCompletion` 里读取沙盒里的 Crash 数据。 - -```objective-c -// 先通过读取文件夹,遍历文件夹内的文件数量来判断 crash 报告的个数 -static int getReportCount() -{ - int count = 0; - DIR* dir = opendir(g_reportsPath); - if(dir == NULL) - { - KSLOG_ERROR("Could not open directory %s", g_reportsPath); - goto done; - }APM 能力中为 Crash 模块设置一个启动器。启动器内部设置 KSCrash 的初始化工作,以及触发 Crash 时候监控所需数据的组装。比如:SESSION_ID、App 启动时间、App 名称、崩溃时间、App 版本号、当前页面信息等基础信息。 - struct dirent* ent; - while((ent = readdir(dir)) != NULL) - { - if(getReportIDFromFilename(ent->d_name) > 0) - { - count++; - } - } - -done: - if(dir != NULL) - { - closedir(dir); - } - return count; -} - -// 通过 crash 文件个数、文件夹信息去遍历,一次获取到文件名(文件名的最后一部分就是 reportID),拿到 reportID 再去读取 crash 报告内的文件内容,写入数组 -- (NSArray*) allReports -{ - int reportCount = kscrash_getReportCount(); - int64_t reportIDs[reportCount]; - reportCount = kscrash_getReportIDs(reportIDs, reportCount); - NSMutableArray* reports = [NSMutableArray arrayWithCapacity:(NSUInteger)reportCount]; - for(int i = 0; i < reportCount; i++) - { - NSDictionary* report = [self reportWithIntID:reportIDs[i]]; - if(report != nil) - { - [reports addObject:report]; - } - } - - return reports; -} - -// 根据 reportID 找到 crash 信息 -- (NSDictionary*) reportWithIntID:(int64_t) reportID -{ - NSData* jsonData = [self loadCrashReportJSONWithID:reportID]; - if(jsonData == nil) - { - return nil; - } - - NSError* error = nil; - NSMutableDictionary* crashReport = [KSJSONCodec decode:jsonData - options:KSJSONDecodeOptionIgnoreNullInArray | - KSJSONDecodeOptionIgnoreNullInObject | - KSJSONDecodeOptionKeepPartialObject - error:&error]; - if(error != nil) - { - KSLOG_ERROR(@"Encountered error loading crash report %" PRIx64 ": %@", reportID, error); - } - if(crashReport == nil) - { - KSLOG_ERROR(@"Could not load crash report"); - return nil; - } - [self doctorReport:crashReport]; - - return crashReport; -} - -// reportID 读取 crash 内容并转换为 NSData 类型 -- (NSData*) loadCrashReportJSONWithID:(int64_t) reportID -{ - char* report = kscrash_readReport(reportID); - if(report != NULL) - { - return [NSData dataWithBytesNoCopy:report length:strlen(report) freeWhenDone:YES]; - } - return nil; -} - -// reportID 读取 crash 数据到 char 类型 -char* kscrash_readReport(int64_t reportID) -{ - if(reportID <= 0) - { - KSLOG_ERROR("Report ID was %" PRIx64, reportID); - return NULL; - } - - char* rawReport = kscrs_readReport(reportID); - if(rawReport == NULL) - { - KSLOG_ERROR("Failed to load report ID %" PRIx64, reportID); - return NULL; - } - - char* fixedReport = kscrf_fixupCrashReport(rawReport); - if(fixedReport == NULL) - { - KSLOG_ERROR("Failed to fixup report ID %" PRIx64, reportID); - } - - free(rawReport); - return fixedReport; -} - -// 多线程加锁,通过 reportID 执行 c 函数 getCrashReportPathByID,将路径设置到 path 上。然后执行 ksfu_readEntireFile 读取 crash 信息到 result -char* kscrs_readReport(int64_t reportID) -{ - pthread_mutex_lock(&g_mutex); - char path[KSCRS_MAX_PATH_LENGTH]; - getCrashReportPathByID(reportID, path); - char* result; - ksfu_readEntireFile(path, &result, NULL, 2000000); - pthread_mutex_unlock(&g_mutex); - return result; -} - -int kscrash_getReportIDs(int64_t* reportIDs, int count) -{ - return kscrs_getReportIDs(reportIDs, count); -} - -int kscrs_getReportIDs(int64_t* reportIDs, int count) -{ - pthread_mutex_lock(&g_mutex); - count = getReportIDs(reportIDs, count); - pthread_mutex_unlock(&g_mutex); - return count; -} -// 循环读取文件夹内容,根据 ent->d_name 调用 getReportIDFromFilename 函数,来获取 reportID,循环内部填充数组 -static int getReportIDs(int64_t* reportIDs, int count) -{ - int index = 0; - DIR* dir = opendir(g_reportsPath); - if(dir == NULL) - { - KSLOG_ERROR("Could not open directory %s", g_reportsPath); - goto done; - } - - struct dirent* ent; - while((ent = readdir(dir)) != NULL && index < count) - { - int64_t reportID = getReportIDFromFilename(ent->d_name); - if(reportID > 0) - { - reportIDs[index++] = reportID; - } - } - - qsort(reportIDs, (unsigned)count, sizeof(reportIDs[0]), compareInt64); - -done: - if(dir != NULL) - { - closedir(dir); - } - return index; -} - -// sprintf(参数1, 格式2) 函数将格式2的值返回到参数1上,然后执行 sscanf(参数1, 参数2, 参数3),函数将字符串参数1的内容,按照参数2的格式,写入到参数3上。crash 文件命名为 "App名称-report-reportID.json" -static int64_t getReportIDFromFilename(const char* filename) -{ - char scanFormat[100]; - sprintf(scanFormat, "%s-report-%%" PRIx64 ".json", g_appName); - - int64_t reportID = 0; - sscanf(filename, scanFormat, &reportID); - return reportID; -} -``` - -![KSCrash 存储 Crash 数据位置](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-31-KSCrashStoreCrashData.png) - -#### 2.7 前端 js 相关的 Crash 的监控 - -##### 2.7.1 JavascriptCore 异常监控 - -这部分简单粗暴,直接通过 JSContext 对象的 exceptionHandler 属性来监控,比如下面的代码 - -```objective-c -jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) { - // 处理 jscore 相关的异常信息 -}; -``` - -##### 2.7.2 h5 页面异常监控 - -当 h5 页面内的 Javascript 运行异常时会 window 对象会触发 `ErrorEvent` 接口的 error 事件,并执行 `window.onerror()`。 - -```js -window.onerror = function (msg, url, lineNumber, columnNumber, error) { - // 处理异常信息 -}; -``` - -![h5 异常监控](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-19-JSErrorCatch.png) - -##### 2.7.3 React Native 异常监控 - -小实验:下图是写了一个 RN Demo 工程,在 Debug Text 控件上加了事件监听代码,内部人为触发 crash - -```jsx - { - 1 + qw; - }} -> - Debug - -``` - -对比组 1: - -条件: iOS 项目 debug 模式。在 RN 端增加了异常处理的代码。 - -模拟器点击 `command + d` 调出面板,选择 Debug,打开 Chrome 浏览器, Mac 下快捷键 `Command + Option + J` 打开调试面板,就可以像调试 React 一样调试 RN 代码了。 - -![React Native Crash Monitor](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-19-ReactNativeCrashMonitor.png) - -查看到 crash stack 后点击可以跳转到 sourceMap 的地方。 - -Tips:RN 项目打 Release 包 - -- 在项目根目录下创建文件夹( release_iOS),作为资源的输出文件夹 - -- 在终端切换到工程目录,然后执行下面的代码 - - ```shell - react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map; - ``` - -- 将 release_iOS 文件夹内的 `.jsbundle` 和 `assets` 文件夹内容拖入到 iOS 工程中即可 - -对比组 2: - -条件:iOS 项目 release 模式。在 RN 端不增加异常处理代码 - -操作:运行 iOS 工程,点击按钮模拟 crash - -现象:iOS 项目奔溃。截图以及日志如下 - -![RN crash](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-22-RNUncaughtCrash.png) - -```shell -2020-06-22 22:26:03.318 [info][tid:main][RCTRootView.m:294] Running application todos ({ - initialProps = { - }; - rootTag = 1; -}) -2020-06-22 22:26:03.490 [info][tid:com.facebook.react.JavaScript] Running "todos" with {"rootTag":1,"initialProps":{}} -2020-06-22 22:27:38.673 [error][tid:com.facebook.react.JavaScript] ReferenceError: Can't find variable: qw -2020-06-22 22:27:38.675 [fatal][tid:com.facebook.react.ExceptionsManagerQueue] Unhandled JS Exception: ReferenceError: Can't find variable: qw -2020-06-22 22:27:38.691300+0800 todos[16790:314161] *** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: ReferenceError: Can't find variable: qw', reason: 'Unhandled JS Exception: ReferenceError: Can't find variable: qw, stack: -onPress@397:1821 -@203:3896 -_performSideEffectsForTransition@210:9689 -_performSideEffectsForTransition@(null):(null) -_receiveSignal@210:8425 -_receiveSignal@(null):(null) -touchableHandleResponderRelease@210:5671 -touchableHandleResponderRelease@(null):(null) -onResponderRelease@203:3006 -b@97:1125 -S@97:1268 -w@97:1322 -R@97:1617 -M@97:2401 -forEach@(null):(null) -U@97:2201 -@97:13818 -Pe@97:90199 -Re@97:13478 -Ie@97:13664 -receiveTouches@97:14448 -value@27:3544 -@27:840 -value@27:2798 -value@27:812 -value@(null):(null) -' -*** First throw call stack: -( - 0 CoreFoundation 0x00007fff23e3cf0e __exceptionPreprocess + 350 - 1 libobjc.A.dylib 0x00007fff50ba89b2 objc_exception_throw + 48 - 2 todos 0x00000001017b0510 RCTFormatError + 0 - 3 todos 0x000000010182d8ca -[RCTExceptionsManager reportFatal:stack:exceptionId:suppressRedBox:] + 503 - 4 todos 0x000000010182e34e -[RCTExceptionsManager reportException:] + 1658 - 5 CoreFoundation 0x00007fff23e43e8c __invoking___ + 140 - 6 CoreFoundation 0x00007fff23e41071 -[NSInvocation invoke] + 321 - 7 CoreFoundation 0x00007fff23e41344 -[NSInvocation invokeWithTarget:] + 68 - 8 todos 0x00000001017e07fa -[RCTModuleMethod invokeWithBridge:module:arguments:] + 578 - 9 todos 0x00000001017e2a84 _ZN8facebook5reactL11invokeInnerEP9RCTBridgeP13RCTModuleDatajRKN5folly7dynamicE + 246 - 10 todos 0x00000001017e280c ___ZN8facebook5react15RCTNativeModule6invokeEjON5folly7dynamicEi_block_invoke + 78 - 11 libdispatch.dylib 0x00000001025b5f11 _dispatch_call_block_and_release + 12 - 12 libdispatch.dylib 0x00000001025b6e8e _dispatch_client_callout + 8 - 13 libdispatch.dylib 0x00000001025bd6fd _dispatch_lane_serial_drain + 788 - 14 libdispatch.dylib 0x00000001025be28f _dispatch_lane_invoke + 422 - 15 libdispatch.dylib 0x00000001025c9b65 _dispatch_workloop_worker_thread + 719 - 16 libsystem_pthread.dylib 0x00007fff51c08a3d _pthread_wqthread + 290 - 17 libsystem_pthread.dylib 0x00007fff51c07b77 start_wqthread + 15 -) -libc++abi.dylib: terminating with uncaught exception of type NSException -(lldb) -``` - -Tips:如何在 RN release 模式下调试(看到 js 侧的 console 信息) - -- 在 `AppDelegate.m` 中引入 `#import ` -- 在 `- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions` 中加入 `RCTSetLogThreshold(RCTLogLevelTrace);` - -对比组 3: - -条件:iOS 项目 release 模式。在 RN 端增加异常处理代码。 - -```js -global.ErrorUtils.setGlobalHandler((e) => { - console.log(e); - let message = { name: e.name, message: e.message, stack: e.stack }; - axios - .get("http://192.168.1.100:8888/test.php", { - params: { message: JSON.stringify(message) }, - }) - .then(function (response) { - console.log(response); - }) - .catch(function (error) { - console.log(error); - }); -}, true); -``` - -操作:运行 iOS 工程,点击按钮模拟 crash。 - -现象:iOS 项目不奔溃。日志信息如下,对比 bundle 包中的 js。 - -![RN release log](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-RNReleaseLog.png) - -结论: - -在 RN 项目中,如果发生了 crash 则会在 Native 侧有相应体现。如果 RN 侧写了 crash 捕获的代码,则 Native 侧不会奔溃。如果 RN 侧的 crash 没有捕获,则 Native 直接奔溃。 - -RN 项目写了 crash 监控,监控后将堆栈信息打印出来发现对应的 js 信息是经过 webpack 处理的,crash 分析难度很大。所以我们针对 RN 的 crash 需要在 RN 侧写监控代码,监控后需要上报,此外针对监控后的信息需要写专门的 crash 信息还原给你,也就是 sourceMap 解析。 - -###### 2.7.3.1 js 逻辑错误 - -写过 RN 的人都知道在 DEBUG 模式下 js 代码有问题则会产生红屏,在 RELEASE 模式下则会白屏或者闪退,为了体验和质量把控需要做异常监控。 - -在看 RN 源码时候发现了 `ErrorUtils`,看代码可以设置处理错误信息。 - -```js -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @flow strict - * @polyfill - */ - -let _inGuard = 0; - -type ErrorHandler = (error: mixed, isFatal: boolean) => void; -type Fn = (...Args) => Return; - -/** - * This is the error handler that is called when we encounter an exception - * when loading a module. This will report any errors encountered before - * ExceptionsManager is configured. - */ -let _globalHandler: ErrorHandler = function onError( - e: mixed, - isFatal: boolean -) { - throw e; -}; - -/** - * The particular require runtime that we are using looks for a global - * `ErrorUtils` object and if it exists, then it requires modules with the - * error handler specified via ErrorUtils.setGlobalHandler by calling the - * require function with applyWithGuard. Since the require module is loaded - * before any of the modules, this ErrorUtils must be defined (and the handler - * set) globally before requiring anything. - */ -const ErrorUtils = { - setGlobalHandler(fun: ErrorHandler): void { - _globalHandler = fun; - }, - getGlobalHandler(): ErrorHandler { - return _globalHandler; - }, - reportError(error: mixed): void { - _globalHandler && _globalHandler(error, false); - }, - reportFatalError(error: mixed): void { - // NOTE: This has an untyped call site in Metro. - _globalHandler && _globalHandler(error, true); - }, - applyWithGuard, TOut>( - fun: Fn, - context?: ?mixed, - args?: ?TArgs, - // Unused, but some code synced from www sets it to null. - unused_onError?: null, - // Some callers pass a name here, which we ignore. - unused_name?: ?string - ): ?TOut { - try { - _inGuard++; - // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work - return fun.apply(context, args); - } catch (e) { - ErrorUtils.reportError(e); - } finally { - _inGuard--; - } - return null; - }, - applyWithGuardIfNeeded, TOut>( - fun: Fn, - context?: ?mixed, - args?: ?TArgs - ): ?TOut { - if (ErrorUtils.inGuard()) { - // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work - return fun.apply(context, args); - } else { - ErrorUtils.applyWithGuard(fun, context, args); - } - return null; - }, - inGuard(): boolean { - return !!_inGuard; - }, - guard, TOut>( - fun: Fn, - name?: ?string, - context?: ?mixed - ): ?(...TArgs) => ?TOut { - // TODO: (moti) T48204753 Make sure this warning is never hit and remove it - types - // should be sufficient. - if (typeof fun !== "function") { - console.warn("A function must be passed to ErrorUtils.guard, got ", fun); - return null; - } - const guardName = name ?? fun.name ?? ""; - function guarded(...args: TArgs): ?TOut { - return ErrorUtils.applyWithGuard( - fun, - context ?? this, - args, - null, - guardName - ); - } - - return guarded; - }, -}; - -global.ErrorUtils = ErrorUtils; - -export type ErrorUtilsT = typeof ErrorUtils; -``` - -所以 RN 的异常可以使用 `global.ErrorUtils` 来设置错误处理。举个例子 - -```js -const defaultHandler = ErrorUtils.getGlobalHandler && ErrorUtils.getGlobalHandler(); - -ErrorUtils.setGlobalHandler( (error: Error, isFatal?: boolean) => { - - // - console.log( - `Global Error Handled: ${JSON.stringify( - { - isFatal, - errorName: error.name, - errorMessage: error.message, - componentStack: error.componentStack, - errorStack: error.stack, - }, - null, - 2, - )}`, - ); - - defaultHandler(error, isFatal); -}); -``` - -红屏产生的原因是 ErrorUtils.setGlobalHandler 捕获全局错误后,调用 LogBox 来显示红屏。红屏报错逻辑涉及 RN 框架源码中2个文件,分别为 [setUpErrorHandling.js](https://github.com/facebook/react-native/blob/8bd3edec88148d0ab1f225d2119435681fbbba33/Libraries/Core/setUpErrorHandling.js#L32) 和 [ExceptionsManager.js](https://github.com/facebook/react-native/blob/b633cc130533f0731b2577123282c4530e4f0abe/Libraries/Core/ExceptionsManager.js#L98-L103),关键代码如下 - -```js -ErrorUtils.setGlobalHandler( (error: Error, isFatal?: boolean) => { - if (__DEV__) { - const LogBox = require('../LogBox/LogBox'); - LogBox.addException({ - message: error.message, - name: error.name, - componentStack: error.componentStack, - stack: error.stack, - isFatal - }); - } -}); -``` - -###### 2.7.3.2 Promise 错误 - -普通的 js 错误,可以通过 try catch 捕获,但是 Promise 错误是不能被 try catch 捕获的。 - -RN 提供了2种 Promise 捕获机制:一种是新架构的 Hermes 引擎提供的捕获机制、另一种是老架构(非 Hermes)引擎提供的捕获机制。分别在 [polyfillPromise.js](https://github.com/facebook/react-native/blob/35800962c16a33eb8e9ff1adfd428cf00bb670d3/Libraries/Core/polyfillPromise.js#L29-L36)、[Promise.js](https://github.com/facebook/react-native/blob/8bd3edec88148d0ab1f225d2119435681fbbba33/Libraries/Promise.js#L18-L22)、[promiseRejectionTrackingOptions.js](https://github.com/facebook/react-native/blob/8bd3edec88148d0ab1f225d2119435681fbbba33/Libraries/promiseRejectionTrackingOptions.js) 中。 - -```js -const defualtRejectionTrackingOptions = { - allRejections: true, - onUnhandled: (id: string, error: Error) => {}, - onHandled : (id: string) => {} -} - -if (global?.HermesInternal?.hasPromise?.()) { - if (__DEV__) { - global.HermesInternal?.enablePromiseRejectionTracker?.( - defualtRejectionTrackingOptions, - ); - } -} else { - if (__DEV__) { - require('promise/setimmediate/rejection-tracking').enable( - defualtRejectionTrackingOptions, - ); - } -} -``` - -首先,定义配置项 `defualtRejectionTrackingOptions`,其中的 onUnhandled 回调函数,该回调函数主要来处理未被 catch 的 Promise 错误。 - -然后通过 `global?.HermesInternal?.hasPromise` 来判断 RN 是否采用 Hermes 引擎: - -- 如果采用 Hermes 引擎,则使用 `enablePromiseRejectionTracker` 方法来捕获未被 catch 的 Promise 错误 -- 如果不是 Hermes 引擎则使用第三方 Promise 库中的 `rejection-tracking` 文件中的 enable 方法来捕获未被 catch 的 Promise 错误 - -那如何捕获 RN 侧产生的 Promise 错误? -做法和上面呼应。先判断是 Hermes 引擎还是非 Hermes 引擎,然后执行对应的异常处理函数,我们只需要注册好对应的异常发生后,数据收集方法即可 -``` -const customPromiseRejectionTrackingOptions = { - allRejections: true, - onUnhandled:(id:string, error: Error) => { - let errorInfo = { - errorMessage: error.message, - errorStack: error.stack, - type: ErrorType.Promise - } - ErrorUtils.save(errorInfo) - }, - onHandled: (id:string) => {} -} - -if (global?.HermesInternal?.hasPromise?.()) { - if (__DEV__) { - global.HermesInternal?.enablePromiseRejectionTracker?.( - defualtRejectionTrackingOptions, - ); - } -} else { - if (__DEV__) { - require('promise/setimmediate/rejection-tracking').enable( - defualtRejectionTrackingOptions, - ); - } -} -``` - -###### 2.7.3.3 组件 render 错误 - -其实对于 RN 的异常监控中 Javascript 错误和未捕获的 Promise 错误外,还有一类需要就是 React/React Native 的 render 错误。 - -在类组件中,render 报错指的是类的 render 方法执行报错;在函数组件中,render 报错指的就是函数本身执行报错了。 - -render 错误在本地就是红屏,线上可能没有反应或者白屏。如何监控?RN 提供了**React Error Boundaries** 专门用来捕获组件的 render 错误。[详细资料](https://zh-hans.reactjs.org/docs/error-boundaries.html) - -> 过去,组件内的 JavaScript 错误会导致 React 的内部状态被破坏,并且在下一次渲染时 [产生](https://github.com/facebook/react/issues/4026) [可能无法追踪的](https://github.com/facebook/react/issues/6895) [错误](https://github.com/facebook/react/issues/8579)。这些错误基本上是由较早的其他代码(非 React 组件代码)错误引起的,但 React 并没有提供一种在组件中优雅处理这些错误的方式,也无法从错误中恢复。 -> -> 部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。 -> -> 错误边界是一种 React 组件,这种组件**可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI**,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。 - -它能捕获子组件生命周期函数中的异常,包括构造函数(constructor)和 render 函数 - -而不能捕获以下异常: - -- Event handlers(事件处理函数) -- Asynchronous code(异步代码,如 setTimeout、promise 等) -- Server side rendering(服务端渲染) -- Errors thrown in the error boundary itself (rather than its children)(异常边界组件本身抛出的异常) - -所以可以通过异常边界组件捕获组件生命周期内的所有异常然后渲染兜底组件 ,防止 App crash,提高用户体验。也可引导用户反馈问题,方便问题的排查和修复 - -不过,React/React Native 只提供了类组件捕获 render 错误的方法,如果是函数组件,必须将其嵌套在类组件中才可以捕获其 render 错误。业界常见做法是将其封装成一个通用方法来给其他组件使用。比如 Sentry 就提供了 ErrorBoundary 组件和 withErrorBoundary 方法来帮助其他类组件和函数组件捕获 render 错误。 - -为了 cover 大多数组件渲染错误导致的 bug,代码如下: - -``` -class ErrorBoundary extends React.component { - constructor(props) { - super(props); - this.state = { hasError: false }; - } - - static getDrivedStateFromError(error) { - // 更新 state 使下一次渲染能够显示出降级后的 UI - return { hasError: true }; - } - - componentDidCatch(error, errorInfo) { - // 将异常信息收集起来,然后按照数据上报策略做异常上报 - ErrorUtils.save(error, errorInfo); - } - - render () { - if (this.state.hasError) { - // 可以是安抚用户情绪的文案或者是统一的 feedback 页 - return 错误引导或者降级页 - } - return this.props.children; - } -} - -// 然后在 App 入口处做统一收口 - - - -``` -如果 App 组件 render 没有报错,则会走 else 分支,也就是 this.props.children 正常渲染;如果 App 组件 render 出错了,则会触发 getDrivedStateFromError 回调,内部将 hasError 设置为 true,再次 render 的时候则展示降级后的页面。同时会触发 componentDidCatch 回调,并记录异常信息。 - - -至此 RN 的 crash 分为 2 种,分别是 js 逻辑错误、组件 js 错误,都已经被监控处理了。接下来就看看如何从工程化层面解决这些问题 - -##### 2.7.4 RN Crash 还原 - -SourceMap 文件对于前端日志的解析至关重要,SourceMap 文件中各个参数和如何计算的步骤都在里面有写,可以查看[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter2%20-%20Web%20FrontEnd/2.41.md)。 - -有了 SourceMap 文件,借助于 [mozilla ](https://github.com/mozilla) 的 [source-map](https://github.com/mozilla/source-map) 项目,可以很好的还原 RN 的 crash 日志。 - -我写了个 NodeJS 脚本,代码如下 - -```js -var fs = require('fs'); -var sourceMap = require('source-map'); -var arguments = process.argv.splice(2); - -function parseJSError(aLine, aColumn) { - fs.readFile('./index.ios.map', 'utf8', function (err, data) { - const whatever = sourceMap.SourceMapConsumer.with(data, null, consumer => { - // 读取 crash 日志的行号、列号 - let parseData = consumer.originalPositionFor({ - line: parseInt(aLine), - column: parseInt(aColumn) - }); - // 输出到控制台 - console.log(parseData); - // 输出到文件中 - fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) { - if(err) { - console.log(err); - } - }); - }); - }); -} - -var line = arguments[0]; -var column = arguments[1]; -parseJSError(line, column); -``` - -接下来做个实验,还是上述的 todos 项目。 - -1. 在 Text 的点击事件上模拟 crash - - ```jsx - { - 1 + qw; - }} - > - Debug - - ``` - -2. 将 RN 项目打 bundle 包、产出 sourceMap 文件。执行命令, - - ```shell - react-native bundle --entry-file index.js --platform android --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.android.map; - ``` - - 因为高频使用,所以给 iterm2 增加 alias 别名设置,修改 `.zshrc` 文件 - - ```shell - alias RNRelease='react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;' # RN 打 Release 包 - ``` - -3. 将 js bundle 和图片资源拷贝到 Xcode 工程中 - -4. 点击模拟 crash,将日志下面的行号和列号拷贝,在 Node 项目下,执行下面命令 - - ```shell - node index.js 397 1822 - ``` - -5. 拿脚本解析好的行号、列号、文件信息去和源代码文件比较,结果很正确。 - -![RN Log analysis](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-23-RNCrashLogAnalysis.png) - -##### 2.7.5 SourceMap 解析系统设计 - -目的:通过平台可以将 RN 项目线上 crash 可以还原到具体的文件、代码行数、代码列数。可以看到具体的代码,可以看到 RN stack trace、提供源文件下载功能。 - -1. 打包系统下管理的服务器: - - 生产环境下打包才生成 source map 文件 - - 存储打包前的所有文件(install) -2. 开发产品侧 RN 分析界面。点击收集到的 RN crash,在详情页可以看到具体的文件、代码行数、代码列数。可以看到具体的代码,可以看到 RN stack trace、Native stack trace。(具体技术实现上面讲过了) -3. 由于 souece map 文件较大,RN 解析过长虽然不久,但是是对计算资源的消耗,所以需要设计高效读取方式 -4. SourceMap 在 iOS、Android 模式下不一样,所以 SoureceMap 存储需要区分 os。 - -### 3. KSCrash 的使用包装 - -然后再封装自己的 Crash 处理逻辑。比如要做的事情就是: - -- 继承自 KSCrashInstallation 这个抽象类,设置初始化工作(抽象类比如 NSURLProtocol 必须继承后使用),实现抽象类中的 `sink` 方法。 - - ```c++ - /** - * Crash system installation which handles backend-specific details. - * - * Only one installation can be installed at a time. - * - * This is an abstract class. - */ - @interface KSCrashInstallation : NSObject - ``` - - ```objective-c - #import "APMCrashInstallation.h" - #import - #import "APMCrashReporterSink.h" - - @implementation APMCrashInstallation - - + (instancetype)sharedInstance { - static APMCrashInstallation *sharedInstance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedInstance = [[APMCrashInstallation alloc] init]; - }); - return sharedInstance; - } - - - (id)init { - return [super initWithRequiredProperties: nil]; - } - - - (id)sink { - APMCrashReporterSink *sink = [[APMCrashReporterSink alloc] init]; - return [sink defaultCrashReportFilterSetAppleFmt]; - } - - @end - ``` - -- `sink` 方法内部的 `APMCrashReporterSink` 类,遵循了 **KSCrashReportFilter** 协议,声明了公有方法 `defaultCrashReportFilterSetAppleFmt` - - ```objective-c - // .h - #import - #import - - @interface APMCrashReporterSink : NSObject - - - (id ) defaultCrashReportFilterSetAppleFmt; - - @end - - // .m - #pragma mark - public Method - - - (id ) defaultCrashReportFilterSetAppleFmt - { - return [KSCrashReportFilterPipeline filterWithFilters: - [APMCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], - self, - nil]; - } - ``` - - 其中 `defaultCrashReportFilterSetAppleFmt` 方法内部返回了一个 `KSCrashReportFilterPipeline` 类方法 `filterWithFilters` 的结果。 - - `APMCrashReportFilterAppleFmt` 是一个继承自 `KSCrashReportFilterAppleFmt` 的类,遵循了 `KSCrashReportFilter` 协议。协议方法允许开发者处理 Crash 的数据格式。 - - ```objective-c - /** Filter the specified reports. - * - * @param reports The reports to process. - * @param onCompletion Block to call when processing is complete. - */ - - (void) filterReports:(NSArray*) reports - onCompletion:(KSCrashReportFilterCompletion) onCompletion; - ``` - - ```objective-c - #import - - @interface APMCrashReportFilterAppleFmt : KSCrashReportFilterAppleFmt - - @end - - // .m - - (void) filterReports:(NSArray*)reports onCompletion:(KSCrashReportFilterCompletion)onCompletion - { - NSMutableArray* filteredReports = [NSMutableArray arrayWithCapacity:[reports count]]; - for(NSDictionary *report in reports){ - if([self majorVersion:report] == kExpectedMajorVersion){ - id monitorInfo = [self generateMonitorInfoFromCrashReport:report]; - if(monitorInfo != nil){ - [filteredReports addObject:monitorInfo]; - } - } - } - kscrash_callCompletion(onCompletion, filteredReports, YES, nil); - } - - /** - @brief 获取Crash JSON中的crash时间、mach name、signal name和apple report - */ - - (NSDictionary *)generateMonitorInfoFromCrashReport:(NSDictionary *)crashReport - { - NSDictionary *infoReport = [crashReport objectForKey:@"report"]; - // ... - id appleReport = [self toAppleFormat:crashReport]; - - NSMutableDictionary *info = [NSMutableDictionary dictionary]; - [info setValue:crashTime forKey:@"crashTime"]; - [info setValue:appleReport forKey:@"appleReport"]; - [info setValue:userException forKey:@"userException"]; - [info setValue:userInfo forKey:@"custom"]; - - return [info copy]; - } - ``` - - ```objective-c - /** - * A pipeline of filters. Reports get passed through each subfilter in order. - * - * Input: Depends on what's in the pipeline. - * Output: Depends on what's in the pipeline. - */ - @interface KSCrashReportFilterPipeline : NSObject - ``` - -- APM 能力中为 Crash 模块设置一个启动器。启动器内部设置 KSCrash 的初始化工作,以及触发 Crash 时候监控所需数据的组装。比如:SESSION_ID、App 启动时间、App 名称、崩溃时间、App 版本号、当前页面信息等基础信息。 - - ```objective-c - /** C Function to call during a crash report to give the callee an opportunity to - * add to the report. NULL = ignore. - * - * WARNING: Only call async-safe functions from this function! DO NOT call - * Objective-C methods!!! - */ - @property(atomic,readwrite,assign) KSReportWriteCallback onCrash; - - + (instancetype)sharedInstance - { - static APMCrashMonitor *_sharedManager = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _sharedManager = [[APMCrashMonitor alloc] init]; - }); - return _sharedManager; - } - #pragma mark - public Method - - (void)startMonitor { -     APMMLog(@"crash monitor started"); - #ifdef DEBUG -     BOOL _trackingCrashOnDebug = [APMMonitorConfig sharedInstance].trackingCrashOnDebug; -     if (_trackingCrashOnDebug) { -         [self installKSCrash]; -     } - #else -     [self installKSCrash]; - #endif - } - - #pragma mark - private method - - static void onCrash(const KSCrashReportWriter* writer) { -     NSString *sessionId = [NSString stringWithFormat:@""%@"", ***]]; -     writer->addJSONElement(writer, "SESSION_ID", [sessionId UTF8String], true); -     NSString *appLaunchTime = ***; -     writer->addJSONElement(writer, "USER_APP_START_DATE", [[NSString stringWithFormat:@""%@"", appLaunchTime] UTF8String], true); -     // ... - } - - -(void)installKSCrash { -     [[APMCrashInstallation sharedInstance] install]; -     [[APMCrashInstallation sharedInstance] sendAllReportsWithCompletion:nil]; -     [APMCrashInstallation sharedInstance].onCrash = onCrash; -     dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.f * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ -        _isCanAddCrashCount = NO; -     }); - } - ``` - - 在 `installKSCrash` 方法中调用了 `[[APMCrashInstallation sharedInstance] sendAllReportsWithCompletion: nil]`,内部实现如下 - - ``` - -(void) sendAllReportsWithCompletion:(KSCrashReportFilterCompletion) onCompletion { -     NSArray* reports = [self allReports]; -     KSLOG_INFO(@"Sending %d crash reports", [reports count]); -     [self sendReports:reports onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) { -         KSLOG_DEBUG(@"Process finished with completion: %d", completed); -         if(error != nil) { -     KSLOG_ERROR(@"Failed to send reports: %@", error); -      } -     if((self.deleteBehaviorAfterSendAll == KSCDeleteOnSucess && completed) || -     self.deleteBehaviorAfterSendAll == KSCDeleteAlways){ -      kscrash_deleteAllReports(); -     } -      kscrash_callCompletion(onCompletion, filteredReports, completed, error); -     }]; - } - ``` - - 该方法内部调用了对象方法 `sendReports: onCompletion:`,如下所示 - - ``` - - (void) sendReports:(NSArray*) reports onCompletion:(KSCrashReportFilterCompletion) onCompletion - { - if([reports count] == 0) - { - kscrash_callCompletion(onCompletion, reports, YES, nil); - return; - } - - if(self.sink == nil) - { - kscrash_callCompletion(onCompletion, reports, NO, - [NSError errorWithDomain:[[self class] description] - code:0 - description:@"No sink set. Crash reports not sent."]); - return; - } - - [self.sink filterReports:reports - onCompletion:^(NSArray* filteredReports, BOOL completed, NSError* error) - { - kscrash_callCompletion(onCompletion, filteredReports, completed, error); - }]; - } - ``` - - 方法内部的 `[self.sink filterReports: onCompletion: ]` 实现其实就是 `APMCrashInstallation` 中设置的 `sink` getter 方法,内部返回了 `APMCrashReporterSink` 对象的 `defaultCrashReportFilterSetAppleFmt` 方法的返回值。内部实现如下 - - ``` - - (id ) defaultCrashReportFilterSetAppleFmt - { - return [KSCrashReportFilterPipeline filterWithFilters: - [KSCrashReportFilterAppleFmt filterWithReportStyle:KSAppleReportStyleSymbolicatedSideBySide], - [KSCrashReportFilterStringToData filter], - [KSCrashReportFilterGZipCompress filterWithCompressionLevel:-1], - self, - nil]; - } - ``` - - 可以看到这个函数内部设置了多个 **filters**,其中一个就是 **self**,也就是 `APMCrashReporterSink` 对象,所以上面的 `[self.sink filterReports: onCompletion:]` ,也就是调用 `APMCrashReporterSink` 内的数据处理方法。完了之后通过 `kscrash_callCompletion(onCompletion, reports, YES, nil);` 告诉 `KSCrash` 本地保存的 Crash 日志已经处理完毕,可以删除了。 - - ``` - - (void) filterReports:(NSArray*) reports - onCompletion:(KSCrashReportFilterCompletion) onCompletion - { - // 处理 Crash 数据,将数据交给统一的数据上报组件处理... - kscrash_callCompletion(onCompletion, filteredReports, YES, nil); - } - ``` - - 至此,概括下 KSCrash 做的事情,提供各种 crash 的监控能力,在 crash 后将进程信息、基本信息、异常信息、线程信息等用 c 高效转换为 json 写入文件,App 下次启动后读取本地的 crash 文件夹中的 crash 日志,让开发者可以自定义 key、value 然后去上报日志到 APM 系统,然后删除本地 crash 文件夹中的日志。 - -- - -### 4. 符号化 - -应用 crash 之后,系统会生成一份崩溃日志,存储在设置中,应用的运行状态、调用堆栈、所处线程等信息会记录在日志中。但是这些日志是地址,并不可读,所以需要进行符号化还原。 - -#### 4.1 .DSYM 文件 - -`.DSYM` (debugging symbol)文件是保存十六进制函数地址映射信息的中转文件,调试信息(symbols)都包含在该文件中。Xcode 工程每次编译运行都会生成新的 `.DSYM` 文件。默认情况下 debug 模式时不生成 `.DSYM` ,可以在 Build Settings -> Build Options -> Debug Information Format 后将值 `DWARF` 修改为 `DWARF with DSYM File`,这样再次编译运行就可以生成 `.DSYM` 文件。 - -所以每次 App 打包的时候都需要保存每个版本的 `.DSYM` 文件。 - -`.DSYM` 文件中包含 DWARF 信息,打开文件的包内容 `Test.app.DSYM/Contents/Resources/DWARF/Test` 保存的就是 `DWARF` 文件。 - -`.DSYM` 文件是从 Mach-O 文件中抽取调试信息而得到的文件目录,发布的时候为了安全,会把调试信息存储在单独的文件,`.DSYM` 其实是一个文件目录,结构如下: - -![.DSYM文件结构](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-29-DYSMStructure.png) - -#### 4.2 DWARF 文件 - -> DWARF is a debugging file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments. - -**DWARF 是一种调试文件格式,它被许多编译器和调试器所广泛使用以支持源代码级别的调试**。它满足许多过程语言(C、C++、Fortran)的需求,它被设计为支持拓展到其他语言。DWARF 是架构独立的,适用于其他任何的处理器和操作系统。被广泛使用在 Unix、Linux 和其他的操作系统上,以及独立环境上。 - -DWARF 全称是 Debugging With Arbitrary Record Formats,是一种使用属性化记录格式的调试文件。 - -DWARF 是可执行程序与源代码关系的一个紧凑表示。 - -大多数现代编程语言都是块结构:每个实体(一个类、一个函数)被包含在另一个实体中。一个 c 程序,每个文件可能包含多个数据定义、多个变量、多个函数,所以 DWARF 遵循这个模型,也是块结构。DWARF 里基本的描述项是调试信息项 DIE(Debugging Information Entry)。一个 DIE 有一个标签,表示这个 DIE 描述了什么以及一个填入了细节并进一步描述该项的属性列表(类比 html、xml 结构)。一个 DIE(除了最顶层的)被一个父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个 DIE 的引用(比如一个函数的返回值类型)。 - -DWARF 文件中的数据如下: - -| 数据列 | 信息说明 | -| --------------- | --------------------------- | -| .debug_loc | 在 DW_AT_location 属性中使用的位置列表 | -| .debug_macinfo | 宏信息 | -| .debug_pubnames | 全局对象和函数的查找表 | -| .debug_pubtypes | 全局类型的查找表 | -| .debug_ranges | 在 DW_AT_ranges 属性中使用的地址范围 | -| .debug_str | 在 .debug_info 中使用的字符串表 | -| .debug_types | 类型描述 | - -常用的标记与属性如下: - -| 数据列 | 信息说明 | -| --------------------------- | ------------------- | -| DW_TAG_class_type | 表示类名称和类型信息 | -| DW_TAG_structure_type | 表示结构名称和类型信息 | -| DW_TAG_union_type | 表示联合名称和类型信息 | -| DW_TAG_enumeration_type | 表示枚举名称和类型信息 | -| DW_TAG_typedef | 表示 typedef 的名称和类型信息 | -| DW_TAG_array_type | 表示数组名称和类型信息 | -| DW_TAG_subrange_type | 表示数组的大小信息 | -| DW_TAG_inheritance | 表示继承的类名称和类型信息 | -| DW_TAG_member | 表示类的成员 | -| DW_TAG_subprogram | 表示函数的名称信息 | -| DW_TAG_formal_parameter | 表示函数的参数信息 | -| DW_TAG_name | 表示名称字符串 | -| DW_TAG_type | 表示类型信息 | -| DW_TAG_artifical | 在创建时由编译程序设置 | -| DW_TAG_sibling | 表示兄弟位置信息 | -| DW_TAG_data_memver_location | 表示位置信息 | -| DW_TAG_virtuality | 在虚拟时设置 | - -简单看一个 DWARF 的例子:将测试工程的 `.DSYM` 文件夹下的 DWARF 文件用下面命令解析 - -```shell -dwarfdump -F --debug-info Test.app.DSYM/Contents/Resources/DWARF/Test > debug-info.txt -``` - -打开如下 - -```shell -Test.app.DSYM/Contents/Resources/DWARF/Test: file format Mach-O arm64 - -.debug_info contents: -0x00000000: Compile Unit: length = 0x0000004f version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00000053) - -0x0000000b: DW_TAG_compile_unit - DW_AT_producer [DW_FORM_strp] ("Apple clang version 11.0.3 (clang-1103.0.32.62)") - DW_AT_language [DW_FORM_data2] (DW_LANG_ObjC) - DW_AT_name [DW_FORM_strp] ("_Builtin_stddef_max_align_t") - DW_AT_stmt_list [DW_FORM_sec_offset] (0x00000000) - DW_AT_comp_dir [DW_FORM_strp] ("/Users/lbp/Desktop/Test") - DW_AT_APPLE_major_runtime_vers [DW_FORM_data1] (0x02) - DW_AT_GNU_dwo_id [DW_FORM_data8] (0x392b5344d415340c) - -0x00000027: DW_TAG_module - DW_AT_name [DW_FORM_strp] ("_Builtin_stddef_max_align_t") - DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") - DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include") - DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") - -0x00000038: DW_TAG_typedef - DW_AT_type [DW_FORM_ref4] (0x0000004b "long double") - DW_AT_name [DW_FORM_strp] ("max_align_t") - DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h") - DW_AT_decl_line [DW_FORM_data1] (16) - -0x00000043: DW_TAG_imported_declaration - DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h") - DW_AT_decl_line [DW_FORM_data1] (27) - DW_AT_import [DW_FORM_ref_addr] (0x0000000000000027) - -0x0000004a: NULL - -0x0000004b: DW_TAG_base_type - DW_AT_name [DW_FORM_strp] ("long double") - DW_AT_encoding [DW_FORM_data1] (DW_ATE_float) - DW_AT_byte_size [DW_FORM_data1] (0x08) - -0x00000052: NULL -0x00000053: Compile Unit: length = 0x000183dc version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00018433) - -0x0000005e: DW_TAG_compile_unit - DW_AT_producer [DW_FORM_strp] ("Apple clang version 11.0.3 (clang-1103.0.32.62)") - DW_AT_language [DW_FORM_data2] (DW_LANG_ObjC) - DW_AT_name [DW_FORM_strp] ("Darwin") - DW_AT_stmt_list [DW_FORM_sec_offset] (0x000000a7) - DW_AT_comp_dir [DW_FORM_strp] ("/Users/lbp/Desktop/Test") - DW_AT_APPLE_major_runtime_vers [DW_FORM_data1] (0x02) - DW_AT_GNU_dwo_id [DW_FORM_data8] (0xa4a1d339379e18a5) - -0x0000007a: DW_TAG_module - DW_AT_name [DW_FORM_strp] ("Darwin") - DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") - DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") - DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") - -0x0000008b: DW_TAG_module - DW_AT_name [DW_FORM_strp] ("C") - DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") - DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") - DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") - -0x0000009c: DW_TAG_module - DW_AT_name [DW_FORM_strp] ("fenv") - DW_AT_LLVM_config_macros [DW_FORM_strp] ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"") - DW_AT_LLVM_include_path [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include") - DW_AT_LLVM_isysroot [DW_FORM_strp] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk") - -0x000000ad: DW_TAG_enumeration_type - DW_AT_type [DW_FORM_ref4] (0x00017276 "unsigned int") - DW_AT_byte_size [DW_FORM_data1] (0x04) - DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/fenv.h") - DW_AT_decl_line [DW_FORM_data1] (154) - -0x000000b5: DW_TAG_enumerator - DW_AT_name [DW_FORM_strp] ("__fpcr_trap_invalid") - DW_AT_const_value [DW_FORM_udata] (256) - -0x000000bc: DW_TAG_enumerator - DW_AT_name [DW_FORM_strp] ("__fpcr_trap_divbyzero") - DW_AT_const_value [DW_FORM_udata] (512) - -0x000000c3: DW_TAG_enumerator - DW_AT_name [DW_FORM_strp] ("__fpcr_trap_overflow") - DW_AT_const_value [DW_FORM_udata] (1024) - -0x000000ca: DW_TAG_enumerator - DW_AT_name [DW_FORM_strp] ("__fpcr_trap_underflow") -// ...... -0x000466ee: DW_TAG_subprogram - DW_AT_name [DW_FORM_strp] ("CFBridgingRetain") - DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h") - DW_AT_decl_line [DW_FORM_data1] (105) - DW_AT_prototyped [DW_FORM_flag_present] (true) - DW_AT_type [DW_FORM_ref_addr] (0x0000000000019155 "CFTypeRef") - DW_AT_inline [DW_FORM_data1] (DW_INL_inlined) - -0x000466fa: DW_TAG_formal_parameter - DW_AT_name [DW_FORM_strp] ("X") - DW_AT_decl_file [DW_FORM_data1] ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h") - DW_AT_decl_line [DW_FORM_data1] (105) - DW_AT_type [DW_FORM_ref4] (0x00046706 "id") - -0x00046705: NULL - -0x00046706: DW_TAG_typedef - DW_AT_type [DW_FORM_ref4] (0x00046711 "objc_object*") - DW_AT_name [DW_FORM_strp] ("id") - DW_AT_decl_file [DW_FORM_data1] ("/Users/lbp/Desktop/Test/Test/NetworkAPM/NSURLResponse+apm_FetchStatusLineFromCFNetwork.m") - DW_AT_decl_line [DW_FORM_data1] (44) - -0x00046711: DW_TAG_pointer_type - DW_AT_type [DW_FORM_ref4] (0x00046716 "objc_object") - -0x00046716: DW_TAG_structure_type - DW_AT_name [DW_FORM_strp] ("objc_object") - DW_AT_byte_size [DW_FORM_data1] (0x00) - -0x0004671c: DW_TAG_member - DW_AT_name [DW_FORM_strp] ("isa") - DW_AT_type [DW_FORM_ref4] (0x00046727 "objc_class*") - DW_AT_data_member_location [DW_FORM_data1] (0x00) -// ...... -``` - -这里就不粘贴全部内容了(太长了)。可以看到 DIE 包含了函数开始地址、结束地址、函数名、文件名、所在行数,对于给定的地址,找到函数开始地址、结束地址之间包含该地址的 DIE,则可以还原函数名和文件名信息。 - -debug_line 可以还原文件行数等信息 - -```shell -dwarfdump -F --debug-line Test.app.DSYM/Contents/Resources/DWARF/Test > debug-inline.txt -``` - -贴部分信息 - -```shell -Test.app.DSYM/Contents/Resources/DWARF/Test: file format Mach-O arm64 - -.debug_line contents: -debug_line[0x00000000] -Line table prologue: - total_length: 0x000000a3 - version: 4 - prologue_length: 0x0000009a - min_inst_length: 1 -max_ops_per_inst: 1 - default_is_stmt: 1 - line_base: -5 - line_range: 14 - opcode_base: 13 -standard_opcode_lengths[DW_LNS_copy] = 0 -standard_opcode_lengths[DW_LNS_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_advance_line] = 1 -standard_opcode_lengths[DW_LNS_set_file] = 1 -standard_opcode_lengths[DW_LNS_set_column] = 1 -standard_opcode_lengths[DW_LNS_negate_stmt] = 0 -standard_opcode_lengths[DW_LNS_set_basic_block] = 0 -standard_opcode_lengths[DW_LNS_const_add_pc] = 0 -standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 -standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 -standard_opcode_lengths[DW_LNS_set_isa] = 1 -include_directories[ 1] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include" -file_names[ 1]: - name: "__stddef_max_align_t.h" - dir_index: 1 - mod_time: 0x00000000 - length: 0x00000000 - -Address Line Column File ISA Discriminator Flags ------------------- ------ ------ ------ --- ------------- ------------- -0x0000000000000000 1 0 1 0 0 is_stmt end_sequence -debug_line[0x000000a7] -Line table prologue: - total_length: 0x0000230a - version: 4 - prologue_length: 0x00002301 - min_inst_length: 1 -max_ops_per_inst: 1 - default_is_stmt: 1 - line_base: -5 - line_range: 14 - opcode_base: 13 -standard_opcode_lengths[DW_LNS_copy] = 0 -standard_opcode_lengths[DW_LNS_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_advance_line] = 1 -standard_opcode_lengths[DW_LNS_set_file] = 1 -standard_opcode_lengths[DW_LNS_set_column] = 1 -standard_opcode_lengths[DW_LNS_negate_stmt] = 0 -standard_opcode_lengths[DW_LNS_set_basic_block] = 0 -standard_opcode_lengths[DW_LNS_const_add_pc] = 0 -standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 -standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 -standard_opcode_lengths[DW_LNS_set_isa] = 1 -include_directories[ 1] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include" -include_directories[ 2] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include" -include_directories[ 3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys" -include_directories[ 4] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach" -include_directories[ 5] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern" -include_directories[ 6] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/architecture" -include_directories[ 7] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_types" -include_directories[ 8] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/_types" -include_directories[ 9] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arm" -include_directories[ 10] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_pthread" -include_directories[ 11] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/arm" -include_directories[ 12] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern/arm" -include_directories[ 13] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/uuid" -include_directories[ 14] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet" -include_directories[ 15] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet6" -include_directories[ 16] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/net" -include_directories[ 17] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/pthread" -include_directories[ 18] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach_debug" -include_directories[ 19] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/os" -include_directories[ 20] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/malloc" -include_directories[ 21] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/bsm" -include_directories[ 22] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/machine" -include_directories[ 23] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/machine" -include_directories[ 24] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/secure" -include_directories[ 25] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/xlocale" -include_directories[ 26] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arpa" -file_names[ 1]: - name: "fenv.h" - dir_index: 1 - mod_time: 0x00000000 - length: 0x00000000 -file_names[ 2]: - name: "stdatomic.h" - dir_index: 2 - mod_time: 0x00000000 - length: 0x00000000 -file_names[ 3]: - name: "wait.h" - dir_index: 3 - mod_time: 0x00000000 - length: 0x00000000 -// ...... -Address Line Column File ISA Discriminator Flags ------------------- ------ ------ ------ --- ------------- ------------- -0x000000010000b588 14 0 2 0 0 is_stmt -0x000000010000b5b4 16 5 2 0 0 is_stmt prologue_end -0x000000010000b5d0 17 11 2 0 0 is_stmt -0x000000010000b5d4 0 0 2 0 0 -0x000000010000b5d8 17 5 2 0 0 -0x000000010000b5dc 17 11 2 0 0 -0x000000010000b5e8 18 1 2 0 0 is_stmt -0x000000010000b608 20 0 2 0 0 is_stmt -0x000000010000b61c 22 5 2 0 0 is_stmt prologue_end -0x000000010000b628 23 5 2 0 0 is_stmt -0x000000010000b644 24 1 2 0 0 is_stmt -0x000000010000b650 15 0 1 0 0 is_stmt -0x000000010000b65c 15 41 1 0 0 is_stmt prologue_end -0x000000010000b66c 11 0 2 0 0 is_stmt -0x000000010000b680 11 17 2 0 0 is_stmt prologue_end -0x000000010000b6a4 11 17 2 0 0 is_stmt end_sequence -debug_line[0x0000def9] -Line table prologue: - total_length: 0x0000015a - version: 4 - prologue_length: 0x000000eb - min_inst_length: 1 -max_ops_per_inst: 1 - default_is_stmt: 1 - line_base: -5 - line_range: 14 - opcode_base: 13 -standard_opcode_lengths[DW_LNS_copy] = 0 -standard_opcode_lengths[DW_LNS_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_advance_line] = 1 -standard_opcode_lengths[DW_LNS_set_file] = 1 -standard_opcode_lengths[DW_LNS_set_column] = 1 -standard_opcode_lengths[DW_LNS_negate_stmt] = 0 -standard_opcode_lengths[DW_LNS_set_basic_block] = 0 -standard_opcode_lengths[DW_LNS_const_add_pc] = 0 -standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1 -standard_opcode_lengths[DW_LNS_set_prologue_end] = 0 -standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0 -standard_opcode_lengths[DW_LNS_set_isa] = 1 -include_directories[ 1] = "Test" -include_directories[ 2] = "Test/NetworkAPM" -include_directories[ 3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/objc" -file_names[ 1]: - name: "AppDelegate.h" - dir_index: 1 - mod_time: 0x00000000 - length: 0x00000000 -file_names[ 2]: - name: "JMWebResourceURLProtocol.h" - dir_index: 2 - mod_time: 0x00000000 - length: 0x00000000 -file_names[ 3]: - name: "AppDelegate.m" - dir_index: 1 - mod_time: 0x00000000 - length: 0x00000000 -file_names[ 4]: - name: "objc.h" - dir_index: 3 - mod_time: 0x00000000 - length: 0x00000000 -// ...... -``` - -可以看到 debug_line 里包含了每个代码地址对应的行数。上面贴了 AppDelegate 的部分。 - -#### 4.3 symbols - -> 在链接中,我们将函数和变量统称为符合(Symbol),函数名或变量名就是符号名(Symbol Name),我们可以将符号看成是链接中的粘合剂,整个链接过程正是基于符号才能正确完成的。 - -上述文字来自《程序员的自我修养》。所以符号就是函数、变量、类的统称。 - -按照类型划分,符号可以分为三类: - -- 全局符号:目标文件外可见的符号,可以被其他目标文件所引用,或者需要其他目标文件定义 -- 局部符号:只在目标文件内可见的符号,指只在目标文件内可见的函数和变量 -- 调试符号:包括行号信息的调试符号信息,行号信息记录了函数和变量对应的文件和文件行号。 - -**符号表(Symbol Table)**:是内存地址与函数名、文件名、行号的映射表。每个定义的符号都有一个对应的值得,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是地址,符号表组成如下 - -```shell -<起始地址> <结束地址> <函数> [<文件名:行号>] -``` - -#### 4.4 如何获取地址 - -image 加载的时候会进行相对基地址进行重定位,并且每次加载的基地址都不一样,函数栈 frame 的地址是重定位后的绝对地址,我们要的是重定位前的相对地址。 - -Binary Images - -拿测试工程的 crash 日志举例子,打开贴部分 Binary Images 内容 - -```shell -// ... -Binary Images: -0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test -0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib -0x103204000 - 0x103267fff dyld arm64 <6f1c86b640a3352a8529bca213946dd5> /usr/lib/dyld -0x189a78000 - 0x189a8efff libsystem_trace.dylib arm64 /usr/lib/system/libsystem_trace.dylib -// ... -``` - -可以看到 Crash 日志的 Binary Images 包含每个 Image 的加载开始地址、结束地址、image 名称、arm 架构、uuid、image 路径。 - -crash 日志中的信息 - -```shell -Last Exception Backtrace: -// ... -5 Test 0x102fe592c -[ViewController testMonitorCrash] + 22828 (ViewController.mm:58) -``` - -```sh -Binary Images: -0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test -``` - -所以 frame 5 的相对地址为 `0x102fe592c - 0x102fe0000 `。再使用 命令可以还原符号信息。 - -使用 atos 来解析,`0x102fe0000` 为 image 加载的开始地址,`0x102fe592c` 为 frame 需要还原的地址。 - -```shell -atos -o Test.app.DSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x102fe592c -``` - -#### 4.5 UUID - -- crash 文件的 UUID - - ```shell - grep --after-context=2 "Binary Images:" *.crash - ``` - - ```shell - Test 5-28-20, 7-47 PM.crash:Binary Images: - Test 5-28-20, 7-47 PM.crash-0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test - Test 5-28-20, 7-47 PM.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib - -- - Test.crash:Binary Images: - Test.crash-0x102fe0000 - 0x102ff3fff Test arm64 <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test - Test.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64 <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib - ``` - - Test App 的 UUID 为 `37eaa57df2523d95969e47a9a1d69ce5`. - -- .DSYM 文件的 UUID - - ```shell - dwarfdump --uuid Test.app.DSYM - ``` - - 结果为 - - ```shell - UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app.DSYM/Contents/Resources/DWARF/Test - ``` - -- app 的 UUID - - ```shell - dwarfdump --uuid Test.app/Test - ``` - - 结果为 - - ```shell - UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app/Test - ``` - -#### 4.6 符号化(解析 Crash 日志) - -上述篇幅分析了如何捕获各种类型的 crash,App 在用户手中我们通过技术手段可以获取 crash 案发现场信息并结合一定的机制去上报,但是这种堆栈是十六进制的地址,无法定位问题,所以需要做符号化处理。 - -上面也说明了[.DSYM 文件](#DSYM) 的作用,**通过符号地址结合 DSYM 文件来还原文件名、所在行、函数名,这个过程叫符号化**。但是 .DSYM 文件必须和 crash log 文件的 bundle id、version 严格对应。 - -获取 Crash 日志可以通过 Xcode -> Window -> Devices and Simulators 选择对应设备,找到 Crash 日志文件,根据时间和 App 名称定位。 - -app 和 .DSYM 文件可以通过打包的产物得到,路径为 `~/Library/Developer/Xcode/Archives`。 - -解析方法一般有 2 种: - -- 使用 **symbolicatecrash** - - symbolicatecrash 是 Xcode 自带的 crash 日志分析工具,先确定所在路径,在终端执行下面的命令 - - ```shell - find /Applications/Xcode.app -name symbolicatecrash -type f - ``` - - 会返回几个路径,找到 `iPhoneSimulator.platform` 所在那一行 - - ``` - /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash - ``` - - 将 symbolicatecrash 拷贝到指定文件夹下(保存了 app、DSYM、crash 文件的文件夹) - - 执行命令 - - ```shell - ./symbolicatecrash Test.crash Test.DSYM > Test.crash - ``` - - 第一次做这事儿应该会报错 `Error: "DEVELOPER_DIR" is not defined at ./symbolicatecrash line 69.`,解决方案:在终端执行下面命令 - - ```shell - export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer - ``` - -- 使用 atos - - 区别于 symbolicatecrash,atos 较为灵活,只要 `.crash` 和 `.DSYM` 或者 `.crash` 和 `.app` 文件对应即可。 - - 用法如下,-l 最后跟得是符号地址 - - ```shell - xcrun atos -o Test.app.DSYM/Contents/Resources/DWARF/Test -arch armv7 -l 0x1023c592c - ``` - - 也可以解析 .app 文件(不存在 .DSYM 文件),其中 xxx 为段地址,xx 为偏移地址 - - ```shell - atos -arch architecture -o binary -l xxx xx - ``` - -因为我们的 App 可能有很多,每个 App 在用户手中可能是不同的版本,所以在 APM 拦截之后需要符号化的时候需要将 crash 文件和 `.DSYM` 文件一一对应,才能正确符号化,对应的原则就是 **UUID** 一致。 - -#### 4.7 系统库符号化解析 - -我们每次真机连接 Xcode 运行程序,会提示等待,其实系统为了堆栈解析,都会把当前版本的系统符号库自动导入到 `/Users/你自己的用户名/Library/Developer/Xcode/iOS DeviceSupport` 目录下安装了一大堆系统库的符号化文件。你可以访问下面目录看看 - -```shell -/Users/你自己的用户名/Library/Developer/Xcode/iOS DeviceSupport/ -``` - -![系统符号化文件](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-05-28-SymbolicateLib.png) - -### 5. 服务端处理 - -##### 5.1 ELK 日志系统 - -业界设计日志监控系统一般会采用基于 ELK 技术。ELK 是 Elasticsearch、Logstash、Kibana 三个开源框架缩写。Elasticsearch 是一个分布式、通过 Restful 方式进行交互的近实时搜索的平台框架。Logstash 是一个中央数据流引擎,用于从不同目标(文件/数据存储/MQ)收集不同格式的数据,经过过滤后支持输出到不同目的地(文件/MQ/Redis/ElasticsSearch/Kafka)。Kibana 可以将 Elasticserarch 的数据通过友好的页面展示出来,提供可视化分析功能。所以 ELK 可以搭建一个高效、企业级的日志分析系统。 - -早期单体应用时代,几乎应用的所有功能都在一台机器上运行,出了问题,运维人员打开终端输入命令直接查看系统日志,进而定位问题、解决问题。随着系统的功能越来越复杂,用户体量越来越大,单体应用几乎很难满足需求,所以技术架构迭代了,通过水平拓展来支持庞大的用户量,将单体应用进行拆分为多个应用,每个应用采用集群方式部署,负载均衡控制调度,假如某个子模块发生问题,去找这台服务器上终端找日志分析吗?显然台落后,所以日志管理平台便应运而生。通过 Logstash 去收集分析每台服务器的日志文件,然后按照定义的正则模版过滤后传输到 Kafka 或 Redis,然后由另一个 Logstash 从 Kafka 或 Redis 上读取日志存储到 ES 中创建索引,最后通过 Kibana 进行可视化分析。此外可以将收集到的数据进行数据分析,做更进一步的维护和决策。 - -![ELK架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-14-ELK.png) - -上图展示了一个 ELK 的日志架构图。简单说明下: - -- Logstash 和 ES 之前存在一个 Kafka 层,因为 Logstash 是架设在数据资源服务器上,将收集到的数据进行实时过滤,过滤需要消耗时间和内存,所以存在 Kafka,起到了数据缓冲存储作用,因为 Kafka 具备非常出色的读写性能。 -- 再一步就是 Logstash 从 Kafka 里面进行读取数据,将数据过滤、处理,将结果传输到 ES -- 这个设计不但性能好、耦合低,还具备可拓展性。比如可以从 n 个不同的 Logstash 上读取传输到 n 个 Kafka 上,再由 n 个 Logstash 过滤处理。日志来源可以是 m 个,比如 App 日志、Tomcat 日志、Nginx 日志等等 - -下图贴一个 Elasticsearch 社区分享的一个 “Elastic APM 动手实战”[主题](https://elasticsearch.cn/slides/257#page=3)的内容截图。 - -![Elasticsearch & APM](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-ElastiicsearchAPM.png) - -##### 5.2 服务侧 - -Crash log 统一入库 Kibana 时是没有符号化的,所以需要符号化处理,以方便定位问题、crash 产生报表和后续处理。 - -![crash log 处理流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-14-CrashLogSymbolicate.png) - -所以整个流程就是:客户端 APM SDK 收集 crash log -> Kafka 存储 -> Mac 机执行定时任务符号化 -> 数据回传 Kafka -> 产品侧(显示端)对数据进行分类、报表、报警等操作。 - -因为公司的产品线有多条,相应的 App 有多个,用户使用的 App 版本也各不相同,所以 crash 日志分析必须要有正确的 .DSYM 文件,那么多 App 的不同版本,自动化就变得非常重要了。 - -自动化有 2 种手段,规模小一点的公司或者图省事,可以在 Xcode 中 添加 runScript 脚本代码来自动在 release 模式下上传 DSYM)。 - -因为我们大前端有一套体系,可以同时管理 iOS SDK、iOS App、Android SDK、Android App、Node、React、React Native 工程项目的初始化、依赖管理、构建(持续集成、Unit Test、Lint、统跳检测)、测试、打包、部署、动态能力(热更新、统跳路由下发)等能力于一身。可以基于各个阶段做能力的插入,所以可以在打包系统中,当调用打包后在打包机上传 `.DSYM` 文件到七牛云存储(规则可以是以 AppName + Version 为 key,value 为 .DSYM 文件)。 - -现在很多架构设计都是微服务,至于为什么选微服务,不在本文范畴。所以 crash 日志的符号化被设计为一个微服务。架构图如下 - -![crash 符号化流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMServerArch.png) - -说明: - -- Symbolication Service 作为整个监控系统的一个组成部分,是专注于 crash report 符号化的微服务。 - -- 接收来自任务调度框架的包含预处理过的 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对 crash report 做符号化解析,计算 hash,并将 hash 响应给「数据处理和任务调度框架」。 - -- 接收来自 APM 管理系统的包含原始 crash report 和 DSYM index 的请求,从七牛拉取对应的 DSYM,对 crash report 做符号化解析,并将符号化的 crash report 响应给 APM 管理系统。 - -- 脚手架 cli 有个能力就是调用打包系统的打包构建能力,会根据项目的特点,选择合适的打包机(打包平台是维护了多个打包任务,不同任务根据特点被派发到不同的打包机上,任务详情页可以看到依赖的下载、编译、运行过程等,打包好的产物包括二进制包、下载二维码等等) - -![符号化流程图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-symolication_flow.png) - -其中符号化服务是大前端背景下大前端团队的产物,所以是 NodeJS 实现的(单线程,所以为了提高机器利用率,就要开启多进程能力)。iOS 的符号化机器是 双核的 Mac mini,这就需要做实验测评到底需要开启几个 worker 进程做符号化服务。结果是双进程处理 crash log,比单进程效率高近一倍,而四进程比双进程效率提升不明显,符合双核 mac mini 的特点。所以开启两个 worker 进程做符号化处理。 - -下图是完整设计图 - -![符号化技术设计图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMServerWorker.png) - -简单说明下,符号化流程是一个主从模式,一台 master 机,多个 slave 机,master 机读取 .DSYM 和 crash 结果的 cache。「数据处理和任务调度框架」调度符号化服务(内部 2 个 symbolocate worker)同时从七牛云上获取 .DSYM 文件。 - -系统架构图如下 - -![符号化服务架构图](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-SymbolicateServerArch.png) - -## 九、Weex、Flutter 异常监控 - -Weex 由于历史原因,不做性能监控了,现有业务代码继续跑着,只业务问题和稳定性问题。 - -### Weex 异常监控 - -#### 背景 - -Weex 由于历史原因,不能很好的统计异常。比如在页面的模版代码中绑定了 data 中的一个对象,此时对象可能并没有值,而是依赖后续的网络请求完成,对象才有了具体的值 data 改变,数据驱动,页面再次 render。所以监控代码会认为第一次 render 的时候访问对象不存在的属性。 - -真正有问题的代码和不影响业务的异常信息,都会被 Vue 官方认为是一场。基于这样的背景,我们无法 pick 出真正异常或者是开发者判空代码没写好的问题。 - -#### 解决思路 - -按照异常的等级,可以划分为影响业务和不影响业务。问题来了,什么叫做“影响业务”?这是我们自己定义的词,也就是影响用户是否正常操作 App。比如说:页面白屏、点击某个按钮无响应等等。定义为 Error。其他不影响业务的定义为 Warning。 - -#### 技术实现 - -##### Warning 异常 - -采用主流方案,React、Vue 都提供了框架自己的异常监控方案,由于 Weex 是在 Vue 基础上实现。包括 Native 和 Vue 的 UI 双线程机制、事件机制等等。其余我们不关心,Weex 开发中,开发和都是写 Vue 去实现页面和逻辑,所以我们监控 Vue 的异常就满足了。 - -```js -/** - * APM 监控,目前是 JS 异常监控,ROI 情况下 Weex 只维护旧的页面,新的几乎是 Flutter,所以性能监控暂时未做。有需求可以企业微信联系:魅影 - */ - class APM { - /** - * 获取当前组件的名称 - * @param {*} vm Vue 对象 - * @returns 组件名称 - */ - fetchComponentName (vm) { - const componentName = vm._isVue - ? (vm.$options && vm.$options.name) || - (vm.$options && vm.$options._componentTag) - : vm.name; - return componentName ? componentName : 'unknown' - } - - /** - * 获取当前组件路径 - * @param {*} vm Vue 对象 - * @returns 组件路径 - */ - fetchComponentPath (vm) { - return vm._isVue && vm.$options && vm.$options.__file ? vm.$options.__file : 'unknown' - } - - /** - * 处理Vue错误提示 - */ - monitor(Vue) { - if(!Vue){ - return; - } - // 错误处理 - Vue.config.errorHandler = (err, vm, info) => { - let componentName = '', componentPath = '' - if (Object.prototype.toString.call(vm) === '[object Object]') { - componentName = this.fetchComponentName(vm) - componentPath = this.fetchComponentPath(vm) - } - - let errorInfo = { - name: err.name, - reason: err.message, - callStack: err.stack, - componentName: componentName, - componentPath: componentPath, - info: info, - level: 'VUE_ERROR', - } - try { - const APMUploader = weex.requireModule('APM') - APMUploader.exceptionReport(errorInfo) - } catch (error) { - console.error('Weex 异常模块未注册,请升级 ZanWeexiOS、 ZanWeexAndroid SDK') - } - } - } -} - -export default APM; -``` - -由于 Weex 代码是单独可运行和部署的,因此前端没有统一的入口,所以在发布阶段监控代码需要配合打包机,利用脚本动态插入到页面代码中。 - -##### Error 监控 - -根据我们对“影响业务”的定义,Error 包括:页面白屏 + 事件无法响应。 - -所以我们来拆解下问题。 - -###### 页面白屏 - -根据 Weex SDK 整个完整流程得出,白屏包括以下几个可能: - -- Weex 资源网络请求失败,存在 Error(_mainBundleLoader.onFinished 里的 Error) - - ```objectivec - _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { - if (error) { - // Weex 资源网络请求失败 - NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": [NSString stringWithFormat:@"download bundle error :%@", WeexSafeString([error localizedDescription])], @"pageName": WeexSafeString(strongSelf.pageName)}; - NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] userInfo:errorDic]; - [[WeexAPM sharedInstance] renderFailed:renderError]; - // ... - return; - } - ``` - -- Weex 资源网络请求成功,但是数据内容为空 - - ```objectivec - _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { - // ... - - if (!data) { - // Weex 资源网络请求成功,但是数据内容为空。也就是下载下来的资源无法消费,页面无法正常渲染 - NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ With no data return", WeexSafeString(request.URL)]; - NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": errorMessage, @"pageName": WeexSafeString(strongSelf.pageName)}; - NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_KEY_EXCEPTION_JS_DOWNLOAD] userInfo:errorDic]; - [[WeexAPM sharedInstance] renderFailed:renderError]; - // ... - return; - } - } - ``` - -- Weex 资源网络请求成功,但是数据 JSON 序列化失败 - - ```objectivec - _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { - // ... - - NSString *jsBundleString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - if (!jsBundleString) { - // Weex 资源网络请求成功,但是网络数据 JSON 序列化失败,也就是下载下来的资源无法消费,页面无法正常渲染 - NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": @"data converting to string failed.", @"pageName": WeexSafeString(strongSelf.pageName)}; - NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", WX_ERR_JSBUNDLE_STRING_CONVERT] userInfo:errorDic]; - [[WeexAPM sharedInstance] renderFailed:renderError]; - // ... - return; - } - } - ``` - -- Weex 资源网络请求异常(网络请求 _mainBundleLoader.onFailed) - - ```objectivec - _mainBundleLoader.onFailed = ^(NSError *loadError) { - NSString *errorMessage = [NSString stringWithFormat:@"Request to %@ occurs an error:%@, info:%@", request.URL, loadError.localizedDescription, loadError.userInfo]; - long wxErrorCode = [loadError.domain isEqualToString:NSURLErrorDomain] && loadError.code == NSURLErrorNotConnectedToInternet ? WX_ERR_NOT_CONNECTED_TO_INTERNET : WX_ERR_JSBUNDLE_DOWNLOAD; - // Weex 资源网络请求失败回调。得不到 Weex 资源,也就是页面无法正常渲染 - NSDictionary *errorDic = @{@"function": @"_renderWithRequest:options:data:", @"exception": errorMessage, @"pageName": WeexSafeString(weakSelf.pageName)}; - NSError *renderError = [NSError errorWithDomain:WX_ERROR_DOMAIN code:[NSString stringWithFormat:@"%d", wxErrorCode] userInfo:errorDic]; - [[WeexAPM sharedInstance] renderFailed:renderError]; - // ... - }; - ``` - -###### 点击事件无响应 - -调试 WeexSDK 发现 SDK 内部通过给 JSContext 注册了 `callNativeModule` 方法来实现调用 Native Module 的 Method。 - -其中有2种可能。 - -第一种:Weex 调用了 Native 的方法,但是 Native 方法的参数类型不匹配,这种 case 下 WeexSDK 会 Crash。要做的事情有2步: - -- 给 `WX_ARGUMENTS_SET(invocation, signature, i, argument, freeList);` 添加 try...catch...,保护代码不要崩溃 -- 收集案发信息。当前 Module 名称、方法名称、参数等信息 - -```objectivec -- (NSInvocation *)invocationWithTarget:(id)target selector:(SEL)selector -{ - // ... - for (int i = 0; i < arguments.count; i ++ ) { - // ... - if (!strcmp(parameterType, blockType)) { - // ... - } else { - argument = obj; - @try { - WX_ARGUMENTS_SET(invocation, signature, i, argument, freeList); - } @catch (NSException *exception) { - NSDictionary *errorDict = @{ - @"page": WeexSafeString([WXSDKEngine topInstance].pageName ?: (self.arguments.count > 0 ? ([self.arguments.firstObject isKindOfClass:[NSDictionary class]] ? [self.arguments.firstObject objectForKey:@"url"] : @"") : @"")), - @"ModuleName": WeexSafeString([self respondsToSelector:@selector(moduleName)] ? [self performSelector:@selector(moduleName)] : @""), - @"MethodName": WeexSafeString(self.methodName), - @"MethodArguments": argument ?: @"", - @"reasone": @"调用 Native Module 方法的参数不符合要求", - @"type": @(WeexAPMTypeCallNativeMethodFailed) - }; - [[WeexAPM sharedInstance] reportError:errorDict]; - } @finally { - - } - } - } - // ... -} -``` - -第二种:Weex 调用 Native Module 的方法,但是方法没有注册或者方法名调错。这种会走到 invoke 代码里。 - -```objectivec -- (NSInvocation *)invoke -{ - // ... - if (![moduleInstance respondsToSelector:selector]) { - // if not implement the selector, then dispatch default module method - if ([self.methodName isEqualToString:@"addEventListener"]) { - [self.instance _addModuleEventObserversWithModuleMethod:self]; - } else if ([self.methodName isEqualToString:@"removeAllEventListeners"]) { - [self.instance _removeModuleEventObserverWithModuleMethod:self]; - } else { - NSString *errorMessage = [NSString stringWithFormat:@"method:%@ for module:%@ doesn't exist, maybe it has not been registered", self.methodName, _moduleName]; - WX_MONITOR_FAIL(WXMTJSBridge, WX_ERR_INVOKE_NATIVE, errorMessage); - } - return nil; - } -``` - -该 `WX_MONITOR_FAIL` 宏定义最终会走到 。所以这一步的有效监控代码就是下面部分 - -```objectivec -+ (void)monitoringPoint:(WXMonitorTag)tag isSuccss:(BOOL)success error:(NSError *)error onPage:(NSString *)pageName -{ - if (!success) { - WXLogError(@"%@", error.localizedDescription); - NSDictionary *errorDict = @{ - @"page": pageName ? : ([WXSDKEngine topInstance].pageName ?: @""), - @"error": @{ - @"errorCode": @(error.code), - @"errorUserInfo": error.userInfo ?: error.userInfo, - }, - @"type": @(WeexAPMTypeCallNativeMethodFailed) - }; - [[WeexAPM sharedInstance] reportError:errorDict]; - } - // ... -} -``` - -##### JS ExceptionHandler - -JS 引擎中异常回调中上报错误 - -```objectivec -- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args -{ - WXLogDebug(@"Calling JS... method:%@, args:%@", method, args); - JSValue *value = nil; - @try { - value = [[_jsContext globalObject] invokeMethod:method withArguments:args]; - } @catch (NSException *exception) { - NSDictionary *errorDic = @{@"errorName": WeexSafeString(exception.name), @"errorReason": WeexSafeString(exception.reason), @"errorInfo": WeexSafeString(exception.userInfo), @"methodName": WeexSafeString(method), @"args": args ?: @"", @"type": @(WeexAPMTypeJSException)}; - [[WeexAPM sharedInstance] reportError:errorDic]; - } - return value; -} -``` - -其中为了后续统计方便,定义了 Weex 异常枚举 - -```objectivec -typedef NS_ENUM(NSUInteger, WeexAPMType) { - WeexAPMTypeDefault = 0, // 默认错误,基本用不到,防止取值判断为0默认值的case - WeexAPMTypeRenderFailed, // 白屏 - WeexAPMTypeCallNativeMethodFailed, // 调用 Native 方法出错 - WeexAPMTypeJSException, // JS ExceptionHandler -}; -``` - -统计线上数据发现,Weex 页面渲染失败率为4.09%,所以看上去页面渲染失败率还是蛮高的。目前发现失败率最高的场景为 App 启动时候按照功能模块组织的配置清单。 -类似于下面的配置: - -``` -"//goods/detail": { - "configParams": "", - "js": "https://xxcdn.cn/bizName/Phone/Goods/detail.js", - "id": 00001, - "pageId": 00101, - "md5": "f2d8d44bc6d5693b46fb7849bcd00e50" -}, -``` - -因此,针对于这样的场景。我们希望针对 Weex 资源的拉取和访问机制做一些优化。 -目前有几个流程不太合理: - -1. 当前启动时的js 预载入流程里的JS下载失败 和 进入Weex页面后的JS拉取失败 两处的上报失败未做区分,没法实际统计加载页面时有多少JS获取失败的情况。 -2. 随着业务迭代, Weex 页面越来越多,需要做 JS 拉取相关优化,避免白屏问题 -3. 目前 JS 缓存逻辑为,每个 Weex 模块单独维护一个 LRUCache,并且会做大量的预加载,但可能大多数页面根本不会访问到。浪费了内存资源去做了一些不会被调用到的页面预加载,可能还会造成 OOM 问题 - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WeexResourcePull.png) - -// todo? 参考前端资源模块化,如何实现资源预加载(全局 LRU或者基于用户行为路径的预热功能,比如某个收银员角色固定的情况下,他日常的操作行为是固定的,商品扫码、开单) - - - -### Flutter 异常监控 - -Flutter 异常指的是,Flutter 程序中 Dart 代码运行时意外发生的错误事件。我们可以通过与 Java 类似的 try-catch 机制来捕获它。但与 Java 不同的是,Dart 程序不强制要求我们必须处理异常。 - -这是由于 Flutter 本身是 Dart 事件循环机制来运行任务的。各个任务的运行状态是相互独立的。即使某个任务出现了异常,Dart 也不会退出,只会导致当前任务的后续代码不会执行,用户还可以继续使用其他功能。 - -Dart 异常可以分为 App 异常和 Framework 异常,分别处理。 - -#### App 异常监控 - -App 异常,就是应用代码的异常,通常由未处理应用层其他模块所抛出的异常引起。根据异常代码的执行时序,App 异常可以分为两类: - -- 同步异常:可以通过 try-catch 机制捕获 -- 异步异常:需要采用 Future 提供的 catchError 语句捕获 - -```dart -// 使用 try-catch 捕获同步异常 -try { - throw StateError('This is a Dart exception.'); -} catch(e) { - print(e); -} - -// 使用 catchError 捕获异步异常 -Future.delayed(Duration(seconds: 1)) - .then((e) => throw StateError('This is a Dart exception in Future.')) - .catchError((e)=>print(e)); -``` - -需要注意的是,这两种方式是不能混用的。可以看到,在上面的代码中,我们是无法使用 try-catch 去捕获一个异步调用所抛出的异常的 - -```dart -// try...catch...无法捕获异步异常 -try { - Future.delayed(Duration(seconds: 1)) - .then((e) => throw StateError('This is a Dart exception in Future.')) -} catch(e) { - print("This line will never be executed. "); -} -``` - -但这适合写业务代码的时候分散处理,如果做 APM,需要一种统一收口的方式监控异常,该怎么办呢? - -Flutter 提供了 `Zone.runZoned` 方法,给代码执行对象指定一个 Zone,在 Dart 中,Zone 表示一个代码执行的环境范围,其概念类似沙盒,不同沙盒之间是互相隔离的。如果我们想要观察沙盒中代码执行出现的异常,沙盒提供了 onError 回调函数,拦截那些在代码执行对象中的未捕获异常。 - -可以将代码都写到 Zone 里,即使没有使用 `try...catch...` 和 `catchError` ,任何同步、异步异常也都被 Zone 捕获到 - -```dart -// 同步异常 -runZoned(() { - throw StateError('This is a Dart exception.'); -}, onError: (dynamic e, StackTrace stack) { - print('Sync error caught by zone'); -}); - -// 异步异常 -runZoned(() { - Future.delayed(Duration(seconds: 1)) - .then((e) => throw StateError('This is a Dart exception in Future.')); -}, onError: (dynamic e, StackTrace stack) { - print('Async error aught by zone'); -}); -``` - -如何收口? - -要集中捕获 Flutter 应用中的未处理异常,可以把 main 函数中的 runApp 语句也放置在 Zone 中。这样在检测到代码中运行异常时,我们就能根据获取到的异常上下文信息,进行统一处理。 - -```dart -runZoned>(() async { - runApp(MyApp()); -}, onError: (error, stackTrace) async { - // 异常数据收集,上报 APM -}); -``` - - - -### Framework 异常监控 - -Framework 异常,就是 Flutter 框架引发的异常,通常是由应用代码触发了 Flutter 框架底层的异常判断引起的。比如,当布局不合规范时,Flutter 就会自动弹出一个红色错误界面。 - -这是由于 Flutter 框架在调用 build 方法构建页面时进行了 `try...catch...` 处理,并提供了一个 `ErrorWidget`,用于组建渲染错误的时候进行信息展示 - -```dart -@override -void performRebuild() { - Widget built; - try { - // 创建页面 - built = build(); - } catch (e, stack) { - // 使用 ErrorWidget 创建页面,展示错误信息 - built = ErrorWidget.builder(_debugReportException(ErrorDescription("building $this"), e, stack)); - ... - } - ... -} -``` - -该方案适合在开发阶段定位问题。但如果让用户看到这样一个页面,就很糟糕。因此,通常会重写 `ErrorWidget.builder` 方法,将这样的错误提示页面替换成一个更加友好的页面。 - -如何重写? - -```dart -ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails){ - return Scaffold( - body: Center( - // ... UI 描述 - ) - ); -}; -``` - -`ErrorWidget.builder`方法提供了一个参数 `FlutterErrorDetails` 用于表示当前的错误上下文,可以将异常信息上报 APM,用于后续分析异常原因。 - -为了集中处理框架异常,Flutter 提供了 `FlutterError` 类,这个类的 `onError` 属性会在接收到框架异常时执行相应的回调。因此,要实现自定义捕获逻辑,我们只要为它提供一个自定义的错误处理回调即可。 - - - -### 如何收口 - -App 异常和 Framework 异常都存在,写2个口子收集也可以。但 Flutter 提供了更加友好的口子。使用 Zone 提供的 `handleUncaughtError` 语句,将 Flutter 框架的异常统一转发到当前的 Zone 中,这样我们就可以统一使用 Zone 去处理应用内的所有异常了 - -```dart -FlutterError.onError = (FlutterErrorDetails details) async { - // Framework 异常转发至 Zone 中 - Zone.current.handleUncaughtError(details.exception, details.stack); -}; - -runZoned>(() async { - // App 异常本身就在 Zone 的 onError 中收集 - runApp(MyApp()); -}, onError: (error, stackTrace) async { - //Do sth for error -}); -``` - -异常信息收集了,走 Native 的数据聚合上报策略。用于后续的异常问题自动定位和告警机制。 - - - -## 十、子线程 UI 监控 -### 为什么不能在子线程操作 UI -UI 必须在主线程(UI 线程)执行,核心原因是 **UI 框架的单线程模型**设计,为了保证 UI 操作的线程安全、状态一致性和渲染效率,几乎所有的主流 UI 框架(Android、iOS、Web、Windows、Web 前端、Flutter、Weex、RN)都限制:只有主线程才可以访问和修改 UI 控件。 - -#### 核心原因:解决“线程安全”与“状态一致性”问题 -UI 控件(如按钮、文本框)是**共享资源**,且设计时**没有内置线程同步机制(比如锁)**,如果允许多线程直接操作 UI,会引发2个致命问题: - -##### 1. 竞态条件(Race Condition):UI 状态错乱 -多线程同时修改同一个 UI 控件的属性,会导致 UI 状态“冲突”,比如: -- 线程 A 设置 UILabel 的文本为“加载中,正在获取服务端数据...” -- 线程 B,设置 UILabel 文字颜色为蓝色 -- 线程 C,设置 UILabel 为隐藏(此时 UILabel 的文字可能还没更改完,状态不对,但立马被设置为隐藏了) -最终出现了 UILabel 的显示错乱,UI 控件卡死,界面闪烁问题 - -##### 2. 渲染流程混乱:绘制结果不可预期 -UI 渲染是个连续的流水线:先计算控件布局(Measure/Layout) -> 绘制控件(Draw) -> 刷新屏幕(Compose)。这个流程需要顺序执行、状态稳定,如果多线程介入: -- 线程 A 正在计算布局(比如调整 UILabel 的位置) -- 线程 B 突然修改 Frame 大小,导致布局计算结果失效 -- 最终绘制出界面可能是“按钮位置偏移”、“控件重叠”等异常 - -#### 为什么不设计线程安全的 UI 控件? -##### 1. 性能暴跌:UI 操作便卡顿 -UI 操作是非常高频的场景(比如列表在滑动的时候,每秒可能刷新几十次),而加锁会导致性能问题(比如线程 A 给 UILabel 加了锁,线程 B 想要修改文字大小,此时必须等待线程 A 释放锁)。频繁的锁竞争会让 UI 的变化延迟响应,甚至出现“滑动掉帧”问题,或者“点击无响应”-这违背了 UI 对流畅性的核心要求 -##### 2. 复杂度爆炸:容易引发死锁 -UI 控件存在依赖关系(比如父子组件、UI 组件树),多线程操作时,可能出现“锁顺序错乱” -- 线程 A 锁父控件 -> 再尝试锁子控件 -- 线程 B 锁子控件 -> 再尝试锁父控件 -2者相互等待出现死锁 - -因此,UI 框架必须满足“牺牲多线程灵活性”,以确保“单线程的简单性、安全性和高效性”,索性一刀切,**所有的 UI 操作必须在主线程上执行**,从根源上避免不符合预期的行为出现 - -##### GUI 平台的单线程模型 -所有主流平台、框架都遵循这一原则,且会主动拦截“子线程操作 UI” 的行为: -1. Android - - 主线程(UI 线程)通过 Looper 循环处理 MessageQueue 的 UI 消息 - - 子线程操作 UI 会直接弹出 `CalledFromWrongThreadException`(只有创建视图层次的原始线程才可以触摸其视图) - - 必须通过 Handler、runOnUiThread、Croutine(Dispatch.Main)等方式更新 UI -2. iOS - - 主线程(UI 线程)通过绑定主 RunLoop,负责处理 UI 事件(点击、触摸、滑动)和渲染 - - 子线程操作 UI 可能导致界面异常(比如 UILabel 文本不更新)、甚至 crash - - 必须通过 dispatch.async 来向主队列派发可以用于主线程执行的任务 -3. Web 前端 - - 浏览器的 DOM 操作是单线程的(主线程负责 DOM 渲染、事件处理) - - 子线程(如 Web Worker)无法直接操作 DOM,必须通过 postMessage 通知主线程操作 UI 更新 - -总结:UI 必须在主线程上更新是因为:UI 控件没有设计线程同步机制(比如加锁),所以框架为了保证线程安全、状态一致、渲染高效,一刀切直接采用了单线程模型。 - - -### 1. 背景介绍 - -可能有些人一直没有遇到过因为在子线程操作 UI,导致在开发阶段 Xcode console 输出了一堆日志,大体如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2022-0204-SubThreadUIXcode1@2x.png) - -本来常见的开发都会规避这些写法,没机会看到子线程操作 UI 的问题,但是 Weex 的业务代码,检测出存在子线程操作 UI 的问题,所以还是有必要增加这个能力的。 - -其实我们可以给 Xcode 打个 `Runtime Issue Breakpoint` ,type 选择 `Main Thread Checker`, 在发生子线程操作 UI 的时候就会被系统检测到并触发断点,同时可以看到堆栈情况 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2022-0204-SubThreadUISymbolBreakpoints@2x.png) - -效果如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2022-0204-SubThreadUIXcodeBreakingPointsHappened@2x.png) - -### 2. 问题及解决方案 - -上述的功能是在 Xcode 自带的,连接 Xcode 做调试才具备的功能,线上包无法检测到。 - -经过探索 Xcode 实现该功能是依赖于设备上的` libMainThreadChecker.dylib` 库,我们可以通过 `dlopen` 方法强制加载该库让非 Xcode 环境下也拥有监测功能。 - -另外在监控到子线程调用 UI 调用时,在 Xcode 环境下,会将调用栈输出到控制台,经过测试,`libMainThreadChecker.dylib` 使用的是进行输出的,由于 NSLog 是将信息输出到 `STDERR`中,我们可以通过 `NSPipe` 与 `dup2` 将 `STDERR` 输出拦截,通过对信息的文案的判断,进而获取监测到的 UI 调用,最后可以通过堆栈打印出来,就可以帮助定位到具体问题。 - -`libMainThreadChecker.dylib` 库具有局限性,仅仅对系统提供的一些特定类的特定 API 在子线程调用会被监控到(例如 UIKit 框架中 UIView 类)。 -但是某些类有些 API 我们也不希望在子线程被调用,这时候 `libMainThreadChecker.dylib`是无法满足的。 - -对 `libMainThreadChecker.dylib` 库的汇编代码研究,发现 `libMainThreadChecker.dylib` 是通过内部 `__main_thread_add_check_for_selector` 这个方法来进行类和方法的注册的。所以如果我们同样可以通过 `dlsym` 来调用该方法,以达到对自定义类和方法的主线程调用监测。 -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2022-0204-SubThreadUIMonitor@2x.PNG) - -另外该功能可以在线下 debug 阶段开启,判断是否是在 Xcode debug 状态,可以通过苹果提供的[官方判断方法](https://developer.apple.com/library/archive/qa/qa1361/_index.html#//apple_ref/doc/uid/DTS10003368)实现。 - -对 [dlopen](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlopen.3.html)、[dlsym](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlsym.3.html) 陌生的小伙伴可以直接看 Apple 官方文档,这里不做展开。 - -具体可以参考这个 [Demo](https://github.com/FantasticLBP/MainThreadChecker) - -## 十一、页面渲染时长统计 - -当我们的产品经理、TL、领导或者任何关心我们产品质量的某个角色问你,你们的 App 看上去好像比较卡,很难用,这时候我们心里一阵空虚,卡吗? - -好像用 APM 的卡顿没发现啥问题。仔细看看发现了问题,我们的卡顿只是监控主线程方法耗时。一个页面加载后可能会触发一些网络请求功能,子线程请求完网络,可能会做一些数组裁剪、组装,拼接为 UI 所需要的信息,这个时候很可能会发生卡顿,所以这种 case 下卡顿监控是无法覆盖的。用下图表示 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/PageLoadFullTime.png) - -页面作为承载用户交互的具体战场,我们需要对页面的性能有个直观的指标。业界一般有2个指标:**页面渲染时长、页面可交互时长**。 - -页面可交互时长业界有好几种方案: - -- 基于图像识别,利用 CoreML 来做识别,判断页面可见区域的变化 - -- 基于页面配置配置接口信息,yml 文件key为页面名称,value 为接口数组。动态监控网络 - -- 8060算法。MaxX(水平视图)>60%页面宽度,MaxY(垂直高度)>80%页面高度,且检查主线程心跳判断是否渲染完成。 - -其中每个方案各有优缺点,都不太满足需求。假设采用网络这个方案,关键步骤如下: - -- 首先切面统计每个页面的 `viewDidLoad` 方法,标记当前页面开始加载流程 - -- NSURLProtocol 监控当前页面的网络请求 - -- 当该页面所有的网络请求成功后,不管是直接 dispatch 任务到主线程去刷新 UI,还是先做数据的聚合裁剪,再 dispatch 任务到主线程去刷新 UI - -这里可能某些场景下监控到的网络请求不一定是当前页面发起的,比如 App 存在 Socket 长连通道,这个时候心跳时间到了,发起网络请求刚到被当前的机制所判定为页面所需网络请求。或者某些其他特殊 case 也会导致。但是这些场景应该比较少,可控,针对耗时监控需要增加黑名单口子,过滤可能会被误判的网络请求。 - -这里存在一个问题,就是业务代码在网络请求成之后数据裁剪聚合是在子线程,最后 dispatch 一个刷新任务到主队列。但是 APM 监控本身也需要 dispatch 任务到主队列。需要做到无痕,那么如何保证 APM 自己的任务一定是在业务代码 dispatch 的任务之后? - -还有个问题就是可能在 viewDidLoad 中触发的网络请求,异步任务结束时刻是在 viewDidAppear 之后,所以如何判断页面渲染结束?比如可以判断 RunLoopMode。一个页面是可以滚动的,如何识别页面网络真正加载完成?假如页面加载完成后,用户下拉加载更多,此时 RunLoopMode 会从 kCFRunLoopDefaultMode 切换到 UITrackingRunLoopMode。辅助结合 RunLoopMode 来判断页面时候发生了滑动。 - -还有一个问题。页面所需的数据不一定是网络,可能是 DB 的 CURD,所以针对网络的 hook 并不能满足所有场景,假如想到一个 case 去写一些兼容代码,那这个方案本身就很不优雅了。 - -最后想了想,还是需要回到 8060算法 + 主线程心跳。页面布局一般分为2种情况: - -页面布局紧凑型。紧凑型的话,页面排版占比满足 MaxX > KScreenWith * 80%,MaxY > KScreenHeight * 60%。在此基础上假如页面还在布局,则可以结合主线程心跳,来判断。只要在8060基础上,后续 dispatch 的任务得到执行,大概率认为页面渲染完毕了。 - -页面布局稀疏型。稀疏的情况上不满足8060,则可以直接判断主线程心跳,心跳得到执行,则这个时刻可以判定为页面渲染完毕。 - -检测时机:那么何时去判断页面是否满足8060?可以基于当前 ViewController 的 `viewDidLayoutSubviews` 。任何 IO 数据主要驱动 UI 渲染,最后都会收口于 viewDidLayoutSubViews 这个方法上。所以可以在这个方法之后做8060检测。 - -> Called to notify the view controller that its view has just laid out its subviews. - -其实没有十全十美方案,目前看上去是一个折中的方案。只要保证统计口径是不变的,那监控数据本身就有参考意义。 - -另外随着问题的暴露或者技术研究的渗入,监控方案本身是可以迭代演进的。 - -1. 8060 会有特例,比如8060满足了,且此时主线程空了。因为某个 ImageView 在子线程上根据 URL 异步请求资源,之后会再次触发渲染。所以这个情况下,方案还是存在问题的 - -## 十二、单点追踪 - -或者某个 Crash 根据案发堆栈能发现并解决问题,或许某个卡顿/ANR 可以根据堆栈找到问题,或许不能。也有一些 OOM 问题,提供的内存分配信息比较难复现或者定位问题。又或者某些线上性能问题比较难直接从某一方面发现问题,这时候往往就需要单点追踪能力了,根据用户某个时间段、设备等信息,将相关的性能数据、用户行为等数据聚合起来,结合分析 - -业务团队经常有关心某个页面或者某个关键业务流程卡顿情况的诉求,比如门店经营团队的同学很关心商品加购到开单整个链路的卡顿情况。那如何实现呢?基于这个背景,可以参考 iOS 系统留给开发者标记某个场景的功能(需要搭配 Instrucments 使用)使用需要导入 `#include signpost.h>` 文件。 - -所以 APM 也提供了类似的能力,`-(void)setPhase:(NSString *)phase`。数据被打标之后,mPaaS 会去查找和检索。 - -这样情况下,业务方可以针对特定的业务流程做性能统计分析。 - -## 十三、 APM 小结 - -1. 通常来说各个端的监控能力是不太一致的,技术实现细节也不统一。所以在技术方案评审的时候需要将监控能力对齐统一。每个能力在各个端的数据字段必须对齐(字段个数、名称、数据类型和精度),因为 APM 本身是一个闭环,监控了之后需符号化解析、数据整理,进行产品化开发、最后需要监控大盘展示等 - -2. 一些 crash 或者 ANR 等根据等级需要邮件、短信、企业内容通信工具告知干系人,之后快速发布版本、hot fix 等。 - -3. 监控的各个能力需要做成可配置,灵活开启关闭。 - -4. 监控数据需要做内存到文件的写入处理,需要注意策略。监控数据需要存储数据库,数据库大小、设计规则等。存入数据库后如何上报,上报机制等会在另一篇文章讲:[打造一个通用、可配置的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) - -5. 尽量在技术评审后,将各端的技术实现写进文档中,同步给相关人员。比如 ANR 的实现 - - ```objective-c - /* - android 端 - - 根据设备分级,一般超过 300ms 视为一次卡顿 - hook 系统 loop,在消息处理前后插桩,用以计算每条消息的时长 - 开启另外线程 dump 堆栈,处理结束后关闭 - */ - new ExceptionProcessor().init(this, new Runnable() { - @Override - public void run() { - //监测卡顿 - try { - ProxyPrinter proxyPrinter = new ProxyPrinter(PerformanceMonitor.this); - Looper.getMainLooper().setMessageLogging(proxyPrinter); - mWeakPrinter = new WeakReference(proxyPrinter); - } catch (FileNotFoundException e) { - } - } - }) - - /* - iOS 端 - - 子线程通过 ping 主线程来确认主线程当前是否卡顿。 - 卡顿阈值设置为 300ms,超过阈值时认为卡顿。 - 卡顿时获取主线程的堆栈,并存储上传。 - */ - - (void) main() { - while (self.cancle == NO) { - self.isMainThreadBlocked = YES; - dispatch_async(dispatch_get_main_queue(), ^{ - self.isMainThreadBlocked = YES; - [self.semaphore singal]; - }); - [Thread sleep:300]; - if (self.isMainThreadBlocked) { - [self handleMainThreadBlock]; - } - [self.semaphore wait]; - } - } - ``` - -6. 整个 APM 的架构图如下 - - ![APM Structure](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-17-APMStructure.jpg) - - 说明: - - - 埋点 SDK,通过 sessionId 来关联日志数据 - -7. APM 技术方案本身是随着技术手段、分析需求不断调整升级的。上图的几个结构示意图是早期几个版本的,目前使用的是在此基础上进行了升级和结构调整,提几个关键词:Hermes、Flink SQL、InfluxDB。 - -8. 获取到 APM 问题数据是第一步,获取之后问题需要及时上报,需要有一个数据上报系统,数据及时准确、高效的上传到服务端(这就是另一个命题,可以查看这篇文章:[打造一个通用、可配置、多句柄的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md)),到 APM 后台。后台产生工单,根据业务模块分配到对应的责任人、群组。根据问题的紧急程度,报警策略和提醒频次也不一样,比如波动报警、自定义报警策略等(这也是另一个命题:智能报警策略、波动报警策略、业务线自定义可配置责任人等)。责任人及时解决问题、修改工单状态、热修复或者发布新版本,问题闭环。 - - - -## 十四、未来规划 - -- 监控能力继续完善、提升监控准确度 -- APM 新方向的研究。UI 自动化结合 iOS Instrucments Server 中的性能数据(DTXMessage 通信) -- 产品侧:开源 SDK + mPaaS 平台(或者 Electron 写一款 桌面端 App 查看性能数据) -- AI 赋能:根据特征数据做到智能化归因 -- 报警平台已具备波动报警、业务域 Owner 报警策略可配置。未来需利用 AI 能力做更多畅想 diff --git a/Chapter1 - iOS/1.75.md b/Chapter1 - iOS/1.75.md deleted file mode 100644 index f06313a..0000000 --- a/Chapter1 - iOS/1.75.md +++ /dev/null @@ -1,1615 +0,0 @@ -# 写好测试,提升应用质量 - -> 相信在国内一些中小型公司,开发者很少会去写软件测试相关的代码。当然这背后有一些原因在。本文就讲讲 iOS 开发中的软件测试相关的内容。 - -## 一、 测试的重要性 - -测试很重要!测试很重要!测试很重要!重要的事情说三遍。 - -场景1:每次我们写完代码后都需要编译运行,以查看应用程序的表现是否符合预期。假如改动点、代码量小,那验证成本低一些,假如不符合预期,则说明我们的代码有问,人工去排查问题花费的时间也少一些。假如改动点很多、受影响的地方较多,我们首先要梳理相关影响点,然后去定位问题、排查问题的成本就很高。 - -场景2:你新接手的 SDK 某个子功能需要做一次技术重构。但是你只有在公司内部的代码托管平台上可以看到一些 Readme、接入文档、系统设计文档、技术方案评估文档等一堆文档。可能你会看完再去动手重构。当你重构完了,找了公司某条业务线的 App 接入测试,点了几下发现发生了奔溃。😂 心想,本地测试、debug 都正常可是为什么接入后就 Crash 了。其实想想也好理解,你本地重构只是确保了你开发的那个功能运行正常,你很难确保你写的代码没有影响其他类、其他功能。假如之前的 SDK 针对每个类都有单元测试代码,那你在新功能开发完毕后完整跑一次单元测试代码就好了,保证每个 Unit Test 都通过、分支覆盖率达到约定的值,那基本上是没问题的,或者说说问题的概率非常低了。 - -场景3:在版本迭代的时候,计划功能 A,从开发、联调、测试、上线共2周时间。老司机做事很自信,这么简单的 UI、动画、交互,代码风骚,参考服务端的「领域驱动」在该 feature 开发阶段落地试验了下。联调、本地测试都通过了,还剩3天时间,本以为测试1天,bug fix 一天,最后一天提交审核。代码跟你开了个玩笑,测试完 n 个 bug(大大超出预期)。为了不影响 App 的发布上架,不得不熬夜修 bug。将所有的测试都通过测试工程师去处理,这个阶段理论上质量应该很稳定,不然该阶段发现代码异常、技术设计有漏洞就来不及了,你需要协调各个团队的资源(可能接口要改动、产品侧要改动),这个阶段造成改动的成本非常大。 - -相信大多数开发者都遇到过上述场景的问题。其实上述这几个问题都有解,那就是“软件测试”。 - -## 二、软件测试 - -### 1. 分类 - -软件测试就是在规定的条件下对应用程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。 - -合理应用软件测试技术,就可以规避掉第一部分的3个场景下的问题。 - -软件测试强调开发、测试同步进行,甚至是测试先行,从需求评审阶段就先考虑好软件测试方案,随后才进行技术方案评审、开发编码、单元测试、集成测试、系统测试、回归测试、验收测试等。 - -软件测试从测试范围分为:单元测试、集成测试、系统测试、回归测试、验收测试(有些公司会谈到“冒烟测试“,这个词的精确定义不知道,但是学软件测试课的时候按照范围就只有上述几个分类)。工程师自己负责的是单元测试。测试工程师、QA 负责的是集成测试、系统测试。 - -单元测试(Unit Testing):又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。「单元」的概念会比较抽象,它不仅仅是我们所编写的某个方法、函数,也可能是某个类、对象等。 - -软件测试从开发模式分为:面向测试驱动开发 TDD (Test-driven development)、面向行为驱动开发 BDD (Behavior-driven development)。 - -### 2. TDD - -TDD 开发过程类似下图: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStructure.png) - -TDD 的思想是:先编写测试用例,再快速开发代码,然后在测试用例的保证下,可以方便安全地进行代码重构,提升应用程序的质量。一言以蔽之就是通过测试来推动开发的进行。正是由于这个特点,TDD 被广泛使用于敏捷开发。 - -也就是说 TDD 模式下,首先考虑如何针对功能进行测试,然后去编写代码实现,再不断迭代,在测试用例的保证下,不断进行代码优化。 - -TDD 强调不断的测试推动代码的开发,保证了代码质量。TDD 强调让写代码的过程形成一个循环,在拿到新需求时: - -- 第一步:先为该功能编写一个单元测试,跑一下发现没有通过(这时候还没有实现代码),即图中的 TEST FAILS,俗称"红灯" -- 第二步:编写能够通过全部测试的“最小代码”,之所以强调“最小代码”,就是为了防止过度优化,现实中我们经常会因为代码过度优化,或者过度设计,导致很多遗留问题,在这个阶段,快速实现需求就好了,不需要太多设计。这个阶段俗称“绿灯” -- 第三步:也是最重最要的一步,即“重构”(Refactor)。前面为了快速赶业务,代码跨年很脏(屎山),但至少保证是正确的。当有充足的测试来保证逻辑的正确,这时候就可以重构代码了,持续重构来保证代码最优。 - -这也得出2个信息: - -1. 单测必须能够快速运行,因为单测是经常需要在本地全量运行的,只有运行足够快,才可以在 TDD 的循环中快速迭代 -2. 好的代码并不是一次完成的,而是持续重构出来的,而单测是持续重构的前提 - - - -**抛出一个问题:TDD 看上去很好,应该用它吗?** - -这个问题不用着急回答,回答了也不会有对错之分。开发中经常是这样一个流程,新的需求出来后,先经过技术评审会议,确定宏观层面的技术方案、确定各个端的技术实现、使用的技术等,整理出开发文档、会议文档。工期评估后开始编码。事情这么简单吗?前期即使想的再充分、再细致,可能还是存在特殊 case 漏掉的情况,导致技术方案或者是技术实现的改变。如果采用 TDD,那么之前新功能给到后,就要考虑测试用例的设计、编写了测试代码,在测试用例的保证下再去实现功能。如果遇到了技术方案的变更,之前的测试用例要改变、测试代码实现要改变。可能新增的某个 case 导致大部分的测试代码和实现代码都要改变。 - -如何开展 TDD** - -1. 新建一个工程,确保 “Include Unit Tests” 选项是选中的状态 - - ![TDD Step 1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep1.png) - -2. 创建后的工程目录如下 - - ![TDD step2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep2.png) - -3. 删除 Xcode 创建的测试模版文件 `TDDDemoTests.m` - -4. 假如我们需要设计一个人类,它具有吃饭的功能,且当他吃完后会说一句“好饱啊”。 - -5. 那么按照 TDD 我们先设计测试用例。假设有个 Person 类,有个对象方法叫做吃饭,吃完饭后会返回一个“好饱啊”的字符串。那测试用例就是 - - | 步骤 | 期望 | 结果 | - | --------------------------------------- | ------------------ | ---- | - | 实例化 Person 对象,调用对象的 eat 方法 | 调用后返回“好饱啊” | ? | - -6. 实现测试用例代码。创建继承自 Unit Test Case class 的测试类,命名为 `工程前缀+测试类名+Test`,也就是 `TDDPersonTest.m`。 - - ![TDD step 3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-13-TDDStep3.png) - -7. 因为要测试 Person 类,所以在主工程中创建 Person 类 - -8. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 - - ```objective-c - - (void)testReturnStatusStringWhenPersonAte - { - // Given - Person *somebody = [[Person alloc] init]; - - // When - NSString *statusMessage = [somebody performSelector:@selector(eat)]; - - // Then - XCTAssert([statusMessage isEqualToString:@"好饱啊"], @"Person 「吃饭后返回“好饱啊”」功能异常"); - } - ``` - -9. Xcode 下按快捷键 `Command + U`,跑测试代码发现是失败的。因为我们的 Person 类根本没实现相应的方法 - -10. 从 [TDD 开发过程](#TDDStructure)可以看到,我们现在是红色的 “Fail” 状态。所以需要去 Person 类中实现功能代码。Person 类如下 - - ```objective-c - #import "Person.h" - - @implementation Person - - - (NSString *)eat - { - [NSThread sleepForTimeInterval:1]; - return @"好饱啊";; - } - - @end - ``` - -11. 再次运行,跑一下测试用例(Command + U 快捷键)。发现测试通过,也就是[TDD 开发过程](#TDDStructure)中的绿色 “Success” 状态。 - -12. 例子比较简单,假如情况需要,可以在 `-(void)setUp` 方法里面做一些测试的前置准备工作,在 `-(void)tearDown` 方法里做资源释放的操作 - -13. 假如 eat 方法实现的不够漂亮。现在在测试用例的保证下,大胆重构,最后确保所有的 Unit Test case 通过即可。 - - - -### 3. BDD - -BDD 即行为驱动开发,是敏捷开发**技术**之一,通过自然语言定义系统行为,以功能使用者的角度,编写需求场景,且这些行为描述可以直接形成需求文档,同时也是测试标准。 - -问题是什么?拆解成哪些子问题、做成这个事情需要哪些步骤。这就是 BDD 的编写流程。BDD 使用 DSL (Domin Specific Language)领域特定语言来描述测试用例,这样编写的测试用例非常易读,看起来跟文档一样易读,BDD 的代码结构是 `Given->When->Then`。 - - - -相比 TDD,BDD 关注的是行为方式的设计,拿上述“人吃饭”举例说明。 - -和 TDD 相比第1~4步骤相同。 - -5. BDD 则需要先实现功能代码。创建 Person 类,实现 `-(void)eat;`方法。代码和上面的相同 - -6. BDD 需要引入好用的框架 `Kiwi`,使用 Pod 的方式引入 - -7. 因为要测试人类在吃饭后说一句“好饱啊”。所以设想那个类目前只有一个吃饭的方法。于是在 `TDDPersonTest.m` 中创建一个测试函数 `-(void)testReturnStatusStringWhenPersonAte;`函数内容如下 - - ```objective-c - #import "kiwi.h" - #import "Person.h" - - SPEC_BEGIN(BDDPersonTest) - - describe(@"Person", ^{ - context(@"when someone ate", ^{ - it(@"should get a string",^{ - Person *someone = [[Person alloc] init]; - NSString *statusMessage = [someone eat]; - [[statusMessage shouldNot] beNil]; - [[statusMessage should] equal:@"好饱啊"]; - }); - }); - }); - - SPEC_END - ``` - - - -### 4. 敏捷开发 - -大多数人觉得写 TDD、BDD 都会影响开发迭代速度,但是 TDD、BDD 恰恰是敏捷开发实践的重要组成部分: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Alige-devement-BDDAndTDD.png) - -我们学习敏捷开发的时候,常常只学习到它的 “快”,而忽略了敏捷开发所提出的质量保证方法。敏捷开发所谓的“快”,是指在代码质量充分保证下的“快”,而不是做完功能就直接上线。 - - - -### 5. 选什么? - -看完了 TDD 和 BDD,那我们选什么? - -TDD 适合新项目,从0开始起步开发的,大多数情况下,我们的手中的项目基本都是已经跑了好几年的业务项目,基本上没机会去实施 TDD。 - -但是对于移动中台来讲,一个新的技术 SDK 是有机会落地 TDD 的。 - -这里聊聊大多数的情况,已有的代码如何开展测试。我们选择 iOS 平台自带的 XCTest。搭配一些测试框架来开展,比如 OCMock 等 - -**开发步骤** - -Xcode 自带的测试系统是 `XCTest`,使用简单。开发步骤如下 - -- 在 `Tests` 目录下为被测的类创建一个继承自 `XCTestCase` 的测试类。 - -- 删除新建的测试代码模版里面的无用方法 `- (void)testPerformanceExample`、`- (void)testExample`。 - -- 跟普通类一样,可以继承,可以写私有属性、私有方法。所以可以在新建的类里面,根据需求写一些私有属性等 - -- 在 `- (void)setUp` 方法里面写一些初始化、启动设置相关的代码。比如测试数据库功能的时候,写一些数据库连接池相关代码 - -- 为被测类里面的每个方法写测试方法。被测类里面可能是 n 个方法,测试类里面可能是 m 个方法(m >= n),根据我们在[第三部分:单元测试编码规范](#codeRules)里讲过的 **一个测试用例只测试一个分支**,方法内部有 if、switch 语句时,需要为每个分支写测试用例 - -- 为测试类每个方法写的测试方法有一定的规范。命名必须是 `test+被测方法名`。函数无参数、无返回值。比如 `- (void)testSharedInstance`。 - -- 测试方法里面的代码按照 `Given->When->Then` 的顺序展开。测试环境所需的先决条件准备;调用所要测试的某个方法、函数;使用断言验证输出和行为是否符合预期。 - -- 在 `- (void)tearDown` 方法里面写一些释放掉资源或者关闭的代码。比如测试数据库功能的时候,写一些数据库连接池关闭的代码 - -**断言相关宏** - -```c++ -/*! - * @function XCTFail(...) - * Generates a failure unconditionally. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTFail(...) \ - _XCTPrimitiveFail(self, __VA_ARGS__) - -/*! - * @define XCTAssertNil(expression, ...) - * Generates a failure when ((\a expression) != nil). - * @param expression An expression of id type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNil(expression, ...) \ - _XCTPrimitiveAssertNil(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertNotNil(expression, ...) - * Generates a failure when ((\a expression) == nil). - * @param expression An expression of id type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNotNil(expression, ...) \ - _XCTPrimitiveAssertNotNil(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssert(expression, ...) - * Generates a failure when ((\a expression) == false). - * @param expression An expression of boolean type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssert(expression, ...) \ - _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertTrue(expression, ...) - * Generates a failure when ((\a expression) == false). - * @param expression An expression of boolean type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertTrue(expression, ...) \ - _XCTPrimitiveAssertTrue(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertFalse(expression, ...) - * Generates a failure when ((\a expression) != false). - * @param expression An expression of boolean type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertFalse(expression, ...) \ - _XCTPrimitiveAssertFalse(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertEqualObjects(expression1, expression2, ...) - * Generates a failure when ((\a expression1) not equal to (\a expression2)). - * @param expression1 An expression of id type. - * @param expression2 An expression of id type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertEqualObjects(expression1, expression2, ...) \ - _XCTPrimitiveAssertEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertNotEqualObjects(expression1, expression2, ...) - * Generates a failure when ((\a expression1) equal to (\a expression2)). - * @param expression1 An expression of id type. - * @param expression2 An expression of id type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNotEqualObjects(expression1, expression2, ...) \ - _XCTPrimitiveAssertNotEqualObjects(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertEqual(expression1, expression2, ...) - * Generates a failure when ((\a expression1) != (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertEqual(expression1, expression2, ...) \ - _XCTPrimitiveAssertEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertNotEqual(expression1, expression2, ...) - * Generates a failure when ((\a expression1) == (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNotEqual(expression1, expression2, ...) \ - _XCTPrimitiveAssertNotEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...) - * Generates a failure when (difference between (\a expression1) and (\a expression2) is > (\a accuracy))). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertEqualWithAccuracy(expression1, expression2, accuracy, ...) \ - _XCTPrimitiveAssertEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__) - -/*! - * @define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...) - * Generates a failure when (difference between (\a expression1) and (\a expression2) is <= (\a accuracy)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param accuracy An expression of C scalar type describing the maximum difference between \a expression1 and \a expression2 for these values to be considered equal. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNotEqualWithAccuracy(expression1, expression2, accuracy, ...) \ - _XCTPrimitiveAssertNotEqualWithAccuracy(self, expression1, @#expression1, expression2, @#expression2, accuracy, @#accuracy, __VA_ARGS__) - -/*! - * @define XCTAssertGreaterThan(expression1, expression2, ...) - * Generates a failure when ((\a expression1) <= (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertGreaterThan(expression1, expression2, ...) \ - _XCTPrimitiveAssertGreaterThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertGreaterThanOrEqual(expression1, expression2, ...) - * Generates a failure when ((\a expression1) < (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertGreaterThanOrEqual(expression1, expression2, ...) \ - _XCTPrimitiveAssertGreaterThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertLessThan(expression1, expression2, ...) - * Generates a failure when ((\a expression1) >= (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertLessThan(expression1, expression2, ...) \ - _XCTPrimitiveAssertLessThan(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertLessThanOrEqual(expression1, expression2, ...) - * Generates a failure when ((\a expression1) > (\a expression2)). - * @param expression1 An expression of C scalar type. - * @param expression2 An expression of C scalar type. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertLessThanOrEqual(expression1, expression2, ...) \ - _XCTPrimitiveAssertLessThanOrEqual(self, expression1, @#expression1, expression2, @#expression2, __VA_ARGS__) - -/*! - * @define XCTAssertThrows(expression, ...) - * Generates a failure when ((\a expression) does not throw). - * @param expression An expression. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertThrows(expression, ...) \ - _XCTPrimitiveAssertThrows(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertThrowsSpecific(expression, exception_class, ...) - * Generates a failure when ((\a expression) does not throw \a exception_class). - * @param expression An expression. - * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertThrowsSpecific(expression, exception_class, ...) \ - _XCTPrimitiveAssertThrowsSpecific(self, expression, @#expression, exception_class, __VA_ARGS__) - -/*! - * @define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...) - * Generates a failure when ((\a expression) does not throw \a exception_class with \a exception_name). - * @param expression An expression. - * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. - * @param exception_name The name of the exception. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertThrowsSpecificNamed(expression, exception_class, exception_name, ...) \ - _XCTPrimitiveAssertThrowsSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__) - -/*! - * @define XCTAssertNoThrow(expression, ...) - * Generates a failure when ((\a expression) throws). - * @param expression An expression. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNoThrow(expression, ...) \ - _XCTPrimitiveAssertNoThrow(self, expression, @#expression, __VA_ARGS__) - -/*! - * @define XCTAssertNoThrowSpecific(expression, exception_class, ...) - * Generates a failure when ((\a expression) throws \a exception_class). - * @param expression An expression. - * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNoThrowSpecific(expression, exception_class, ...) \ - _XCTPrimitiveAssertNoThrowSpecific(self, expression, @#expression, exception_class, __VA_ARGS__) - -/*! - * @define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...) - * Generates a failure when ((\a expression) throws \a exception_class with \a exception_name). - * @param expression An expression. - * @param exception_class The class of the exception. Must be NSException, or a subclass of NSException. - * @param exception_name The name of the exception. - * @param ... An optional supplementary description of the failure. A literal NSString, optionally with string format specifiers. This parameter can be completely omitted. -*/ -#define XCTAssertNoThrowSpecificNamed(expression, exception_class, exception_name, ...) \ - _XCTPrimitiveAssertNoThrowSpecificNamed(self, expression, @#expression, exception_class, exception_name, __VA_ARGS__) -``` - -**经验小结** - -1. XCTestCase 类和其他类一样,你可以定义基类,这里面封装一些常用的方法。 - - ``` - // HCTTestCase.h - #import - - NS_ASSUME_NONNULL_BEGIN - - @interface HCTTestCase : XCTestCase - - @property (nonatomic, assign) NSTimeInterval networkTimeout; - - /// 用一个默认时间设置异步测试 XCTestExpectation 的超时处理 - - (void)waitForExpectationsWithCommonTimeout; - - /** - 用一个默认时间设置异步测试的 - @param handler 超时的处理逻辑 - */ - - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler; - - /** - 生成 Crash 类型的 meta 数据 - @return meta 类型的字典 - */ - - (NSDictionary *)generateCrashMetaDataFromReport; - - @end - - NS_ASSUME_NONNULL_END - - // HCTTestCase.m - #import "HCTTestCase.h" - #import ... - - @implementation HCTTestCase - - #pragma mark - life cycle - - - (void)setUp { - [super setUp]; - self.networkTimeout = 20.0; - // 1. 设置平台信息 - [self setupAppProfile]; - // 2. 设置 Mget 配置 - [[TITrinityInitManager sharedInstance] setup]; - // .... - // 3. 设置 HermesClient - [[HermesClient sharedInstance] setup]; - } - - - (void)tearDown { - [super tearDown]; - } - - #pragma mark - public Method - - (void)waitForExpectationsWithCommonTimeout { - [self waitForExpectationsWithCommonTimeoutUsingHandler:nil]; - } - - - (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler __nullable)handler { - [self waitForExpectationsWithTimeout:self.networkTimeout handler:handler]; - } - - - (NSDictionary *)generateCrashMetaDataFromReport { - NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; - NSDate *crashTime = [NSDate date]; - metaDictionary[@"MONITOR_TYPE"] = @"appCrash"; - // ... - metaDictionary[@"USER_CRASH_DATE"] = @([crashTime timeIntervalSince1970] * 1000); - return [metaDictionary copy]; - } - - #pragma mark - private method - - (void)setupAppProfile { - [[CMAppProfile sharedInstance] setMPlatform:@"70"]; - // ... - } - @end - ``` - -2. 上述说的基本是开发规范相关。测试方法内部如果调用了其他类的方法,则在测试方法内部必须 Mock 一个外部对象,限制好返回值等。 - -3. 在 XCTest 内难以使用 mock 或 stub,这些是测试中非常常见且重要的功能 - -**例子** - -这里举个例子,是测试一个数据库操作类 `HCTDatabase`,代码只放某个方法的测试代码。 - -```objective-c -- (void)testRemoveLatestRecordsByCount { - XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库删除最新数据功能"]; - // 1. 先清空数据表 - [dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta]; - // 2. 再插入一批数据 - NSMutableArray *insertModels = [NSMutableArray array]; - NSMutableArray *reportIDS = [NSMutableArray array]; - - for (NSInteger index = 1; index <= 100; index++) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = index; - // ... - if (index > 90 && index <= 100) { - [reportIDS addObject:model.report_id]; - } - [insertModels addObject:model]; - } - [dbInstance add:insertModels inTableType:HCTLogTableTypeMeta]; - - // 3. 将早期的数据删除掉(id > 90 && id <= 100) - [dbInstance removeLatestRecordsByCount:10 inTableType:HCTLogTableTypeMeta]; - - // 4. 拿到当前的前10条数据和之前存起来的前10条 id 做比较。再判断当前表中的总记录条数是否等于 90 - [dbInstance getLatestRecoreds:10 inTableType:HCTLogTableTypeMeta completion:^(NSArray * _Nonnull records) { - NSArray *latestRTentRecords = records; - - [dbInstance getOldestRecoreds:100 inTableType:HCTLogTableTypeMeta completion:^(NSArray * _Nonnull records) { - NSArray *currentRecords = records; - - __block BOOL isEarlyData = NO; - [latestRTentRecords enumerateObjectsUsingBlock:^(HCTLogModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([reportIDS containsObject:obj.report_id]) { - isEarlyData = YES; - } - }]; - - XCTAssert(!isEarlyData && currentRecords.count == 90, @"***Database「删除最新n条数据」功能:异常"); - [exception fulfill]; - }]; - - }]; - [self waitForExpectationsWithCommonTimeout]; -} -``` - -### 6. 测试框架 - -#### 1. Kiwi - -BDD 框架里的 Kiwi 可圈可点。使用 CocoaPods 引入 `pod 'Kiwi'`。看下面的例子 - -被测类(Planck 项目是一个基于 WebView 的 SDK,根据业务场景,发现针对 WebView 的大部分功能定制都是基于 WebView 的生命周期内发生的,所以参考 NodeJS 的中间件思想,设计了基于生命周期的 WebView 中间件) - -```objective-c -#import - -@interface TPKTrustListHelper : NSObject - -+(void)fetchRemoteTrustList; - -+(BOOL)isHostInTrustlist:(NSString *)scheme; - -+(NSArray *)trustList; - -@end -``` - -测试类 - -```objective-c -SPEC_BEGIN(TPKTrustListHelperTest) -describe(@"Middleware Wrapper", ^{ - - context(@"when get trustlist", ^{ - it(@"should get a array of string",^{ - NSArray *array = [TPKTrustListHelper trustList]; - [[array shouldNot] beNil]; - NSString *first = [array firstObject]; - [[first shouldNot] beNil]; - [[NSStringFromClass([first class]) should] equal:@"__NSCFString"]; - }); - }); - - context(@"when check a string wether contained in trustlist ", ^{ - it(@"first string should contained in trustlist",^{ - NSArray *array = [TPKTrustListHelper trustList]; - NSString *first = [array firstObject]; - [[theValue([TPKTrustListHelper isHostInTrustlist:first]) should] equal:@(YES)]; - }); - }); -}); -SPEC_END -``` - -例子包含 Kiwi 的最基础元素。`SPEC_BEGIN` 和 `SPEC_END` 表示测试类;`describe` 描述需要被测试的类;`context` 表示一个测试场景,也就是 `Given->When->Then` 里的 `Given`;`it` 表示要测试的内容,也就是也就是 `Given->When->Then` 里的 `When` 和 `Then`。1个 `describe` 下可以包含多个 `context`,1个 `context` 下可以包含多个 `it`。 - -Kiwi 的使用分为:[Specs](https://github.com/kiwi-bdd/Kiwi/wiki/Specs)、 [Expectations](https://github.com/kiwi-bdd/Kiwi/wiki/Expectations) 、 [Mocks and Stubs](https://github.com/kiwi-bdd/Kiwi/wiki/Mocks-and-Stubs) 、[Asynchronous Testing](https://github.com/kiwi-bdd/Kiwi/wiki/Asynchronous-Testing) 四部分。点击可以访问详细的说明文档。 - -`it` 里面的代码块是真正的测试代码,使用链式调用的方式,简单上手。 - -**测试领域中 Mock 和 Stub 非常重要。Mock 模拟对象可以降低对象之间的依赖,模拟出一个纯净的测试环境(类似初中物理课上“控制变量法”的思想)。Kiwi 也支持的非常好,可以模拟对象、模拟空对象、模拟遵循协议的对象等等,点击 [Mocks and Stubs](https://github.com/kiwi-bdd/Kiwi/wiki/Mocks-and-Stubs) 查看。Stub 存根可以控制某个方法的返回值,这对于方法内调用别的对象的方法返回值很有帮助。减少对于外部的依赖,单一测试当前行为是否符合预期。** - -针对异步测试,XCTest 则需要创建一个 `XCTestExpectation` 对象,在异步实现里面调用该对象的 `fulfill` 方法,最后设置最大等待时间和完成的回调 `- (void)waitForExpectationsWithTimeout:(NSTimeInterval)timeout handler:(nullable XCWaitCompletionHandler)handler;` 如下例子 - -``` -XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"]; - [dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta]; - NSMutableArray *insertModels = [NSMutableArray array]; - for (NSInteger index = 1; index <= 10000; index++) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = index; - // 。。。 - [insertModels addObject:model]; - } - [dbInstance add:insertModels inTableType:HCTLogTableTypeMeta]; - [dbInstance recordsCountInTableType:HCTLogTableTypeMeta completion:^(NSInteger count) { - XCTAssert(count == insertModels.count, @"**Database「数据增加」功能:异常"); - [exception fulfill]; - }]; - [self waitForExpectationsWithCommonTimeout]; -``` - -#### 2. expecta、Specta - -expecta 和 Specta 都出自 [orta](https://github.com/orta) 之手,他也是 Cocoapods 的开发者之一。太牛逼了,工程化、质量保证领域的大佬。 - -Specta 是一个轻量级的 BDD 测试框架,采用 DSL 模式,让测试更接近于自然语言,因此更易读。 - -特点: - -- 易于集成到项目中。在 Xcode 中勾选 `Include Unit Tests` ,和 XCTest 搭配使用 -- 语法很规范,对比 Kiwi 和 Specta 的文档,发现很多东西都是相同的,也就是很规范,所以学习成本低、后期迁移到其他框架很平滑。 - -Expecta 是一个匹配(断言)框架,相比 Xcode 的断言 XCAssert,Excepta 提供更加丰富的断言。 - -特点: - -- Eepecta 没有数据类型限制,比如 1,并不关心是 NSInteger 还是 CGFloat -- 链式编程,写起来很舒服 -- 反向匹配,很灵活。断言匹配用 `except(...).to.equal(...)`,断言不匹配则使用 `.notTo` 或者 `.toNot` -- 延时匹配,可以在链式表达式后加入 `.will`、`.willNot`、`.after(interval)` 等 - -#### 3. OCMock - -使用 OCMock 经常做的事情就是准备数据、添加预期、执行断言。具体用法可以查看[文档](https://ocmock.org/reference/)。 - -简单看看原理。 - -``` -id mockObject = OCMClassMock([Person class]); -OCMStub([mockObject engineer]).andReturn(mockObject); -OCMStub([mockObject work]).andReturn(100); -// Assert -``` - -OCMClassMock 展开如下 - -``` -id mockObject = [OCMockObject niceMockForClass:[Person class]]; -``` - -OCMockObject 其实就是 NSProxy 的子类,用于实现消息转发,`niceMockForClass` 就是调用了 - -``` -+ (id)mockForClass:(Class)aClass { - return [[[OCClassMockObject alloc] initWithClass:aClass] autorelease]; -} -``` - -OCMStub 是 OCMock 最核心的功能, - -``` -({ - [OCMMacroState beginStubMacro]; - OCMStubRecorder *recorder = ((void *)0); - @try{ - [mockObject work]; - } @finally { - recorder = [OCMMacroState endStubMacro]; - } - recorder; -}); -``` - -上面的 begin 和 end 方法就是为了增加一个 OCMStubRecorder 标记,保存在当前线程的字典中 - -``` -+ (void)beginStubMacro { - OCMStubRecorder *recorder = [[[OCMStubRecorder alloc] init] autorelease]; - OCMMacroState *macroState = [[OCMMacroState alloc] initWithRecorder:recorder]; - [NSThread currentThread].threadDictionary[OCMGlobalStateKey] = macroState; - [macroState release]; -} - -+ (OCMStubRecorder *)endStubMacro { - NSMutableDictionary *threadDictionary = [NSThread currentThread].threadDictionary; - OCMMacroState *globalState = threadDictionary[OCMGlobalStateKey]; - OCMStubRecorder *recorder = [(OCMStubRecorder *)[globalState recorder] retain]; - [threadDictionary removeObjectForKey:OCMGlobalStateKey]; - return [recorder autorelease]; -} -``` - -剩下的不展开讲了,不是本文重点。OCMock 其实就是利用 NSProxy 和 Runtime 消息转发去实现的。 - - - -### 7. 小结 - -Xcode 自带的 `XCTestCase` 比较适合 TDD,不影响源代码,系统独立且不影响 App 包大小。适合简单场景下的测试。且每个函数在最左侧又个测试按钮,点击后可以单独测试某个函数。 - -Kiwi 是一个强大的 BDD 框架,适合稍微复杂写的项目,写法舒服、功能强大,模拟对象、存根语法、异步测试等满足几乎所有的测试场景。不能和 XCTest 继承。 - -Specta 也是一个 BDD 框架,基于 XCTest 开发,可以和 XCTest 模版集合使用。相比 Kiwi,Specta 轻量一些。开发中一般搭配 Excepta 使用。如果需要使用 Mock 和 Stud 可以搭配 OCMock。 - -Excepta 是一个匹配框架,比 XCTest 的断言则更加全面一些。 - -没办法说哪个最好、最合理,根据项目需求选择合适的组合。 - - - -## 三、 单元测试编码规范 - -本文的主要重点是针对日常开发阶段工程师可以做的事情,也就是单元测试而展开。 - -编写功能、业务代码的时候一般会遵循 `kiss 原则` ,所以类、方法、函数往往不会太大,分层设计越好、职责越单一、耦合度越低的代码越适合做单元测试,单元测试也倒逼开发过程中代码分层、解耦。 - -可能某个功能的实现代码有30行,测试代码有50行。单元测试的代码如何编写才更合理、整洁、规范呢? - -### 1. 编码分模块展开 - -先贴一段代码。 - -```objective-c -- (void)testInsertDataInOneSpecifiedTable { - XCTestExpectation *exception = [self expectationWithDescription:@"测试数据库插入功能"]; - // given - [dbInstance removeAllLogsInTableType:HCTLogTableTypeMeta]; - NSMutableArray *insertModels = [NSMutableArray array]; - for (NSInteger index = 1; index <= 10000; index++) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = index; - // ... - [insertModels addObject:model]; - } - // when - [dbInstance add:insertModels inTableType:HCTLogTableTypeMeta]; - // then - [dbInstance recordsCountInTableType:HCTLogTableTypeMeta completion:^(NSInteger count) { - XCTAssert(count == insertModels.count, @"「数据增加」功能:异常"); - [exception fulfill]; - }]; - [self waitForExpectationsWithCommonTimeout]; -} -``` - -可以看到这个方法的名称为 testInsertDataInOneSpecifiedTable,这段代码做的事情通过函数名可以看出来:测试插入数据到某个特定的表。这个测试用例分为3部分:测试环境所需的先决条件准备;调用所要测试的某个方法、函数;验证输出和行为是否符合预期。 - -其实,每个测试用例的编写也要按照该种方式去组织代码。步骤分为3个阶段:Given->When->Then。 - -所以单元测试的代码规范也就出来了。此外单元测试代码规范统一后,每个人的测试代码都按照这个标准展开,那其他人的阅读起来就更加容易、方便。按照这3个步骤去阅读、理解测试代码,就可以清晰明了的知道在做什么。 - -### 2. 一个测试用例只测试一个分支 - -我们写的代码有很多语句组成,有各种逻辑判断、分支(if...else、swicth)等等,因此一个程序从一个单一入口进去,过程可能产生 n 个不同的分支,但是程序的出口总是一个。所以由于这样的特性,我们的测试也需要针对这样的现状走完尽可能多的分支。相应的指标叫做「分支覆盖率」。 - -假如某个方法内部有 if...else...,我们在测试的时候尽量将每种情况写成一个单独的测试用例,单独的输入、输出,判断是否符合预期。这样每个 case 都单一的测试某个分支,可读性也很高。 - -比如对下面的函数做单元测试,测试用例设计如下 - -```objective-c -- (void)shouldIEatSomething { - BOOL shouldEat = [self getAteWeight] < self.dailyFoodSupport; - if (shouldEat) { - [self eatSomemuchFood]; - } else { - [self doSomeExercise]; - } -} -``` - -Bad Case: - -```objective-c -- (void)testShouldIEatSomething { - // case1 - // case2 -} -``` - -Good Case: - -```objective-c -- (void)testShouldIEatSomethingWhenHungry { - // .... -} - -- (void)testShouldIEatSomethingWhenFull -{ - // ... -} -``` - -QA:一个被测方法,有诸多 case,为什么不写在一个测试方法中将这些 case 写全? - -因为测试有个原则,就是每个测试 case 都是能够单独可运行的。如果2个case都在一个方法内,那就不是单独可运行。怎么理解,贴个图(点击最左边的小菱形,该 case 是需要单独可运行的) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UnitTest-FunctionStandard.png) - -再有一个参照物,大家都在用的 ITerm2 的源码是开源的,看看 ITerm2 是如何写测试的 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ITerm2-TestCase.png) - -### 3. 明确标识被测试类 - -这条主要站在团队合作和代码可读性角度出发来说明。写过单元测试的人都知道,可能某个函数本来就10行代码,可是为了测试它,测试代码写了30行。一个方法这样写问题不大,多看看就看明白是在测试哪个类的哪个方法。可是当这个类本身就很大,测试代码很大的情况下,不管是作者自身还是多年后负责维护的其他同事,看这个代码阅读成本会很大,需要先看测试文件名 `代码类名 + Test` 才知道是测试的是哪个类,看测试方法名 `test + 方法名` 才知道是测试的是哪个方法。 - -这样的代码可读性很差,所以应该为当前的测试对象特殊标记,这样测试代码可读性越强、阅读成本越低。比如定义局部变量 `_sut` 用来标记当前被测试类(sut,System under Test,软件测试领域有个词叫做**被测系统**,用来表示正在被测试的系统)。 - -```objective-c -#import -#import "HCTLogPayloadModel.h" - -@interface HCTLogPayloadModelTest : HCTTestCase { - HCTLogPayloadModel *_sut; -} - -@end - -@implementation HCTLogPayloadModelTest - -- (void)setUp { - [super setUp]; - HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init]; - model.log_id = 1; - // ... - _sut = model; -} - -- (void)tearDown { - _sut = nil; - [super tearDown]; -} - -- (void)testGetDictionary { - NSDictionary *payloadDictionary = [_sut getDictionary]; - XCTAssert([(NSString *)payloadDictionary[@"report_id"] isEqualToString:@"001"] && - [payloadDictionary[@"size"] integerValue] == 102 && - [(NSString *)payloadDictionary[@"meta"] containsString:@"meiying"], - @"HCTLogPayloadModel 的 「getDictionary」功能异常"); -} - -@end -``` - -### 4. 使用分类来暴露私有方法、私有变量 - -某些场景下写的测试方法内部可能需要调用被测对象的私有方法,也可能需要访问被测对象的某个私有属性。但是测试类里面是访问不到被测类的私有属性和私有方法的,借助于 Runtime 特性,我们可以给被测类添加 **Category** 来解决无法访问私有方法和私有属性的问题。 - -为测试类添加一个分类,后缀名为 `UnitTest`。如下所示 - - `HermesClient` 类有私有属性 `@property (nonatomic, strong) NSString *name;`,私有方法 `- (void)hello`。为了在测试用例中访问私有属性和私有方法,写了如下分类 - -```objective-c -@interface HermesClient : NSObject -@end - -@interface HermesClient () -@property (nonatomic, assign) int b; -@end - -@implementation HermesClient -- (instancetype)init { - if (self = [super init]) { - self.b = 10; - } - return self; -} - -- (int)add:(int)a { - return a+b; -} -@end - -// HermesClientTest.m -@interface HermesClient (UnitTest) -@property (nonatomic, assign) int b; -- (int)add:(int)a; -@end - -@interface HermesClientTest() { - HermesClientTest *_sut; -} -@end - -@implementation HermesClientTest -- (void)setUp { - _sut = [[HermesClientTest alloc] init]; -} -- (void)testAdd -{ - int result = [_sut add:20]; - XCTAssert(result == 30, - @"Oops, there is something wrong with add: method of HermesClientTest."); -} -@end -``` - -### 5. 单测需要确定性 - -避免脆弱测试,Mock不确定的依赖:时间、随机数、并发性、基础设施、现存数据、持久化、网络等等。一个 case 应该只针对某个逻辑分支进行测试,那么对于特定输入、一定有一个特定输出,来判断是否符合预期。为此,其他的因素都应该是唯一确定的,保证测试环境的确定性。 - -````objective-c -/// 测试数据库的查询最新一条记录的能力 -- (void)testSelectLastesRecord { - // 首先需要保证数据库只有一些需要的记录 - [_db clear]; - // 插入一批学生数据,学号从0到20自增 - [_db insertAutoIncrementStudent:studentModel count:20]; - - Student *model = [_db selectLastesRecord]; - XCTAssert(model.Sno == 20, @"Oops, there is comething wrong with selectLastesRecord method of DB"); -} -```` - -### 6. 避免耗时操作,导致测试执行缓慢 - -单测的目的就是为了快速验证被测代码的正确性,如果大家写测试代码的时候不注意,加一些 sleep 之类的代码,整个单测工程运行就会很慢,问题验证或者查看整个工程的覆盖率就会很慢。 - -举一个工程中的例子。 - -```objc -// bad case -+ (OKFJsonRequest*)getHotTokensData:(NSString*)chainId walletId:(NSString * _Nonnull)walletId { - OKFMockRequest* request = [OKFMockRequest mockRequestWithResponseJsonString:[MockData jsonStringFromFile:@"hot_token"]]; - request.mockResponseDuration = 0.1; - return request; -} -``` - -网络 Mock 工具类中针对请求耗时都写了 0.1s,整个工程就会很慢。`mockResponseDuration` 字段是为了控制弱网环境下的表现,但是看了测试代码,根本没有这方面的测试。所以 `mockResponseDuration` 可以不用设置,默认就是0. - -### 7. 避免过度指定 - -对于过度指定的讨论,其核心问题就是要我们「判断哪些方法是单元测试应该覆盖的」,哪些是应该留给其他测试手段的。如果一个场景,单元测试覆盖之后,经常导致单测失败,需要不断更新维护,那就需要 double check 下,这段代码是不是可以考虑不做单元测试覆盖。ROI 合适吗? - -像素完美是一个典型的,经常被人们拿出来聊的例子,Flutter 的 Golden Test 就是一个 golden master testing 的例子;下面是《有效的单元测试》中关于像素完美的讨论: - -> 像素完美:顾名思义,是一种特定于图形和图像生成的测试坏味道。它混杂了魔法数字和基本断言,使得测试极难阅读也极其脆弱。 -> -> 这种测试几乎无法阅读,因为即使测试在语义上是处于高层概念的,却仍然会针对硬编码的底层细节例如像素坐标和颜色来进行断言。指定坐标上的像素是黑还是白,与两个图形是否相连或堆叠的概念是有区别的。 -> -> 这种测试极其脆弱,因为即使很小的和不相关的输入变化——是否是另一个图像,或图形对象的渲染方式——都足以影响输出、打破测试,谁让你非要精确地检查像素坐标和颜色呢。同样的问题在采用golden master技术时也会遇到,其做法是事先将图像录制下来,并手工检查其正确性,以后再进行测试时就将渲染出的图像与之进行比对。 -> -> 这些可不是我们愿意去维护的测试。我们不希望带着这种脆弱的精确度去编写测试,而是使用模糊匹配和智能算法来代替繁琐的数值比较。 - -### 8. 测试不要名不副实 - -避免测试的描述和测试内容不符,测试结果必须精准,测试断言信息也需要精准。 - -```objective-c -// bad case -- (void)testDB { - [_sut clear]; - [_sut insert:datasource]; - XCTAssert([_sut count] == datasource.count, @"Oops, there is something wrong with DB"); -} -``` - -单通过方法名根本不知道该测试用例是在测试什么。所以这个就是一个名不副实。 - -### 9. 使用有意义的断言 - -断言的错误信息要有意义,出现问题能够明确错误的原因 - -```objective-c -// good case -- (void)testInsert { - [_sut clear]; - [_sut insert:datasource]; - XCTAssert([_sut count] == datasource.count, @"Oops, there is something wrong with insert: method of DB."); -} -``` - -### 10. 清理测试环境 - -在 teardown 阶段清理测试环境,例如还原全局的配置、清理创建的文件目录、断开数据库连接句柄等 - -```objective-c -- (void)testWriteMethodOfFileManager { - [_sut createFile:filepath]; - [_sut write:filepath content:jsonString encoding:NSUTF8StringEncoding]; - NSString *fetchContent = [_sut readContents:filepath]; - XCTAssertTrue([fetchContent isEqualToString:jsonString], @"Oops, thers is something wrong with write:content:encoding: method of FileManager"); -} - -- (void)tearDown { - [_sut deleteFile:filepath]; -} -``` - -### 11. 写单测的过程也是一次 Code Review 的过程 - -看到某个方法难以编写单测、或者不好测,当梳理清楚后,可以对该段逻辑代码进行重构。 - - - -### 12. 把单测视为“一等公民” - -测试用例应该被视为“一等公民”,同样需要 Code Review,同样需要重视设计和质量,确保单元测试的有效性。 - -单元测试的代码评审的过程,也是团队同学互相学习借鉴的过程,沉淀最佳实践的过程。 - - - -### 13. 结合好官方工具 - -给当前工程打开测试覆盖率开关。路径:项目 icon 下的 Edit Scheme - 左侧选择 Test ,右侧选择 Options,然后选择 Code Coverage,勾选即可 - - - -对于每次测试后,可以在 Xcode 倒数第四个选项可以看到总的测试用例数,失败的个数。如果要清楚的知道哪些 case 失败,则可以点击右下角中间的按钮,可以筛选出失败的 case。 - - - - - -### 14. setUp 方法中做一些设置的初始化配置 - -比如需要对一个数据库工具类进行测试,那么可以在 setUp 方法内,设置数据库连接句柄、设置打开的数据库地址信息,然后给当前测试类设置一个全局变量 - -``` -#import "DBHelper.h" - -@interface DBHelpTest:XCTestCase { - DBHelper *_sut; -} -@end - -@implementation DBHelpTest -- (void)setUp { - NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; - NSString *dbPath = [docsPath stringByAppendingPathComponent:HCT_LOG_DATABASE_NAME]; - _sut = [[DBHelper alloc] initWithDB:dbPath]; -} - -- (void)testCreateTable { - [_sut createTable:tableName]; - NSString *tableNameGot = [_sut allTable].first; - XCTAssert([tableNameGot isEqualToString:tableName], @"Oops, there is something wrong with createTable: method of DBHelper"); -} - -// ... -@end - -``` - - - -### 15. Swift 测试用例代码测试 OC 被测代码 - -编写 Swift 测试代码去测试 OC 被测类的时候,需要做一些处理: - -1. 主工程不管是不是混编,但为了让 Swift 测试代码,可以访问到 OC 被测类,需要创建一个 Swift 文件,系统会自动创建 bridge 文件,且需要在 `AppTestingExplore-Bridging-Header.h` 文件中导出需要被测的头文件 -2. 在 Swift 测试文件中,导入主工程 module。 - - - - - -`@testable` 是 Swift 语言的一个特性,它允许测试用例访问应用程序或框架中标记为 `internal` 或 `private` 的属性、方法和其他成员。这样做可以在不改变访问级别的情况下编写测试用例,从而保持代码的封装性和安全性。使用 `@testable` 可以增强测试覆盖率,因为它允许测试那些通常因为访问级别限制而无法测试的内部实现细节。同时,它还有助于保持代码的封装性,因为不需要将内部实现细节暴露为 `public` 就可以进行单元测试。此外,`@testable` 提高了测试的灵活性,在不修改代码访问级别的情况下,能够对代码进行全面的测试 - - - -## 四、网络测试 - -我们在测试某个方法的时候可能会遇到方法内部调用了网络通信能力,网络请求成功,可能刷新 UI 或者给出一些成功的提示;网络失败或者网络不可用则给出一些失败的提示。所以需要对网络通信去看进行模拟。 - -iOS 中很多网络都是基于 NSURL 系统下的类实现的。所以我们可以利用 `NSURLProtocol` 的能力来监控网络并 mock 网络数据。如果感兴趣可以查看[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md#五-app-网络监控)。 - -开源项目 [OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs) 就是一个对网络模拟的库。它可以拦截 HTTP 请求,返回 json 数据,定制各种头信息。 - -> Stub your network requests easily! Test your apps with fake network data and custom response time, response code and headers! - -几个主要类及其功能:`HTTPStubsProtocol` 拦截网络请求;`HTTPStubs` 单例管理 `HTTPStubsDescriptor` 实例对象;`HTTPStubsResponse` 伪造 HTTP 请求。 - -`HTTPStubsProtocol` 继承自 `NSURLProtocol`,可以在 HTTP 请求发送之前对 request 进行过滤处理 - -```objective-c -+ (BOOL)canInitWithRequest:(NSURLRequest *)request -{ - BOOL found = ([HTTPStubs.sharedInstance firstStubPassingTestForRequest:request] != nil); - if (!found && HTTPStubs.sharedInstance.onStubMissingBlock) { - HTTPStubs.sharedInstance.onStubMissingBlock(request); - } - return found; -} -``` - -`firstStubPassingTestForRequest` 方法内部会判断请求是否需要被当前对象处理 - -紧接着开始发送网络请求。实际上在 `- (void)startLoading` 方法中可以用任何网络能力去完成请求,比如 NSURLSession、NSURLConnection、AFNetworking 或其他网络框架。OHHTTPStubs 的做法是获取 request、client 对象。如果 HTTPStubs 单例中包含 `onStubActivationBlock` 对象,则执行该 block,然后利用 responseBlock 对象返回一个 HTTPStubsResponse 响应对象。 - -OHHTTPStubs 的具体 API 可以查看[文档](https://github.com/AliSoftware/OHHTTPStubs/wiki/Usage-Examples)。 - -举个例子,利用 Kiwi、OHHTTPStubs 测试离线包功能。代码如下 - -```objective-c -@interface HORouterManager (Unittest) - -- (void)fetchOfflineInfoIfNeeded; - -@end - -SPEC_BEGIN(HORouterTests) - -describe(@"routerTests", ^{ - context(@"criticalPath", ^{ - __block HORouterManager *routerManager = nil; - beforeAll(^{ - routerManager = [[HORouterManager alloc] init]; - }); - it(@"getLocalPath", ^{ - __block NSString *pagePath = nil; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - pagePath = [routerManager filePathOfUrl:@"http://***/resource1"]; - }); - [[expectFutureValue(pagePath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil]; - - __block NSString *rescPath = nil; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - rescPath = [routerManager filePathOfUrl:@"http://***/resource1"]; - }); - [[expectFutureValue(rescPath) shouldEventuallyBeforeTimingOutAfter(5)] beNonNil]; - }); - it(@"fetchOffline", ^{ - [HOOfflineManager sharedInstance].offlineInfoInterval = 0; - [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { - return [request.URL.absoluteString containsString:@"h5-offline-pkg"]; - } withStubResponse:^OHHTTPStubsResponse*(NSURLRequest *request) { - NSMutableDictionary *dict = [NSMutableDictionary dictionary]; - dict[@"code"] = @(0); - dict[@"data"] = @"f722fc3efce547897819e9449d2ac562cee9075adda79ed74829f9d948e2f6d542a92e969e39dfbbd70aa2a7240d6fa3e51156c067e8685402727b6c13328092ecc0cbc773d95f9e0603b551e9447211b0e3e72648603e3d18e529b128470fa86aeb45d16af967d1a21b3e04361cfc767b7811aec6f19c274d388ddae4c8c68e857c14122a44c92a455051ae001fa7f2b177704bdebf8a2e3277faf0053460e0ecf178549e034a086470fa3bf287abbdd0f79867741293860b8a29590d2c2bb72b749402fb53dfcac95a7744ad21fe7b9e188881d1c24047d58c9fa46b3ebf4bc42a1defc50748758b5624c6c439c182fe21d4190920197628210160cf279187444bd1cb8707362cc4c3ab7486051af088d7851846bea21b64d4a5c73bd69aafc4bb34eb0862d1525c4f9a62ce64308289e2ecbc19ea105aa2bf99af6dd5a3ff653bbe7893adbec37b44a088b0b74b80532c720c79b7bb59fda3daf85b34ef35"; - NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; - return [OHHTTPStubsResponse responseWithData:data - statusCode:200 - headers:@{@"Content-Type":@"application/json"}]; - }]; - [routerManager fetchOfflineInfoIfNeeded]; - [[HOOfflineInfo shouldEventually] receive:@selector(saveToLocal:)]; - }); - }); -}); - -SPEC_END -``` - -😂 插一嘴,我贴的代码已经好几次可以看到不同的测试框架组合了,所以不是说选了框架 A 就完事,根据场景选择最优解。 - -## 五、UI 测试 - -### 概念 -UI 测试:属于端到端测试,是从应用程序启动到结束的测试过程。完全按照用户与应用程序交互的方式来复制与应用程序的交互。比但愿测试慢得多,运行起来更消耗资源。 - -### 测试原则 - -FIRST -- Fast:测试模块应该是快速高效的 -- Independent/Isolated:测试模块应该是独立、互相不影响的 -- Repeatable:测试实例应该是可以重复使用的,测试结果应该是相同的 -- Self-validating:测试应完全自动化。输出结果要么成功、要么失败 -- Timely:理想情况下,应该在编写要测试的生产代码之前编写测试(测试驱动开发) - -### 需要测试什么? -- 视觉表现验证 - - 元素渲染:控件尺寸、颜色、字体、图标资源(accessibilityIdentifier 定位) - - 布局规则:动态布局(Auto Layout 约束断裂检测)、横竖屏适配、多语言截断 - - 动效完整性:转场动画时长、交互反馈(如按钮点击态) - -- 交互行为验证 - - | 交互类型 | 测试要点 | 工具示例 | - | :----------- | :----------------------------------------------- | :------------------------------------- | - | **手势操作** | 滑动/长按/捏合等触发事件 | `XCUITest: swipeUp()` | - | **表单输入** | 键盘类型切换、输入校验(正则)、自动填充 | `typeText("test@email.com")` | - | **导航流** | 页面跳转栈深度、返回逻辑(物理返回 vs 程序返回) | `navigationBars.buttons["Back"].tap()` | - | **异步状态** | 加载中/空状态/错误页的显示与隐藏 | `waitForExistence(timeout: 5)` | - -- 数据驱动验证 - - API 数据映射:Mock 不同 API 响应(200/404/500),检查 UI 渲染正确性 - - 本地数据同步:Core Data/Realm 更新后 UI 即时刷新 - - 动态内容:富文本(含超链接)、图片懒加载、视频播放器状态 -- 边界场景验证(Edge Cases) - - 设备兼容:从 iPhone SE 到 iPad Pro 的适配 - - 系统版本:iOS 14~17 的关键行为差异(如权限弹窗样式) - - 极端操作:快速连续点击、低内存告警恢复 - - 无障碍支持:VoiceOver 焦点顺序、Dynamic Type 超大字体布局 - - - -### 基础使用 - -上面文章大篇幅的讲了单元测试相关的话题,单元测试十分适合代码质量、逻辑、网络等内容的测试,但是针对最终产物 App 来说单元测试就不太适合了,如果测试 UI 界面的正确性、功能是否正确显然就不太适合了。Apple 在 Xcode 7 开始推出的 `UI Testing` 就是苹果自己的 UI 测试框架。 - -很多 UI 自动化测试框架的底层实现都依赖于 `Accessibility`,也就是 App 可用性。`UI Accessibility` 是 iOS 3.0 引入的一个人性化功能,帮助身体不便的人士方便使用 App。 - -Accessibility 通过对 UI 元素进行分类和标记。分类成类似按钮、文本框、文本等类型,使用 identifier 来区分不同 UI 元素。[无痕埋点的设计与实现](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.55.md)里面也使用 `accessibilityIdentifier` 来绑定业务数据。 - -1. 使用 Xcode 自带的 UI测试则在创建工程的时候需要勾选 “Include UI Tests”。 -2. 像单元测试意义,UI 测试方法命名以 test 开头。将鼠标光标移到方法内,点击 Xcode 左下方的红色按钮,开始录制 UI 脚本。 - -![UI 脚本录制](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-15-XcodeUITesting.png) - -解释说明: - -```objective-c -/*! Proxy for an application that may or may not be running. */ -@interface XCUIApplication : XCUIElement -// ... -@end -``` - -- `XCUIApplication launch` 来启动测试。`XCUIApplication` 是 UIApplication 在测试进程中的代理,用来和 App 进行一些交互。 - -- 使用 `staticTexts`来获取当前屏幕上的静态文本(UILabel)元素的代理。等价于 `[app descendantsMatchingType:XCUIElementTypeStaticText]`。XCUIElementTypeStaticText 参数是枚举类型。 - - ```objective-c - typedef NS_ENUM(NSUInteger, XCUIElementType) { - XCUIElementTypeAny = 0, - XCUIElementTypeOther = 1, - XCUIElementTypeApplication = 2, - XCUIElementTypeGroup = 3, - XCUIElementTypeWindow = 4, - XCUIElementTypeSheet = 5, - XCUIElementTypeDrawer = 6, - XCUIElementTypeAlert = 7, - XCUIElementTypeDialog = 8, - XCUIElementTypeButton = 9, - XCUIElementTypeRadioButton = 10, - XCUIElementTypeRadioGroup = 11, - XCUIElementTypeCheckBox = 12, - XCUIElementTypeDisclosureTriangle = 13, - XCUIElementTypePopUpButton = 14, - XCUIElementTypeComboBox = 15, - XCUIElementTypeMenuButton = 16, - XCUIElementTypeToolbarButton = 17, - XCUIElementTypePopover = 18, - XCUIElementTypeKeyboard = 19, - XCUIElementTypeKey = 20, - XCUIElementTypeNavigationBar = 21, - XCUIElementTypeTabBar = 22, - XCUIElementTypeTabGroup = 23, - XCUIElementTypeToolbar = 24, - XCUIElementTypeStatusBar = 25, - XCUIElementTypeTable = 26, - XCUIElementTypeTableRow = 27, - XCUIElementTypeTableColumn = 28, - XCUIElementTypeOutline = 29, - XCUIElementTypeOutlineRow = 30, - XCUIElementTypeBrowser = 31, - XCUIElementTypeCollectionView = 32, - XCUIElementTypeSlider = 33, - XCUIElementTypePageIndicator = 34, - XCUIElementTypeProgressIndicator = 35, - XCUIElementTypeActivityIndicator = 36, - XCUIElementTypeSegmentedControl = 37, - XCUIElementTypePicker = 38, - XCUIElementTypePickerWheel = 39, - XCUIElementTypeSwitch = 40, - XCUIElementTypeToggle = 41, - XCUIElementTypeLink = 42, - XCUIElementTypeImage = 43, - XCUIElementTypeIcon = 44, - XCUIElementTypeSearchField = 45, - XCUIElementTypeScrollView = 46, - XCUIElementTypeScrollBar = 47, - XCUIElementTypeStaticText = 48, - XCUIElementTypeTextField = 49, - XCUIElementTypeSecureTextField = 50, - XCUIElementTypeDatePicker = 51, - XCUIElementTypeTextView = 52, - XCUIElementTypeMenu = 53, - XCUIElementTypeMenuItem = 54, - XCUIElementTypeMenuBar = 55, - XCUIElementTypeMenuBarItem = 56, - XCUIElementTypeMap = 57, - XCUIElementTypeWebView = 58, - XCUIElementTypeIncrementArrow = 59, - XCUIElementTypeDecrementArrow = 60, - XCUIElementTypeTimeline = 61, - XCUIElementTypeRatingIndicator = 62, - XCUIElementTypeValueIndicator = 63, - XCUIElementTypeSplitGroup = 64, - XCUIElementTypeSplitter = 65, - XCUIElementTypeRelevanceIndicator = 66, - XCUIElementTypeColorWell = 67, - XCUIElementTypeHelpTag = 68, - XCUIElementTypeMatte = 69, - XCUIElementTypeDockItem = 70, - XCUIElementTypeRuler = 71, - XCUIElementTypeRulerMarker = 72, - XCUIElementTypeGrid = 73, - XCUIElementTypeLevelIndicator = 74, - XCUIElementTypeCell = 75, - XCUIElementTypeLayoutArea = 76, - XCUIElementTypeLayoutItem = 77, - XCUIElementTypeHandle = 78, - XCUIElementTypeStepper = 79, - XCUIElementTypeTab = 80, - XCUIElementTypeTouchBar = 81, - XCUIElementTypeStatusItem = 82, - }; - ``` - -- 通过 `XCUIApplication` 实例化对象调用 `descendantsMatchingType:` 方法得到的是 `XCUIElementQuery` 类型。比如 `@property (readonly, copy*) XCUIElementQuery *staticTexts;` - - ```objective-c - /*! Returns a query for all descendants of the element matching the specified type. */ - - (XCUIElementQuery *)descendantsMatchingType:(XCUIElementType)type; - ``` - -- `descendantsMatchingType` 返回所有后代的类型匹配对象。`childrenMatchingType` 返回当前层级子元素的类型匹配对象 - - ```objective-c - /*! Returns a query for direct children of the element matching the specified type. */ - - (XCUIElementQuery *)childrenMatchingType:(XCUIElementType)type; - ``` - -- 拿到 `XCUIElementQuery` 后不能直接拿到 `XCUIElement`。和 `XCUIApplication` 类似,`XCUIElement` 不能直接访问 UI 元素,它是 UI 元素在测试框架中的代理。可以通过 `Accessibility` 中的 `frame`、`identifier` 来获取。 - -对比很多自动化测试框架都需要找出 UI 元素,也就是借助于 `Accessibility` 的 `identifier`。这里的唯一标识生成对比[为 UIAutomation 添加自动化测试标签的探索](http://yulingtianxia.com/blog/2016/03/28/Add-UITest-Label-for-UIAutomation/)] - -第三方 UI 自动化测试框架挺多的,可以查看下典型的 [appium](https://github.com/appium/appium)、[macaca](https://github.com/alibaba/macaca)。 - - - -### 经验心得 - -UI 测试另一个问题是,某些 UI 方法比如 AppDelegate 里包含太多 SDK 的或者拉接口的场景,启动会比较慢,测试的诉求是:单个测试 case 需要快速运行。而 UI 测试聚焦的不是借口业务逻辑,所以期望 AppDelegate 里的拉接口这样的逻辑不要走,太慢影响测试速度。 - -理论分析:如果可以从 `NSClassFromString(@"XCTestCase")` 方式获取到值,说明是测试环境,可以简化 AppDelegate 逻辑。 - -具体做法是在开发阶段预留测试口子。非测试模式,走正常的业务逻辑;测试模式,走简化版 AppDelegate 逻辑。 - -第一步:改造 `main.m` - -```objective-c -int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { - // Setup code that might create autoreleased objects goes here. - id testClass = NSClassFromString(@"XCTestCase"); - appDelegateClassName = testClass ? @"TestMockAppDelegate" : NSStringFromClass([AppDelegate class]); - } - return UIApplicationMain(argc, argv, nil, appDelegateClassName); -} -``` - -第二步:创建 mock 的简化版 `TestMockAppDelegate`,可以剔除一些 UI 测试不关心的逻辑。甚至只需要完成这个方法基础实现都可以。 - - - -### 单元测试的原理窥探 - -开发的单元测试代码,运行的背后也是一个可执行文件。查看内部,可以发现一堆和测试相关的 framework。 - - - -思考:一个非 UI 测试工程(正常的 App 工程),是不是加载这几个测试相关的动态库,写好测试代码,就可以运行测试用例了? - -细节不贴了。Demo App 引入 `XCTest.framework` 后业务代码里即可引入 `#import ` 然后就可以编写测试代码了。 - -写法1: - -```objective-c -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - // 测试管理集 - XCTestSuite *suite = [XCTestSuite defaultTestSuite]; - // 初始化 TestCase - LoginUITests *loginTest = [LoginUITests testCaseWithSelector:@selector(testDidClickLoginAction)]; - - // 添加测试用例到当前 suite - [suite addTest:loginTest]; - - // 遍历并运行测试用例 - for (XCTest *test in suite.tests) { - [test runTest]; - } -} -``` - -写法2: - -```objective-c -- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - // 测试管理集 - XCTestSuite *suite = [XCTestSuite testSuiteForTestCaseClass:LoginUITests.class]; - // 初始化 TestCase - LoginUITests *loginTest = [LoginUITests new]; - - // 添加测试用例到当前 suite - [suite addTest:loginTest]; - - // 遍历并运行测试用例 - for (XCTest *test in suite.tests) { - [test runTest]; - } -} -``` - - - -#### 执行时机 - -这种情况下,整体流程为: - -```mermaid -graph TD - A[测试套件启动] --> B[创建测试类实例] - B --> C1[执行 testMethod1] - C1 --> D1[调用 setUp] - D1 --> E1[执行 testMethod1 本体] - E1 --> F1[调用 tearDown] - - B --> C2[执行 testMethod2] - C2 --> D2[调用 setUp] - D2 --> E2[执行 testMethod2 本体] - E2 --> F2[调用 tearDown] - - B --> C3[执行 testMethod3] - C3 --> D3[调用 setUp] - D3 --> E3[执行 testMethod3 本体] - E3 --> F3[调用 tearDown] -``` - -分析: - -- 当有多个测试方法时,XCTest 会为**每个测试方法创建单独的类实例** - -- 每个测试方法执行时都会触发完整的生命周期 - - ```objective-c - // 伪代码展示 XCTest 内部执行流程 - for (XCTest *test in allTests) { - [test invokeTest]; // 实际执行入口 - } - - // invokeTest 内部实现: - - (void)invokeTest { - [self setUp]; // 每次测试前调用 - [self performTest]; // 执行测试方法本体 - [self tearDown]; // 每次测试后调用 - } - ``` - -- 三个测试方法的执行示例 - - 测试代码: - - ```objective-c - - (void)testValidLogin { ... } - - (void)testInvalidPassword { ... } - - (void)testNetworkErrorHandling { ... } - ``` - - 实际执行顺序 - - ```objective-c - // 测试1 - [LoginUITests setUp]; - [LoginUITests testValidLogin]; - [LoginUITests tearDown]; - - // 测试2 - [LoginUITests setUp]; // 全新状态! - [LoginUITests testInvalidPassword]; - [LoginUITests tearDown]; - - // 测试3 - [LoginUITests setUp]; // 再次重置状态 - [LoginUITests testNetworkErrorHandling]; - [LoginUITests tearDown]; - ``` - -思考:为什么需要每次重置? - -1. **测试隔离原则** - - 防止测试间的状态污染 - - 确保每个测试都是独立可重复的 -2. **资源管理** - - 每次 `tearDown` 释放测试占用的资源 - - 避免内存泄漏累积 -3. **环境一致性** - - `setUp` 确保每次测试初始条件相同 - - 不受前次测试副作用影响 - -QA:单独执行每个测试方法,都会走 setup 和 teardown。按了快捷键 command + u,运行所有的测试 case,会执行几次 setup?比如 Login 有3个测试 case。点击后执行流程是什么样的? - -```mermaid -sequenceDiagram - participant X as XCTestRunner - participant C as LoginUITests Class - participant I1 as 实例1 (testA) - participant I2 as 实例2 (testB) - participant I3 as 实例3 (testC) - - X->>C: 调用 +[LoginUITests setUp] (类方法) - activate C - - X->>I1: 创建实例1 (testA) - activate I1 - I1->>I1: -setUp (实例方法) - I1->>I1: -testA (测试方法) - I1->>I1: -tearDown (实例方法) - deactivate I1 - - X->>I2: 创建实例2 (testB) - activate I2 - I2->>I2: -setUp (实例方法) - I2->>I2: -testB (测试方法) - I2->>I2: -tearDown (实例方法) - deactivate I2 - - X->>I3: 创建实例3 (testC) - activate I3 - I3->>I3: -setUp (实例方法) - I3->>I3: -testC (测试方法) - I3->>I3: -tearDown (实例方法) - deactivate I3 - - X->>C: 调用 +[LoginUITests tearDown] (类方法) - deactivate C -``` - -分析: - -- 类级别初始化(只执行一次)。`+[LoginUITests setUp]` 类方法 - - 在**所有测试开始前**执行一次 - - 适合做全局初始化(如启动模拟服务器) - - 执行频率:1次/测试类 -- 每个测试方法的独立执行。对于每个测试方法(testA, testB, testC): - - 创建**新的测试类实例** - - **-setUp** 实例方法(每个测试方法前执行) - - 执行测试方法(如 **-testA**) - - **-tearDown** 实例方法(每个测试方法后执行) -- 类级别清理(只执行一次)。`+[LoginUITests tearDown]` 类方法 - - 在**所有测试结束后**执行一次 - - 适合做全局清理(如关闭模拟服务器) - - 执行频率:1次/测试类 - - - -## 六、精准测试 - -精准测试是最近很火的一个概念,但是也不算在概念阶段,很多公司都落地并实施了精准测试。单测是开发者为了方法级别写的测试用例。精准测试是代码级别的测试覆盖。 - -价值: - -- 协助研发小伙伴发现问题和漏测、感知 QA 同学测试范围 -- 测试人员感知研发同学代码质量,反向驱动研发侧质量把控,增加质量把控抓手 -- 在产品交付流程中多个环节赋能质量 - -比如目前都是产品设计师去设计测试用例,那么测试用例全不全是一回事,这个时候就需要业务老司机去参与测试用例的评审,并给出一些建议和测试 case 的补充。 - -另外假设有了n条测试用例,那这 n 条测试用例,能不能覆盖开发同学写的每一行代码?可能有些人会好奇单测不是保证了方法被覆盖了吗?单测主要针对逻辑方法,可是用户使用的产品是经过逻辑运算后,还有一部分逻辑是 UI 层的,可能拿到 Model 后在界面展示的地方,走了几个 UI 方法,数据就不对了,这时候可能就是线上 bug 产生的原因。所以这一环节,精准测试可以去发现并解决。 - -精准测试是拿当前开发的代码和上一次基准分支的代码进行比较,判断变动了m行,如果产品设计师提供的测试用例只覆盖了其中的n行,那么剩下的 m-n 行就是风险代码。要催促补充测试用例,将这剩下的 m-n 行代码覆盖完全,这样的代码才是稳定的、可靠的。 - -下面是之前在有赞,开发完精准测试系统后,落地到一个业务项目中取得的价值,帮助2位 QA 发现漏测的代码,倒逼 QA 去设计更完善的测试 case、分析覆盖率低是开发者的兜底代码太多还是真的漏掉了业务 case。 - - - -精准测试助力业务,质量更加稳定。 - -精准测试怎么实现?核心问题是 iOS 侧开发语言有 OC、Swift,分别对应不同的编译器:clang、swiftc,插桩手段不一样。具体实现原理和细节可以看这篇文章:[精准测试最佳实践](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.108.md) - - - -## 七、 测试经验总结 - -TDD 写好测试再写业务代码,BDD 先写实现代码,再写基于行为的测试代码。另一种思路是没必要针对每个类的私有方法或者每个方法进行测试,因为等全部功能做完后针对每个类的接口测试,一般会覆盖据大多数的方法。等测试完看如果方法未被覆盖,则针对性的补充 `Unit Test`。 - -目前,UI 测试(appium) 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归了,目前来看价值是方便后续的迭代和维护上有一些便利性。其他的功能性测试还是走 BDD。 - -对于类、函数、方法的走 TDD,老老实实写 UT、走 UT 覆盖率的把控。 - -UITesting 还是建议在核心逻辑且长时间没有改动的情况下去做,这样子每次发版本的时候可以当作核心逻辑回归,目前来看价值是方便后续的迭代和维护上有一些便利性。例如用户中心 SDK 升级后,当时有了UITesing,基本上免去了测试人员介入。 - -如果是一些活动页和逻辑经常变动的,老老实实走测试黑盒... - -我觉得大家一直有个误区,就是觉得软件测试是为了质量额外做的功,这部分功是有用功还是无用功是不一定的。其实,有了正确的开发姿势后(那什么叫正确的开发姿势?设计一个函数的时候就应该想好该方法如何更方便的被测试),最后可以可以实现事半功倍的效果,良好的质量都是附送的,也就是说测试先行是让开发更快更好的展开。 - -![测试占比](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-14-TestingPercentage.png) - -WWDC 这张图也很清楚,UI 其实需要的占比较小,还是要靠单测驱动。 - -## \ No newline at end of file diff --git a/Chapter1 - iOS/1.76.md b/Chapter1 - iOS/1.76.md deleted file mode 100644 index 8f4b6d8..0000000 --- a/Chapter1 - iOS/1.76.md +++ /dev/null @@ -1,61 +0,0 @@ -# iOS Crash分析 - -> 目前在重构 APM,在做测试的时候有这样一个需求,分析线上环境的 top10 Crash, APM SDK 的提测 Demo 工程中需要有模拟能力并全链路分析是否可以在 Demo 工程中生成 Crash -> 上报组件上报 -> 服务端符号化. 在查询目前 App 的 top 10 Crash 的时候发现系统层面的 Crash 还是无法符号化成功. 虽然这不重要,但是还是想探讨下如何可以解析这种系统层面的 Crash 信息. - - -## 背景 - -APM 监控到 Crash,然后线程回溯拿到堆栈信息,再调用上报组件的能力,上报组件按照一定的策略去上报数据,服务端符号化解析堆栈信息.为了方便查看拿 stack_trace_id 去查询堆栈信息.接口信息如下图 - -![接口堆栈信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-10-10-APM_Stack_trace_api.png) - -拿到堆栈信息里面的 json 本地保存成拓展名为 ***.crash** 文件,Mac 可以打开拓展名为 crash 的文件. 然后根据 **Crashed Thread** 后面的数字去查找对应的 Thread 里面的信息 - -![Crash 信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-10-10-APM_Crash.png) - -结果发现是系统层级的信息,看不懂 - -```Shell -Thread 12 Crashed: -0 libsystem_platform.dylib 0x00000001c34a87e8 0x1c34a2000 + 26600 ( + 16) -1 Foundation 0x00000001c42fe698 0x1c41ea000 + 1132184 ( + 52) -2 Foundation 0x00000001c42091fc 0x1c41ea000 + 127484 ( + 1744) -3 Foundation 0x00000001c4208af4 0x1c41ea000 + 125684 ( + 1232) -4 Foundation 0x00000001c42fecec 0x1c41ea000 + 1133804 ( + 272) -5 libdispatch.dylib 0x00000001c32d8a38 0x1c3279000 + 391736 ( + 24) -6 libdispatch.dylib 0x00000001c32d97d4 0x1c3279000 + 395220 ( + 16) -7 libdispatch.dylib 0x00000001c32b0c34 0x1c3279000 + 228404 ( + 404) -8 libdispatch.dylib 0x00000001c32b0314 0x1c3279000 + 226068 ( + 592) -9 libdispatch.dylib 0x00000001c32bc9d4 0x1c3279000 + 276948 ( + 340) -10 libdispatch.dylib 0x00000001c32bd248 0x1c3279000 + 279112 ( + 116) -11 libsystem_pthread.dylib 0x00000001c34b91b4 0x1c34ad000 + 49588 (_pthread_wqthread + 464) -``` - -为了搞懂这种 Crash,这篇文章就诞生了. - - -## 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.77.md b/Chapter1 - iOS/1.77.md deleted file mode 100644 index d774b7b..0000000 --- a/Chapter1 - iOS/1.77.md +++ /dev/null @@ -1,271 +0,0 @@ -# iOS 打包系统构建加速 - - - -### 目标 - -> iOS 单包构建加速、支持多包并行打包 - - - -### 基础知识 - -CI、CD 在稍微有点规模的公司内部都会内建一套自己的系统。目前主流的是在 Jenkins 的基础上进行的打包系统。公司只有1个 App 的情况下一台打包机就够了,但是有多个 SDK、App 那肯定不够的,各个业务线都需要测试、上架等等,任务太多了,一台机器别人要等到花儿谢了... - -分布式构建系统可解决上述问题,即一个 master 为中心,多个 slave 来进行具体的构建操作。多台执行机来进行任务的构建以及自动化脚本的执行。Jenkins 具备分布式特性,是 Master/Slave 模式(主从模式,将设备分为主设备和从设备,主设备负责分配工作并整合结果,或作为指令的来源;从设备负责完成任务,从设备一般只和主设备通信)。这个模式有2个好处: - -- 能够有效分担主节点的压力,加快构建速度 -- 能够指定特定的任务在特定的主机上进行 - - - -## 背景 - -![打包平台](https://github.com/FantasticLBP/knowledge-kit/blob/master/assets/2019-12-16-candle.png) - -- 描述现状 - - 我们公司的 WAES 平台下子平台 candle 是专门用来打包构建的,可以打包 iOS SDK、iOS App、Android SDK、Android App、React Native 包、H5、Node 包、React 包等等。iOS 端将 SDK、App 通过 candle 打包平台进行任务创建、排队、打包,根据任务的特点和语言去调度合适的打包机器进行打包。 现状是整体速度觉得较慢,还有加速空间。 - -- 问题原因 - - 1. iOS 打包目前都是在使用旧的打包构建系统,所以在单包构建方面会慢一些; - 2. 在 pod install 这一步,打包机使用的 1.3.1 版本的 cocoapods 版本在进行依赖分析,它本质上是操纵打包机上全局的 git 目录,由于本质如此所以没办法多包并行打包。 - -- 风险预警 - - 1. cocoapods 升级到最新版本,目前的 cocoapods 的相关的 ruby 脚本可能会有问题,没办法良好运行。 - 2. 开启 Xcode 的新构建系统,可能会造成现有工程报错,没办法编译成功,需要改动。这些改动可能不只是主工程,也许是各个 SDK 自身的修改,所以会比较零散。 - - - - - - - -## 改造 - -#### 单包加速 - - - -##### 一些背景: - -1. 打包机暂时不支持各个依赖的 SDK 以静态库的方式引入。 - - 原因是目前 APM 监控系统不支持。因为多个静态库则会生成多个 DYSM 文件,这样子 APM 定位 Crash、ANR 等都会生成多个 DYSM 文件的信息,配套的后端在符号化处理的时候只支持一个 DYSM 的模式,所以在如此背景下打包机不支持静态库。 - -2. 看到博客说可以通过 `generate_multiple_pod_projects` 和 `disable_input_output_paths` 来加速构建速度,这些在本地开发过程中是可以提高构建速度的。但是在打包机这种环境下是不太适用的。因为 iOS 在打包机环境下都会执行 pod install 的过程. - ```ruby - install! 'cocoapods', :generate_multiple_pod_projects => true, :incremental_installation => true, :disable_input_output_paths => true - ``` - - generate_multiple_pod_projects - 生成多个 XcodeProj。在 1.7.0 之前,cocoapods 只生成一个 Pod.xcodeproj,随着 Library 增多,文件越来越大,解析时间越来越长,在 1.7.0 之后每个 Library 都允许生成单独的 Project,提高项目的编译时间。 默认关闭 - - incremental_installation - 增量安装,每次执行 pod install 都会生成整个 workspace,现在支持只更新的 Library 编译。节省时间 - -3. 另外网上的部分优化提速手段也不太适合,因为这些手段基本上只是会加快一些速度,但是不可能把一个项目的构建速度提升明显,所以这次的方案主要是单包开启 New Build System 和支持多包并行能力。 - - - -##### 理论基础 - -本质上就是开启 New Build System,苹果在 WWDC 2017 中描述新构建系统的有点为:**降低构建开销,尤其可以降低大型项目的构建开销**。但是在新构建系统下现有的工程会报错。经过查看报错信息,基本都是在资源方面的错误(图片等)和偶尔一些 SDK 不规范造成的问题。 - -苹果从 Xcode 9 开始推出了新构建系统(New Build System),并在 Xcode 10 使用其为默认构建系统来替代旧构建系统(Legacy Build System)。采用新构建系统能够减少构建时间。 - -简要介绍一下原理,对于旧构建系统,当我们构建一个程序的时候,会明确所需要构建的所有 Target Dependencies、Link Binary With Libraries,这些 Target 之间的依赖关系,以及这些 Target 构建的顺序。采用顺序会造成多处理器系统资源的浪费,从而表现为编译时间的浪费,解决这个问题的方式就是采用并行编译,这也是新构建系统优化的核心思想。详细了解新构建系统,探究 Xcode New Build System 对于构建速度的提升。 - - - - - -##### 测试实验 - - - -注意: 报错提示找不到 `coderay`. 可以运行 `sudo gem install coderay` 解决该问题。 - -本地 cocoapods 版本为 1.4.0,打包机环境为 1.3.1,所以方案评估有问题。这几天花时间做了对比实验,数据如下 - -1. New Build System 是否可以让单包构建变快? - cocoapods 模拟打包机环境 1.3.1。在 Legacy Build System 和 New Build System 下运行项目。 - 1.3.1 不能开启 New Build System。报错信息: New Build System Multiple commands produce script phase “[CP] Copy Pods Resources” - 1.3.1 Legacy Build System 构建时间为 335.4s. - 所以尝试升级 cocoapods 继续做对比实验 - -2. cocoapods 小版本升级到 1.4.0 在 Legacy Build System 和 New Build System 下运行项目(为什么选择升级到 1.4.0? cocoapods 小版本升级则改动较小,业务线可以快速享受到 New Build System 改动带来的收益) - New Build System: 383.5s - Legacy Build System: 302.9s - -3. 升级 cocoapods 到 1.8.0,查看在 New Build System 和 Legacy Build System 下的构建时间 - cocoapods 升级到 1.8.0 会报错,修改错误后运行对比。 - New Build System: 324.4s - Legacy Build System: 262.2s - - - -实验数据如下: - -| App| 构建系统 | Cocoapods 版本 | Build 结果 | 编译时间 | -|:-:|:-:|:-:|:-:|:-:| -| **App | New Build System |1.3.1 | 失败 | ~ | -| **App | Legacy Build System |1.3.1| 成功| 335.4s | -| **App | New Build System | 1.4.0 | 失败 | 383.5s | -| **App | Legacy Build System |1.4.0 | 成功 | 302.9s | -| **App | New Build System | 1.8.4 |成功 | 324.4s| -| **App | Legacy Build System | 1.8.4 | 成功 | 262.2s | - - -结论:从实验数据来看, New Build System 并不能单包加速。所以 New Build System 不做了。构建加速是升级cocoapods 1.8.4 带来的,并不是 new build system 带来的。后续计划分2步: -1. 升级 cocoapods 到 1.8.4,可以体验到单包构建加速的效果。 -2. 自建 CDN。 - -拿自己的电脑部署脚本,当作本地打包机;拿**App App 打包,指定打包机为自己的电脑 -| App| Cocoapods 版本 | 编译时间 | -|:-:|:-:|:-:| -| **App | 1.3.1 | 8m37s | -| **App | 1.8.4 | 7min47s | - - - - -开启 New Build System 带来的改动 - -1. SDK 中图片是通过 resource 的方式管理的,cocoapods 1.8.4 会将它打包到 `Assets.car` 和 App 主工程图片打包的结果一致,导致 Xcode 主工程报错,大体意思是说工程包含多个 **Assets.car**. 原因在于 SDK 通过 resource 管理图片,打出包所以可以使用 ``resource_bundles`` 的形式管理 - - 涉及到的 SDK:TrinityConfiguration(公共 SDK) - - 改造点: - 注意:改变 SDK 内部修改 xcassets 文件名是无用的,Xcode 编译后查看包内容,结果还是 `Assests.car` 。 - 图片使用方式改变。由之前的 **resources** 方式改为 **`resource_bundles`** 的形式。这样做有2个优点:解决了图片资源打包后造成 `Assets.car` 冲突的问题;`resource_bundles` 还可以解决图片访问速度的优化。 -2. 图片资源重复 - **App工程中有些图片和 理财的 SDK `SdkFinanceHome` 里面的图片资源重名,但是内容却不一致,需要协商改动。 - - - 涉及到的 SDK:SdkFinanceHome (理财业务线 SDK) - - 改造点: - 有2张图片在 SdkFinanceHome SDK 内重复出现2次(形状、大小一致)。App 也存在同名的图片,图形一致、尺寸大小不一致。所以需要**App业务线开发者确认,保留什么图片或者资源重命名。建议图片资源也用 **`resource_bundles`** 的形式管理 - ```ruby - s.resource_bundles = { - 'SdkFinanceHome' => ['***/Assets/*.xcassets'] - } - ``` - -升级 1.8.4 带来的改动点: - -1. 部分 SDK 的头文件引用方式有问题 - - 涉及到的 SDK:SdkFundWax - - 改造点:将 `FCH5AuthRouter.m` 文件中关于 NativeQS 中头文件的引入方式改变下。`#import ` 改为 `#import `,或者改为 `#import "NQSParser.h"` - ```ruby - "dependencies": { - "NativeQS": "~> 1.0" - }, - ``` - - 注意: - 因为打包机目前是源码引入编译成 .a 文件。如果是以 framework 的形式,则必须以依赖描述的方式进行调整。 - - 新版本 cocoapods 中: - - - cocoapods 在 SDK 里面引用别的 SDK 如果 **podspec** 里面存在 **dependencies** 描述,则可以使用 `#import ` 或者 `#import "NativeQS.h"`;如果不存在 **dependencies** 描述,则需要使用 `#import ` - - 在主工程中引用 SDK 的头文件,使用 `#import `、`#import "NativeQS.h"` 都可以 - - 旧版本 cocoapods 中: - - - SDK、App 主工程都可以使用 `#import `、`#import "NativeQS.h"`、`#import ` - -2. 部分 SDK 的使用了未在 podspec 文件中声明的依赖,在新版本 cocoapods 下会报错(某些 SDK 由于历史原因造成新版本丢失依赖描述) - - - - - - - 涉及到的 SDK:CMRCTToast - - 改造点: - 问题基本定位是在于, App 主工程引用的 SdkBbs2 SDK 依赖了 SdkBbs2 版本(该版本的依赖描述为 `CMRCTToast (~> 0.1)` ) - 历史原因: 早期在做 RN SDK 封装的时候在第一个版本的时候只有某个版本的 React Native 库,所以在 `0.1.0` 的时候依赖的描述可以看到如下的代码 - - ```ruby - s.dependency 'React/Core', '0.41.2' - s.dependency 'React/RCTNetwork', '0.41.2' - s.dependency 'React/RCTImage', '0.41.2' - s.dependency 'React/RCTText', '0.41.2' - s.dependency 'React/RCTWebSocket', '0.41.2' - s.dependency 'React/RCTAnimation', '0.41.2' - ``` - 随着版本的不断迭代,在第二个版本 `0.1.1` 的时候可以看到下面的描述. 可以看到对 RN 的描述不存在了,因为当时的代码对 RN 的2个版本都做了兼容,所以 App 主工程肯定是有 RN 的库,所以索性就不在单独描述,直接随着 App 依赖的 RN 库而使用。之后的版本也是如此。 - ```ruby - s.dependency 'CMDevice', '~> 0.1' - ``` - 所以, 将 `CMRCTToast.podspec` 中的依赖修改掉。需要兼容不同 RN SDK 的版本。 - -3. 部分 pod 的 hook 脚本会失败。 - - 涉及到的 SDK:无 - - 改造点: - `TrinityParams.rb` 类方法 `generate_mods` 会报错。逻辑是通过遍历每个 pod_target,获取到 PBXNativeTarget,然后访问 source_build_phase 属性去遍历内部的每个文件,判断是否是 `properties.yml`。 - 1. [官方文档地址](https://rubydoc.info/gems/xcodeproj/Xcodeproj/Project/Object/PBXNativeTarget#source_build_phase-instance_method). - 2. 公共组做的库,Android 和 iOS 都是对应的,但是 SDK 的名字不一定严格一致。但是通跳后台是配置的时候不可能设置多个名字,所以设置了一个通用的名字,然后 iOS SDK 和 Android SDK 各自用一个描述文件将本地的 SDK 和下发需要命中的 SDK 名字做一个对映射关系。iOS 端用 `properties.yml`来描述 - cocoapods 新版本里面每个 pod_target 没有 native_target 属性,也就是没办法获取到 PBXNativeTarget。 - 感觉之前的脚本写法有问题,内部基既有 file_accessors,也有 pod_target.native_target 的形式继续访问 yml 文件。所以升级 cocoapods 1.8.4 之后修改脚本直接改为用 file_accessors 寻找 yml - - - - - - -#### 多包加速 - -目前不能开启多包并行的瓶颈在于打包机操作的是本地下载下来的 `.cocoapods` 文件夹,所以当一个项目操作的时候其他项目没办法操作。 - -CDN 提供了通过网络接口处理依赖的能力,通过网络去操作文件,所以是可以多包并行打包的。 - -但是由于以下2个原因,我们需要自建``` CDN``` 的能力: - -1. 我们的依赖存在的位置有2个,1个是私有源、1个是官方源。目前使用官方``` CDN``` 就是官方源,因为我们要并行打包,所以私有源也需要 `CDN` 化。不然难以免于多个项目进行文件的读写锁操作问题。 -2. 但是``` CDN``` 跟网络的状态有关,依据所处位置附近的服务器有关系,严重依赖于外界因素,不可控。所以想拥有快速稳定的`` CDN`` 查询能力就需要自建`` CDN`` 了。 - -另外一个可预期的点就是自建了` CDN` ,wax SDK 发布的相关逻辑也需要修改。 - -根据 Cocoapods 的 changeLog 知道 CDN 的实现是借助 **Netlify** 实现的。所以接下去的研究方向就是如何利用 Netlify 自建 CDN。 - -> ### Directory Listing Denied -> -> It was obvious to many that the spec repo should be put behind a CDN, but there were several constraints: -> -> 1. It had to be a free CDN, as the project is free and open-source. -> 2. It had to allow some way of obtaining directory listings, for retrieving versions of pods. -> 3. It had to auto-update from GitHub as the source of truth. -> -> The [first implementation](https://github.com/CocoaPods/Core/pull/469) was a shell script, polling GitHub and piping `find` into `ls` into index files. This ran on a machine that was not open or free and therefore could not be the true solution. Nevertheless, this auto-updated repo was put behind a [jsDelivr CDN](https://www.jsdelivr.com/) and the client interfacing with it was released in [1.7.0](http://blog.cocoapods.org/CocoaPods-1.7.0-beta#cdn-support) labeled "highly experimental". -> -> ### Final Lap with Netlify -> -> The [final version](https://github.com/CocoaPods/Core/pull/541) of the CDN for CocoaPods/Specs was implemented on [Netlify](https://www.netlify.com/), a static site hosting service supporting flexible site generation. This solution ticked all the boxes: a generous open-source plan, fast CDN and continuous deployment from GitHub. -> -> Upon each commit, Netlify runs a [specialized script](https://github.com/CocoaPods/Specs/tree/master/Scripts) which generates a per-shard index for all the pods and versions in the repo. If you've ever noticed that the directory structure for our Podspecs repo was strange, this is what we call sharding. An example of a shard index can be found at https://cdn.cocoapods.org/all_pods_versions_2_2_2.txt. This would correspond to `~/.cocoapods/repos/master/Specs/2/2/2/` locally. -> -> Additionally, we create an `all_pods.txt` file which contains a list of all pods. -> -> Finally, any other request made is redirected to GitHub's CDN. - -### - -#### 接入方式 - -考虑到业务线 App 升级是分开的,不可能同步进行,所以需要考虑到接入计划。 - -- 能否提供 wax 项目指定到特定环境打包机的能力(该打包机升级了 cocoapods 版本) -- 假如没有上述能力,则考虑其他方式支持业务线自定义打包所需的 cocoapods 版本 - - 将 2个版本的 cocoapods 做成2个 Bundle 包,读取 wax 工程配置,指定某个 Bundle - - 假如打包机由于某些原因没办法升级 cocoapods 版本,但是某个 wax 项目又需要新版的 cocoapods 进行打包,则需要则代码上传的时候提交 `Pods` 文件夹。这样在打包机上面不需要执行 install 的操作,将本地的 Pods 目录上传上来,全部使用本地的一套。 - - - - - -## 参考资料 - -[1. cocoapods changeLog](http://blog.cocoapods.org/CocoaPods-1.7.2/) - -[2. 版本清单](https://github.com/CocoaPods/Specs/tree/master/Scripts) - -[3.探究Xcode New Build System对于构建速度的提升](https://blog.csdn.net/TuGeLe/article/details/84885211) - diff --git a/Chapter1 - iOS/1.78.md b/Chapter1 - iOS/1.78.md deleted file mode 100644 index 43577e2..0000000 --- a/Chapter1 - iOS/1.78.md +++ /dev/null @@ -1,242 +0,0 @@ -# App 上架包预检 - -## 一、 iOS 端常见被拒原因汇总 - -1. App 内包含分发下载分发功能(引导用户下载 App 等功能) -2. 提供的测试账号无法查看实际功能 -3. 通过接口返回布尔值判断 App 是否升级,但审核期间该接口不请求 -4. 审核账号,任何时候在任何 ip 登录看到的都是审核版 -5. 提供的登陆账号和密码不对,登陆不上 -6. 运营填写的营销关键字有问题 -7. 元数据问题,iPhoneX 截图中 iPhone 壳子是 iPhone7 的,应该是 iPhoneX -8. 说明隐私权限的作用 -9. 营销文字,某些能力需要资质。此类功能在审核期间都关闭 -10. 修改隐私权限相关的文案,做到让审核人员看得懂,做到「信达雅」 -11. App 无法登陆进去,属于 bug 级别 -12. App 没有适配 ipad -13. Privacy - Data Collection and Storage,说明 App 没有做隐私权限的收集。 -14. 访问 h5 页面出现问题。 属于 bug 级别 - - - - -## 二、 App 被拒原因汇总 - -从 Android 和 iOS 2端 App 被驳回的一些信息来看,驳回原因一般划分为下面几类: - -1. 审核期间,资源和配置都应该调节为审核模式 -2. App 包含某些关键字 -3. 审核相关的元数据问题(截图与实际内容不匹配、机型和截图不匹配、提供给审核的账号和密码登陆不上) -4. 使用的隐私权限必须说明,文案描述必须清晰 -5. App 存在 bug (账号无法登陆、没有适配 ipad、访问 h5 打不开 ) -6. 诱导用户打开查看更多 App -7. Android 应用未加固 -8. 应用缺乏相关的资质和证书 - - - - -## 三、 方案 - -常见审核失败的原因很多,很大比重一个就是代码或者文本里面存在一些敏感词,所以本文的侧重点在于关键词扫描。像上架设置的截图和当前设备不匹配、提供的账号无法使用功能 😂 这种情况打一顿就好了,非主流行为不在本文范围内 - -### 3.1 词云谁去收集? - -每个公司一般来说都不止一条业务线,所以每个业务线的 App 情况和内容也不一样,所以敏感词也是千差万别。敏感词收集这个事情,应该由业务线主要负责 App 的开发者来收集,根据平时的上架情况,苹果的驳回的邮件来整理。 - -### 3.2 方案设计 - -公司自研工具 cli(iOS SDK、iOS App、Android SDK、Android App、RN、Node、React 依赖分析、构建、打包、测试、热修复、埋点、构建),各个端都是通过「模版」来提供能力。包含若干子项目,每个子项目就是所谓的 “**模版**”,每个模版其实就是一个 Node 工程,一个 npm 模块,主要负责以下功能:特定项目类型的目录结构、自定义命令供开发、构建等使用、模版持续更新及 patch 等。 - -所以可以在打包构建(各个端将项目提交到打包系统,打包系统根据项目语言、平台调度打包机)的时候,拿到源代码进行扫描。基于这个现状,所以方案是「扫描是基于源代码出发的扫描的」。 - -按照 iOS 端 `pod install` 这个过程,cocoapods 为我们预留了钩子:`PreInstallHook.rb`、`PostInstallHook.rb`,允许我们在不同的阶段为工程做一些自定义的操作,所以我们的 iOS 模版设计也参考了这个思想,在打包构建前、构建中、构建后提供了钩子:`prebuild`、`build`、`postbuild`。定位好了问题,要做的就是在 prebuild 里面进行关键词扫描的编码工作。 - -确定了什么时候做什么事情,接下来就要讨论怎么做才合适。 - -### 3.3 技术方案选择 - -字符串匹配算法 KMP 是一开始想到的内容,针对某个 App 进行时机测试,发现50多个敏感词的情况下,代码扫描耗时60秒钟,觉得非常不理想,看 KMP 算法没有啥问题,所以换个思路走下去。 - -因为模版本质上 Node 项目,所以 Node 下的 **glob** 模块正好提供根据正则匹配到合适的文件,也可以匹配文件里面的字符串。然后继续做实验,数据如下:9个铭感词语、代码文件5967个,耗时3.5秒 - - -### 3.4 完整方案 - -1. 业务线需要自定义敏感词云(因为每条业务线的关键词云都不一样) -2. 敏感词需要划分等级:error、warning。扫描到 error 需要马上停止构建,并提示「已扫描到你的源码中存在敏感词***,可能存在提交审核失败的可能,请修改后再次构建」。warning 的情况不需要马上停止构建,等任务全部结束后汇总给出提示「已扫描到你的源码中存在敏感词***、***...,可能存在提交审核失败的可能,请开发者自己确认」 -3. 铭感词云的格式 `scaner.yml` 文件。 - - error: 数组的格式。后面写需要扫描的关键词,且等级为 error,表示扫描到 error 则马上停止构建 - - warning:数组的格式。后面写需要扫描的关键词,且等级为 warning,扫描结果不影响构建,最终只是展示出来 - - searchPath:字符串格式。可以让业务线自定义需要进行扫描的路径。 - - fileType:数组格式。可以让业务线自定义需要扫描的文件类型。默认为 `sh|pch|json|xcconfig|mm|cpp|h|m` - - warningkeywordsScan:布尔值。业务线可以设置是否需要扫描 warning 级别的关键词。 - - errorKeywordsScan:布尔值。业务线可以设置是否需要扫描 error 级别的关键词。 - - ```yml - error: - - checkSwitch - warning: - - loan - - online - - ischeck - searchPath: - ../fixtures - fileType: - - h - - m - - cpp - - mm - - js - warningkeywordsScan: true - errorKeywordsScan: true - ``` - -4. iOS 端存在私有 api 的情况,Android 端不存在该问题 - 私有 api 70111个文件,每个文件假设10个方法,则共70万个 api。所以计划找出 top 100.去扫描匹配,支持业务线是否开启的选项 - -其实这些问题都是业界标准的做法,肯定需要预留这样的能力,所以自定义规则的格式可以查看上面 yml 文件的各个字段所确定。明确了做什么事,以及做事情的标准,那就可以很快的开展并落地实现。 - -```javascript -'use strict' - -const { Error, logger } = require('@company/BFF-utils') -const fs = require('fs-extra') -const glob = require('glob') -const YAML = require('yamljs') - -module.exports = class PreBuildCommand { - constructor(ctx) { - this.ctx = ctx - this.projectPath = '' - this.fileNum = 0 - this.isExist = false - this.errorFiles = [] - this.warningFiles = [] - this.keywordsObject = {} - this.errorReg = null - this.warningReg = null - this.warningkeywordsScan = false - this.errorKeywordsScan = false - this.scanFileTypes = '' - } - - async fetchCodeFiles(dirPath, fileType = 'sh|pch|json|xcconfig|mm|cpp|h|m') { - return new Promise((resolve, reject) => { - glob(`**/*.?(${fileType})`, { root: dirPath, cwd: dirPath, realpath: true }, (err, files) => { - if (err) reject(err) - resolve(files) - }) - }) - } - - async scanConfigurationReader(keywordsPath) { - return new Promise((resolve, reject) => { - fs.readFile(keywordsPath, 'UTF-8', (err, data) => { - if (!err) { - let keywords = YAML.parse(data) - resolve(keywords) - } else { - reject(err) - } - }) - }) - } - - async run() { - const { argv } = this.ctx - const buildParam = { - scheme: argv.opts.scheme, - cert: argv.opts.cert, - env: argv.opts.env - } - - // 处理包关键词扫描(敏感词汇 + 私有 api) - this.keywordsObject = (await this.scanConfigurationReader(this.ctx.cwd + '/.scaner.yml')) || {} - this.warningkeywordsScan = this.keywordsObject.warningkeywordsScan || false - this.errorKeywordsScan = this.keywordsObject.errorKeywordsScan || false - if (Array.isArray(this.keywordsObject.fileType)) { - this.scanFileTypes = this.keywordsObject.fileType.join('|') - } - if (Array.isArray(this.keywordsObject.error)) { - this.errorReg = this.keywordsObject.error.join('|') - } - if (Array.isArray(this.keywordsObject.warning)) { - this.warningReg = this.keywordsObject.warning.join('|') - } - - // 从指定目录下获取所有文件 - this.projectPath = this.keywordsObject ? this.keywordsObject.searchPath : this.ctx.cwd - const files = await this.fetchCodeFiles(this.projectPath, this.scanFileTypes) - - if (this.errorReg && this.errorKeywordsScan) { - await Promise.all( - files.map(async file => { - try { - const content = await fs.readFile(file, 'utf-8') - const result = await content.match(new RegExp(`(${this.errorReg})`, 'g')) - if (result) { - if (result.length > 0) { - this.isExist = true - this.fileNum++ - this.errorFiles.push( - `编号: ${this.fileNum}, 所在文件: ${file}, 出现次数: ${result && - (result.length || 0)}` - ) - } - } - } catch (error) { - throw error - } - }) - ) - } - - if (this.errorFiles.length > 0) { - throw new Error( - `从你的项目中扫描到了 error 级别的敏感词,建议你修改方法名称、属性名、方法注释、文档描述。\n敏感词有 「${ - this.errorReg - }」\n存在问题的文件有 ${JSON.stringify(this.errorFiles, null, 2)}` - ) - } - - // warning - if (this.warningReg && !this.isExist && this.fileNum === 0 && this.warningkeywordsScan) { - await Promise.all( - files.map(async file => { - try { - const content = await fs.readFile(file, 'utf-8') - const result = await content.match(new RegExp(`(${this.warningReg})`, 'g')) - if (result) { - if (result.length > 0) { - this.isExist = true - this.fileNum++ - this.warningFiles.push( - `编号: ${this.fileNum}, 所在文件: ${file}, 出现次数: ${result && - (result.length || 0)}` - ) - } - } - } catch (error) { - throw error - } - }) - ) - - if (this.warningFiles.length > 0) { - logger.info( - `从你的项目中扫描到了 warning 级别的敏感词,建议你修改方法名称、属性名、方法注释、文档描述。\n敏感词有 「${ - this.warningReg - }」。有问题的文件有${JSON.stringify(this.warningFiles, null, 2)}` - ) - } - } - - for (const key in buildParam) { - if (!buildParam[key]) { - throw new Error(`build: ${key} 参数缺失`) - } - } - } -} -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.79.md b/Chapter1 - iOS/1.79.md deleted file mode 100644 index f7c4715..0000000 --- a/Chapter1 - iOS/1.79.md +++ /dev/null @@ -1,86 +0,0 @@ -# 深入理解各种锁 - -## 乐观锁、悲观锁 - -乐观锁对应于现实生活中乐观的人,思考事情总往好的方向发展;悲观锁对应于现实生活悲观的人,思考事情总往坏的方向发展。不同性格的人都有优缺点,不能抛开场景说一种人好而另一种人不好。 - -乐观锁和悲观锁是一种广义上的概念,体现了看待线程同步问题的不同角度,在 iOS、Java、数据库中都有此概念。 - - - -### 悲观锁 - -对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定会有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。 -这种线程一旦得到锁,其他需要锁的线程就挂起。共享资源每次只给一个线程使用,其他线程阻塞,用完再把资源转让给其他线程。传统的关系型数据库就用到很多悲观锁这种机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。 - -### 乐观锁 - -乐观锁认为自己在使用数据的时候不会有别的线程来修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据,如果这个数据没有被更新,当前线程将自己修改的数据成功写入,如果数据已经被别的线程更新,则根据不同方式执行不同操作(例如报错或者自动重试)。 - -可以根据版本号机制和 CAS 算法实现。 - -乐观锁适合多读少写的应用类型或者场景,即冲突真的很少发生的场景,这样省去了锁的开销,加大了系统的吞吐量。但是如果多写少读的情况,一般会经常发生冲突,这样会导致上层应用层不断 retry,这样反而降低了性能,所以一般建议多写的场景下使用悲观锁比较合适。 - - -![lock](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-12-19-lock.png) - - - -### 乐观锁常见的实现方式 - -乐观锁一般使用版本号机制或者 CAS 算法实现。 - - - -#### 1. 版本号机制 - -在数据表增加一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时, version 值加1。当线程1更新数据的时候,先拿到数据并读取出 version 值,修改完数据进行提交更新的时候时,若读取出的 version 值为当前数据库中 version 值相等时才更新,否则重试更新操作,直到更新成功。 - -举个例子: -假设数据库中账户信息表有一个字段 version,值为1;当前账户余额为100。当需要对账户信息表进行更新的时候,需要读取 version 字段,以及账户余额信息 - -- 用户 A 读出数据:version = 1,balance = 100。从账户余额中扣除 50, balacne = 50 - -- 用户 B 比用户 A 刚刚晚一点点时间,读出数据 :version = 1, balance = 100。从账户余额中扣除 20,balance = 80 - -- 用户 A 完成修改操作,需要提交更新,但是在更新之前会先判断数据库中的版本号 version 值和自己读取到的 version 值是否一致,如果一致,则将版本号 version 字段的值加1(version = 2),连同账户扣除后的余额(balance = 50),提交到数据库服务器执行更新操作,此时由于提交数据中版本号大于数据库记录中的版本,则数据被更新,数据库记录 version = 2 - -- 用户 B 完成修改操作,同样在更新之前先读取数据库中的版本号 version 值和自己读取到的 version 值是否一致,但此时发现自己读取到的 version = 1,数据库中的 version = 2,很显然不满足“当前最后更新的版本号 version 与操作员第一次读取到的版本号 version 相等”的乐观锁策略,因此用户 B 的提交被驳回。 - -这样,就避免了用户 B 基于 version = 1 的旧数据修改的结果覆盖用户 A 操作的结果, - -#### 2. CAS 算法 - -**compare and swap(比较与交换)** ,是一种有名的**无锁算法**。 无锁编程,即在不使用锁的情况下实现多线程之间的数据同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫做**非阻塞同步(Non-blocking Synchorization)**。CAS 算法涉及到的三个操作数 - -- 需要读写的内存值 V -- 进行比较的值 A -- 拟写入的新值 B - -当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V,否则不会执行任何操作。比较和替换是一个原则操作。一般情况下是一个自旋操作,即不断的重试 - - - -### 乐观锁的缺点 - -1. ABA 问题 - 如果一个变量 V 初次读取的时候的值为 A,并且在准备赋值的时候检查到变量 V 的值仍然是 A,那么可以说是 V 的值从来没被其他线程修改吗?很明显不能,因为有可能变量 V 的值,从 A 变到 B,然后又改回到 A,那么 CAS 的标准就会认为变量 V 从来没被修改过,这类问题被成为 CAS 的 **ABA** 问题。 -2. 循环时间长、开销大 - 自旋 CAS (也就是不成功就一直循环操作直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。 -3. 只能保证一个共享变量的原则操作 - CAS 只对单个变量共享有效,当操作涉及到多个共享变量时,CSA 无效。 - - - -### CAS 与 synchorized 的使用场景 - -一般来说, CAS 适用于乐观锁,多读少写场景,冲突一般较少,则自旋操作的情况非常少,不会消耗 CPU,该场景合适。synchorized 使用悲观锁,多写少读场景,冲突一般较多。 - -1. 对于资源竞争比较少(线程冲突较轻)的情况,如果使用 synchorized 同步锁进行线程阻塞和唤醒切换以及用户内核态间的切换操作额外浪费 CPU 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋的几率较少,因此可以获得更高的性能。 -2. 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的几率会比较大,从而浪费更多的 CPU 资源。效率低于 synchorized - - - - -### 参考资料 -- [不可不说的Java“锁”事](https://tech.meituan.com/2018/11/15/java-lock.html) \ No newline at end of file diff --git a/Chapter1 - iOS/1.8.md b/Chapter1 - iOS/1.8.md deleted file mode 100644 index a339224..0000000 --- a/Chapter1 - iOS/1.8.md +++ /dev/null @@ -1,141 +0,0 @@ - -# 教你实现微信公众号效果:长按图片保存到相册 - -> 不知道各位对于这个需求要如何解决? -> -> 可能有些人会想到js与原生交互,js监听图片点击事件,然后将图片的url传递给原生App端,然后原生App将图片保存到相册,这样子麻烦吗?超麻烦。(1)、js监听图片长按事件;(2)、js将图片url传递给原生;(3)、原生通过图片的url生成UIImage;(4)、保存UIImage到系统相册,巨麻烦啊,大哥,我很懒的好不好 - -#### 那么问题跑出来了,怎么办最简单? - -* 鉴于个人道行尚浅,我就将自己的想法说出来 - -* 有个js的api:`Document.elementFromPoint()` - -> The`elementFromPoint()`method of the[`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document)interface returns the topmost element at the specified coordinates. - -所以根据这个提示,我们完全可以只在App原生端做一些代码开发,实现这个需求 - -#### 开发步骤 - -- 给UIWebView添加长按手势 -- 监听手势动作,拿到坐标点(x,y) -- `UIWebView注入js:Document.elementFromPoint(x,y).src` 拿到 img 标签的 src -- 判断拿到的 src 是否有值,有值则代表点击的网页上的 img 标签,此时弹出对话框,是否保存到相册。如果 src为空,则代表点击网页上的非img标签,则不需要弹出对话框 -- 拿到图片的 url,生成 UIImage,再将图片保存到相册 - -#### 有巨坑 - -* 长按手势事件不能每次都响应,据我猜测UIWebView本身就有很多事件,所以实现下UIGestureRecognizerDelegate代理方法。长按手势准确率100% - -``` -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{ - return YES; -} -``` - - -``` -// -// ViewController.m -// WebView长按图片保存到相册 -// -// Created by 杭城小刘 on 2017/8/2. -// Copyright © 2017年 杭城小刘. All rights reserved. -// - -#import "ViewController.h" - -@interface ViewController () -@property (weak, nonatomic) IBOutlet UIWebView *webView; - -@end - -@implementation ViewController - -#pragma mark -- life cycle -- (void)viewDidLoad{ - [super viewDidLoad]; - - NSString *htmlURL = [[NSBundle mainBundle] pathForResource:@"saveImage" ofType:@"html"]; - [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL fileURLWithPath:htmlURL]]]; - //给UIWebView添加手势 - UILongPressGestureRecognizer* longPressed = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressed:)]; - longPressed.delegate = self; - [self.webView addGestureRecognizer:longPressed]; -} - -#pragma mark -- UIGestureRecognizerDelegate -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer{ - UIActivityTypeAddToReadingList - return YES; -} - -- (void)longPressed:(UILongPressGestureRecognizer*)recognizer{ - if (recognizer.state != UIGestureRecognizerStateBegan) { - return; - } - CGPoint touchPoint = [recognizer locationInView:self.webView]; - NSString *imgURL = [NSString stringWithFormat:@"document.elementFromPoint(%f, %f).src", touchPoint.x, touchPoint.y]; - NSString *urlToSave = [self.webView stringByEvaluatingJavaScriptFromString:imgURL]; - if (urlToSave.length == 0) { - return; - } - - UIAlertController *alertVC = [UIAlertController alertControllerWithTitle:@"大宝贝儿" message:@"你真的要保存图片到相册吗?" preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"真的啊" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self saveImageToDiskWithUrl:urlToSave]; - }]; - UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"大哥,我点错了,不好意思" style:UIAlertActionStyleDefault handler:nil]; - [alertVC addAction:okAction]; - [alertVC addAction:cancelAction]; - [self presentViewController:alertVC animated:YES completion:nil]; -} - -#pragma mark - private method -- (void)saveImageToDiskWithUrl:(NSString *)imageUrl{ - NSURL *url = [NSURL URLWithString:imageUrl]; - - NSURLSessionConfiguration * configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; - - NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue new]]; - - NSURLRequest *imgRequest = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReturnCacheDataElseLoad timeoutInterval:30.0]; - - NSURLSessionDownloadTask *task = [session downloadTaskWithRequest:imgRequest completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) { - if (error) { - return ; - } - NSData * imageData = [NSData dataWithContentsOfURL:location]; - dispatch_async(dispatch_get_main_queue(), ^{ - - UIImage * image = [UIImage imageWithData:imageData]; - UIImageWriteToSavedPhotosAlbum(image, self, @selector(imageSavedToPhotosAlbum:didFinishSavingWithError:contextInfo:), NULL); - }); - }]; - [task resume]; -} - -#pragma mark 保存图片后的回调 -- (void)imageSavedToPhotosAlbum:(UIImage*)image didFinishSavingWithError: (NSError*)error contextInfo:(id)contextInfo{ - NSString*message =@"嘿嘿"; - if(!error) { - UIAlertController *alertControl = [UIAlertController alertControllerWithTitle:@"提示" message:@"成功保存到相册" preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *action = [UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDestructive handler:nil]; - [alertControl addAction:action]; - [self presentViewController:alertControl animated:YES completion:nil]; - }else{ - message = [error description]; - UIAlertController *alertControl = [UIAlertController alertControllerWithTitle:@"提示" message:message preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *action = [UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleCancel handler:nil]; - [alertControl addAction:action]; - [self presentViewController:alertControl animated:YES completion:nil]; - } -} - -@end -``` - -附上关键的js官方文档:[Document.elementFromPoint()](https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint) - -附上Demo:[Demo](https://github.com/FantasticLBP/BlogDemos) diff --git a/Chapter1 - iOS/1.80.md b/Chapter1 - iOS/1.80.md deleted file mode 100644 index 0cf0eea..0000000 --- a/Chapter1 - iOS/1.80.md +++ /dev/null @@ -1,2512 +0,0 @@ -# 打造一个通用、可配置、多句柄的数据上报 SDK - -> 一个 App 一般会存在很多场景去上传 App 中产生的数据,比如 APM、埋点统计、开发者自定义的数据等等。所以本篇文章就讲讲如何设计一个通用的、可配置的、多句柄的数据上报 SDK。 -> - - - -## 前置说明 - -因为这篇文章和 APM 是属于姊妹篇,所以看这篇文章的时候有些东西不知道活着好奇的时候可以看[带你打造一套 APM 监控系统](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md)。 - -另外看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。 - -- 数据上报 SDK 叫 `HermesClient`,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 `HCT` -- 给 Category 命名,规则为 `类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 `NSDate+HCT_TimeStamp` -- 给 Category 的方法命名,规则为 `SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述`。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 `+ (long long)HCT_currentTimestamp;` - - - -## 一、 首先定义需要做什么 - -我们要做的是「一个通用可配置、多句柄的数据上报 SDK」,也就是说这个 SDK 具有这么几个功能: - -- 具有从服务端拉取配置信息的能力,这些配置用来控制 SDK 的上报行为(需不需要默认行为?) -- SDK 具有多句柄特性,也就是拥有多个对象,每个对象具有自己的控制行为,彼此之间的运行、操作互相隔离 -- APM 监控作为非常特殊的能力存在,它也使用数据上报 SDK。它的能力是 App 质量监控的保障,所以针对 APM 的数据上报通道是需要特殊处理的。 -- 数据先根据配置决定要不要存,存下来之后再根据配置决定如何上报 - -明白我们需要做什么,接下来的步骤就是分析设计怎么做。 - - - -## 二、 拉取配置信息 - -### 1. 需要哪些配置信息 - -首先明确几个原则: - -- 因为监控数据上报作为数据上报的一个特殊 case,那么监控的配置信息也应该特殊处理。 -- 监控能力包含很多,比如卡顿、网络、奔溃、内存、电量、启动时间、CPU 使用率。每个监控能力都需要一份配置信息,比如监控类型、是否仅 WI-FI 环境下上报、是否实时上报、是否需要携带 Payload 数据。(注:Payload 其实就是经过 gZip 压缩、AES-CBC 加密后的数据) -- 多句柄,所以需要一个字段标识每份配置信息,也就是一个 namespace 的概念 -- 每个 namespace 下都有自己的配置,比如数据上传后的服务器地址、上报开关、App 升级后是否需要清除掉之前版本保存的数据、单次上传数据包的最大体积限制、数据记录的最大条数、在非 WI-FI 环境下每天上报的最大流量、数据过期天数、上报开关等 -- 针对 APM 的数据配置,还需要一个是否需要采集的开关。 - -所以数据字段基本如下 - -```objective-c -@interface HCTItemModel : NSObject - -@property (nonatomic, copy) NSString *type; /<上报数据类型*/ -@property (nonatomic, assign) BOOL onlyWifi; /<是否仅 Wi-Fi 上报*/ -@property (nonatomic, assign) BOOL isRealtime; /<是否实时上报*/ -@property (nonatomic, assign) BOOL isUploadPayload; /<是否需要上报 Payload*/ - -@end - -@interface HCTConfigurationModel : NSObject - -@property (nonatomic, copy) NSString *url; /<当前 namespace 对应的上报地址 */ -@property (nonatomic, assign) BOOL isUpload; /<全局上报开关*/ -@property (nonatomic, assign) BOOL isGather; /<全局采集开关*/ -@property (nonatomic, assign) BOOL isUpdateClear; /<升级后是否清除数据*/ -@property (nonatomic, assign) NSInteger maxBodyMByte; /<最大包体积单位 M (范围 < 3M)*/ -@property (nonatomic, assign) NSInteger periodicTimerSecond; /<定时上报时间单位秒 (范围1 ~ 30秒)*/ -@property (nonatomic, assign) NSInteger maxItem; /<最大条数 (范围 < 100)*/ -@property (nonatomic, assign) NSInteger maxFlowMByte; /<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ -@property (nonatomic, assign) NSInteger expirationDay; /<数据过期时间单位 天 (范围 < 30)*/ -@property (nonatomic, copy) NSArray *monitorList; /<配置项目*/ - -@end -``` - -因为数据需要持久化保存,所以需要实现 `NSCoding` 协议。 - -一个小窍门,每个属性写 `encode`、`decode` 会很麻烦,可以借助于宏来实现快速编写。 - -```objective-c -#define HCT_DECODE(decoder, dataType, keyName) \ -{ \ -_##keyName = [decoder decode##dataType##ForKey:NSStringFromSelector(@selector(keyName))]; \ -}; - -#define HCT_ENCODE(aCoder, dataType, key) \ -{ \ -[aCoder encode##dataType:_##key forKey:NSStringFromSelector(@selector(key))]; \ -}; - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - HCT_DECODE(aDecoder, Object, type) - HCT_DECODE(aDecoder, Bool, onlyWifi) - HCT_DECODE(aDecoder, Bool, isRealtime) - HCT_DECODE(aDecoder, Bool, isUploadPayload) - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - HCT_ENCODE(aCoder, Object, type) - HCT_ENCODE(aCoder, Bool, onlyWifi) - HCT_ENCODE(aCoder, Bool, isRealtime) - HCT_ENCODE(aCoder, Bool, isUploadPayload) -} -``` - - - - - -抛出一个问题:既然监控很重要,那别要配置了,直接全部上传。 - -我们想一想这个问题,监控数据都是不直接上传的,监控 SDK 的责任就是收集监控数据,而且监控后的数据非常多,App 运行期间的网络请求可能都有 n 次,App 启动时间、卡顿、奔溃、内存等可能不多,但是这些数据直接上传后期拓展性非常差,比如根据 APM 监控大盘分析出某个监控能力暂时先关闭掉。这时候就无力回天了,必须等下次 SDK 发布新版本。监控数据必须先存储,假如 crash 了,则必须保存了数据等下次启动再去组装数据、上传。而且数据在消费、新数据在不断生产,假如上传失败了还需要对失败数据的处理,所以这些逻辑还是挺多的,对于监控 SDK 来做这个事情,不是很合适。答案就显而易见了,必须要配置(监控开关的配置、数据上报的行为配置)。 - - - -### 2. 默认配置 - -因为监控真的很特殊,App 一启动就需要去收集 App 的性能、质量相关数据,所以需要一份默认的配置信息。 - -```objective-c -// 初始化一份默认配置 -- (void)setDefaultConfigurationModel { - HCTConfigurationModel *configurationModel = [[HCTConfigurationModel alloc] init]; - configurationModel.url = @"https://***DomainName.com"; - configurationModel.isUpload = YES; - configurationModel.isGather = YES; - configurationModel.isUpdateClear = YES; - configurationModel.periodicTimerSecond = 5; - configurationModel.maxBodyMByte = 1; - configurationModel.maxItem = 100; - configurationModel.maxFlowMByte = 20; - configurationModel.expirationDay = 15; - - HCTItemModel *appCrashItem = [[HCTItemModel alloc] init]; - appCrashItem.type = @"appCrash"; - appCrashItem.onlyWifi = NO; - appCrashItem.isRealtime = YES; - appCrashItem.isUploadPayload = YES; - - HCTItemModel *appLagItem = [[HCTItemModel alloc] init]; - appLagItem.type = @"appLag"; - appLagItem.onlyWifi = NO; - appLagItem.isRealtime = NO; - appLagItem.isUploadPayload = NO; - - HCTItemModel *appBootItem = [[HCTItemModel alloc] init]; - appBootItem.type = @"appBoot"; - appBootItem.onlyWifi = NO; - appBootItem.isRealtime = NO; - appBootItem.isUploadPayload = NO; - - HCTItemModel *netItem = [[HCTItemModel alloc] init]; - netItem.type = @"net"; - netItem.onlyWifi = NO; - netItem.isRealtime = NO; - netItem.isUploadPayload = NO; - - HCTItemModel *netErrorItem = [[HCTItemModel alloc] init]; - netErrorItem.type = @"netError"; - netErrorItem.onlyWifi = NO; - netErrorItem.isRealtime = NO; - netErrorItem.isUploadPayload = NO; - configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; - self.configurationModel = configurationModel; -} -``` - -上面的例子是一份默认配置信息 - - - -### 3. 拉取策略 - -网络拉取使用了基础 SDK (非网络 SDK)的能力 mGet,根据 key 注册网络服务。这些 key 一般是 SDK 内部的定义好的,比如统跳路由表等。 - -这类 key 的共性是 App 在打包阶段会内置一份默认配置,App 启动后会去拉取最新数据,然后完成数据的缓存,缓存会在 `NSDocumentDirectory` 目录下按照 SDK 名称、 App 版本号、打包平台上分配的打包任务 id、 key 建立缓存文件夹。 - -此外它的特点是等 App 启动完成后才去请求网络,获取数据,不会影响 App 的启动。 - -流程图如下 - -![数据上报配置信息获取流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-06-29-DataUploadConfigurationStructure.png) - -下面是一个截取代码,对比上面图看看。 - -```objective-c -@synthesize configurationDictionary = _configurationDictionary; - -#pragma mark - Initial Methods - -+ (instancetype)sharedInstance { - static HCTConfigurationService *_sharedInstance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _sharedInstance = [[self alloc] init]; - }); - return _sharedInstance; -} - -- (instancetype)init { - if (self = [super init]) { - [self setUp]; - } - return self; -} - -#pragma mark - public Method - -- (void)registerAndFetchConfigurationInfo { - __weak typeof(self) weakself = self; - NSDictionary *params = @{@"deviceId": [[HermesClient sharedInstance] getCommon].SYS_DEVICE_ID}; - - [self.requester fetchUploadConfigurationWithParams:params success:^(NSDictionary * _Nonnull configurationDictionary) { - weakself.configurationDictionary = configurationDictionary; - [NSKeyedArchiver archiveRootObject:configurationDictionary toFile:[self savedFilePath]]; - } failure:^(NSError * _Nonnull error) { - - }]; -} - -- (HCTConfigurationModel *)getConfigurationWithNamespace:(NSString *)namespace { - if (!HCT_IS_CLASS(namespace, NSString)) { - NSAssert(HCT_IS_CLASS(namespace, NSString), @"需要根据 namespace 参数获取对应的配置信息,所以必须是 NSString 类型"); - return nil; - } - if (namespace.length == 0) { - NSAssert(namespace.length > 0, @"需要根据 namespace 参数获取对应的配置信息,所以必须是非空的 NSString"); - return nil; - } - id configurationData = [self.configurationDictionary objectForKey:namespace]; - if (!configurationData) { - return nil; - } - if (!HCT_IS_CLASS(configurationData, NSDictionary)) { - return nil; - } - NSDictionary *configurationDictionary = (NSDictionary *)configurationData; - return [HCTConfigurationModel modelWithDictionary:configurationDictionary]; -} - - -#pragma mark - private method - -- (void)setUp { - // 创建数据保存的文件夹 - [[NSFileManager defaultManager] createDirectoryAtPath:[self configurationDataFilePath] withIntermediateDirectories:YES attributes:nil error:nil]; - [self setDefaultConfigurationModel]; - [self getConfigurationModelFromLocal]; -} - -- (NSString *)savedFilePath { - return [NSString stringWithFormat:@"%@/%@", [self configurationDataFilePath], HCT_CONFIGURATION_FILEPATH]; -} - -// 初始化一份默认配置 -- (void)setDefaultConfigurationModel { - HCTConfigurationModel *configurationModel = [[HCTConfigurationModel alloc] init]; - configurationModel.url = @"https://.com"; - configurationModel.isUpload = YES; - configurationModel.isGather = YES; - configurationModel.isUpdateClear = YES; - configurationModel.periodicTimerSecond = 5; - configurationModel.maxBodyMByte = 1; - configurationModel.maxItem = 100; - configurationModel.maxFlowMByte = 20; - configurationModel.expirationDay = 15; - - HCTItemModel *appCrashItem = [[HCTItemModel alloc] init]; - appCrashItem.type = @"appCrash"; - appCrashItem.onlyWifi = NO; - appCrashItem.isRealtime = YES; - appCrashItem.isUploadPayload = YES; - - HCTItemModel *appLagItem = [[HCTItemModel alloc] init]; - appLagItem.type = @"appLag"; - appLagItem.onlyWifi = NO; - appLagItem.isRealtime = NO; - appLagItem.isUploadPayload = NO; - - HCTItemModel *appBootItem = [[HCTItemModel alloc] init]; - appBootItem.type = @"appBoot"; - appBootItem.onlyWifi = NO; - appBootItem.isRealtime = NO; - appBootItem.isUploadPayload = NO; - - HCTItemModel *netItem = [[HCTItemModel alloc] init]; - netItem.type = @"net"; - netItem.onlyWifi = NO; - netItem.isRealtime = NO; - netItem.isUploadPayload = NO; - - HCTItemModel *netErrorItem = [[HCTItemModel alloc] init]; - netErrorItem.type = @"netError"; - netErrorItem.onlyWifi = NO; - netErrorItem.isRealtime = NO; - netErrorItem.isUploadPayload = NO; - configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem]; - self.configurationModel = configurationModel; -} - -- (void)getConfigurationModelFromLocal { - id unarchiveObject = [NSKeyedUnarchiver unarchiveObjectWithFile:[self savedFilePath]]; - if (unarchiveObject) { - if (HCT_IS_CLASS(unarchiveObject, NSDictionary)) { - self.configurationDictionary = (NSDictionary *)unarchiveObject; - [self.configurationDictionary enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { - if ([key isEqualToString:HermesNAMESPACE]) { - if (HCT_IS_CLASS(obj, NSDictionary)) { - NSDictionary *configurationDictionary = (NSDictionary *)obj; - self.configurationModel = [HCTConfigurationModel modelWithDictionary:configurationDictionary]; - } - } - }]; - } - } -} - - -#pragma mark - getters and setters - -- (NSString *)configurationDataFilePath { - NSString *filePath = [NSString stringWithFormat:@"%@/%@/%@/%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject, @"hermes", [CMAppProfile sharedInstance].mAppVersion, [[HermesClient sharedInstance] getCommon].WAX_CANDLE_TASK_ID]; - return filePath; -} - -- (HCTRequestFactory *)requester { - if (!_requester) { - _requester = [[HCTRequestFactory alloc] init]; - } - return _requester; -} - -- (void)setConfigurationDictionary:(NSDictionary *)configurationDictionary -{ - @synchronized (self) { - _configurationDictionary = configurationDictionary; - } -} - -- (NSDictionary *)configurationDictionary -{ - @synchronized (self) { - if (_configurationDictionary == nil) { - NSDictionary *hermesDictionary = [self.configurationModel getDictionary]; - _configurationDictionary = @{HermesNAMESPACE: hermesDictionary}; - } - return _configurationDictionary; - } -} - -@end -``` - - - -## 三、数据存储 - -### 1. 数据存储技术选型 - -记得在做数据上报技术的评审会议上,Android 同事说用 [WCDB](https://github.com/Tencent/wcdb),特色是 ORM、多线程安全、高性能。然后就被质疑了。因为上个版本使用的技术是基于系统自带的 sqlite2,单纯为了 ORM、多线程问题就额外引入一个三方库,是不太能说服人的。有这样几个疑问 - -- ORM 并不是核心诉求,利用 Runtime 可以在基础上进行修改,也可支持 ORM 功能 - -- 线程安全。WCDB 在线程安全的实现主要是基于`Handle`,`HandlePool` 和 `Database` 三个类完成的。`Handle` 是 sqlite3 指针,`HandlePool` 用来处理连接。 - - ```c++ - RecyclableHandle HandlePool::flowOut(Error &error) - { - m_rwlock.lockRead(); - std::shared_ptr handleWrap = m_handles.popBack(); - if (handleWrap == nullptr) { - if (m_aliveHandleCount < s_maxConcurrency) { - handleWrap = generate(error); - if (handleWrap) { - ++m_aliveHandleCount; - if (m_aliveHandleCount > s_hardwareConcurrency) { - WCDB::Error::Warning( - ("The concurrency of database:" + - std::to_string(tag.load()) + " with " + - std::to_string(m_aliveHandleCount) + - " exceeds the concurrency of hardware:" + - std::to_string(s_hardwareConcurrency)) - .c_str()); - } - } - } else { - Error::ReportCore( - tag.load(), path, Error::CoreOperation::FlowOut, - Error::CoreCode::Exceed, - "The concurrency of database exceeds the max concurrency", - &error); - } - } - if (handleWrap) { - handleWrap->handle->setTag(tag.load()); - if (invoke(handleWrap, error)) { - return RecyclableHandle( - handleWrap, [this](std::shared_ptr &handleWrap) { - flowBack(handleWrap); - }); - } - } - - handleWrap = nullptr; - m_rwlock.unlockRead(); - return RecyclableHandle(nullptr, nullptr); - } - - void HandlePool::flowBack(const std::shared_ptr &handleWrap) - { - if (handleWrap) { - bool inserted = m_handles.pushBack(handleWrap); - m_rwlock.unlockRead(); - if (!inserted) { - --m_aliveHandleCount; - } - } - } - ``` - - 所以 WCDB 连接池通过读写锁保证线程安全。所以之前版本的地方要实现线程安全修改下缺陷就可以。增加了 sqlite3,虽然看起来就是几兆大小,但是这对于公共团队是致命的。业务线开发者每次接入 SDK 会注意App 包体积的变化,为了数据上报增加好几兆,这是不可以接受的。 - -- 高性能的背后是 WCDB 自带的 sqlite3 开启了 `WAL模式` (Write-Ahead Logging)。当 WAL 文件超过 1000 个页大小时,SQLite3 会将 WAL 文件写会数据库文件。也就是 checkpointing。当大批量的数据写入场景时,如果不停提交文件到数据库事务,效率肯定低下,WCDB 的策略就是在触发 checkpoint 时,通过延时队列去处理,避免不停的触发 WalCheckpoint 调用。通过 `TimedQueue` 将同个数据库的 `WalCheckpoint` 合并延迟到2秒后执行 - - ```c++ - { - Database::defaultCheckpointConfigName, - [](std::shared_ptr &handle, Error &error) -> bool { - handle->registerCommittedHook( - [](Handle *handle, int pages, void *) { - static TimedQueue s_timedQueue(2); - if (pages > 1000) { - s_timedQueue.reQueue(handle->path); - } - static std::thread s_checkpointThread([]() { - pthread_setname_np( - ("WCDB-" + Database::defaultCheckpointConfigName) - .c_str()); - while (true) { - s_timedQueue.waitUntilExpired( - [](const std::string &path) { - Database database(path); - WCDB::Error innerError; - database.exec(StatementPragma().pragma( - Pragma::WalCheckpoint), - innerError); - }); - } - }); - static std::once_flag s_flag; - std::call_once(s_flag, - []() { s_checkpointThread.detach(); }); - }, - nullptr); - return true; - }, - (Configs::Order) Database::ConfigOrder::Checkpoint, - }, - ``` - - - - - -一般来说公共组做事情,SDK 命名、接口名称、接口个数、参数个数、参数名称、参数数据类型是严格一致的,差异是语言而已。实在万不得已,能力不能堆砌的情况下是可以不一致的,但是需要在技术评审会议上说明原因,需要在发布文档、接入文档都有所体现。 - - - -所以最后的结论是在之前的版本基础上进行修改,之前的版本是 FMDB。 - - - -### 2. 数据库维护队列 - -#### 1. FMDB 队列 - -`FMDB` 使用主要是通过 `FMDatabaseQueue` 的 `- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block` 和 `- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block`。这2个方法的实现如下 - -```objective-c -- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block { -#ifndef NDEBUG - /* Get the currently executing queue (which should probably be nil, but in theory could be another DB queue - * and then check it against self to make sure we're not about to deadlock. */ - FMDatabaseQueue *currentSyncQueue = (__bridge id)dispatch_get_specific(kDispatchQueueSpecificKey); - assert(currentSyncQueue != self && "inDatabase: was called reentrantly on the same queue, which would lead to a deadlock"); -#endif - - FMDBRetain(self); - - dispatch_sync(_queue, ^() { - - FMDatabase *db = [self database]; - - block(db); - - if ([db hasOpenResultSets]) { - NSLog(@"Warning: there is at least one open result set around after performing [FMDatabaseQueue inDatabase:]"); - -#if defined(DEBUG) && DEBUG - NSSet *openSetCopy = FMDBReturnAutoreleased([[db valueForKey:@"_openResultSets"] copy]); - for (NSValue *rsInWrappedInATastyValueMeal in openSetCopy) { - FMResultSet *rs = (FMResultSet *)[rsInWrappedInATastyValueMeal pointerValue]; - NSLog(@"query: '%@'", [rs query]); - } -#endif - } - }); - - FMDBRelease(self); -} -``` - -```objective-c -- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block { - [self beginTransaction:FMDBTransactionExclusive withBlock:block]; -} - -- (void)beginTransaction:(FMDBTransaction)transaction withBlock:(void (^)(FMDatabase *db, BOOL *rollback))block { - FMDBRetain(self); - dispatch_sync(_queue, ^() { - - BOOL shouldRollback = NO; - - switch (transaction) { - case FMDBTransactionExclusive: - [[self database] beginTransaction]; - break; - case FMDBTransactionDeferred: - [[self database] beginDeferredTransaction]; - break; - case FMDBTransactionImmediate: - [[self database] beginImmediateTransaction]; - break; - } - - block([self database], &shouldRollback); - - if (shouldRollback) { - [[self database] rollback]; - } - else { - [[self database] commit]; - } - }); - - FMDBRelease(self); -} -``` - -上面的 `_queue` 其实是一个串行队列,通过 `_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL);` 创建。所以,`FMDB` 的核心就是以同步的形式向串行队列提交任务,来保证多线程操作下的读写问题(比每个操作加锁效率高很多)。只有一个任务执行完毕,才可以执行下一个任务。 - -上一个版本的数据上报 SDK 功能比较简单,就是上报 APM 监控后的数据,所以数据量不会很大,之前的人封装超级简单,仅以事务的形式封装了一层 FMDB 的增删改查操作。那么就会有一个问题。假如 SDK 被业务线接入,业务线开发者不知道数据上报 SDK 的内部实现,直接调用接口去写入大量数据,结果 App 发生了卡顿,那不得反馈你这个 SDK 超级难用啊。 - - - -#### 2. 针对 FMDB 的改进 - -改法也比较简单,我们先弄清楚 `FMDB` 这样设计的原因。数据库操作的环境可能是主线程、子线程等不同环境去修改数据,主线程、子线程去读取数据,所以创建了一个串行队列去执行真正的数据增删改查。 - -目的就是让不同线程去使用 `FMDB` 的时候不会阻塞当前线程。既然 `FMDB` 内部维护了一个串行队列去处理多线程情况下的数据操作,那么改法也比较简单,那就是创建一个并发队列,然后以异步的方式提交任务到 `FMDB` 中去,`FMDB` 内部的串行队列去执行真正的任务。 - -代码如下 - -```objective-c -// 创建队列 -self.dbOperationQueue = dispatch_queue_create(HCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); -self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]]; - -// 以删除数据为例,以异步任务的方式向并发队列提交任务,任务内部调用 FMDatabaseQueue 去串行执行每个任务 -- (void)removeAllLogsInTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeAllLogsInTable:tableName]; - }); -} - -- (void)removeAllLogsInTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString]; - }]; -} -``` - -小实验模拟下流程 - -```objective-c -sleep(1); -NSLog(@"1"); -dispatch_queue_t concurrentQueue = dispatch_queue_create("HCT_DATABASE_OPERATION_QUEUE", DISPATCH_QUEUE_CONCURRENT); -dispatch_async(concurrentQueue, ^{ - sleep(2); - NSLog(@"2"); -}); -sleep(1); -NSLog(@"3"); -dispatch_async(concurrentQueue, ^{ - sleep(3); - NSLog(@"4"); -}); -sleep(1); -NSLog(@"5"); - -2020-07-01 13:28:13.610575+0800 Test[54460:1557233] 1 -2020-07-01 13:28:14.611937+0800 Test[54460:1557233] 3 -2020-07-01 13:28:15.613347+0800 Test[54460:1557233] 5 -2020-07-01 13:28:15.613372+0800 Test[54460:1557280] 2 -2020-07-01 13:28:17.616837+0800 Test[54460:1557277] 4 -``` - - - -![MainThread Dispatch Async Task To ConcurrentQueue](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-01-MainThreadDispatchTaskToConcurrentQueue.png) - - - -### 3. 数据表设计 - -通用的数据上报 SDK 的功能是数据的保存和上报。从数据的角度来划分,数据可以分为 APM 监控数据和业务线的业务数据。 - -数据各有什么特点呢?APM 监控数据一般可以划分为:基本信息、异常信息、线程信息,也就是最大程度的还原案发线程的数据。业务线数据基本上不会有所谓的大量数据,最多就是数据条数非常多。鉴于此现状,可以将数据表设计为 **meta 表**、**payload 表**。meta 表用来存放 APM 的基础数据和业务线的数据,payload 表用来存放 APM 的线程堆栈数据。 - -数据表的设计是基于业务情况的。那有这样几个背景 - -- APM 监控数据需要报警(具体可以查看 APM 文章,地址在开头 ),所以数据上报 SDK 上报后的数据需要实时解析 -- 产品侧比如监控大盘可以慢,所以符号化系统是异步的 -- 监控数据实在太大了,如果同步解析会因为压力较大造成性能瓶颈 - -所以把监控数据拆分为2块,即 meta 表、payload 表。meta 表相当于记录索引信息,服务端只需要关心这个。而 payload 数据在服务端是不会处理的,会有一个异步服务单独处理。 - -meta 表、payload 表结构如下: - -```sql -create table if not exists ***_meta (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL); - -create table if not exists ***_payload (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL); -``` - - - -### 4. 数据库表的封装 - -```objective-c -#import "HCTDatabase.h" -#import - -static NSString *const HCT_LOG_DATABASE_NAME = @"***.db"; -static NSString *const HCT_LOG_TABLE_META = @"***_hermes_meta"; -static NSString *const HCT_LOG_TABLE_PAYLOAD = @"***_hermes_payload"; -const char *HCT_DATABASE_OPERATION_QUEUE = "com.***.HCT_database_operation_QUEUE"; - -@interface HCTDatabase () - -@property (nonatomic, strong) dispatch_queue_t dbOperationQueue; -@property (nonatomic, strong) FMDatabaseQueue *dbQueue; -@property (nonatomic, strong) NSDateFormatter *dateFormatter; - -@end - -@implementation HCTDatabase - -#pragma mark - life cycle -+ (instancetype)sharedInstance { - static HCTDatabase *_sharedInstance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _sharedInstance = [[self alloc] init]; - }); - return _sharedInstance; -} - -- (instancetype)init { - self = [super init]; - self.dateFormatter = [[NSDateFormatter alloc] init]; - [self.dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss A Z"]; - self.dbOperationQueue = dispatch_queue_create(HCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT); - self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [self createLogMetaTableIfNotExist:db]; - [self createLogPayloadTableIfNotExist:db]; - }]; - return self; -} - -#pragma mark - public Method - -- (void)add:(NSArray *)logs inTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself add:logs inTable:tableName]; - }); -} - -- (void)remove:(NSArray *)logs inTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself remove:logs inTable:tableName]; - }); -} - -- (void)removeAllLogsInTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeAllLogsInTable:tableName]; - }); -} - -- (void)removeOldestRecordsByCount:(NSInteger)count inTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeOldestRecordsByCount:count inTable:tableName]; - }); -} - -- (void)removeLatestRecordsByCount:(NSInteger)count inTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeLatestRecordsByCount:count inTable:tableName]; - }); -} - -- (void)removeRecordsBeforeDays:(NSInteger)day inTableType:(HCTLogTableType)tableType { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeRecordsBeforeDays:day inTable:tableName]; - }); - [self rebuildDatabaseFileInTableType:tableType]; -} - -- (void)removeDataUseCondition:(NSString *)condition inTableType:(HCTLogTableType)tableType { - if (!HCT_IS_CLASS(condition, NSString)) { - NSAssert(HCT_IS_CLASS(condition, NSString), @"自定义删除条件必须是字符串类型"); - return; - } - if (condition.length == 0) { - NSAssert(!(condition.length == 0), @"自定义删除条件不能为空"); - return; - } - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself removeDataUseCondition:condition inTable:tableName]; - }); -} - -- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTableType:(HCTLogTableType)tableType { - if (!HCT_IS_CLASS(state, NSString)) { - NSAssert(HCT_IS_CLASS(state, NSString), @"数据表字段更改命令必须是合法字符串"); - return; - } - if (state.length == 0) { - NSAssert(!(state.length == 0), @"数据表字段更改命令必须是合法字符串"); - return; - } - - if (!HCT_IS_CLASS(condition, NSString)) { - NSAssert(HCT_IS_CLASS(condition, NSString), @"数据表字段更改条件必须是字符串类型"); - return; - } - if (condition.length == 0) { - NSAssert(!(condition.length == 0), @"数据表字段更改条件不能为空"); - return; - } - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself updateData:state useCondition:condition inTable:tableName]; - }); -} - -- (void)recordsCountInTableType:(HCTLogTableType)tableType completion:(void (^)(NSInteger count))completion { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - NSInteger recordsCount = [weakself recordsCountInTable:tableName]; - if (completion) { - completion(recordsCount); - } - }); -} - -- (void)getLatestRecoreds:(NSInteger)count inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - NSArray *records = [weakself getLatestRecoreds:count inTable:tableName]; - if (completion) { - completion(records); - } - }); -} - -- (void)getOldestRecoreds:(NSInteger)count inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - NSArray *records = [weakself getOldestRecoreds:count inTable:tableName]; - if (completion) { - completion(records); - } - }); -} - -- (void)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray *records))completion { - if (!HCT_IS_CLASS(condition, NSString)) { - NSAssert(HCT_IS_CLASS(condition, NSString), @"自定义查询条件必须是字符串类型"); - if (completion) { - completion(nil); - } - } - if (condition.length == 0) { - NSAssert(!(condition.length == 0), @"自定义查询条件不能为空"); - if (completion) { - completion(nil); - } - } - [self isExistInTable:tableType]; - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - NSArray *records = [weakself getRecordsByCount:count condtion:condition inTable:tableName]; - if (completion) { - completion(records); - } - }); -} - -- (void)rebuildDatabaseFileInTableType:(HCTLogTableType)tableType { - __weak typeof(self) weakself = self; - dispatch_async(self.dbOperationQueue, ^{ - NSString *tableName = HCTGetTableNameFromType(tableType); - [weakself rebuildDatabaseFileInTable:tableName]; - }); -} - -#pragma mark - CMDatabaseDelegate - -- (void)add:(NSArray *)logs inTable:(NSString *)tableName { - if (logs.count == 0) { - return; - } - __weak typeof(self) weakself = self; - [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { - [db setDateFormat:weakself.dateFormatter]; - for (NSInteger index = 0; index < logs.count; index++) { - id obj = logs[index]; - // meta 类型数据的处理逻辑 - if (HCT_IS_CLASS(obj, HCTLogMetaModel)) { - HCTLogMetaModel *model = (HCTLogMetaModel *)obj; - if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { - HCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); - return; - } - - NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?)", tableName]; - [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.namespace, @(model.is_used), @(model.size)]]; - } - - // payload 类型数据的处理逻辑 - if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) { - HCTLogPayloadModel *model = (HCTLogPayloadModel *)obj; - if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) { - HCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace); - return; - } - - NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, payload, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", tableName]; - [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.payload ?: [NSData data], model.namespace, @(model.is_used), @(model.size)]]; - } - } - }]; -} - -- (NSInteger)remove:(NSArray *)logs inTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where report_id = ?", tableName]; - [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) { - [logs enumerateObjectsUsingBlock:^(HCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - [db executeUpdate:sqlString withArgumentsInArray:@[obj.report_id]]; - }]; - }]; - return 0; -} - -- (void)removeAllLogsInTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString]; - }]; -} - -- (void)removeOldestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time asc limit ? )", tableName, tableName]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; - }]; -} - -- (void)removeLatestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time desc limit ?)", tableName, tableName]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]]; - }]; -} - -- (void)removeRecordsBeforeDays:(NSInteger)day inTable:(NSString *)tableName { - // 找出从create到现在已经超过最大 day 天的数据,然后删除 :delete from ***_hermes_meta where strftime('%s', date('now', '-2 day')) >= created_time; - NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) >= created_time", tableName, day]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString]; - }]; -} - -- (void)removeDataUseCondition:(NSString *)condition inTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where %@", tableName, condition]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString]; - }]; -} - -- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTable:(NSString *)tableName -{ - NSString *sqlString = [NSString stringWithFormat:@"update %@ set %@ where %@", tableName, state, condition]; - [self.dbQueue inDatabase:^(FMDatabase * _Nonnull db) { - BOOL res = [db executeUpdate:sqlString]; - HCTLOG(res ? @"更新成功" : @"更新失败"); - }]; -} - -- (NSInteger)recordsCountInTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"select count(*) as count from %@", tableName]; - __block NSInteger recordsCount = 0; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - FMResultSet *resultSet = [db executeQuery:sqlString]; - [resultSet next]; - recordsCount = [resultSet intForColumn:@"count"]; - [resultSet close]; - }]; - return recordsCount; -} - -- (NSArray *)getLatestRecoreds:(NSInteger)count inTable:(NSString *)tableName { - __block NSMutableArray *records = [NSMutableArray new]; - NSString *sql = [NSString stringWithFormat:@"select * from %@ order by created_time desc limit ?", tableName]; - - __weak typeof(self) weakself = self; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db setDateFormat:weakself.dateFormatter]; - FMResultSet *resultSet = [db executeQuery:sql withArgumentsInArray:@[@(count)]]; - while ([resultSet next]) { - if ([tableName isEqualToString:HCT_LOG_TABLE_META]) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - [records addObject:model]; - - } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) { - HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.payload = [resultSet dataForColumn:@"payload"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - [records addObject:model]; - } - } - [resultSet close]; - }]; - return records; -} - -- (NSArray *)getOldestRecoreds:(NSInteger)count inTable:(NSString *)tableName { - __block NSMutableArray *records = [NSMutableArray array]; - NSString *sqlString = [NSString stringWithFormat:@"select * from %@ order by created_time asc limit ?", tableName]; - - __weak typeof(self) weakself = self; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db setDateFormat:weakself.dateFormatter]; - - FMResultSet *resultSet = [db executeQuery:sqlString withArgumentsInArray:@[@(count)]]; - while ([resultSet next]) { - if ([tableName isEqualToString:HCT_LOG_TABLE_META]) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - [records addObject:model]; - - } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) { - HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.payload = [resultSet dataForColumn:@"payload"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - [records addObject:model]; - } - } - [resultSet close]; - }]; - return records; -} - -- (NSArray *)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTable:(NSString *)tableName { - __block NSMutableArray *records = [NSMutableArray array]; - __weak typeof(self) weakself = self; - NSString *sqlString = [NSString stringWithFormat:@"select * from %@ where %@ order by created_time desc limit %zd", tableName, condition, count]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db setDateFormat:weakself.dateFormatter]; - - FMResultSet *resultSet = [db executeQuery:sqlString]; - - while ([resultSet next]) { - if ([tableName isEqualToString:HCT_LOG_TABLE_META]) { - HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - [records addObject:model]; - - } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) { - HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init]; - model.log_id = [resultSet intForColumn:@"id"]; - model.report_id = [resultSet stringForColumn:@"report_id"]; - model.monitor_type = [resultSet stringForColumn:@"monitor_type"]; - model.created_time = [resultSet stringForColumn:@"created_time"]; - model.meta = [resultSet stringForColumn:@"meta"]; - model.payload = [resultSet dataForColumn:@"payload"]; - model.namespace = [resultSet stringForColumn:@"namespace"]; - model.size = [resultSet intForColumn:@"size"]; - model.is_biz = [resultSet boolForColumn:@"is_biz"]; - model.is_used = [resultSet boolForColumn:@"is_used"]; - [records addObject:model]; - } - } - [resultSet close]; - }]; - return records; -} - -- (void)rebuildDatabaseFileInTable:(NSString *)tableName { - NSString *sqlString = [NSString stringWithFormat:@"vacuum %@", tableName]; - [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) { - [db executeUpdate:sqlString]; - }]; -} - -#pragma mark - private method - -+ (NSString *)databaseFilePath { - NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; - NSString *dbPath = [docsPath stringByAppendingPathComponent:HCT_LOG_DATABASE_NAME]; - HCTLOG(@"上报系统数据库文件位置 -> %@", dbPath); - return dbPath; -} - -- (void)createLogMetaTableIfNotExist:(FMDatabase *)db { - NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL)", HCT_LOG_TABLE_META]; - BOOL result = [db executeStatements:createMetaTableSQL]; - HCTLOG(@"确认日志Meta表是否存在 -> %@", result ? @"成功" : @"失败"); -} - -- (void)createLogPayloadTableIfNotExist:(FMDatabase *)db { - NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL)", HCT_LOG_TABLE_PAYLOAD]; - BOOL result = [db executeStatements:createMetaTableSQL]; - HCTLOG(@"确认日志Payload表是否存在 -> %@", result ? @"成功" : @"失败"); -} - -NS_INLINE NSString *HCTGetTableNameFromType(HCTLogTableType type) { - if (type == HCTLogTableTypeMeta) { - return HCT_LOG_TABLE_META; - } - if (type == HCTLogTableTypePayload) { - return HCT_LOG_TABLE_PAYLOAD; - } - return @""; -} - -// 每次操作前检查数据库以及数据表是否存在,不存在则创建数据库和数据表 -- (void)isExistInTable:(HCTLogTableType)tableType { - NSString *databaseFilePath = [HCTDatabase databaseFilePath]; - BOOL isExist = [[NSFileManager defaultManager] fileExistsAtPath:databaseFilePath]; - if (!isExist) { - self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]]; - } - [self.dbQueue inDatabase:^(FMDatabase *db) { - NSString *tableName = HCTGetTableNameFromType(tableType); - BOOL res = [db tableExists:tableName]; - if (!res) { - if (tableType == HCTLogTableTypeMeta) { - [self createLogMetaTableIfNotExist:db]; - } - if (tableType == HCTLogTableTypeMeta) { - [self createLogPayloadTableIfNotExist:db]; - } - } - }]; -} - -@end -``` - -上面有个地方需要注意下,因为经常需要根据类型来判读操作那个数据表,使用频次很高,所以写成内联函数的形式 - -```objective-c -NS_INLINE NSString *HCTGetTableNameFromType(HCTLogTableType type) { - if (type == HCTLogTableTypeMeta) { - return HCT_LOG_TABLE_META; - } - if (type == HCTLogTableTypePayload) { - return HCT_LOG_TABLE_PAYLOAD; - } - return @""; -} -``` - - - -### 5. 数据存储流程 - - APM 监控数据会比较特殊点,比如 iOS 当发生 crash 后是没办法上报的,只有将 crash 信息保存到文件中,下次 App 启动后读取 crash 日志文件夹再去交给数据上报 SDK。Android 在发生 crash 后由于机制不一样,可以马上将 crash 信息交给数据上报 SDK。 - -由于 payload 数据,也就是堆栈数据非常大,所以上报的接口也有限制,一次上传接口中报文最大包体积的限制等等。 - -可以看一下 Model 信息, - -```objective-c -@interface HCTItemModel : NSObject - -@property (nonatomic, copy) NSString *type; /**<上报数据类型*/ -@property (nonatomic, assign) BOOL onlyWifi; /**<是否仅 Wi-Fi 上报*/ -@property (nonatomic, assign) BOOL isRealtime; /**<是否实时上报*/ -@property (nonatomic, assign) BOOL isUploadPayload; /**<是否需要上报 Payload*/ - -@end - -@interface HCTConfigurationModel : NSObject - -@property (nonatomic, copy) NSString *url; /**<当前 namespace 对应的上报地址 */ -@property (nonatomic, assign) BOOL isUpload; /**<全局上报开关*/ -@property (nonatomic, assign) BOOL isGather; /**<全局采集开关*/ -@property (nonatomic, assign) BOOL isUpdateClear; /**<升级后是否清除数据*/ -@property (nonatomic, assign) NSInteger maxBodyMByte; /**<最大包体积单位 M (范围 < 3M)*/ -@property (nonatomic, assign) NSInteger periodicTimerSecond; /**<定时上报时间单位秒 (范围1 ~ 30秒)*/ -@property (nonatomic, assign) NSInteger maxItem; /**<最大条数 (范围 < 100)*/ -@property (nonatomic, assign) NSInteger maxFlowMByte; /**<每天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/ -@property (nonatomic, assign) NSInteger expirationDay; /**<数据过期时间单位 天 (范围 < 30)*/ -@property (nonatomic, copy) NSArray *monitorList; /**<配置项目*/ - -@end -``` - -监控数据存储流程: - -1. 每个数据(监控数据、业务线数据)过来先判断该数据所在的 namespace 是否开启了收集开关 - -2. 判断数据是否可以落库,根据数据接口中 type 能否命中上报配置数据中的 monitorList 中的任何一项的 type - -3. 监控数据先写入 meta 表,然后判断是否写入 payload 表。判断标准是计算监控数据的 payload 大小是否超过了上报配置数据的 `maxBodyMByte`。超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限 - -4. 走监控接口过来的数据,在方法内部会为监控数据增加基础信息(比如 App 名称、App 版本号、打包任务 id、设备类型等等) - - ```objective-c - @property (nonatomic, copy) NSString *xxx_APP_NAME; /** 0, warning, type); - return; - } - - if (!HCT_IS_CLASS(meta, NSDictionary)) { - return; - } - if (meta.allKeys.count == 0) { - return; - } - - // 2. 判断当前 namespace 是否开启了收集 - if (!self.configureModel.isGather) { - HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); - return ; - } - - // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) - BOOL isValidate = [self validateLogData:type]; - if (!isValidate) { - return; - } - - // 3. 先写入 meta 表 - HCTCommonModel *commonModel = [[HCTCommonModel alloc] init]; - [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; - - // 4. 如果 payload 不存在则退出当前执行 - if (!HCT_IS_CLASS(payload, NSData) && !payload) { - return; - } - - // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) - CGFloat payloadSize = [self calculateDataSize:payload]; - if (payloadSize > self.configureModel.maxBodyMByte) { - NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; - NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); - return; - } - - // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 - NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; - NSDictionary *commonDictionary = [commonModel getDictionary]; - // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 - if ([type isEqualToString:@"appCrash"]) { - [metaDictionary addEntriesFromDictionary:commonDictionary]; - [metaDictionary addEntriesFromDictionary:meta]; - } else { - [metaDictionary addEntriesFromDictionary:meta]; - [metaDictionary addEntriesFromDictionary:commonDictionary]; - } - - NSError *error; - NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; - if (error) { - HCTLOG(@"%@", error); - return; - } - NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; - - // 7. 计算上报时 payload 这条数据的大小(meta+payload) - NSMutableData *totalData = [NSMutableData data]; - [totalData appendData:metaData]; - [totalData appendData:payload]; - - // 8. 再写入 payload 表 - HCTLogPayloadModel *payloadModel = [[HCTLogPayloadModel alloc] init]; - payloadModel.is_used = NO; - payloadModel.namespace = HermesNAMESPACE; - payloadModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID); - payloadModel.monitor_type = HCT_SAFE_STRING(type); - payloadModel.is_biz = NO; - payloadModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME); - payloadModel.meta = HCT_SAFE_STRING(metaContentString); - payloadModel.payload = payload; - payloadModel.size = totalData.length; - [HCT_DATABASE add:@[payloadModel] inTableType:HCTLogTableTypePayload]; - - // 9. 判断是否触发实时上报 - [self handleUploadDataWithtype:type]; -} -``` - -业务线数据存储流程基本和监控数据的存储差不多,有差别的是某些字段的标示,用来区分业务线数据。 - - - -## 四、数据上报机制 - -### 1. 数据上报流程和机制设计 - -数据上报机制需要结合数据特点进行设计,数据分为 APM 监控数据和业务线上传数据。先分析下2部分数据的特点。 - -- 业务线数据可能会要求实时上报,需要有根据上报配置数据控制的能力 - -- 整个数据聚合上报过程需要有根据上报配置数据控制的能力定时器周期的能力,隔一段时间去触发上报 - -- 整个数据(业务数据、APM 监控数据)的上报与否需要有通过配置数据控制的能力 - -- 因为 App 在某个版本下收集的数据可能会对下个版本的时候无效,所以上报 SDK 启动后需要有删除之前版本数据的能力(上报配置数据中删除开关打开的情况下) - -- 同样,需要删除过期数据的能力(删除距今多少个自然天前的数据,同样走下发而来的上报配置项) - -- 因为 APM 监控数据非常大,且数据上报 SDK 肯定数据比较大,所以一个网络通信方式的设计好坏会影响 SDK 的质量,为了网络性能不采用传统的 `key/value` 传输。采用**自定义报文结构** - -- 数据的上报流程触发方式有3种:App 启动后触发(APM 监控到 crash 的时候写入本地,启动后处理上次 crash 的数据,是一个特殊 case );定时器触发;数据调用数据上报 SDK 接口后命中实时上报逻辑 - -- 数据落库后会触发一次完整的上报流程 - -- 上报流程的第一步会先判断该数据的 type 能否名字上报配置的 type,命中后如果实时上报配置项为 true,则马上执行后续真正的数据聚合过程;否则中断(只落库,不触发上报) - -- 由于频率会比较高,所以需要做节流的逻辑 - - 很多人会搞不清楚防抖和节流的区别。一言以蔽之:“函数防抖关注一定时间连续触发的事件只在最后执行一次,而函数节流侧重于一段时间内只执行一次”。此处不是本文重点,感兴趣的的可以查看[这篇文章](https://segmentfault.com/a/1190000018445196) - -- 上报流程会首先判断(为了节约用户流量) - - - 判断当前网络环境为 WI-FI 则实时上报 - - 判断当前网络环境不可用,则实时中断后续 - - 判断当前网络环境为蜂窝网络, 则做是否超过**1个自然天内使用流量是否超标**的判断 - - T(当前时间戳) - T(上次保存时间戳) > 24h,则清零已使用的流量,记录当前时间戳到上次上报时间的变量中 - - T(当前时间戳) - T(上次保存时间戳) <= 24h,则判断一个自然天内已使用流量大小是否超过下发的数据上报配置中的流量上限字段,超过则 exit;否则执行后续流程 - -- 数据聚合分表进行,且会有一定的规则 - - - 优先获取 crash 数据 - - 单次网络上报中,整体数据条数不能数据上报配置中的条数限制;数据大小不能超过数据配置中的数据大小 - -- 数据取出后将这批数据标记为 dirty 状态 - -- meta 表数据需要先 `gZip` 压缩,再使用 `AES 128` 加密 - -- payload 表数据需组装自定义格式的报文。格式如下 - - Header 部分: - - ```shell - 2字节大小、数据类型 unsigned short 表示 meta 数据大小 + n 条 payload 数据结构(2字节大小、数据类型为 unsigned int 表示单条 payload 数据大小) - ``` - - ```shell - header + meta 数据 + payload 数据 - ``` - -- 发起数据上报网络请求 - - - 成功回调:删除标记为`dirty` 的数据。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 - - 失败回调:更新标记为`dirty` 的数据为正常状态。判断为流量环境,则将该批数据大小叠加到1个自然天内已使用流量大小的变量中。 - -整个上报流程图如下: - -![数据上报流程](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-04-DataUploaderSDKStructure.png) - - - -### 2. 踩过的坑 && 做得好的地方 - -- 之前做针对网络接口基本上都是使用现有协议的 `key/value` 协议上开发的,它的优点是使用简单,缺点是协议体太大。在设计方案的时候分析道数据上报 SDK 网络上报肯定是非常高频的所以我们需要设计自定义的报文协议,这部分的设计上可以参考 `TCP 报文头结构`。 - -- 当时和后端对接接口的时候发现数据上报过去,服务端解析不了。断点调试发现数据聚合后的大小、条数、压缩、加密都是正常的,在本地 Mock 后完全可以反向解析出来。但为什么到服务端就解析不了,联调后发现是**字节端序**(Big-Endian)的问题。简单介绍如下,关于大小端序的详细介绍请查看我的[这篇文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter5%20-%20Network/5.3.md) - - 主机字节顺序HBO(Host Byte Order):与 CPU 类型有关。Big-Endian: PowerPC、IBM、Sun。Little-Endian:x86、DEC - - 网络字节顺序 NBO(Network Byte Order):网络默认为大端序。 - -- 上面的逻辑有一步是当网络上报成功后需要删除标记为 dirty 的数据。但是测试了一下发现,大量数据删除后数据库文件的大小不变,理论上需要腾出内存数据大小的空间。 - - sqlite 采用的是变长记录存储,当数据被删除后,未使用的磁盘空间被添加到一个内在的“空闲列表”中,用于下次插入数据,这属于优化机制之一,sqlite 提供 `vacuum` 命令来释放。 - - 这个问题类似于 Linux 中的文件引用计数的意思,虽然不一样,但是提出来做一下参考。实验是这样的 - - 1. 先看一下当前各个挂载目录的空间大小:`df -h` - - 2. 首先我们产生一个50M大小的文件 - - 3. 写一段代码读取文件 - - ```c++ - #include - #include - int main(void) - {    FILE *fp = NULL;    - fp = fopen("/boot/test.txt", "rw+");    - if(NULL == fp){       - perror("open file failed");    - return -1;    - }     - while(1){       - //do nothing       sleep(1);    - }    - fclose(fp);   - return 0; - } - ``` - - 4. 命令行模式下使用 `rm` 删除文件 - - 5. 查看文件大小: `df -h`,发现文件被删除了,但是该目录下的可用空间并未变多 - - 解释:实际上,只有当一个文件的引用计数为0(包括硬链接数)的时候,才可能调用 unlink 删除,只要它不是0,那么就不会被删除。所谓的删除,也不过是文件名到 inode 的链接删除,只要不被重新写入新的数据,磁盘上的 block 数据块不会被删除,因此,你会看到,即便删库跑路了,某些数据还是可以恢复的。换句话说,当一个程序打开一个文件的时候(获取到文件描述符),它的引用计数会被+1,rm虽然看似删除了文件,实际上只是会将引用计数减1,但由于引用计数不为0,因此文件不会被删除。 - -- 在数据聚合的时候优先获取 crash 数据,总数据条数需要小于上报配置数据的条数限制、总数据大小需要小于上报配置数据的大小限制。这里的处理使用了递归,改变了函数参数 - - ```objective-c - - (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { - // 1. 获取到合适的 Crash 类型的数据 - [self fetchCrashDataByCount:self.configureModel.maxFlowMByte - inTable:tableType - upperBound:self.configureModel.maxBodyMByte - completion:^(NSArray *records) { - NSArray *crashData = records; - // 2. 计算剩余需要的数据条数和剩余需要的数据大小 - NSInteger remainingCount = self.configureModel.maxItem - crashData.count; - float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; - // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 - BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); - [self fetchDataExceptCrash:remainingCount - inTable:tableType - upperBound:remainingSize - isWiFI:isWifi - completion:^(NSArray *records) { - NSArray *dataExceptCrash = records; - - NSMutableArray *dataSource = [NSMutableArray array]; - [dataSource addObjectsFromArray:crashData]; - [dataSource addObjectsFromArray:dataExceptCrash]; - if (completion) { - completion([dataSource copy]); - } - }]; - }]; - } - - - (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { - // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 - __block NSMutableArray *conditions = [NSMutableArray array]; - [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (isWifi) { - if (![obj.type isEqualToString:@"appCrash"]) { - [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; - } - } else { - if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { - [conditions addObject:[NSString stringWithFormat:@"'%@'", HCT_SAFE_STRING(obj.type)]]; - } - } - }]; - NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; - - // 2. 根据是否有 Wifi 查找对应的数据 - [HCT_DATABASE getRecordsByCount:count - condtion:queryCrashDataCondition - inTableType:tableType - completion:^(NSArray *_Nonnull records) { - // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 - float dataSize = [self calculateDataSize:records]; - - // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 - if (size == 0) { - if (completion) { - completion(records); - } - } else if (dataSize > size) { - NSInteger currentCount = count - 1; - return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; - } else { - if (completion) { - completion(records); - } - } - }]; - } - ``` - -- 整个 SDK 的 Unit Test 通过率 100%,代码分支覆盖率为 93%。测试基于 TDD 和 BDD。测试框架:系统自带的 `XCTest`,第三方的 `OCMock`、`Kiwi`、`Expecta`、`Specta`。测试使用了基础类,后续每个文件都设计继承自测试基类的类。 - - Xcode 可以看到整个 SDK 的测试覆盖率和单个文件的测试覆盖率 - - ![Xcode 测试覆盖率](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-07-06-XcodeTestCoverage.png) - - 也可以使用 [slather](https://github.com/SlatherOrg/slather)。在项目终端环境下新建 `.slather.yml` 配置文件,然后执行语句 `slather coverage -s --scheme hermes-client-Example --workspace hermes-client.xcworkspace hermes-client.xcodeproj`。 - - 关于质量保证的最基础、可靠的方案之一软件测试,在各个端都有一些需要注意的地方,还需要结合工程化,我会写专门的[文章](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.75.md)谈谈经验心得。 - - - -## 五、 接口设计及核心实现 - -### 1. 接口设计 - -```objective-c -@interface HermesClient : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -+ (instancetype)new NS_UNAVAILABLE; - -/** - 单例方式初始化全局唯一对象。单例之后必须马上 setUp - - @return 单例对象 - */ -+ (instancetype)sharedInstance; - -/** - 当前 SDK 初始化。当前功能:注册配置下发服务。 - */ -- (void)setup; - -/** - 上报 payload 类型的数据 - - @param type 监控类型 - @param meta 元数据 - @param payload payload类型的数据 - */ -- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload; - -/** - 上报 meta 类型的数据,需要传递三个参数。type 表明是什么类型的数据;prefix 代表前缀,上报到后台会拼接 prefix+type;meta 是字典类型的元数据 - - @param type 数据类型 - @param prefix 数据类型的前缀。一般是业务线名称首字母简写。比如记账:JZ - @param meta description元数据 - */ -- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta; - -/** - 获取上报相关的通用信息 - - @return 上报基础信息 - */ -- (HCTCommonModel *)getCommon; - -/** - 是否需要采集上报 - - @return 上报开关 - */ -- (BOOL)isGather:(NSString *)namespace; - -@end -``` - -`HermesClient` 类是整个 SDK 的入口,也是接口的提供者。其中 `- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta;` 接口给业务方使用。 - -`- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload;` 给监控数据使用。 - -`setup` 方法内部开启多个 namespace 下的处理 handler。 - -```objective-c -- (void)setup { - // 注册 mget 获取监控和各业务线的配置信息,会产生多个 namespace,彼此平行、隔离 - [[HCTConfigurationService sharedInstance] registerAndFetchConfigurationInfo]; - - [self.configutations enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - HCTService *service = [[HCTService alloc] initWithNamespace:obj]; - [self.services setObject:service forKey:obj]; - }]; - HCTService *hermesService = [self.services objectForKey:HermesNAMESPACE]; - if (!hermesService) { - hermesService = [[HCTService alloc] initWithNamespace:HermesNAMESPACE]; - [self.services setObject:hermesService forKey:HermesNAMESPACE]; - } -} -``` - - - -### 2. 核心实现 - -真正处理逻辑的是 `HCTService` 类。 - -```objective-c -#define HCT_SAVED_FLOW @"HCT_SAVED_FLOW" -#define HCT_SAVED_TIMESTAMP @"HCT_SAVED_TIMESTAMP" - -@interface HCTService () - -@property (nonatomic, copy) NSString *requestBaseUrl; /**<需要配置的baseUrl*/ -@property (nonatomic, copy) HCTConfigurationModel *configureModel; /**<当前 namespace 下的配置信息*/ -@property (nonatomic, copy) NSString *metaURL; /** 0, warning, type); - return; - } - - if (!HCT_IS_CLASS(meta, NSDictionary)) { - return; - } - if (meta.allKeys.count == 0) { - return; - } - - // 2. 判断当前 namespace 是否开启了收集 - if (!self.configureModel.isGather) { - HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); - return ; - } - - // 3. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) - BOOL isValidate = [self validateLogData:type]; - if (!isValidate) { - return; - } - - // 3. 先写入 meta 表 - HCTCommonModel *commonModel = [[HCTCommonModel alloc] init]; - [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel]; - - // 4. 如果 payload 不存在则退出当前执行 - if (!HCT_IS_CLASS(payload, NSData) && !payload) { - return; - } - - // 5. 添加限制(超过大小的数据就不能入库,因为这是服务端消耗 payload 的一个上限) - CGFloat payloadSize = [self calculateDataSize:payload]; - if (payloadSize > self.configureModel.maxBodyMByte) { - NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte]; - NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString); - return; - } - - // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所需要的 meta 信息 - NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; - NSDictionary *commonDictionary = [commonModel getDictionary]; - // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 - if ([type isEqualToString:@"appCrash"]) { - [metaDictionary addEntriesFromDictionary:commonDictionary]; - [metaDictionary addEntriesFromDictionary:meta]; - } else { - [metaDictionary addEntriesFromDictionary:meta]; - [metaDictionary addEntriesFromDictionary:commonDictionary]; - } - - NSError *error; - NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; - if (error) { - HCTLOG(@"%@", error); - return; - } - NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; - - // 7. 计算上报时 payload 这条数据的大小(meta+payload) - NSMutableData *totalData = [NSMutableData data]; - [totalData appendData:metaData]; - [totalData appendData:payload]; - - // 8. 再写入 payload 表 - HCTLogPayloadModel *payloadModel = [[HCTLogPayloadModel alloc] init]; - payloadModel.is_used = NO; - payloadModel.namespace = HermesNAMESPACE; - payloadModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID); - payloadModel.monitor_type = HCT_SAFE_STRING(type); - payloadModel.is_biz = NO; - payloadModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME); - payloadModel.meta = HCT_SAFE_STRING(metaContentString); - payloadModel.payload = payload; - payloadModel.size = totalData.length; - [HCT_DATABASE add:@[payloadModel] inTableType:HCTLogTableTypePayload]; - - // 9. 判断是否触发实时上报 - [self handleUploadDataWithtype:type]; -} - -- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta { - // 1. 校验参数合法性 - NSString *prefixWarning = [NSString stringWithFormat:@"%@不能是空字符串", prefix]; - if (!HCT_IS_CLASS(prefix, NSString)) { - NSAssert1(HCT_IS_CLASS(prefix, NSString), prefixWarning, prefix); - return; - } - if (prefix.length == 0) { - NSAssert1(prefix.length > 0, prefixWarning, prefix); - return; - } - - NSString *typeWarning = [NSString stringWithFormat:@"%@不能是空字符串", type]; - if (!HCT_IS_CLASS(type, NSString)) { - NSAssert1(HCT_IS_CLASS(type, NSString), typeWarning, type); - return; - } - if (type.length == 0) { - NSAssert1(type.length > 0, typeWarning, type); - return; - } - - if (!HCT_IS_CLASS(meta, NSDictionary)) { - return; - } - if (meta.allKeys.count == 0) { - return; - } - - // 2. 私有接口处理 is_biz 逻辑 - HCTCommonModel *commonModel = [[HCTCommonModel alloc] init]; - [self sendWithType:type namespace:prefix meta:meta isBiz:YES commonModel:commonModel]; -} - - -#pragma mark - private method - -// 基础配置 -- (void)setupConfig { - _requestBaseUrl = @"https://***DomainName.com"; - _metaURL = @"hermes/***"; - _payloadURL = @"hermes/***"; -} - -- (void)executeHandlerWhenAppLaunched -{ - // 1. 删除非法数据 - [self handleInvalidateData]; - // 2. 回收数据库磁盘碎片空间 - [self rebuildDatabase]; - // 3. 开启定时器去定时上报数据 - [self executeTimedTask]; -} - -/* - 1. 当 App 版本变化的时候删除数据 - 2. 删除过期数据 - 3. 删除 Payload 表里面超过限制的数据 - 4. 删除上传接口网络成功,但是突发 crash 造成没有删除这批数据的情况,所以启动完成后删除 is_used = YES 的数据 - */ -- (void)handleInvalidateData -{ - NSString *currentVersion = [[HermesClient sharedInstance] getCommon].APP_VERSION; - NSString *savedVersion = [[NSUserDefaults standardUserDefaults] stringForKey:HCT_SAVED_APP_VERSION] ?: [currentVersion copy]; - - NSInteger threshold = [NSDate HCT_currentTimestamp]; - if (![currentVersion isEqualToString:savedVersion] && self.configureModel.isUpdateClear) { - [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:HCT_SAVED_APP_VERSION]; - } else { - threshold = [NSDate HCT_currentTimestamp] - self.configureModel.expirationDay * 24 * 60 * 60 *1000; - } - NSInteger sizeUpperLimit = self.configureModel.maxBodyMByte * 1024 * 1024; - NSString *sqlString = [NSString stringWithFormat:@"(created_time < %zd and namespace = '%@') or size > %zd or is_used = 1", threshold, self.namespace, sizeUpperLimit]; - [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypeMeta]; - [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypePayload]; -} - -// 启动时刻清理数据表空间碎片,回收磁盘大小 -- (void)rebuildDatabase { - [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypeMeta]; - [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypePayload]; -} - -// 判断数据是否可以落库 -- (BOOL)validateLogData:(NSString *)dataType { - NSArray *monitors = self.configureModel.monitorList; - __block BOOL isValidate = NO; - [monitors enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if ([obj.type isEqualToString:dataType]) { - isValidate = YES; - *stop = YES; - } - }]; - return isValidate; -} - -- (void)executeTimedTask { - __weak typeof(self) weakself = self; - self.taskExecutor = [[TMLoopTaskExecutor alloc] init]; - TMTaskOption *dataUploadOption = [[TMTaskOption alloc] init]; - dataUploadOption.option = TMTaskRunOptionRuntime; - dataUploadOption.interval = self.configureModel.periodicTimerSecond; - TMTask *dataUploadTask = [[TMTask alloc] init]; - dataUploadTask.runBlock = ^{ - [weakself upload]; - }; - [self.taskExecutor addTask:dataUploadTask option:dataUploadOption]; -} - -- (void)handleUploadDataWithtype:(NSString *)type { - __block BOOL canUploadInTime = NO; - [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if ([type isEqualToString:obj.type]) { - if (obj.isRealtime) { - canUploadInTime = YES; - *stop = YES; - } - } - }]; - if (canUploadInTime) { - // 节流 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self upload]; - }); - } -} - -// 对内和对外的存储都走这个流程。通过这个接口设置 is_biz 信息 -- (void)sendWithType:(NSString *)type namespace:(NSString *)namespace meta:(NSDictionary *)meta isBiz:(BOOL)is_biz commonModel:(HCTCommonModel *)commonModel { - // 0. 判断当前 namespace 是否开启了收集 - if (!self.configureModel.isGather) { - HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]); - return ; - } - - // 1. 检查参数合法性 - NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type]; - if (!HCT_IS_CLASS(type, NSString)) { - NSAssert1(HCT_IS_CLASS(type, NSString), warning, type); - return; - } - if (type.length == 0) { - NSAssert1(type.length > 0, warning, type); - return; - } - - if (!HCT_IS_CLASS(meta, NSDictionary)) { - return; - } - if (meta.allKeys.count == 0) { - return; - } - - // 2. 判断是否是有效的数据。可以落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等) - BOOL isValidate = [self validateLogData:type]; - if (!isValidate) { - return; - } - - // 3. 合并 meta 与 Common 基础数据 - NSMutableDictionary *mutableMeta = [NSMutableDictionary dictionaryWithDictionary:meta]; - mutableMeta[@"MONITOR_TYPE"] = is_biz ? [NSString stringWithFormat:@"%@-%@", namespace, type] : type; - meta = [mutableMeta copy]; - - commonModel.IS_BIZ = is_biz; - NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary]; - NSDictionary *commonDictionary = [commonModel getDictionary]; - - // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖 - if ([type isEqualToString:@"appCrash"]) { - [metaDictionary addEntriesFromDictionary:commonDictionary]; - [metaDictionary addEntriesFromDictionary:meta]; - } else { - [metaDictionary addEntriesFromDictionary:meta]; - [metaDictionary addEntriesFromDictionary:commonDictionary]; - } - - // 4. 转换为 NSData - NSError *error; - NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error]; - if (error) { - HCTLOG(@"%@", error); - return; - } - - // 5. 添加限制(超过 10K 的数据就不能入库,因为这是服务端消耗 meta 的一个上限) - CGFloat metaSize = [self calculateDataSize:metaData]; - if (metaSize > 10 / 1024.0) { - NSAssert(metaSize <= 10 / 1024.0, @"meta 数据的大小超过临界值 10KB"); - return; - } - - NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; - - // 6. 构造 MetaModel 模型 - HCTLogMetaModel *metaModel = [[HCTLogMetaModel alloc] init]; - metaModel.namespace = namespace; - metaModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID); - metaModel.monitor_type = HCT_SAFE_STRING(type); - metaModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME); - metaModel.meta = HCT_SAFE_STRING(metaContentString); - metaModel.size = metaData.length; - metaModel.is_biz = is_biz; - - // 7. 写入数据库 - [HCT_DATABASE add:@[metaModel] inTableType:HCTLogTableTypeMeta]; - - // 8. 判断是否触发实时上报(对内的接口则在函数内部判断,如果是对外的则在这里判断) - if (is_biz) { - [self handleUploadDataWithtype:type]; - } -} - -- (BOOL)needUploadPayload:(HCTLogPayloadModel *)model { - __block BOOL needed = NO; - [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if ([obj.type isEqualToString:model.monitor_type] && obj.isUploadPayload) { - needed = YES; - *stop = YES; - } - }]; - return needed; -} - -/* - 计算 数据包大小,分为2种情况。 - 1. 上传前使用数据表中的 size 字段去判断大小 - 2. 上报完成后则根据真实网络通信中组装的 payload 进行大小计算 - */ -- (float)calculateDataSize:(id)data { - if (HCT_IS_CLASS(data, NSArray)) { - __block NSInteger dataLength = 0; - NSArray *uploadDatasource = (NSArray *)data; - [uploadDatasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if (HCT_IS_CLASS(obj, HCTLogModel)) { - HCTLogModel *uploadModel = (HCTLogModel *)obj; - dataLength += uploadModel.size; - } - }]; - return dataLength / (1024 * 1024.0); - } else if (HCT_IS_CLASS(data, NSData)) { - NSData *rawData = (NSData *)data; - return rawData.length / (1024 * 1024.0); - } else { - return 0; - } -} - -// 上报流程的主函数 -- (void)upload { - /* - 1. 判断能否上报 - 2. 数据聚合 - 3. 加密压缩 - 4. 1分钟内的网络请求合并为1次 - 5. 上报(全局上报开关是开着的情况) - - 成功:删除本地数据、调用更新策略的接口 - - 失败:不删除本地数据 - */ - [self judgeCanUploadCompletionBlock:^(BOOL canUpload, NetworkingManagerStatusType networkType) { - if (canUpload && self.configureModel.isUpload) { - [self handleUploadTask:networkType]; - } - }]; -} - -/** - 上报前的校验 - - 判断网络情况,分为 wifi 和 非 Wi-Fi 、网络不通的情况。 - - 从配置下发的 monitorList 找出 onlyWifi 字段为 true 的 type,组成数组 [appCrash、appLag...] - - 网络不通,则不能上报 - - 网络通,则判断上报校验 - 1. 当前GMT时间戳-保存的时间戳超过24h。则认为是一个新的自然天 - - 清除 currentFlow - - 触发上报流程 - 2. 当前GMT时间戳-保存的时间戳不超过24h - - 当前的流量是否超过配置信息里面的最大流量,未超过(<):触发上报流程 - - 当前的流量是否超过配置信息里面的最大流量,超过:结束流程 - */ -- (void)judgeCanUploadCompletionBlock:(void (^)(BOOL canUpload, NetworkingManagerStatusType networkType))completionBlock { - // WIFI 的情况下不判断直接上传;不是 WIFI 的情况需要判断「当日最大限制流量」 - [self.requester networkStatusWithBlock:^(NetworkingManagerStatusType status) { - switch (status) { - case NetworkingManagerStatusUnknown: { - HCTLOG(@"没有网络权限哦"); - if (completionBlock) { - completionBlock(NO, NetworkingManagerStatusUnknown); - } - break; - } - case NetworkingManagerStatusNotReachable: { - if (completionBlock) { - completionBlock(NO, NetworkingManagerStatusNotReachable); - } - break; - } - case NetworkingManagerStatusReachableViaWiFi: { - if (completionBlock) { - completionBlock(YES, NetworkingManagerStatusReachableViaWiFi); - } - break; - } - case NetworkingManagerStatusReachableViaWWAN: { - if ([self currentGMTStyleTimeStamp] - self.currentTimestamp.integerValue > 24 * 60 * 60 * 1000) { - self.currentFlow = [NSNumber numberWithFloat:0]; - self.currentTimestamp = [NSNumber numberWithInteger:[self currentGMTStyleTimeStamp]]; - if (completionBlock) { - completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); - } - } else { - if (self.currentFlow.floatValue < self.configureModel.maxFlowMByte) { - if (completionBlock) { - completionBlock(YES, NetworkingManagerStatusReachableViaWWAN); - } - } else { - if (completionBlock) { - completionBlock(NO, NetworkingManagerStatusReachableViaWWAN); - } - } - } - break; - } - } - }]; -} - -- (void)handleUploadTask:(NetworkingManagerStatusType)networkType { - // 数据聚合(2张表分别扫描) -> 压缩 -> 上报 - [self handleUploadTaskInMetaTable:networkType]; - [self handleUploadTaskInPayloadTable:networkType]; -} - -- (void)handleUploadTaskInMetaTable:(NetworkingManagerStatusType)networkType { - __weak typeof(self) weakself = self; - // 1. 数据聚合 - [self assembleDataInTable:HCTLogTableTypeMeta - networkType:networkType - completion:^(NSArray *records) { - if (records.count == 0) { - return; - } - // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) - __block NSMutableString *metaStrings = [NSMutableString string]; - __block NSMutableArray *usedReportIds = [NSMutableArray array]; - - // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 - [records enumerateObjectsUsingBlock:^(HCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if (HCT_IS_CLASS(obj, HCTLogMetaModel)) { - HCTLogMetaModel *metaModel = (HCTLogMetaModel *)obj; - BOOL shouldAppendLineBreakSymbol = idx < (records.count - 1); - [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", metaModel.report_id]]; - [metaStrings appendString:[NSString stringWithFormat:@"%@%@", metaModel.meta, shouldAppendLineBreakSymbol ? @"\n" : @""]]; - } - }]; - if (metaStrings.length == 0) { - return; - } - // 2.2 拼接后的内容先压缩再加密 - NSData *data = [HCTDataSerializer compressAndEncryptWithString:metaStrings]; - - // 3. 将取出来用于接口请求的数据标记为 dirty - NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; - [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypeMeta]; - - // 4. 请求网络 - NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.metaURL]; - - [weakself.requester postDataWithRequestURL:requestURL - bodyData:data - success:^{ - [weakself deleteInvalidateData:records inTableType:HCTLogTableTypeMeta]; - if (networkType == NetworkingManagerStatusReachableViaWWAN) { - float currentFlow = [weakself.currentFlow floatValue]; - currentFlow += [weakself calculateDataSize:data]; - weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; - } - } - failure:^(NSError *_Nonnull error) { - [[HCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:HCTLogTableTypeMeta]; - if (networkType == NetworkingManagerStatusReachableViaWWAN) { - float currentFlow = [weakself.currentFlow floatValue]; - currentFlow += [weakself calculateDataSize:data]; - weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; - } - }]; - }]; -} - -- (NSData *)handlePayloadData:(NSArray *)rawArray { - // 1. 数据校验 - if (rawArray.count == 0) { - return nil; - } - // 2. 加密压缩处理:(meta 整体先加密再压缩,payload一条条先加密再压缩) - __block NSMutableString *metaStrings = [NSMutableString string]; - __block NSMutableArray *payloads = [NSMutableArray array]; - - - // 2.1. 遍历拼接model,取出 meta,用 \n 拼接 - [rawArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) { - HCTLogPayloadModel *payloadModel = (HCTLogPayloadModel *)obj; - BOOL shouldAppendLineBreakSymbol = idx < (rawArray.count - 1); - - [metaStrings appendString:[NSString stringWithFormat:@"%@%@", HCT_SAFE_STRING(payloadModel.meta), shouldAppendLineBreakSymbol ? @"\n" : @""]]; - - // 2.2 判断是否需要上传 payload 信息。如果需要则将 payload 取出。 - if ([self needUploadPayload:payloadModel]) { - if (payloadModel.payload) { - NSData *payloadData = [HCTDataSerializer compressAndEncryptWithData:payloadModel.payload]; - if (payloadData) { - [payloads addObject:payloadData]; - } - } - } - } - }]; - - NSData *metaData = [HCTDataSerializer compressAndEncryptWithString:metaStrings]; - - __block NSMutableData *headerData = [NSMutableData data]; - unsigned short metaLength = (unsigned short)metaData.length; - HTONS(metaLength); // 处理2字节的大端序 - [headerData appendData:[NSData dataWithBytes:&metaLength length:sizeof(metaLength)]]; - - Byte payloadCountbytes[] = {payloads.count}; - NSData *payloadCountData = [[NSData alloc] initWithBytes:payloadCountbytes length:sizeof(payloadCountbytes)]; - [headerData appendData:payloadCountData]; - - [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - unsigned int payloadLength = (unsigned int)obj.length; - HTONL(payloadLength); // 处理4字节的大端序 - [headerData appendData:[NSData dataWithBytes:&payloadLength length:sizeof(payloadLength)]]; - }]; - - __block NSMutableData *uploadData = [NSMutableData data]; - // 先添加 header 基础信息,不需要加密压缩 - [uploadData appendData:[headerData copy]]; - // 再添加 meta 信息,meta 信息需要先压缩再加密 - [uploadData appendData:metaData]; - // 再添加 payload 信息 - [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - [uploadData appendData:obj]; - }]; - return [uploadData copy]; -} - -- (void)handleUploadTaskInPayloadTable:(NetworkingManagerStatusType)networkType { - __weak typeof(self) weakself = self; - // 1. 数据聚合 - [self assembleDataInTable:HCTLogTableTypePayload - networkType:networkType - completion:^(NSArray *records) { - if (records.count == 0) { - return; - } - // 2. 取出可以上传的 payload 数据 - NSArray *canUploadPayloadData = [self fetchDataCanUploadPayload:records]; - - if (canUploadPayloadData.count == 0) { - return; - } - - // 3. 将取出来用于接口请求的数据标记为 dirty - __block NSMutableArray *usedReportIds = [NSMutableArray array]; - [canUploadPayloadData enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (HCT_IS_CLASS(obj, HCTLogModel)) { - HCTLogModel *model = (HCTLogModel *)obj; - [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", model.report_id]]; - } - }]; - NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]]; - - [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypePayload]; - - // 4. 将取出的数据聚合,组成报文 - NSData *uploadData = [self handlePayloadData:canUploadPayloadData]; - - // 5. 请求网络 - NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.payloadURL]; - - [weakself.requester postDataWithRequestURL:requestURL - bodyData:uploadData - success:^{ - [weakself deleteInvalidateData:canUploadPayloadData inTableType:HCTLogTableTypePayload]; - if (networkType == NetworkingManagerStatusReachableViaWWAN) { - float currentFlow = [weakself.currentFlow floatValue]; - currentFlow += [weakself calculateDataSize:uploadData]; - weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; - } - } - failure:^(NSError *_Nonnull error) { - [[HCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:HCTLogTableTypePayload]; - if (networkType == NetworkingManagerStatusReachableViaWWAN) { - float currentFlow = [weakself.currentFlow floatValue]; - currentFlow += [weakself calculateDataSize:uploadData]; - weakself.currentFlow = [NSNumber numberWithFloat:currentFlow]; - } - }]; - }]; -} - -// 清除过期数据 -- (void)deleteInvalidateData:(NSArray *)data inTableType:(HCTLogTableType)tableType { - [HCT_DATABASE remove:data inTableType:tableType]; -} - -// 以秒为单位的时间戳 -- (NSInteger)currentGMTStyleTimeStamp { - return [NSDate HCT_currentTimestamp]/1000; -} - -#pragma mark-- 数据库操作 - -/** - 根据接口配置信息中的条件获取表中的上报数据 - - Wi-Fi 的时候都上报 - - 不为 Wi-Fi 的时候:onlyWifi 为 false 的类型进行上报 - */ -- (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray *records))completion { - // 1. 获取到合适的 Crash 类型的数据 - [self fetchCrashDataByCount:self.configureModel.maxFlowMByte - inTable:tableType - upperBound:self.configureModel.maxBodyMByte - completion:^(NSArray *records) { - NSArray *crashData = records; - // 2. 计算剩余需要的数据条数和剩余需要的数据大小 - NSInteger remainingCount = self.configureModel.maxItem - crashData.count; - float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData]; - // 3. 获取除 Crash 类型之外的其他数据,且需要符合相应规则 - BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi); - [self fetchDataExceptCrash:remainingCount - inTable:tableType - upperBound:remainingSize - isWiFI:isWifi - completion:^(NSArray *records) { - NSArray *dataExceptCrash = records; - - NSMutableArray *dataSource = [NSMutableArray array]; - [dataSource addObjectsFromArray:crashData]; - [dataSource addObjectsFromArray:dataExceptCrash]; - if (completion) { - completion([dataSource copy]); - } - }]; - }]; -} - - -- (NSArray *)fetchDataCanUploadPayload:(NSArray *)datasource { - __weak typeof(self) weakself = self; - __block NSMutableArray *array = [NSMutableArray array]; - if (!HCT_IS_CLASS(datasource, NSArray)) { - NSAssert(HCT_IS_CLASS(datasource, NSArray), @"参数必须是数组"); - return nil; - } - [datasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { - if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) { - HCTLogPayloadModel *payloadModel = (HCTLogPayloadModel *)obj; - // 判断是否需要上传 payload 信息 - if ([weakself needUploadPayload:payloadModel]) { - [array addObject:payloadModel]; - } - } - }]; - return [array copy]; -} - -// 递归获取符合条件的 Crash 数据集合(count < maxItem && size < maxBodySize) -- (void)fetchCrashDataByCount:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(NSInteger)size completion:(void (^)(NSArray *records))completion { - // 1. 先通过接口拿到的 maxItem 数去查询表中的 Crash 数据集合 - NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type = 'appCrash' and is_used = 0 and namespace = '%@'", self.namespace]; - [HCT_DATABASE getRecordsByCount:count - condtion:queryCrashDataCondition - inTableType:tableType - completion:^(NSArray *_Nonnull records) { - // 2. Crash 数据集合大小是否超过配置接口拿到的最大包体积(单位M) maxBodySize - float dataSize = [self calculateDataSize:records]; - - // 3. 大于最大包体积则递归获取 maxItem-- 条 Crash 数据集合并判断数据大小 - if (size == 0) { - if (completion) { - completion(records); - } - } else if (dataSize > size) { - NSInteger currentCount = count - 1; - [self fetchCrashDataByCount:currentCount inTable:tableType upperBound:size completion:completion]; - } else { - if (completion) { - completion(records); - } - } - }]; -} - -- (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray *records))completion { - // 1. 根据剩余需要数据条数去查询表中非 Crash 类型的数据集合 - __block NSMutableArray *conditions = [NSMutableArray array]; - [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (isWifi) { - if (![obj.type isEqualToString:@"appCrash"]) { - [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]]; - } - } else { - if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) { - [conditions addObject:[NSString stringWithFormat:@"'%@'", HCT_SAFE_STRING(obj.type)]]; - } - } - }]; - NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace]; - - // 2. 根据是否有 Wifi 查找对应的数据 - [HCT_DATABASE getRecordsByCount:count - condtion:queryCrashDataCondition - inTableType:tableType - completion:^(NSArray *_Nonnull records) { - // 3. 非 Crash 类型的数据集合大小是否超过剩余需要的数据大小 - float dataSize = [self calculateDataSize:records]; - - // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小 - if (size == 0) { - if (completion) { - completion(records); - } - } else if (dataSize > size) { - NSInteger currentCount = count - 1; - return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion]; - } else { - if (completion) { - completion(records); - } - } - }]; -} - - -#pragma mark - getters and setters - -- (HCTRequestFactory *)requester { - if (!_requester) { - _requester = [[HCTRequestFactory alloc] init]; - } - return _requester; -} - -- (NSNumber *)currentTimestamp { - if (!_currentTimestamp) { - NSInteger currentTimestampValue = [[NSUserDefaults standardUserDefaults] integerForKey:HCT_SAVED_TIMESTAMP]; - _currentTimestamp = [NSNumber numberWithInteger:currentTimestampValue]; - } - return _currentTimestamp; -} - -- (void)setCurrentTimestamp:(NSNumber *)currentTimestamp { - [[NSUserDefaults standardUserDefaults] setInteger:[currentTimestamp integerValue] forKey:HCT_SAVED_TIMESTAMP]; - _currentTimestamp = currentTimestamp; -} - -- (NSNumber *)currentFlow { - if (!_currentFlow) { - float currentFlowValue = [[NSUserDefaults standardUserDefaults] floatForKey:HCT_SAVED_FLOW]; - _currentFlow = [NSNumber numberWithFloat:currentFlowValue]; - } - return _currentFlow; -} - -- (void)setCurrentFlow:(NSNumber *)currentFlow { - [[NSUserDefaults standardUserDefaults] setFloat:[currentFlow floatValue] forKey:HCT_SAVED_FLOW]; - _currentFlow = currentFlow; -} - -- (HCTConfigurationModel *)configureModel -{ - return [[HCTConfigurationService sharedInstance] getConfigurationWithNamespace:self.namespace]; -} - -- (NSString *)requestBaseUrl -{ - return self.configureModel.url ? self.configureModel.url : @"https://common.***.com"; -} - -- (BOOL)isAppLaunched -{ - id isAppLaunched = [[HermesClient sharedInstance] valueForKey:@"isAppLaunched"]; - return [isAppLaunched boolValue]; -} - -@end -``` - - - -## 六、 总结与思考 - -### 1. 技术方面 - -多线程技术很强大,但是很容易出问题。普通做业务的时候用一些简单的 GCD、NSOperation 等就可以满足基本需求了,但是做 SDK 就不一样,你需要考虑各种场景。比如 FMDB 在多线程读写的时候,设计了 FMDatabaseQueue 以串行队列的方式同步执行任务。但是这样一来假如使用者在主线程插入 n 次数据到数据库,这样会发生 ANR,所以我们还得维护一个任务派发队列,用来维护业务方提交的任务,是一个并发队列,以异步任务的方式提交给 FMDB 以同步任务的方式在串行队列上执行。 - -AFNetworking 2.0 使用了 NSURLConnection,同时维护了一个常驻线程,去处理网络成功后的回调。AF 存在一个常驻线程,假如其他 n 个 SDK 的其中 m 个 SDK 也开启了常驻线程,那你的 App 集成后就有 1+m 个常驻线程。 - -AFNetworking 3.0 使用 NSURLSession 替换 NSURLConnection,取消了常驻线程。为什么换了? 😂 逼不得已呀,Apple 官方出了 NSURLSession,那就不需要 NSURLConnection,并为之创建常驻线程了。至于为什么 NSURLSession 不需要常驻线程?它比 NSURLConnecction 多做了什么,以后再聊 - -创建线程的过程,需要用到物理内存,CPU 也会消耗时间。新建一个线程,系统会在该进程空间分配一定的内存作为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 主线程堆栈大小是 1MB,新创建的子线程堆栈大小是 512KB。此外线程创建得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候需要寻址,而寻址的过程有 CPU 消耗。线程过多时内存、CPU 都会有大量的消耗,出现 ANR 甚至被强杀。 - -举了 🌰 是 FMDB 和 AFNetworking 的作者那么厉害,设计的 FMDB 不包装会 ANR,AFNetworking 必须使用常驻线程,为什么?正是由于多线程太强大、灵活了,开发者骚操作太多,所以 FMDB 设计最简单保证数据库操作线程安全,具体使用可以自己维护队列去包一层。AFNetworking 内的多线程也严格基于系统特点来设计。 - -所以有必要再研究下多线程,建议读 GCD 源码,也就是 [libdispatch](https://opensource.apple.com/tarballs/libdispatch/) - - - -### 2. 规范方面 - -很多开发都不做测试,我们公司都严格约定测试。写基础 SDK 更是如此,一个 App 基础功能必须质量稳定,所以测试是保证手段之一。一定要写好 Unit Test。这样子不断版本迭代,对于 UT,输入恒定,输出恒定,这样内部实现如何变动不需要关心,只需要判断恒定输入,恒定输出就足够了。(针对每个函数单一原则的基础上也是满足 UT)。还有一个好处就是当和别人讨论的的时候,你画个技术流程图、技术架构图、测试的 case、测试输入、输出表述清楚,听的人再看看边界情况是否都考虑全,基本上很快沟通完毕,效率考高。 - -在做 SDK 的接口设计的时候,方法名、参数个数、参数类型、参数名称、返回值名称、类型、数据结构,尽量要做到 iOS 和 Android 端一致,除非某些特殊情况,无法保证一致的输出。别问为什么?好处太多了,成熟 SDK 都这么做。 - -比如一个数据上报 SDK。需要考虑数据来源是什么,我设计的接口需要暴露什么信息,数据如何高效存储、数据如何校验、数据如何高效及时上报。 假如我做的数据上报 SDK 可以上报 APM 监控数据、同时也开放能力给业务线使用,业务线自己将感兴趣的数据并写入保存,保证不丢失的情况下如何高效上报。因为数据实时上报,所以需要考虑上传的网络环境、Wi-Fi 环境和 4G 环境下的逻辑不一样的、数据聚合组装成自定义报文并上报、一个自然天内数据上传需要做流量限制等等、App 版本升级一些数据可能会失去意义、当然存储的数据也存在时效性。种种这些东西就是在开发前需要考虑清楚的。所以基础平台做事情基本是 **设计思考时间:编码时间 = 7:3**。 - - 为什么?假设你一个需求,预期10天时间;前期架构设计、类的设计、Uint Test 设计估计7天,到时候编码开发2天完成。 这么做的好处很多,比如: - -1. 除非是非常优秀,不然脑子想的再前面到真正开发的时候发现有出入,coding 完发现和前期方案设计不一样。所以建议用流程图、UML图、技术架构图、UT 也一样,设计个表格,这样等到时候编码也就是 coding 的工作了,将图翻译成代码 - -2. 后期和别人讨论或者沟通或者 CTO 进行 code review 的时候不需要一行行看代码。你将相关的架构图、流程图、UML 图给他看看。他再看看一些关键逻辑的 UT,保证输入输出正确,一般来说这样就够了 - - - -### 3. 质量保证 - -UT 是质量保证的一个方面,另一个就是 MR 机制。我们团队 MR 采用 `+1` 机制。每个 merge request 必须有团队内至少3个人 +1,且其中一人必须为同技术栈且比你资深一些的同事 +1,一人为和你参加同一个项目的同事。 - -当有人评论或者有疑问时,你必须解答清楚,别人提出的修改点要么修改好,要么解释清楚,才可以 +1。当 +1 数大于3,则合并分支代码。 - -连带责任制。当你的线上代码存在 bug 时,为你该次 MR +1 的同事具有连带责任。 - - - - - - - -## 参考资料 - -- [WAL](https://en.wikipedia.org/wiki/Write-ahead_logging) -- [WCDB 的 WAL 模式和异步 Checkpoint](https://cloud.tencent.com/developer/article/1031030) -- [sqlite vacuum](https://www.sqlite.org/lang_vacuum.html) -- [彻底弄懂函数防抖和函数节流](https://segmentfault.com/a/1190000018445196) - - - - - - - diff --git a/Chapter1 - iOS/1.81.md b/Chapter1 - iOS/1.81.md deleted file mode 100644 index ccce7ed..0000000 --- a/Chapter1 - iOS/1.81.md +++ /dev/null @@ -1,66 +0,0 @@ -# __asm__ 重命名符号 - -> 最近看到 __asm__ ,所以在自己简单实验下,有了本文。 - - -## 探索 - -1. 实验1 -```c -#import 【爬sw - -int age __asm__("objc_age") = 25; - -void foo(void) __asm__("@杭城小刘"); -void foo (void) { - printf("Hello world\n"); -} - -int main(int argc, const char * argv[]) { - foo(); - return 0; -} -``` - -在 foo 方法里面下断点,见下图 - -![rename symbol](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-25-asm.png) - -可以看到,`foo` 方法的 symbol 被变为 `@杭城小刘`,变量 `age` 被变为 `objc_age`。 - -2. 实验2 -```objective-c -#import "AppDelegate.h" - -int main(int argc, char * argv[]) __asm__("mook_main"); -int main(int argc, char * argv[]) { - NSString * appDelegateClassName; - @autoreleasepool { - // Setup code that might create autoreleased objects goes here. - appDelegateClassName = NSStringFromClass([AppDelegate class]); - } - return UIApplicationMain(argc, argv, nil, appDelegateClassName); -} -``` - -![App main 方法 rename 失败](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-02-25-asm2.png) - -可以看到 App 工程主入口函数 `main` 函数,想修改为 `mook_main`。但是报错 `ld: entry point (_main) undefined. fir architecture x86_64` - -当把 `main` 函数修改为 `_main` 发现成功。 - - - - -## 应用 - -鉴于 __asm__ 可以修改 symbol 名称,那么我们可以给工程做混淆。 - -等待深入研究后继续更新... - - - - -## 引用 - -- [Assembler labels](http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0491f/Cacgegch.html) diff --git a/Chapter1 - iOS/1.82.md b/Chapter1 - iOS/1.82.md deleted file mode 100644 index c0710e4..0000000 --- a/Chapter1 - iOS/1.82.md +++ /dev/null @@ -1,4076 +0,0 @@ -# Runtime - -> 做很多技术项目或者业务项目、再或者是技术细节验证的时候会用到 Runtime 技术,用了挺久的了,本文就结合一些场景和源码分析来系统化学习。 -> -> 2个问题: -> -> - OC 的消息机制是什么样的? -> - OC 中 super 底层是什么?[super superclass]、[self superclass] 打印结果是什么 -> -> 带着问题学习本文 - - - -## 动态语言 - -静态语言:在编译阶段确定了变量数据类型、函数地址等,无法动态修改。 - -动态语言:只有在运行的时候才可以决定变量属于什么类型、方法真正的地址。 - -OC 是一门动态性语言,Runtime 是实现 OC 语言动态的 API。 - -```objectivec -@interface Person : NSObject -{ - NSString *_name; -} -@property (nonatomic, strong) NSString *hobby; -@end - -malloc_size((__bridge const void *)(p)) // 24 isa占8字节 + _name 指针占8字节 + hobby 指针占8字节 = 24 -class_getInstanceSize(p.class) // 32 ,系统内存对齐 -``` - -QA:为什么系统是由16字节对齐的? - -成员变量占用 8 字节对齐,每个对象的第一个都是 isa 指针,必须要占用8字节。举例一个极端 case,假设 n 个对象,其中 m 个对象没有成员变量,只有 isa 指针占用8字节,其中的 n-m个对象既有 isa 指针,又有成员变量。每个类交错排列,那么 CPU 在访问对象的时候会耗费大量时间去识别具体的对象。很多时候会取舍,这个 case 就是时间换空间。以16字节对齐,会加快访问速度(参考链表和数组的设计) - -接下去深入探索下 runtime。 - - - -## 使用较少内存存储属性值 - -### 位运算的设计 - -假设给 Person 对象设置高、富、帅3个属性,如果要用最少的内存,该怎么设计呢? - -先看几组位运算的特点: - -```shell -// 如何把最右边的0变为1,其他位不变???与 00000001 按位或即可 -0010 1000 -0000 0001 ------------ -0010 1001 - - -// 如何把最右边的1变位0,其他位不变???与 11111110 按位与即可。也就是将(0000 0001)取反,再按位与即可 -0010 0001 -1111 1110 ------------ -0010 1110 -``` - -也就是说,我们可以根据位运算的特点,可以更改某个位的数据,可以随意更改为0或者1 - - - -Person 类存在3个 BOOL 属性: - -- 用一个 char 来存储 tall、rich、handsome 3个属性的值,用3个位的0、1表示 BOOL -- 从最右到左的3位代表高富帅3个布尔值。只有高则表示为:`0000 0001`,只有富则表示为 `0000 0010` ,只有帅则表示为 `0000 0100` -- 对高的 getter 问题转换为对一个字节数据的特定位取值问题。该 case,判断高的 BOOL 值变为,对最后一位的取值。可以与 `0000 0001` 按位与,然后转换为 BOOL 即可 -- 对高的 setter ,则演变为对一个字节数据的特点位存值的问题。需要区分真假,如果为真,则可以与 `0000 0001` 按位或运算,或运算,一真为真。如果是假,则与 `1111 1110` 按位与即可,`1111 1110` 也就是 `0000 0001` 取反,也就是 `~0000 0001` - -上 Demo - - - - - -### 结构体位域 - -位运算方案虽然实现了使用较少内存存储了 Person 的3个 BOOL 属性值。但是后续增加属性不够灵活,需要关心位运算,不具备较好拓展性 - -新方案采用:**结构体的位域能力**,限定单个成员变量所占用的内存。代码如下: - - - - - - - - - -### 共用体 - -虽然上述方式都可以实现存储 Person 类3个属性的目的,但是还有第三种方案,参考 iOS 系统设计,采用 Union 实现。代码如下 - - - -分析: - -```c++ -union { - char bits; - struct { - char tall : 1; - char rich : 1; - char handsome : 1; - }; -} _infoUnit; -``` - -Union 里面的内容等价于下面的内容,因为 struct 使用了位域限制了成员变量的大小,所以占用3个空间的结构体还是小于等于 char 所占用的空间。 - -```c++ -union { - char bits; -} _infoUnit; -``` - -但是增加下面的结构体,是为了增加代码的可读性,内存无负向影响。 - -说明: - -- 联合体的所有成员 **共享同一块内存空间**,其大小由最大的成员决定。 - -- char 类型的 `bits` 和匿名结构体 **共享同一块内存**(1个字节大小的空间) - -- struct 里面的 tall 第 0 位,rich 第 1 位,handsome 第 2 位。这样,3 个成员总共只占用 3 位,而 1 字节有 8 位,因此可以完全容纳 - -- **本质是共享内存**:联合体的 `bits` 和结构体是同一块内存的不同“解释方式”。 - - - 通过 `bits`,你可以直接读写整个字节。 - - 通过结构体位域,你可以单独操作某一位。 - - **位域的紧凑存储**:3 个 `char :1` 成员仅占用 3 位,而 1 字节有 8 位,因此足够存储。 - -### 位运算设计 API - -系统很多 API 都有位或运算。比如 KVO 中的 options,可以传递 `NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld` ,那么系统是如何知道到底传递了哪几个值?搞清楚这个问题,我们就也可以设计位运算这样的 API。 - -先看看:按位或运算 - -```shell -0b0000 0001 // 1 -0b0000 0010 // 2 -0b0000 0100 // 4 ------------- -0b0000 0111 // 7 -``` - -可以看到上面3个数,按位或之后的结果为 `0b0000 0111` - -按位与运算。 - -```shell -0b0000 0111 -0b0000 0001 ------------ -0b0000 0001 - -0b0000 0111 -0b0000 0010 ------------ -0b0000 0010 - -0b0000 0111 -0b0000 0100 ------------ -0b0000 0100 - -0b0000 0111 -0b0000 1000 ------------ -0b0000 0000 -``` - -我们发现上面:3个数**按位或之后的结果,再分别与每个数按位与,得到的结果就可以还原数据本身**。 - -也就是: - -- 方法参数可以传递各个枚举值按位或的结果 -- 方法内部再拿这个参数,分别与各个枚举值按位与,得到的结果如果是 YES,则说明参数中传递了该枚举值 - -与一个不是3个数之一的数按位与,得到的结果为`0b0000 0000`。利用这个特性我们可以判断传递来的参数是不是包含了某个值 - - - -有了上面的铺垫,就可以更好的查看 runtime 中 isa 的定义了。 - - - - - -## Runtime 源码阅读 - -### id 的本质 - -`id` 在 Objective-C 的运行时头文件(``)中被定义为指向 `struct objc_object` 的指针: - -```objective-c -typedef struct objc_object *id; -``` - -那 `objc_object` 的核心结构是什么? - -```objective-c -struct objc_object { -private: - isa_t isa; - -public: - // ISA() assumes this is NOT a tagged pointer object - Class ISA(); - - // getIsa() allows this to be a tagged pointer object - Class getIsa(); - // ... -} -``` - -`isa` 指针:所有 Objective-C 对象的内存起始位置都是一个 `isa` 指针,指向该对象的类(`Class`)。 - -注意:Arm64 之后,isa 变成了 union isa_t,里面存储了更多信息 - - - -### isa 本质 - -- 在 arm64 架构之前,isa 就是一个普通的指针,存储着 Class 或 Meta-Class 对象的内存地址。 - -- 在 arm64 之后,对 isa 进行了优化,变成了一个共用体(union)结构,还使用了位域技术来存储更多的信息(位运算) - -```c++ -union isa_t { - // ... - uintptr_t bits; - struct { - ISA_BITFIELD; // defined in isa.h - }; - // ... -}; -``` - -跳转到 `isa.h` 中查看 isa 里结构体的定义(arm64 系统为例,源码是 objc4-838.1 版本),笔者将无用的代码删除了,isa 的 union 内容如下: - -```c++ -union isa_t { - uintptr_t bits; - struct { - uintptr_t nonpointer : 1; - uintptr_t has_assoc : 1; - uintptr_t has_cxx_dtor : 1; - uintptr_t shiftcls : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ - uintptr_t magic : 6; - uintptr_t weakly_referenced : 1; - uintptr_t unused : 1; - uintptr_t has_sidetable_rc : 1; - uintptr_t extra_rc : 19 - }; -}; -``` - -struct 内部的成员变量可以指定占用内存位数, `uintptr_t nonpointer : 1` 代表占用1个字节,是结构体里面的 **位域** - -- nonpointer:0,代表普通的指针,存储着 class、meta-class 对象的内存地址;1,代表优化过,使用位域存储更多的信息 - -- has_assoc:是否有设置过关联对象,如果没有,释放时会更快 - -- has_cxx_dtor:是否有 c++ 的析构函数(`.cxx_destruct`),如果没有,释放时会更快 - - 当 `has_cxx_dtor = 1` 时,表示该 Objective-C 对象 **持有 C++ 成员变量或继承自 C++ 类**,需要在对象释放时调用 C++ 析构函数 - - - Objective-C 的 `dealloc` 方法在释放对象内存前,会检查 `has_cxx_dtor` 标志位。若为 `1`,则调用 `object_cxxDestruct` 函数执行 C++ 析构逻辑 - -- shiftcls:存储着 class、meta-class 对象的内存地址信息 - -- magic:用于在调试时分辨对象是否未完成初始化 - -- weakly_referenced:是否有被弱引用指向过,如果没有,释放时会更快 - -- unused(deallocating):对象是否正在释放 - -- extra_rc:里面存储的值是引用计数器减 1(刚创建出的对象,查看这个信息位 0,因为存储着 -1 之后的引用计数) - -- has_sidetable_rc:引用计数器是否过大无法存储在 isa 中;如果为1,那么引用计数会存储在一个叫 SideTable 的类的属性中 - - - -上面说的「如果没有,释放时会更快」,是如何得出结论的? - -查看 objc4 源代码看到对象执行销毁函数的时候会判断对象是否有关联对象、析构函数,有的话分别调用析构函数、移除关联对象等逻辑。 - -```c++ -/*********************************************************************** -* objc_destructInstance -* Destroys an instance without freeing memory. -* Calls C++ destructors. -* Calls ARC ivar cleanup. -* Removes associative references. -* Returns `obj`. Does nothing if `obj` is nil. -**********************************************************************/ -void *objc_destructInstance(id obj) -{ - if (obj) { - // Read all of the flags at once for performance. - bool cxx = obj->hasCxxDtor(); - bool assoc = obj->hasAssociatedObjects(); - - // This order is important. - // 存在析构函数则执行析构函数 - if (cxx) object_cxxDestruct(obj); - // 存在关联对象,则移除关联对象 - if (assoc) _object_remove_assocations(obj); - // 执行销毁逻辑 - obj->clearDeallocating(); - } - - return obj; -} -``` - -isa 在 arm64 之后必须通过 `ISA_MASK` 去查询 class(类对象、元类对象) 真正的地址 - -`0x0000000ffffffff8ULL` 用程序员模式打开计算器 - - - - - -其中,结构体中的数据存放大体是下面的结构: - -extra_rc、has_sidetable_rc、deallocating、weakly_referenced、magic、shiftcls、has_has_cxx_dtor、assoc、nonpointer - -知道结构体可以指定存储大小这个功能后,可以看到 `isa_t` 联合体与 `ISA_MASK` 按位与之后的地址,其实就是类真实的地址信息(可能是类对象、也有可能是元类对象) - - - - - - - -如果要找出下面中间的 `1010` 如何实现?按位与即可,且要找的位置补充位1,其他位置为0 - -```shell -0b0010 1000 - -0b0011 1100 ------------ -0b0010 1000 -``` - -结论:**根据按位与的效果。`ISA_MASK` 的后3位都是0,所以经过 isa union 位运算后得到的类对象地址或者元类对象地址,用二进制表示时,最后3位一定为0** - -我们可以验证下 - -```objectivec -Person *p = [[Person alloc] init]; -NSLog(@"%p", [p class]); // 0x1000081d8 -NSLog(@"%p", object_getClass([Person class])); // 0x100008200 -NSLog(@"%p", object_getClass([NSObject class])); // 0x7ff84cb29fe0 -NSLog(@"%p", object_getClass([NSString class])); // 0x7ff84c9dcc28 -``` - -为什么有的结尾是8?因为 `0x` 是16进制。16进制的8转为二进制,`0x1000` - -关于这部分的调试,需要在真机上运行,真机上 arm64,拷贝对象地址到系统自带的运算器(程序员模式),查看64位地址。按照下面的顺序一一查看 - -`extra_rc、has_sidetable_rc、deallocating、weakly_referenced、magic、shiftcls、has_has_cxx_dtor、assoc、nonpointer` - -所以可以根据 isa 信息查看对象是否创建过关联对象、有没有设置弱引用、 - - - -QA:如何理解 isa 指针? - -isa 指针,在 arm64 之前,isa 指针就是普通的对象,存储着类对象或元类对象的地址值。 - -从 arm64 开始,采用共用体 union 的形式,共64位,存储了很多信息,其中33位用来存储 class、meta-class 对象的内存地址信息,其他的31位用来存储其他信息,比如 `has_assoc` 占1位,其代表是否有设置过关联对象,如果没有,释放时会更快。 - - - -有类对象、为什么设计元类对象? - -复用消息机制。比如 `[Person new]`。元类对象:isa、元类方法。 - -`objc_msgSend` 设计初衷就是为了消息发送很快。假如没有元类,则类方法也存储在类对象的方法信息中,则可能需要加额外的字段来标记某个方法是类方法还是对象方法。遍历或者寻找会比较慢。所以引入元类(单一职责),设计元类的目的就是为了提高 `objc_msgSend` 的效率。 - - - -### 类对象 Class 的结构 - -查看 objc4 源代码看看 - -```c -struct objc_object { -private: - isa_t isa; - -public: - // ISA() assumes this is NOT a tagged pointer object - Class ISA(); - - // getIsa() allows this to be a tagged pointer object - Class getIsa(); - // ... -} -struct objc_class : objc_object { - // Class ISA; - Class superclass; - cache_t cache; // formerly cache pointer and vtable - class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags -}; -``` - -结构体继承于 `objc_object` 等同于下面代码 - -```c -struct objc_class : objc_object { - isa_t isa; - Class superclass; - cache_t cache; // 方法缓存 - class_data_bits_t bits; // 用于获取具体的类信息 -}; -``` - -```c -struct class_rw_t { - // Be warned that Symbolication knows the layout of this structure. - uint32_t flags; - uint32_t version; - - const class_ro_t *ro; - - method_array_t methods; // 方法列表 - property_array_t properties; // 属性列表 - protocol_array_t protocols; // 协议列表 - - Class firstSubclass; - Class nextSiblingClass; - - char *demangledName; -}; - -struct class_data_bits_t { - // Values are the FAST_ flags above. - uintptr_t bits; -public: - - class_rw_t* data() { - return (class_rw_t *)(bits & FAST_DATA_MASK); - } -} -``` - -可以看到 `objc_class` 获取 bits 里的真实数据需要经过按位与 `FAST_DATA_MASK` - -```c -struct class_ro_t { - uint32_t flags; - uint32_t instanceStart; - uint32_t instanceSize; // instance 对象占用的内存空间 -#ifdef __LP64__ - uint32_t reserved; -#endif - - const uint8_t * ivarLayout; - - const char * name; // 类名 - method_list_t * baseMethodList; - protocol_list_t * baseProtocols; - const ivar_list_t * ivars; // 成员变量列表 - - const uint8_t * weakIvarLayout; - property_list_t *baseProperties; - - method_list_t *baseMethods() const { - return baseMethodList; - } -}; -``` - -具体关系整理如下图 - - - - - -源码解读: - -- 元类对象可以看成是特殊的类对象,数据类型都是 `Class`,所以大部分数据结构都一样,区别在于某些值是否有值 - -- `class_rw_t`:里面的 methods、properties、protocols 是二维数组(其实不能叫二维数组,因为每个子列表 `method_list_t` 的长度可以不同,应该叫 **方法列表的列表**)其结构类似:`method_array_t` -> `method_list_t` -> `method_t`,是可读可写的,包含了类的初始内容、分类的内容。 - - - - - - 比如访问 method 的过程 - - ```objectivec - // 新版 - const method_array_t methods() const { - auto v = get_ro_or_rwe(); - if (v.is()) { - return v.get(&ro_or_rw_ext)->methods; - } else { - return method_array_t{v.get(&ro_or_rw_ext)->baseMethods}; - } - } - ``` - -- `class_ro_t` 里面的 baseMethodList、baseProtocols、ivars、baseProperties 是一维数组,是只读的,包含了类的(原始信息)初始内容 - - - -- 当系统运行 Runtime 会把类自身的信息(class_ro_t) 中的信息和 Category 中的信息合并起来,放到 class_rw_t 中的信息去 - - 源码 `objc-runtime-new.mm` 如下 - - ```c++ - static Class realizeClassWithoutSwift(Class cls, Class previously) - { - // ... - class_rw_t *rw; - Class supercls; - Class metacls; - // ... - - // fixme verify class is not in an un-dlopened part of the shared cache? - - auto ro = cls->safe_ro(); // 获取编译期确定的只读元数据(class_ro_t) - auto isMeta = ro->flags & RO_META; // 判断是否为元类 - if (ro->flags & RO_FUTURE) { - // This was a future class. rw data is already allocated. - // Future Class 已预分配 rw 数据。Future Class:共享缓存(shared cache)中预生成但未完全实现的类。 - rw = cls->data(); // 直接获取已分配的 rw - ro = cls->data()->ro(); // 更新 ro 指针 - ASSERT(!isMeta); - cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE); // 更新标志位:从 RW_FUTURE 转换为 RW_REALIZED|RW_REALIZING。 - } else { - // Normal class. Allocate writeable class data. - // 为普通类分配可读写数据 - rw = objc::zalloc(); // 分配归零内存 - rw->set_ro(ro); // 绑定只读数据到 rw - rw->flags = RW_REALIZED|RW_REALIZING|isMeta; // 设置状态标志 - cls->setData(rw); // 将 rw 关联到类 - } - // ... - return cls; - } - ``` - - 总结: - - - 系统刚运行起来的时候 `objc_class` 结构体的 `class_data_bits_t bits` 的 data 指向的是 class_ro_t 的 - - 经过 Runtime 会把类的原始信息和 Category 信息(Property、method、protocol)进行整合,创建 class_rw_t 结构体,把 class_ro_t 复制给 class_rw_t 的 `ro` 成员变量。 - - 最后把 `objc_class` 结构体的 `class_data_bits_t bits` 的 data 指向的是 class_rw_t - - ```c++ - // 编译期 - objc_class.bits.data() → class_ro_t - - // 运行时初始化后 - objc_class.bits.data() → class_rw_t - class_rw_t.ro → class_ro_t - ``` - - - - 阐述下类的数据存储演进流程 - - 阶段 1:编译期(`class_ro_t`) - - - **`class_ro_t`(Read Only)**: - - **内容**:由编译器生成,包含类名、父类指针、实例大小、基础方法列表(`baseMethodList`)、属性列表(`baseProperties`)、协议列表(`baseProtocols`)等。 - - **内存位置**:存储在 Mach-O 文件的 `__DATA __objc_const` 段中。 - - **访问方式**:通过 `objc_class->bits.data()` 直接访问。 - - 阶段 2:运行时初始化(`class_rw_t`) - - - **触发时机**: - - - 首次调用 `[MyClass alloc]` 或 `objc_getClass("MyClass")` 时触发类的 `realize`。 - - 运行时通过 `realizeClassWithoutSwift()` 函数完成初始化。 - - - **核心操作**: - - 1. **分配 `class_rw_t`**:动态创建可读写结构体。 - 2. **绑定 `class_ro_t`**:将 `rw->ro` 指向编译期的 `class_ro_t`。 - 3. **更新指针**:将 `objc_class->bits.data()` 指向 `class_rw_t`。 - 4. **合并 Category**:附加所有分类(Category)的方法、属性、协议到 `class_rw_t`。 - - - **内存变化**: - - ``` - // 编译期 - objc_class.bits.data() → class_ro_t - - // 运行时初始化后 - objc_class.bits.data() → class_rw_t - class_rw_t.ro → class_ro_t - ``` - - `class_rw_t` 与 `class_ro_t` 的核心区别 - - | 特性 | `class_ro_t`(只读) | `class_rw_t`(读写) | - | :------------- | :------------------- | :--------------------------------- | - | **生成时机** | 编译期生成 | 运行时动态分配 | - | **内存位置** | Mach-O 文件数据段 | 堆内存 | - | **内容可变性** | 不可修改 | 可动态添加方法、属性、协议 | - | **包含数据** | 基础方法、属性、协议 | 基础数据 + 分类数据 + 动态扩展数据 | - | **性能优化** | 无锁访问 | 需要线程安全措施(如锁) | - - Category 的合并逻辑 - - - **附加到 `class_rw_t`**: - - - **方法**:分类的方法会被插入到 `class_rw_t.methods` 数组的**头部**,因此分类方法优先于原类方法被调用。 - - **属性**:分类的属性通过关联对象(Associated Object)实现,不会真正添加到 `class_rw_t.properties`。 - - **协议**:分类的协议会被合并到 `class_rw_t.protocols` 列表中。 - - - **源码关键路径**: - - ``` - // objc-runtime-new.mm - static void attachCategories(Class cls, const locstamped_category_t *cats_list, uint32_t cats_count) { - // 遍历所有分类,合并方法、属性、协议到 class_rw_t - for (uint32_t i = 0; i < cats_count; i++) { - auto& entry = cats_list[i]; - method_list_t *mlist = entry.cat->methodsForMeta(isMeta); - if (mlist) { - rw->methods.attachLists(&mlist, 1); // 方法合并 - } - // 类似处理属性和协议 - } - } - ``` - - 设计意义 - - - **内存优化**: - - 未初始化的类保持 `class_ro_t` 轻量级存储,减少内存占用。 - - 按需分配 `class_rw_t`,仅在类首次使用时初始化。 - - **动态性支持**: - - 运行时可通过 `class_addMethod()` 或分类动态扩展方法。 - - 支持方法交换(Method Swizzling)等高级特性。 - - **性能权衡**: - - 方法查找需遍历 `class_rw_t.methods` 的多个方法列表(原类 + 所有分类)。 - - 引入 `class_rw_ext_t` 进一步优化(将常用数据如方法缓存分离) - -QA: - -1. `method_array_t` 中存储了 `method_list_t`,`method_list_t` 的元素为 `method_list_t`, 为什么不直接称它为二维数组? - - 严格来说,不是二维数组,只不过是 array 里添加的对象也是 array,且各个数组不等长,也不会补空。 - -2. 为什么需要设计为这样的结构? - - 调用方法,比如调用 load 是 runtime 加载的时候找到方法地址直接调用的,普通方法走的是消息机制。首先判断是对象方法还是类方法,然后根据 isa 找类对象(对象方法)和元类对象(对象方法)信息中先从方法缓存中查找方法是否有缓存(方法缓存查找的过程是:先根据方法的 SEL,SEL 本质上是一个指向方法名的 C 字符串指针,将其转换为 uintptr_t,然后和方法缓存哈希表的 MASK 进行按位与,MASK 值初始为 `1<isInitialized()) return; - - // Make sure the entry wasn't added to the cache by some other thread - // before we grabbed the cacheUpdateLock. - if (cache_getImp(cls, sel)) return; - - cache_t *cache = getCache(cls); - cache_key_t key = getKey(sel); - - // Use the cache as-is if it is less than 3/4 full - mask_t newOccupied = cache->occupied() + 1; - mask_t capacity = cache->capacity(); - if (cache->isConstantEmptyCache()) { - // Cache is read-only. Replace it. - // 此处传入的第二个参数为 INIT_CACHE_SIZE - cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE); - } - else if (newOccupied <= capacity / 4 * 3) { - // Cache is less than 3/4 full. Use it as-is. - } - else { - // Cache is too full. Expand it. - cache->expand(); - } - - // Scan for the first unused slot and insert there. - // There is guaranteed to be an empty slot because the - // minimum size is 4 and we resized at 3/4 full. - bucket_t *bucket = cache->find(key, receiver); - if (bucket->key() == 0) cache->incrementOccupied(); - bucket->set(key, imp); - } - - void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity) - { - bool freeOld = canBeFreed(); - - bucket_t *oldBuckets = buckets(); - bucket_t *newBuckets = allocateBuckets(newCapacity); - - // Cache's old contents are not propagated. - // This is thought to save cache memory at the cost of extra cache fills. - // fixme re-measure this - - assert(newCapacity > 0); - assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1); - // 设置默认的 Mask,为 1<filetype == MH_EXECUTE) { - // Size some data structures based on main executable's size -#if __OBJC2__ - // If dyld3 optimized the main executable, then there shouldn't - // be any selrefs needed in the dynamic map so we can just init - // to a 0 sized map - if ( !hi->hasPreoptimizedSelectors() ) { - size_t count; - _getObjc2SelectorRefs(hi, &count); - selrefCount += count; - _getObjc2MessageRefs(hi, &count); - selrefCount += count; - } -#else - _getObjcSelectorRefs(hi, &selrefCount); -#endif - -#if SUPPORT_GC_COMPAT - // Halt if this is a GC app. - if (shouldRejectGCApp(hi)) { - _objc_fatal_with_reason - (OBJC_EXIT_REASON_GC_NOT_SUPPORTED, - OS_REASON_FLAG_CONSISTENT_FAILURE, - "Objective-C garbage collection " - "is no longer supported."); - } -#endif - } - - hList[hCount++] = hi; - - if (PrintImages) { - _objc_inform("IMAGES: loading image for %s%s%s%s%s\n", - hi->fname(), - mhdr->filetype == MH_BUNDLE ? " (bundle)" : "", - hi->info()->isReplacement() ? " (replacement)" : "", - hi->info()->hasCategoryClassProperties() ? " (has class properties)" : "", - hi->info()->optimizedByDyld()?" (preoptimized)":""); - } - } - } - - // Perform one-time runtime initialization that must be deferred until - // the executable itself is found. This needs to be done before - // further initialization. - // (The executable may not be present in this infoList if the - // executable does not contain Objective-C code but Objective-C - // is dynamically loaded later. - if (firstTime) { - sel_init(selrefCount); - arr_init(); - -#if SUPPORT_GC_COMPAT - // Reject any GC images linked to the main executable. - // We already rejected the app itself above. - // Images loaded after launch will be rejected by dyld. - - for (uint32_t i = 0; i < hCount; i++) { - auto hi = hList[i]; - auto mh = hi->mhdr(); - if (mh->filetype != MH_EXECUTE && shouldRejectGCImage(mh)) { - _objc_fatal_with_reason - (OBJC_EXIT_REASON_GC_NOT_SUPPORTED, - OS_REASON_FLAG_CONSISTENT_FAILURE, - "%s requires Objective-C garbage collection " - "which is no longer supported.", hi->fname()); - } - } -#endif - -#if TARGET_OS_OSX - // Disable +initialize fork safety if the app is too old (< 10.13). - // Disable +initialize fork safety if the app has a - // __DATA,__objc_fork_ok section. - -// if (!dyld_program_sdk_at_least(dyld_platform_version_macOS_10_13)) { -// DisableInitializeForkSafety = true; -// if (PrintInitializing) { -// _objc_inform("INITIALIZE: disabling +initialize fork " -// "safety enforcement because the app is " -// "too old.)"); -// } -// } - - for (uint32_t i = 0; i < hCount; i++) { - auto hi = hList[i]; - auto mh = hi->mhdr(); - if (mh->filetype != MH_EXECUTE) continue; - unsigned long size; - if (getsectiondata(hi->mhdr(), "__DATA", "__objc_fork_ok", &size)) { - DisableInitializeForkSafety = true; - if (PrintInitializing) { - _objc_inform("INITIALIZE: disabling +initialize fork " - "safety enforcement because the app has " - "a __DATA,__objc_fork_ok section"); - } - } - break; // assume only one MH_EXECUTE image - } -#endif - - } - - if (hCount > 0) { - _read_images(hList, hCount, totalClasses, unoptimizedTotalClasses); - } - - firstTime = NO; - - // Call image load funcs after everything is set up. - for (auto func : loadImageFuncs) { - for (uint32_t i = 0; i < mhCount; i++) { - func(mhdrs[i]); - } - } -} -``` - -最后会调用 - -```c++ -void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses) -{ - header_info *hi; - uint32_t hIndex; - size_t count; - size_t i; - Class *resolvedFutureClasses = nil; - size_t resolvedFutureClassCount = 0; - static bool doneOnce; - bool launchTime = NO; - TimeLogger ts(PrintImageTimes); - - runtimeLock.assertLocked(); - -#define EACH_HEADER \ - hIndex = 0; \ - hIndex < hCount && (hi = hList[hIndex]); \ - hIndex++ - - if (!doneOnce) { - doneOnce = YES; - launchTime = YES; - -#if SUPPORT_NONPOINTER_ISA - // Disable non-pointer isa under some conditions. - -# if SUPPORT_INDEXED_ISA - // Disable nonpointer isa if any image contains old Swift code - for (EACH_HEADER) { - if (hi->info()->containsSwift() && - hi->info()->swiftUnstableVersion() < objc_image_info::SwiftVersion3) - { - DisableNonpointerIsa = true; - if (PrintRawIsa) { - _objc_inform("RAW ISA: disabling non-pointer isa because " - "the app or a framework contains Swift code " - "older than Swift 3.0"); - } - break; - } - } -# endif - -# if TARGET_OS_OSX - // Disable non-pointer isa if the app is too old - // (linked before OS X 10.11) - // Note: we must check for macOS, because Catalyst and Almond apps - // return false for a Mac SDK check! rdar://78225780 -// if (dyld_get_active_platform() == PLATFORM_MACOS && !dyld_program_sdk_at_least(dyld_platform_version_macOS_10_11)) { -// DisableNonpointerIsa = true; -// if (PrintRawIsa) { -// _objc_inform("RAW ISA: disabling non-pointer isa because " -// "the app is too old."); -// } -// } - - // Disable non-pointer isa if the app has a __DATA,__objc_rawisa section - // New apps that load old extensions may need this. - for (EACH_HEADER) { - if (hi->mhdr()->filetype != MH_EXECUTE) continue; - unsigned long size; - if (getsectiondata(hi->mhdr(), "__DATA", "__objc_rawisa", &size)) { - DisableNonpointerIsa = true; - if (PrintRawIsa) { - _objc_inform("RAW ISA: disabling non-pointer isa because " - "the app has a __DATA,__objc_rawisa section"); - } - } - break; // assume only one MH_EXECUTE image - } -# endif - -#endif - - if (DisableTaggedPointers) { - disableTaggedPointers(); - } - - initializeTaggedPointerObfuscator(); - - if (PrintConnecting) { - _objc_inform("CLASS: found %d classes during launch", totalClasses); - } - - // namedClasses - // Preoptimized classes don't go in this table. - // 4/3 is NXMapTable's load factor - int namedClassesSize = - (isPreoptimized() ? unoptimizedTotalClasses : totalClasses) * 4 / 3; - gdb_objc_realized_classes = - NXCreateMapTable(NXStrValueMapPrototype, namedClassesSize); - - ts.log("IMAGE TIMES: first time tasks"); - } - - // Fix up @selector references - // Note this has to be before anyone uses a method list, as relative method - // lists point to selRefs, and assume they are already fixed up (uniqued). - static size_t UnfixedSelectors; - { - mutex_locker_t lock(selLock); - for (EACH_HEADER) { - if (hi->hasPreoptimizedSelectors()) continue; - - bool isBundle = hi->isBundle(); - SEL *sels = _getObjc2SelectorRefs(hi, &count); - UnfixedSelectors += count; - for (i = 0; i < count; i++) { - const char *name = sel_cname(sels[i]); - SEL sel = sel_registerNameNoLock(name, isBundle); - if (sels[i] != sel) { - sels[i] = sel; - } - } - } - } - - ts.log("IMAGE TIMES: fix up selector references"); - - // Discover classes. Fix up unresolved future classes. Mark bundle classes. - bool hasDyldRoots = dyld_shared_cache_some_image_overridden(); - - for (EACH_HEADER) { - if (! mustReadClasses(hi, hasDyldRoots)) { - // Image is sufficiently optimized that we need not call readClass() - continue; - } - - classref_t const *classlist = _getObjc2ClassList(hi, &count); - - bool headerIsBundle = hi->isBundle(); - bool headerIsPreoptimized = hi->hasPreoptimizedClasses(); - - for (i = 0; i < count; i++) { - Class cls = (Class)classlist[i]; - Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized); - - if (newCls != cls && newCls) { - // Class was moved but not deleted. Currently this occurs - // only when the new class resolved a future class. - // Non-lazily realize the class below. - resolvedFutureClasses = (Class *) - realloc(resolvedFutureClasses, - (resolvedFutureClassCount+1) * sizeof(Class)); - resolvedFutureClasses[resolvedFutureClassCount++] = newCls; - } - } - } - - ts.log("IMAGE TIMES: discover classes"); - - // Fix up remapped classes - // Class list and nonlazy class list remain unremapped. - // Class refs and super refs are remapped for message dispatching. - - if (!noClassesRemapped()) { - for (EACH_HEADER) { - Class *classrefs = _getObjc2ClassRefs(hi, &count); - for (i = 0; i < count; i++) { - remapClassRef(&classrefs[i]); - } - // fixme why doesn't test future1 catch the absence of this? - classrefs = _getObjc2SuperRefs(hi, &count); - for (i = 0; i < count; i++) { - remapClassRef(&classrefs[i]); - } - } - } - - ts.log("IMAGE TIMES: remap classes"); - -#if SUPPORT_FIXUP - // Fix up old objc_msgSend_fixup call sites - for (EACH_HEADER) { - message_ref_t *refs = _getObjc2MessageRefs(hi, &count); - if (count == 0) continue; - - if (PrintVtables) { - _objc_inform("VTABLES: repairing %zu unsupported vtable dispatch " - "call sites in %s", count, hi->fname()); - } - for (i = 0; i < count; i++) { - fixupMessageRef(refs+i); - } - } - - ts.log("IMAGE TIMES: fix up objc_msgSend_fixup"); -#endif - - - // Discover protocols. Fix up protocol refs. - for (EACH_HEADER) { - extern objc_class OBJC_CLASS_$_Protocol; - Class cls = (Class)&OBJC_CLASS_$_Protocol; - ASSERT(cls); - NXMapTable *protocol_map = protocols(); - bool isPreoptimized = hi->hasPreoptimizedProtocols(); - - // Skip reading protocols if this is an image from the shared cache - // and we support roots - // Note, after launch we do need to walk the protocol as the protocol - // in the shared cache is marked with isCanonical() and that may not - // be true if some non-shared cache binary was chosen as the canonical - // definition - if (launchTime && isPreoptimized) { - if (PrintProtocols) { - _objc_inform("PROTOCOLS: Skipping reading protocols in image: %s", - hi->fname()); - } - continue; - } - - bool isBundle = hi->isBundle(); - - protocol_t * const *protolist = _getObjc2ProtocolList(hi, &count); - for (i = 0; i < count; i++) { - readProtocol(protolist[i], cls, protocol_map, - isPreoptimized, isBundle); - } - } - - ts.log("IMAGE TIMES: discover protocols"); - - // Fix up @protocol references - // Preoptimized images may have the right - // answer already but we don't know for sure. - for (EACH_HEADER) { - // At launch time, we know preoptimized image refs are pointing at the - // shared cache definition of a protocol. We can skip the check on - // launch, but have to visit @protocol refs for shared cache images - // loaded later. - if (launchTime && hi->isPreoptimized()) - continue; - protocol_t **protolist = _getObjc2ProtocolRefs(hi, &count); - for (i = 0; i < count; i++) { - remapProtocolRef(&protolist[i]); - } - } - - ts.log("IMAGE TIMES: fix up @protocol references"); - - // Discover categories. Only do this after the initial category - // attachment has been done. For categories present at startup, - // discovery is deferred until the first load_images call after - // the call to _dyld_objc_notify_register completes. rdar://problem/53119145 - if (didInitialAttachCategories) { - for (EACH_HEADER) { - load_categories_nolock(hi); - } - } - - ts.log("IMAGE TIMES: discover categories"); - - // Category discovery MUST BE Late to avoid potential races - // when other threads call the new category code before - // this thread finishes its fixups. - - // +load handled by prepare_load_methods() - - // Realize non-lazy classes (for +load methods and static instances) - for (EACH_HEADER) { - classref_t const *classlist = hi->nlclslist(&count); - for (i = 0; i < count; i++) { - Class cls = remapClass(classlist[i]); - if (!cls) continue; - - addClassTableEntry(cls); - - if (cls->isSwiftStable()) { - if (cls->swiftMetadataInitializer()) { - _objc_fatal("Swift class %s with a metadata initializer " - "is not allowed to be non-lazy", - cls->nameForLogging()); - } - // fixme also disallow relocatable classes - // We can't disallow all Swift classes because of - // classes like Swift.__EmptyArrayStorage - } - realizeClassWithoutSwift(cls, nil); - } - } - - ts.log("IMAGE TIMES: realize non-lazy classes"); - - // Realize newly-resolved future classes, in case CF manipulates them - if (resolvedFutureClasses) { - for (i = 0; i < resolvedFutureClassCount; i++) { - Class cls = resolvedFutureClasses[i]; - if (cls->isSwiftStable()) { - _objc_fatal("Swift class is not allowed to be future"); - } - realizeClassWithoutSwift(cls, nil); - cls->setInstancesRequireRawIsaRecursively(false/*inherited*/); - } - free(resolvedFutureClasses); - } - - ts.log("IMAGE TIMES: realize future classes"); - - if (DebugNonFragileIvars) { - realizeAllClasses(); - } - - - // Print preoptimization statistics - if (PrintPreopt) { - static unsigned int PreoptTotalMethodLists; - static unsigned int PreoptOptimizedMethodLists; - static unsigned int PreoptTotalClasses; - static unsigned int PreoptOptimizedClasses; - - for (EACH_HEADER) { - if (hi->hasPreoptimizedSelectors()) { - _objc_inform("PREOPTIMIZATION: honoring preoptimized selectors " - "in %s", hi->fname()); - } - else if (hi->info()->optimizedByDyld()) { - _objc_inform("PREOPTIMIZATION: IGNORING preoptimized selectors " - "in %s", hi->fname()); - } - - classref_t const *classlist = _getObjc2ClassList(hi, &count); - for (i = 0; i < count; i++) { - Class cls = remapClass(classlist[i]); - if (!cls) continue; - - PreoptTotalClasses++; - if (hi->hasPreoptimizedClasses()) { - PreoptOptimizedClasses++; - } - - const method_list_t *mlist; - if ((mlist = cls->bits.safe_ro()->baseMethods)) { - PreoptTotalMethodLists++; - if (mlist->isFixedUp()) { - PreoptOptimizedMethodLists++; - } - } - if ((mlist = cls->ISA()->bits.safe_ro()->baseMethods)) { - PreoptTotalMethodLists++; - if (mlist->isFixedUp()) { - PreoptOptimizedMethodLists++; - } - } - } - } - - _objc_inform("PREOPTIMIZATION: %zu selector references not " - "pre-optimized", UnfixedSelectors); - _objc_inform("PREOPTIMIZATION: %u/%u (%.3g%%) method lists pre-sorted", - PreoptOptimizedMethodLists, PreoptTotalMethodLists, - PreoptTotalMethodLists - ? 100.0*PreoptOptimizedMethodLists/PreoptTotalMethodLists - : 0.0); - _objc_inform("PREOPTIMIZATION: %u/%u (%.3g%%) classes pre-registered", - PreoptOptimizedClasses, PreoptTotalClasses, - PreoptTotalClasses - ? 100.0*PreoptOptimizedClasses/PreoptTotalClasses - : 0.0); - _objc_inform("PREOPTIMIZATION: %zu protocol references not " - "pre-optimized", UnfixedProtocolReferences); - } - -#undef EACH_HEADER -} -``` - -核心相关代码 - -```c++ -/*********************************************************************** -* realizeClassWithoutSwift -* Performs first-time initialization on class cls, -* including allocating its read-write data. -* Does not perform any Swift-side initialization. -* Returns the real class structure for the class. -* Locking: runtimeLock must be write-locked by the caller -**********************************************************************/ -static Class realizeClassWithoutSwift(Class cls, Class previously) -{ - // ... - class_rw_t *rw; - Class supercls; - Class metacls; - auto ro = (const class_ro_t *)cls->data(); - auto isMeta = ro->flags & RO_META; - if (ro->flags & RO_FUTURE) { - // This was a future class. rw data is already allocated. - rw = cls->data(); - ro = cls->data()->ro(); - ASSERT(!isMeta); - cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE); - } else { - // Normal class. Allocate writeable class data. - rw = objc::zalloc(); - rw->set_ro(ro); - rw->flags = RW_REALIZED|RW_REALIZING|isMeta; - cls->setData(rw); - } - cls->cache.initializeToEmptyOrPreoptimizedInDisguise(); - // ... -} - -struct class_rw_ext_t { - DECLARE_AUTHED_PTR_TEMPLATE(class_ro_t) - class_ro_t_authed_ptr ro; - method_array_t methods; - property_array_t properties; - protocol_array_t protocols; - char *demangledName; - uint32_t version; -}; -``` - -查看 objc4 源码,runtime 启动步骤可以知道: - -- `struct objc_class` 结构体中的 `bits` 一开始指向的是 `class_ro_t` 结构体,然后经过 runtime 的 `realizeClassWithoutSwift` 方法,将 `bits` 指向一个新创建的 `class_rw_t` 结构体,新创建的结构体中的成员变量 `ro` 指向类原本的 `class_ro_t`。也就是说 runtime 会对类自身信息和 Category 信息进行组合。 -- `class_ro_t` 在编译时期生成的,`class_rw_t` 是在运行时期生成的。 - -那么什么是 `class_rw_ext_t`首先明确 2。个概念 - -- clean memory:加载后不会被修改。当系统内存紧张时,可以从内存中移除,需要时可以再次加载 - -- dirty memory:加载后会被修改,一直处于内存中 - - - -runtime 初始化的时候,遇到一个类,则会利用类的 `class_ro_t` 中的基础信息(methods、properties、protocols)来创建 `class_rw_t` 对象。`class_rw_t` 设计的目的就是为了 runtime 所需(Category 增加属性、协议、动态增加方法等),但是实际上写了很多的类,只有少部分类才需要 runtime 能力。所以 Apple 为了内存优化,在 iOS 14 对 `class_rw_t` 拆分出 `class_rw_ext_t`,用来存储 Methods、Protocols、Properties 信息,只有在使用的时候才创建,节省更多内存。 - - - -## Runtime - 方法 - -### Method_t - -`method_t` 是对方法、函数的封装 - -```c++ -struct method_t { - // ... - struct big { - SEL name; // 函数名、方法名 - const char *types; // 编码(返回值类型、参数类型) - MethodListIMP imp; // 指向函数的指针(函数地址,给方法下断点的话,汇编模式的第一条指令的地址就是函数地址) - }; - struct small { - RelativePointer name; - RelativePointer types; - RelativePointer imp; - }; - // ... -} -``` - -`IMP` 代表函数的具体实现 - -```c -typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); -``` - -`SEL` 代表方法、函数名,一般叫做选择器,底层结构跟 `char *` 类似。本质上是一个指向方法名 C 字符串的指针。 - -```c -typedef struct objc_selector *SEL; -``` - -- 可以通过 `@selector()` 和 `sel_registerName()` 获得 - -- 可以通过 `sel_getName()` 和 `NSStringFromSelector()` 转成字符串 - -- 不同类中相同名字的方法,所对应的方法选择器是相同的。也就是 SEL 不具备唯一性,方法命名需要规范,否则 runtime 调用起来就会发生不符合预期的行为。 - -`types` 包含了函数返回值、参数编码的字符串。`返回值|参数1|参数2| ... | 参数n` - - - -### Type Encoding - -iOS 中提供了一个叫做 `@encode` 的指令,可以将具体的类型表示成字符串编码 - -```objective-c -NSLog(@"%s", @encode(int)); -NSLog(@"%s", @encode(id)); -NSLog(@"%s", @encode(void)); -NSLog(@"%s", @encode(SEL)); -NSLog(@"%s", @encode(Person)); -// console -i -@ -v -: -{Person=#} -``` - -可以对照下面的表格进行查看: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-method-encoding.png) - -```objectivec -- (int)calcuate:(int)baseHeight heigith:(float)height; -``` - -比如这个方法的 `type encoding` 为 `i24@0:8i16f20` - -解读下,上面的方法其实携带了2个基础参数,`(id)self _cmd:(SEL)_cmd` : - -- `i` 代表方法返回值为 int - -- `24` 代表参数共占24个字节大小。4个参数分别为 id 类型的 `self`、`SEL` 类型的 `_cmd`, int 类型的 age、float 类型的 height。8+8+4+4 共24个字节(id、SEL 都为指针,长度为8) - -- `@` 代表第一个参数为 id 类型,从第0个字节开始,即 self - -- `:`代表第二个参数为 SEL,从第8个字节开始 - -- `i` 代表第三个参数为 int,从第16个字节开始,占4个字节 - -- `f` 代表第四个参数为 float,从第20个字节开始 - - - -### 方法缓存 - -`Class` 内部结构中有个方法缓存(cache_t),用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度 - -- 用于快速查找方法执行函数 -- 是可增量拓展的哈希表结构 -- 是**局部性原理**的最佳应用(一般调用方法的时候,并不会每个方法都调用,一般来说可能会调用某个类的某几个方法,这几个方法每调用过1次,就缓存起来,下次再去调用的时候,就省去了 runtime 中方法查找的流程,提高效率) - -```c++ -/// Represents an instance of a class. -struct objc_object { - Class _Nonnull isa OBJC_ISA_AVAILABILITY; -}; - -struct objc_class : objc_object { - // Class ISA; - // ... - Class superclass; - cache_t cache; // formerly cache pointer and vtable - class_data_bits_t bits; -} -``` - -调用方法的本质: - -- 比如说对象方法,先根据对象的 isa 找到类对象,在 arm64 下,isa 是非指针的 union,利用64位中的不同位存储不同信息,比如用33位来存储类对象地址、元类对象地址信。用 `ISA_MASK` 来提取类对象的真实地址 -- 在类对象的 `method_list_t` 类型的 methods 方法数组(Array 中的元素是方法 Array)中(类的Category1、类的 Category2... 类自身的方法)查找方法 -- 找不到则调用 superclass 查找父类的 methods 方法数组(Array 中的元素是方法 Array),看是否存在方法。找不到则继续在当前类的 superclass 继续向上查找... -- 假设某个类的方法调用 `[Worker live]`,需要通过 superclass 查找8次,每次都是在方法的“二维数组”里遍历。某个逻辑就需要调用 `[Worker live]`6次,每次需要8次二维数组的遍历,6*8= 48次,可想而知,效率低下。 - -所以为了方便,给类设置了**方法缓存**。比如调用 Worker 对象的 live 方法,通过 isa 找到元类对象,元类对象中不存在,则通过 superclass 找父类的元类对象, 不断找,假设经历了8次 superclass,最后在 Person 类中找到了,则将 Person 类中的 eat 方法缓存在 Worker 的 `cache_t` 类型的 cache 中。 - - - -#### Cache 完整流程 - -1. 通过实例的 `isa` 指针找到类对象 - - 实例方法存储在类对象中,类方法存储在元类中。方法调用时,首先通过实例的 `isa` 指针定位到类对象。 - -2. 找类对象的 `cache` - - - 检查类对象的方法缓存 `cache`(哈希表结构),若找到方法实现(IMP),直接调用并结束流程。 - - - **缓存目的**:哈希表查找时间复杂度为 O(1),避免频繁方法查找的性能损耗。 - -3. 查找类对象的 `method_list_t` 方法列表 - - - 若 `cache` 未命中,遍历类对象的 `method_list_t`(方法列表)。 - - - **方法列表结构**: - - 由多个方法数组构成,顺序为:**后加载的 Category 方法在前,类自身方法在后**(例如:Category2 → Category1 → 类原始方法)。 - - 这是 Runtime 在启动时合并 `class_ro_t`(只读数据)到 `class_rw_t`(可读写数据)时确定的,后编译的 Category 方法会插入到列表前端,覆盖同名方法。 - - - **命中缓存**:若在方法列表中找到方法,将其**缓存到当前类对象的 `cache`** 中,随后调用方法。 - -4. 沿继承链向父类逐级查找 - - 若当前类未找到方法,通过 `superclass` 指针查找父类,重复以下步骤: - - - 查找父类 `cache`:若父类 `cache` 命中,将方法**缓存到最初类对象的 `cache`**(即子类的 `cache`),调用方法 - - 如果 cache 中 没有找到,则查找父类 `method_list_t`:若父类方法列表命中,同样**缓存到子类的 `cache`**,调用方法 - - 递归查找:若未找到,继续向更上层父类查找... - - 直到根类(`NSObject`),如果 NSObject 的 cache 中找到方法缓存则执行方法,且把方法在当前的子类的 cache 中缓存一份。如果没找到则继续在 method_list_t 中查找,找到也继续在当前的子类 cache 中缓存一份 - -5. 若当前类未找到方法,通过 `superclass` 指针查找父类,重复上述步骤:,未找到方法时触发消息转发若继承链全部查找未果,进入动态消息解析流程: - - - 动态方法解析(`+resolveInstanceMethod:` 或 `+resolveClassMethod:`): - - 快速消息转发(`-forwardingTargetForSelector:`):将消息转发给其他对象处理。 - - 慢速消息转发(`-methodSignatureForSelector:` 和 `-forwardInvocation:`):创建 `NSInvocation` 对象,可修改参数、目标、方法等 - - 如果还没处理,则报标准的 “unrecognized selector sent to instance” 错误,导致程序崩溃。 - - - -#### 源码剖析 - -为什么说空间换时间? - -假设 Person 类存在 test1、test2 方法。经过运算后 test1 需要存储在 bucket_t 数组的第8个位置,test2 存储在第5个位置,其他的位置都是 NULL。也就是预留了一些闲置的空间。存储的时候都会经过 hash 运算。效率会很高。空间换时间的实现。 - -```c -struct cache_t { - struct bucket_t *_buckets; // 散列表 -> | bucket_t |bucket_t |bucket_t |bucket_t |... - mask_t _mask; // 散列表的长度 -1 - mask_t _occupied; // 已经缓存的方法数量 -} -``` - -```c -struct bucket_t { - cache_key_t _key; // SEL 作为 key - IMP _imp; // 函数的内存地址 -} -``` - -方法缓存查找原理,就是利用散列表(哈希表)查找。涉及:空间换时间,哈希表拓容策略,哈希碰撞算法 - -objc4 源码 `objc-cache.mm` - -```c++ -typedef uintptr_t cache_key_t; - -// 根据 SEL 计算方法缓存 key 就是根据 SEL(方法名 C 字符串的指针)转换为一个用于存储指针的整数值(这个类型是一个无符号整数类型,uintptr_t 提供了一种方式来将指针转换为一个整数,以及将整数转换回指针) -cache_key_t getKey(SEL sel) -{ - assert(sel); - return (cache_key_t)sel; -} - -void cache_t::insert(SEL sel, IMP imp, id receiver) -{ - runtimeLock.assertLocked(); - - // 避免在类完成初始化之前,修改方法缓存 - // Never cache before +initialize is done - if (slowpath(!cls()->isInitialized())) { - return; - } - - if (isConstantOptimizedCache()) { - _objc_fatal("cache_t::insert() called with a preoptimized cache for %s", - cls()->nameForLogging()); - } - -#if DEBUG_TASK_THREADS - return _collecting_in_critical(); -#else -#if CONFIG_USE_CACHE_LOCK - mutex_locker_t lock(cacheUpdateLock); -#endif - - ASSERT(sel != 0 && cls()->isInitialized()); - - // Use the cache as-is if until we exceed our expected fill ratio. - mask_t newOccupied = occupied() + 1; - unsigned oldCapacity = capacity(), capacity = oldCapacity; - if (slowpath(isConstantEmptyCache())) { - // Cache is read-only. Replace it. - if (!capacity) capacity = INIT_CACHE_SIZE; - reallocate(oldCapacity, capacity, /* freeOld */false); - } - else if (fastpath(newOccupied + CACHE_END_MARKER <= cache_fill_ratio(capacity))) { - // Cache is less than 3/4 or 7/8 full. Use it as-is. - } -#if CACHE_ALLOW_FULL_UTILIZATION - else if (capacity <= FULL_UTILIZATION_CACHE_SIZE && newOccupied + CACHE_END_MARKER <= capacity) { - // Allow 100% cache utilization for small buckets. Use it as-is. - } -#endif - else { - capacity = capacity ? capacity * 2 : INIT_CACHE_SIZE; - if (capacity > MAX_CACHE_SIZE) { - capacity = MAX_CACHE_SIZE; - } - reallocate(oldCapacity, capacity, true); - } - - bucket_t *b = buckets(); - mask_t m = capacity - 1; - mask_t begin = cache_hash(sel, m); - mask_t i = begin; - - // Scan for the first unused slot and insert there. - // There is guaranteed to be an empty slot. - do { - if (fastpath(b[i].sel() == 0)) { - // 找到合适的位置,插入新的 method_t - incrementOccupied(); - b[i].set(b, sel, imp, cls()); - return; - } - if (b[i].sel() == sel) { - // SEL 已经存在,被其他线程加入 - // The entry was added to the cache by some other thread - // before we grabbed the cacheUpdateLock. - return; - } - // while 条件满足一次,则尝试插入一次。没有插入成功,则继续尝试 while,意味着发生了哈希冲突,开放定址法,线性尝试 index - } while (fastpath((i = cache_next(i, m)) != begin)); - - bad_cache(receiver, (SEL)sel); -#endif // !DEBUG_TASK_THREADS -} - -void cache_t::copyCacheNolock(objc_imp_cache_entry *buffer, int len) -{ -#if CONFIG_USE_CACHE_LOCK - cacheUpdateLock.assertLocked(); -#else - runtimeLock.assertLocked(); -#endif - int wpos = 0; - -#if CONFIG_USE_PREOPT_CACHES - if (isConstantOptimizedCache()) { - auto cache = preopt_cache(); - auto mask = cache->mask; - uintptr_t sel_base = objc_opt_offsets[OBJC_OPT_METHODNAME_START]; - uintptr_t imp_base = (uintptr_t)&cache->entries; - - for (uintptr_t index = 0; index <= mask && wpos < len; index++) { - auto &ent = cache->entries[index]; - if (~ent.sel_offs) { - buffer[wpos].sel = (SEL)(sel_base + ent.sel_offs); - buffer[wpos].imp = (IMP)(imp_base - ent.imp_offset()); - wpos++; - } - } - return; - } -#endif - { - bucket_t *buckets = this->buckets(); - uintptr_t count = capacity(); - - for (uintptr_t index = 0; index < count && wpos < len; index++) { - if (buckets[index].sel()) { - buffer[wpos].imp = buckets[index].imp(buckets, cls()); - buffer[wpos].sel = buckets[index].sel(); - wpos++; - } - } - } -} - -bucket_t * cache_t::find(cache_key_t k, id receiver) -{ - assert(k != 0); - - bucket_t *b = buckets(); - mask_t m = mask(); - mask_t begin = cache_hash(k, m); // 该方法实现就是 `key & mask`,按位与来计算哈希索引 - mask_t i = begin; - do { - // 从 buckets 里的 beign 位置开始寻找方法缓存,如果找到则返回 buckets[i] - if (b[i].key() == 0 || b[i].key() == k) { - return &b[i]; - } - // while 的一个为true则代表初始化找到的哈希索引位置没有找到,意味着发生了哈希冲突,则用开放定址法去查找下一个可能的位置。调用的方法是 cache_next - } while ((i = cache_next(i, m)) != begin); - - // hack - Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache)); - cache_t::bad_cache(receiver, (SEL)k, cls); -} - - -// 哈希冲突的时候,调用 cache_next 方法来寻找合适的 index。是标准的开放定址法 -#if CACHE_END_MARKER -static inline mask_t cache_next(mask_t i, mask_t mask) { - return (i+1) & mask; -} -#elif __arm64__ -static inline mask_t cache_next(mask_t i, mask_t mask) { - return i ? i-1 : mask; -} -#else -``` - -可以看到在查找方法缓存的时候: - -- 首先根据缓存 key,利用 `cache_hash` 方法计算出一个 begin,赋值给 i -- 然后利用 i 在方法缓存 `cache_t` 的 `buckets` 中查找,假设 key 相等,则直接返回对应的方法 -- 如果没有找到则执行 `cache_next` 方法,该方法则会判断 i 是不是等于0,不等于0则自减1,等于0则设置为 mask 的值 -- mask 值,在设计上等于 `buckets` 散列表的长度减1。 - - 1. 任何一个值,与一个 mask 按位与之后的结果,一定小于等于 mask 的值。比如: - - ```shell - 1. 按位与之后,最大的等于自己的值本身 - 0x 1000 1001 - & 0x 1111 1111 - ----------------- - 0x 1000 10001 - - - 2. 按位与之后,结果小于自己的值本身 - 0x 1111 0001 - & 0x 0000 0001 - ----------------- - 0x 0000 0001 - - 0x 1111 0001 - & 0x 0000 1111 - ----------------- - 0x 0000 1111 - ``` - - 2. 所以哈希函数设计位:方法选择器 `SEL` 的指针地址(`uintptr_t` 类型)作为 key,与 `_mask` 按位与的结果。其中 `_mask` 为当前散列表的长度减1 - - - -散列表长度不够了,则会哈希拓容(),此时之前存储的方法缓存则会被释放,执行 `cache_collect_free` - -```c -void cache_t::expand() -{ - cacheUpdateLock.assertLocked(); - uint32_t oldCapacity = capacity(); - // 扩容,容量变为原来的2倍大小 - uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE; - if ((uint32_t)(mask_t)newCapacity != newCapacity) { - // mask overflow - can't grow further - // fixme this wastes one bit of mask - newCapacity = oldCapacity; - } - reallocate(oldCapacity, newCapacity); -} - -void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity) -{ - bool freeOld = canBeFreed(); - bucket_t *oldBuckets = buckets(); - bucket_t *newBuckets = allocateBuckets(newCapacity); - // Cache's old contents are not propagated. - // This is thought to save cache memory at the cost of extra cache fills. - // fixme re-measure this - assert(newCapacity > 0); - assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1); - setBucketsAndMask(newBuckets, newCapacity - 1); - if (freeOld) { - // 释放之前的方法缓存 - cache_collect_free(oldBuckets, oldCapacity); - cache_collect(false); - } -} -``` - -哈希查找元素核心是一个求 key 的过程,Java 中是求余,iOS 中是按位与 `key & mask`。 - -```c -static inline mask_t cache_hash(cache_key_t key, mask_t mask) -{ - return (mask_t)(key & mask); -} -``` - -空间换时间的一个实现。且按位与的特点,`(key & mask)` 的结果一定比 mask 值小。 - - - -#### 模拟方法缓存工作流程 - -举个例子,来直观了解下 cache 的工作流程: - -假设初始缓存容量为 **4**(`_mask = 3`),逐步插入 `test1`、`test2`、`test3`、`test4` 方法后触发扩容,最终容量变为 **8**(`_mask = 7`)。 - -1. 初始状态,散列表容量为4,`_mask` =3,_occupied = 0,目前没有方法写入缓存。散列桶数组为空 - - ```shell - buckets[0]: null - buckets[1]: null - buckets[2]: null - buckets[3]: null - ``` - -2. 不断插入方法: - - - 假设 test1 的 sel 的 uintptr_t 为5,计算哈希索引 `5 & 3 = 1` - - ```shell - 0b 0000 0101 - & 0b 0000 0011 - --------------- - 0b 0000 0001 - ``` - - 所以插入索引为1的位置。buckets[1] = test1, _occupied= 1 - - ```shell - buckets[0]: null - buckets[1]: test1 - buckets[2]: null - buckets[3]: null - ``` - - - 假设 test2 的 sel 的 uintptr_t 为7,计算哈希索引 `7 & 3 = 3`。所以插入索引为3的位置。buckets[3] = test2, _occupied= 2 - - ``` - buckets[0]: null - buckets[1]: test1 - buckets[2]: null - buckets[3]: test2 - ``` - - - 假设 test3 的 sel 的 uintptr_t 为9,计算哈希索引 `9 & 3 = 1`,发现位置1被占用了,此时采用开放定址法寻找合适的位置(也就是哈希冲突的解法)。比如先从1开始尝试,index = 2,发现没有被占用,则开放定址的过程终止,则将 test3 放到位置2上 - - ``` - buckets[0]: null - buckets[1]: test1 - buckets[2]: test3 - buckets[3]: test2 - ``` - - 假设哈希函数为: `index = Hash(key) mod _mask` - - 补充说明:**针对哈希冲突的开放定址法一般有:线性探测法、二次探测法、伪随机探测法** - - - 线性探测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为1, 2, 3, ... _mask - 1 构成的线性序列 - - 二次探测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为1^2, 2^2, 3^2, ...直到平方数小于等于 _mask - 1 的数,构成的二次序列 - - 伪随机数测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为伪随机数,构成的二次序列 - - 所以插入索引为3的位置。buckets[3] = test2, _occupied= 2 - - - 假设 test4 的 sel uintptr_t 为11,计算哈希索引 `11 & 3 = 3`,发现位置3被占用了,此时采用开放定址法寻找合适的位置(也就是哈希冲突的解法)。比如先从1开始尝试,index = 0,发现没有被占用,则将 test4 放到位置0上 - - ``` - buckets[0]: test4 - buckets[1]: test1 - buckets[2]: test3 - buckets[3]: test2 - ``` - - 此时,`_occupied = 4`,此时 `_occupied > 3/4 * capacity`(`3/4 * 4 = 3`),**触发扩容**。 - -3. 扩容与哈希重建 - - 新容量:capacity = 8, _mask = 7 - - 创建新桶 - - ```shell - buckets[0]: null - buckets[1]: null - buckets[2]: null - buckets[3]: null - buckets[4]: null - buckets[5]: null - buckets[6]: null - buckets[7]: null - ``` - - 安装上述哈希方法,重新计算位置并保存到桶中 - - | 方法 | 旧索引 | 新索引计算(哈希值 & 7) | - | ----- | ------ | ------------------------ | - | test1 | 1 | 5&7 = 5 | - | test2 | 3 | 7&7 =7 | - | test3 | 2 | 9 & 7 = 1 | - | Test4 | 0 | 11 & 7 = 3 | - - 桶的最新状态 - - ``` - buckets[0]: null - buckets[1]: test3 - buckets[2]: null - buckets[3]: test4 - buckets[4]: null - buckets[5]: test1 - buckets[6]: null - buckets[7]: test2 - ``` - - 更新缓存结构: - - - _buckets 指向新的桶数组 - - _mask = 7 - - _occupied = 4 - -4. 读取方法流程 - - - 从缓存中查找 test1 方法,假设 test1 的 sel 的 uintptr_t 为5,计算哈希索引 `5 & 7 = 5`,则返回桶中 buckets[5] 的 method_t - - 从缓存中查找 test2 方法,假设 test2 的 sel 的 uintptr_t 为7,计算哈希索引 `7 & 7 = 7`,则返回桶中 buckets[7] 的 method_t - - 从缓存中查找 test3 方法。假设 test3 的 sel 的 uintptr_t 为9,计算哈希索引 `9 & 7 = 1`,则返回桶中 buckets[1] 的 method_t - - 从缓存中查找 test4 方法,假设 test4 的 sel 的 uintptr_t 为11,计算哈希索引 `11 & 7 = 3`,则返回桶中 buckets[3] 的 method_t - -结论: - -1. 存储位置动态变化:扩容后方法的存储位置会发生变化(如 `test3` 从旧索引 2 → 新索引 1),但通过 **重新哈希**,所有方法被重新分配到新容量的正确位置。 -2. 查找逻辑一致性:无论缓存是否扩容,查找时始终使用 **当前的 `_mask`** 计算索引,确保能正确命中方法。 -3. 性能与空间的平衡:空间换时间 - - **扩容代价**:重新哈希需要遍历旧桶,时间复杂度为 O(n),但扩容频率随容量指数级降低。 - - **查找效率**:平均时间复杂度接近 O(1),即使位置变化也不影响性能。 - -设计哲学: - -- 以空间换时间:通过扩容减少哈希冲突,牺牲内存换取更快的查找速度。 -- 惰性扩容:仅在负载因子超过阈值时扩容,避免频繁内存分配(标准做法,没有哪个成熟方案是随便扩容的) -- 幂等性保证:无论扩容多少次,方法的最终存储位置始终由 `SEL & _mask` 决定,确保逻辑正确。 - -通过这种设计,Objective-C 的方法缓存在高频调用场景下依然能保持极速响应,同时动态适应方法数量的增长 - - - -实验:查找类的方法缓存 Demo - -```objective-c -GoodStudent *goodStudent = [[GoodStudent alloc] init]; -mock_objc_class *personClass = (__bridge mock_objc_class *)[GoodStudent class]; -[goodStudent goodStudentSay]; // 断点1 -[goodStudent studentSay]; // 断点2 -[goodStudent personSay]; // 断点3 -NSLog(@""); -``` - -流程: - -断点1的地方可以看到 `mock_objc_class` 结构体 `cache` 的 `_occupied` 为1,`_mask` 为3,初始化哈希表长度为4 - -在断点1的地方,`_occupied` 为1则代表只有 init 方法被缓存,本行代码执行完,`_occupied` 为2. - -在断点2的地方,`_occupied` 为2则代表只有 init、goodStudentSay 方法被缓存。本行代码执行完,`_occupied` 为3 - -在断点3的地方,`_occupied` 为3则代表只有 init 、goodStudentSay 、studentSay方法被缓存。本行代码执行完,`_occupied` 为1,且 `_mask` 为7。 - -奇了怪了,为什么 `_occupied`为1,且`_mask` 为7? - -因为哈希表长度为4,缓存3个方法后,到第4个方法需要缓存的时候会执行哈希表拓容,缓存会失效。拓容策略为乘以2 即 `uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;` 所以长度为8,mask 为`长度-1` ,则为7,第4个方法刚好被缓存下来,`_occupied` 为1。 - -```c -void cache_t::expand() -{ - cacheUpdateLock.assertLocked(); - uint32_t oldCapacity = capacity(); - uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE; - if ((uint32_t)(mask_t)newCapacity != newCapacity) { - // mask overflow - can't grow further - // fixme this wastes one bit of mask - newCapacity = oldCapacity; - } - reallocate(oldCapacity, newCapacity); -} -``` - -继续运行 - -在断点4的地方,`_occupied` 为1则代表只有 personSay方法被缓存。本行代码执行完,`_occupied` 为2,且 `_mask` 为7。 - -在断点5的地方,`_occupied` 为2则代表只有 personSay、goodStudentSay 方法被缓存。本行代码执行完,`_occupied` 为3,且 `_mask` 为7。 - -在断点6的地方,`_occupied` 为3则代表只有 personSay、goodStudentSay、studentSay 方法被缓存, `_mask` 为7。 - - - -#### 如何根据方法散列表查找某个方法 - -所有的方法都在 buckets 数组里。所以问题转换为:如何根据方法计算出在 bucktes 中的索引? - -本质上就是考察对于核心原理和源码的理解程度: `bucket_t goodStudentSayBucket = buckets[(uintptr_t)@selector(personSay) & cache._mask]` - -```objectivec -GoodStudent *goodStudent = [[GoodStudent alloc] init]; -mock_objc_class *personClass = (__bridge mock_objc_class *)[GoodStudent class]; -[goodStudent goodStudentSay]; -[goodStudent studentSay]; -[goodStudent personSay]; - -cache_t cache = personClass->cache; -bucket_t *buckets = cache._buckets; -// for (int i = 0; i <= cache._mask; i++) { -// bucket_t bucket = buckets[i]; -// NSLog(@"%s -- %p", (SEL)bucket._key, bucket._imp); -// } -bucket_t goodStudentSayBucket = buckets[(uintptr_t)@selector(personSay) & cache._mask]; -NSLog(@"%s %p", goodStudentSayBucket._key, goodStudentSayBucket._imp); -``` - - - - - - - -原理就是根据类对象结构体找到 cache 结构体,cache 结构体内部的 `_buckets` 是一个方法散列表,查看源代码,根据散列表的哈希寻找策略 `(key & mask)` 找到哈希索引,然后找到方法对象 bucket,其中寻找方法索引的 key 就是方法 selector。 - -```c -static inline mask_t cache_hash(cache_key_t key, mask_t mask) -{ - return (mask_t)(key & mask); -} -``` - -注意:上述的模拟只是实现了系统哈希计算后查找 cache 的一半流程。还剩一半流程是:当哈希冲突的时候,计算出的 index 里存储的是另一个方法信息。此时就需要利用开放定植法去尝试合适的索引。 - -系统代码如下: - -```c++ -mask_t begin = cache_hash(k, m); // 该方法实现就是 `key & mask`,按位与来计算哈希索引 -mask_t i = begin; // 初始先从该位置获取 cache 里的方法,做比较 - -// 如果不满足,则利用 do...while 实现的开放定址法寻找方法缓存 -do { - // 从 buckets 里的 beign 位置开始寻找方法缓存,如果找到则返回 buckets[i] - if (b[i].key() == 0 || b[i].key() == k) { - return &b[i]; - } - // while 的一个为true则代表初始化找到的哈希索引位置没有找到,意味着发生了哈希冲突,则用开放定址法去查找下一个可能的位置。调用的方法是 cache_next -} while ((i = cache_next(i, m)) != begin); -``` - - - -`[student live]`当调用对象方法的时候。会根据对象的 isa 指针,找到类对象,然后在类对象的方法缓存中查找有没有方法实现 - -- 如果找到了则立马执行 -- 如果缓存中没有方法实现,则会在 class_rw_t 的 methods 里面查找有没有方法实现 - - 如果找到了则将方法更新到方法缓存中去,然后立马执行(查找的过程则是根据 SEL 的方法名 C 字符串指针地址值与 _mask 按位与运算,然后在方法缓存的哈希表中查找 比如 `cache.buckets[@selector(methodName) & _mask]` ) - - 如果此时还是找不到。则根据类对象的 superclass 指针查找父类的类对象。到父类的类对象这一步,也先从方法缓存中查找有没有方法缓存: - - 如果找到了,则立马执行,同时将方法缓存到子类自己的缓存中去。下次调用,则直接在子类自己的方法缓存中查找即可 - - 如果没找到,则继续在 class_rw_t 的 methods 中查找方法实现。 - 1. 如果找到了,则更新到子类类对象的方法缓存中,然后执行方法 - 2. 如果没找到,则继续沿着父类类对象的 superclass 指针,继续往上找,查找流程和上面的步骤类似 -- 如果一直找到根类,还是找不到则开始走消息机制... - - - -#### 散列表的设计哲学 - -阅读了方法缓存相关的源码,可以看到 Cache 的实现就是一个散列表。因为底层源码需要保证高性能,所以采用时间换空间的策略。 - -利用散列表的散列函数,来实现快速计算存储和访问所需的 index。但是带来的一个问题是散列表可能会有一些闲置空间;且散列表计算出来的位置可能会冲突,所以需要哈希碰撞策略(比如开放定址法和拉链法);另外散列表容量快满的时候则需要哈希扩容。 - -有个压力位的设计:例如 Apple OC 对于方法缓存的压力位就是 `_capacity * 3 / 4`。 - -- 当方法缓存的 `_occupied`(已占用的槽位数)大于等于 `_capacity * 3 / 4` 时,就会触发扩容操作。 -- 种设计是为了在缓存的查找效率和存储空间之间取得平衡。当缓存的占用率达到 3/4 时,意味着缓存的使用较为频繁,并且继续添加新缓存可能会导致较多的哈希冲突,从而降低查找效率。此时进行扩容可以有效地减少哈希冲突,提高后续方法查找的速度。 - - - -**针对哈希冲突的开放定址法一般有:线性探测法、二次探测法、伪随机探测法** - -- 线性探测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为1, 2, 3, ... _mask - 1 构成的线性序列 -- 二次探测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为1^2, 2^2, 3^2, ...直到平方数小于等于 _mask - 1 的数,构成的二次序列 -- 伪随机数测法就是:`index = (Hash(key) + di) mod _mask`,其中 di 为伪随机数,构成的二次序列 - - - -## Runtime - objc_msgSen - - - - - -```c++ -Person *p = [[Person alloc] init]; -[p eat]; -objc_msgSend(p, sel_registerName("eat")); - -[Person sayHi]; -objc_msgSend(object_getClass("Person"), sel_registerName("sayHi")); -``` - -oc 方法(对象方法、类方法)调用本质就是 `objc_msgSend` 方法。`objc_msgSend` 可以分为3个阶段: - -- 消息发送 - -- 动态方法解析 - -- 消息转发 - -这3个阶段,还是没调用成功则抛出错误:`unrecognized selector sent to instance 0x600000ad0260` - -查看源码 `objc-msg-arm64.s` - -```shell -ENTRY _objc_msgSend - UNWIND _objc_msgSend, NoFrame - MESSENGER_START -    // x0 寄存器代表消息接受者,receiver。objc_msgSend(person, sel_registerName("eat")) 的 person - cmp x0, #0 // nil check and tagged pointer check - // b 代表指令跳转。le 代表 小于等于。<=0则跳转到 LNilOrTagged - b.le LNilOrTagged // (MSB tagged pointer looks negative) - ldr x13, [x0] // x13 = isa // ldr 代表加载指令。这里的意思是将 x0 寄存器信息写入到 x13中 - and x16, x13, #ISA_MASK // x16 = class // 这里就是将 x13 与 ISA_MASK 按位与,然后得到真实的 isa 信息,然后写入到 x16 中 -LGetIsaDone: - CacheLookup NORMAL // calls imp or objc_msgSend_uncached // 这里执行 objc_msgSend_uncached 逻辑,CacheLookup 是一个汇编宏,看下面的说明 - -LNilOrTagged: - // 判断为 nil 则跳转到 LReturnZero - b.eq LReturnZero // nil check - - // tagged - mov x10, #0xf000000000000000 - cmp x0, x10 - b.hs LExtTag - adrp x10, _objc_debug_taggedpointer_classes@PAGE - add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF - ubfx x11, x0, #60, #4 - ldr x16, [x10, x11, LSL #3] - b LGetIsaDone - -LExtTag: - // ext tagged - adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE - add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF - ubfx x11, x0, #52, #8 - ldr x16, [x10, x11, LSL #3] - b LGetIsaDone - -LReturnZero: - // x0 is already zero - mov x1, #0 - movi d0, #0 - movi d1, #0 - movi d2, #0 - movi d3, #0 - MESSENGER_END_NIL - // 汇编中 ret 代表 return - ret - - END_ENTRY _objc_msgSend - - -.macro CacheLookup // 汇编宏,可以看到根据 (SEL & mask) 来寻找真正的方法地址 - // x1 = SEL, x16 = isa - ldp x10, x11, [x16, #CACHE] // x10 = buckets, x11 = occupied|mask - and w12, w1, w11 // x12 = _cmd & mask - add x12, x10, x12, LSL #4 // x12 = buckets + ((_cmd & mask)<<4) - // 可以看到 cache.buckets[_cmd & mask] == SEL 就是在做方法缓存判断逻辑,看看有没有命中方法缓存 - ldp x9, x17, [x12] // {x9, x17} = *bucket -1: cmp x9, x1 // if (bucket->sel != _cmd) - b.ne 2f // scan more - CacheHit $0 // call or return imp - -2: // not hit: x12 = not-hit bucket - CheckMiss $0 // miss if bucket->sel == 0 - cmp x12, x10 // wrap if bucket == buckets - b.eq 3f - ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket - b 1b // loop - -3: // wrap: x12 = first bucket, w11 = mask - add x12, x12, w11, UXTW #4 // x12 = buckets+(mask<<4) - - // Clone scanning loop to miss instead of hang when cache is corrupt. - // The slow path may detect any corruption and halt later. - - ldp x9, x17, [x12] // {x9, x17} = *bucket -1: cmp x9, x1 // if (bucket->sel != _cmd) - b.ne 2f // scan more - CacheHit $0 // call or return imp - // cachehit 就是命中缓存,则调用或者 return imp -2: // not hit: x12 = not-hit bucket - // checkmiss,就是缓存没命中,也就是方法查找失败,则走 checkMiss 逻辑,具体看下面 - CheckMiss $0 // miss if bucket->sel == 0,NORMAL - cmp x12, x10 // wrap if bucket == buckets - b.eq 3f - ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket - b 1b // loop -3: // double wrap - JumpMiss $0 - -.endmacro - -// CheckMiss 汇编宏,上面传入了参数 Normal,内部走 __objc_msgSend_uncached 流程。不同参数,走不同的方法调用逻辑 -.macro CheckMiss - // miss if bucket->sel == 0 -.if $0 == GETIMP - cbz x9, LGetImpMiss -.elseif $0 == NORMAL - cbz x9, __objc_msgSend_uncached -.elseif $0 == LOOKUP - cbz x9, __objc_msgLookup_uncached -.else -.abort oops -.endif -.endmacro - - -// __objc_msgSend_uncached 内部其实走 MethodTableLookup 逻辑 -STATIC_ENTRY __objc_msgSend_uncached -UNWIND __objc_msgSend_uncached, FrameWithNoSaves - -// THIS IS NOT A CALLABLE C FUNCTION -// Out-of-band x16 is the class to search - -MethodTableLookup -br x17 - -END_ENTRY __objc_msgSend_uncached - -// MethodTableLookup 是一个汇编宏,内部指令跳转到 __class_lookupMethodAndLoadCache3。 -.macro MethodTableLookup - - // push frame - stp fp, lr, [sp, #-16]! - mov fp, sp - - // save parameter registers: x0..x8, q0..q7 - sub sp, sp, #(10*8 + 8*16) - stp q0, q1, [sp, #(0*16)] - stp q2, q3, [sp, #(2*16)] - stp q4, q5, [sp, #(4*16)] - stp q6, q7, [sp, #(6*16)] - stp x0, x1, [sp, #(8*16+0*8)] - stp x2, x3, [sp, #(8*16+2*8)] - stp x4, x5, [sp, #(8*16+4*8)] - stp x6, x7, [sp, #(8*16+6*8)] - str x8, [sp, #(8*16+8*8)] - - // receiver and selector already in x0 and x1 - mov x2, x16 - bl __class_lookupMethodAndLoadCache3 - - // imp in x0 - mov x17, x0 - - // restore registers and return - ldp q0, q1, [sp, #(0*16)] - ldp q2, q3, [sp, #(2*16)] - ldp q4, q5, [sp, #(4*16)] - ldp q6, q7, [sp, #(6*16)] - ldp x0, x1, [sp, #(8*16+0*8)] - ldp x2, x3, [sp, #(8*16+2*8)] - ldp x4, x5, [sp, #(8*16+4*8)] - ldp x6, x7, [sp, #(8*16+6*8)] - ldr x8, [sp, #(8*16+8*8)] - - mov sp, fp - ldp fp, lr, [sp], #16 - -.endmacro -``` - -Tips:c 方法在汇编中使用的时候,需要在方法名前加 `_` 。所以在汇编中某个方法为 `_xxx`,则在其他地方查找实现,需要去掉 `_`。 -此时 `__class_lookupMethodAndLoadCache3` 在汇编中没有实现,则去掉一个 `_` 按照 `_class_lookupMethodAndLoadCache3` 在非汇编代码中查找 - -```c -IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) -{ - return lookUpImpOrForward(cls, sel, obj, - YES/*initialize*/, NO/*cache*/, YES/*resolver*/); -} - -IMP lookUpImpOrForward(Class cls, SEL sel, id inst, - bool initialize, bool cache, bool resolver) -{ - IMP imp = nil; - bool triedResolver = NO; - - runtimeLock.assertUnlocked(); - - // Optimistic cache lookup - // 首先从方法缓存中查找,如果找到则 return imp,也就是把方法地址返回给 bl 指令 - if (cache) { - imp = cache_getImp(cls, sel); - if (imp) return imp; - } - - // runtimeLock is held during isRealized and isInitialized checking - // to prevent races against concurrent realization. - - // runtimeLock is held during method search to make - // method-lookup + cache-fill atomic with respect to method addition. - // Otherwise, a category could be added but ignored indefinitely because - // the cache was re-filled with the old value after the cache flush on - // behalf of the category. - - runtimeLock.read(); - - if (!cls->isRealized()) { - // Drop the read-lock and acquire the write-lock. - // realizeClass() checks isRealized() again to prevent - // a race while the lock is down. - runtimeLock.unlockRead(); - runtimeLock.write(); - - realizeClass(cls); - - runtimeLock.unlockWrite(); - runtimeLock.read(); - } - - if (initialize && !cls->isInitialized()) { - runtimeLock.unlockRead(); - _class_initialize (_class_getNonMetaClass(cls, inst)); - runtimeLock.read(); - // If sel == initialize, _class_initialize will send +initialize and - // then the messenger will send +initialize again after this - // procedure finishes. Of course, if this is not being called - // from the messenger then it won't happen. 2778172 - } - - - retry: - runtimeLock.assertReading(); - // 这里也先从方法缓存中查找,目的是前面查找后,后续又通过 runtime 的方式增加了方法缓存 - // Try this class's cache. - imp = cache_getImp(cls, sel); - if (imp) goto done; // 找到方法则执行 done 里面的逻辑,done 里面也是将 IMP 的地址返回出去 - - // Try this class's method lists. - { - // 通过 getMethodNoSuper_nolock 方法查找 cls 中有没有 SEL 方法 - Method meth = getMethodNoSuper_nolock(cls, sel); - if (meth) { - log_and_fill_cache(cls, meth->imp, sel, inst, cls); - imp = meth->imp; - goto done; - } - } - - // Try superclass caches and method lists. - { - unsigned attempts = unreasonableClassCount(); - for (Class curClass = cls->superclass; - curClass != nil; - curClass = curClass->superclass) - { - // Halt if there is a cycle in the superclass chain. - if (--attempts == 0) { - _objc_fatal("Memory corruption in class list."); - } - - // Superclass cache. - imp = cache_getImp(curClass, sel); - if (imp) { - if (imp != (IMP)_objc_msgForward_impcache) { - // Found the method in a superclass. Cache it in this class. - log_and_fill_cache(cls, imp, sel, inst, curClass); - goto done; - } - else { - // Found a forward:: entry in a superclass. - // Stop searching, but don't cache yet; call method - // resolver for this class first. - break; - } - } - - // Superclass method list. - Method meth = getMethodNoSuper_nolock(curClass, sel); - if (meth) { - log_and_fill_cache(cls, meth->imp, sel, inst, curClass); - imp = meth->imp; - goto done; - } - } - } - - // No implementation found. Try method resolver once. - - if (resolver && !triedResolver) { - runtimeLock.unlockRead(); - _class_resolveMethod(cls, sel, inst); - runtimeLock.read(); - // Don't cache the result; we don't hold the lock so it may have - // changed already. Re-do the search from scratch instead. - triedResolver = YES; - goto retry; - } - - // No implementation found, and method resolver didn't help. - // Use forwarding. - - imp = (IMP)_objc_msgForward_impcache; - cache_fill(cls, sel, imp, inst); - - done: - runtimeLock.unlockRead(); - - return imp; -} -``` - -### 消息发送阶段 - -上面的代码走到 `getMethodNoSuper_nolock` 寻找类里的方法 - -```c -static method_t * -getMethodNoSuper_nolock(Class cls, SEL sel) -{ - runtimeLock.assertLocked(); - - assert(cls->isRealized()); - // fixme nil cls? - // fixme nil sel? - // 这里根据类结构体找到 data(),然后找到 methods (Array 数组,数组元素是方法 Array) - /* - data() 其实就是 class_rw_t* data() { - return (class_rw_t *)(bits & FAST_DATA_MASK); - } - */ - for (auto mlists = cls->data()->methods.beginLists(), - end = cls->data()->methods.endLists(); - mlists != end; - ++mlists) - { - method_t *m = search_method_list(*mlists, sel); - if (m) return m; - } - - return nil; -} - -static method_t *search_method_list(const method_list_t *mlist, SEL sel) -{ - int methodListIsFixedUp = mlist->isFixedUp(); - int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t); - // 排好序则调用 `findMethodInSortedMethodList`,其内部是二分查找实现。 - if (__builtin_expect(methodListIsFixedUp && methodListHasExpectedSize, 1)) { - return findMethodInSortedMethodList(sel, mlist); - } else { - // 没排序则线性查找,顺序依次遍历查找 - // Linear search of unsorted method list - for (auto& meth : *mlist) { - if (meth.name == sel) return &meth; - } - } - -#if DEBUG - // sanity-check negative results - if (mlist->isFixedUp()) { - for (auto& meth : *mlist) { - if (meth.name == sel) { - _objc_fatal("linear search worked when binary search did not"); - } - } - } -#endif - - return nil; -} -static method_t *findMethodInSortedMethodList(SEL key, const method_list_t *list) -{ - assert(list); - - const method_t * const first = &list->first; - const method_t *base = first; - const method_t *probe; - uintptr_t keyValue = (uintptr_t)key; - uint32_t count; - - for (count = list->count; count != 0; count >>= 1) { - probe = base + (count >> 1); - - uintptr_t probeValue = (uintptr_t)probe->name; - - if (keyValue == probeValue) { - // `probe` is a match. - // Rewind looking for the *first* occurrence of this value. - // This is required for correct category overrides. - while (probe > first && keyValue == (uintptr_t)probe[-1].name) { - probe--; - } - return (method_t *)probe; - } - - if (keyValue > probeValue) { - base = probe + 1; - count--; - } - } - - return nil; -} -``` - -`cls->data()->methods.beginLists` 这里根据类结构体调用到 data() 方法,获取到 `class_rw_t` - -```c -class_rw_t *data() { - return bits.data(); -} -``` - -然后通过 `class_rw_t` 找到 methods (Array 数组,数组元素是方法 Array)。内部调用 `search_method_list` 方法。 - -`search_method_list` 方法内部判断方法数组是否排好序 - -- 排好序则调用 `findMethodInSortedMethodList`,其内部是二分查找实现。 - -- 没排序,则线性查找 (Linear search of unsorted method list) - -`getMethodNoSuper_nolock` 执行完则会将方法写入到当前类对象的缓存中,调用 `log_and_fill_cache` 方法 - -```c -static void -log_and_fill_cache(Class cls, IMP imp, SEL sel, id receiver, Class implementer) -{ -#if SUPPORT_MESSAGE_LOGGING - if (objcMsgLogEnabled) { - bool cacheIt = logMessageSend(implementer->isMetaClass(), - cls->nameForLogging(), - implementer->nameForLogging(), - sel); - if (!cacheIt) return; - } -#endif - cache_fill (cls, sel, imp, receiver); // 继续走 cache_fill 逻辑 -} - -void cache_fill(Class cls, SEL sel, IMP imp, id receiver) -{ -#if !DEBUG_TASK_THREADS - mutex_locker_t lock(cacheUpdateLock); - cache_fill_nolock(cls, sel, imp, receiver); // 继续走 cache_fill_nolock 逻辑 -#else - _collecting_in_critical(); - return; -#endif -} - -static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) -{ - cacheUpdateLock.assertLocked(); - - // Never cache before +initialize is done - if (!cls->isInitialized()) return; - - // Make sure the entry wasn't added to the cache by some other thread - // before we grabbed the cacheUpdateLock. - if (cache_getImp(cls, sel)) return; - // 根据类对象/类的元类对象获得方法缓存 cache_t - cache_t *cache = getCache(cls); - //根据方法 key 调用 getKey 方法,获取方法缓存哈希表的 key - cache_key_t key = getKey(sel); - - // Use the cache as-is if it is less than 3/4 full - mask_t newOccupied = cache->occupied() + 1; - mask_t capacity = cache->capacity(); - if (cache->isConstantEmptyCache()) { - // Cache is read-only. Replace it. - cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE); - } - else if (newOccupied <= capacity / 4 * 3) { - // Cache is less than 3/4 full. Use it as-is. - } - else { - // Cache is too full. Expand it. - cache->expand(); - } - - // Scan for the first unused slot and insert there. - // There is guaranteed to be an empty slot because the - // minimum size is 4 and we resized at 3/4 full. - // 根据方法 key 和 receiver 查找方法缓存哈希表 bucket - bucket_t *bucket = cache->find(key, receiver); - if (bucket->key() == 0) cache->incrementOccupied(); - // 将方法缓存 key,方法地址 IMP 保存到方法缓存哈希 bucket 中 - bucket->set(key, imp); -} -``` - -摘出 `lookUpImpOrForward` 方法中的一段代码 - -```c -// Try this class's cache. -imp = cache_getImp(cls, sel); -if (imp) goto done; -// Try this class's method lists. -{ - Method meth = getMethodNoSuper_nolock(cls, sel); - if (meth) { - log_and_fill_cache(cls, meth->imp, sel, inst, cls); - imp = meth->imp; - goto done; - } -} -// Try superclass caches and method lists. -``` - -如果代码没有找到,则不会 `goto` 到 `done`,开始走父类缓存查找逻辑 - -```c -// Try superclass caches and method lists. -{ - unsigned attempts = unreasonableClassCount(); - // for 循环不断查找,找当前类的父类,父类的父类...直到当前类为 nil(一轮查找后当前类 curClass = curClass->superclass)。 - for (Class curClass = cls->superclass; - curClass != nil; - curClass = curClass->superclass) - { - // Halt if there is a cycle in the superclass chain. - if (--attempts == 0) { - _objc_fatal("Memory corruption in class list."); - } - - // Superclass cache. - // 先在父类的方法缓存中查找(根据 sel & mask,得到方法缓存哈希中的 key)`cache_getImp` ,找到则将方法写入到自身类的方法缓存中去 `log_and_fill_cache(cls, imp, sel, inst, curClass);` - imp = cache_getImp(curClass, sel); - if (imp) { - if (imp != (IMP)_objc_msgForward_impcache) { - // Found the method in a superclass. Cache it in this class. - // 找到则将 IMP 写入到当前类 cls 的方法缓存中去,比如调用 [student personSay] 方法,从父类的方法列表找到 personSay,则将 personSay 的 IMP 写入到 student 类的方法缓存中去 - log_and_fill_cache(cls, imp, sel, inst, curClass); - goto done; - } - else { - // Found a forward:: entry in a superclass. - // Stop searching, but don't cache yet; call method - // resolver for this class first. - break; - } - } - - // Superclass method list. - // 如果在父类的方法缓存中没找到,则调用 `getMethodNoSuper_nolock` 父类的 方法数组(Array 元素为方法数组),按照排序好和没排序好分别走二分查找和线性查找。 - Method meth = getMethodNoSuper_nolock(curClass, sel); - if (meth) { - // 如果找到则继续填充到当前类的方法缓存中去 - log_and_fill_cache(cls, meth->imp, sel, inst, curClass); - imp = meth->imp; - goto done; - } - } -} -``` - -上面的流程是整个 `objc_msgSend` 的消息发送阶段的整个流程。可以用下图表示 - - - - - - - -注意: - -当找到方法后,缓存只保存到当前消息调用者,也就是子类的方法 Cache 中去。 - -方法缓存,也叫快速映射表(fast map),即使是快速执行路径(fast path),还是不如“静态绑定的函数调用操作”(statically bound function call)那样迅速,但大多数情况下这不是性能瓶颈。如果真的很在意,可以用纯 c 函数去实现。 - -上述消息发送阶段结束,方法依旧没有找到并执行,则开始进入动态方法解析阶段。 - - - -### 动态方法解析阶段 - -接着查看源码 - -```c -IMP lookUpImpOrForward(Class cls, SEL sel, id inst, - bool initialize, bool cache, bool resolver) -{ - //... - // No implementation found. Try method resolver once. - if (resolver && !triedResolver) { - runtimeLock.unlockRead(); - _class_resolveMethod(cls, sel, inst); - runtimeLock.read(); - // Don't cache the result; we don't hold the lock so it may have - // changed already. Re-do the search from scratch instead. - triedResolver = YES; - goto retry; - } - // ... -} - -void _class_resolveMethod(Class cls, SEL sel, id inst) -{ - if (! cls->isMetaClass()) { - // try [cls resolveInstanceMethod:sel] - _class_resolveInstanceMethod(cls, sel, inst); - } - else { - // try [nonMetaClass resolveClassMethod:sel] - // and [cls resolveInstanceMethod:sel] - _class_resolveClassMethod(cls, sel, inst); - if (!lookUpImpOrNil(cls, sel, inst, - NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) - { - _class_resolveInstanceMethod(cls, sel, inst); - } - } -} -``` - -判断当前类没有走过动态方法解析阶段,则走动态方法解析阶段,调用 `_class_resolveMethod` 方法。 - -内部会判断但前类是不是元类对象、还是类对象走不同逻辑。 - -类对象走 `_class_resolveInstanceMethod` 逻辑 - -```c -static void _class_resolveInstanceMethod(Class cls, SEL sel, id inst) -{ - if (! lookUpImpOrNil(cls->ISA(), SEL_resolveInstanceMethod, cls, - NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) - { - // Resolver not implemented. - return; - } - BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend; - bool resolved = msg(cls, SEL_resolveInstanceMethod, sel); - // Cache the result (good or bad) so the resolver doesn't fire next time. - // +resolveInstanceMethod adds to self a.k.a. cls - IMP imp = lookUpImpOrNil(cls, sel, inst, - NO/*initialize*/, YES/*cache*/, NO/*resolver*/); - - if (resolved && PrintResolving) { - if (imp) { - _objc_inform("RESOLVE: method %c[%s %s] " - "dynamically resolved to %p", - cls->isMetaClass() ? '+' : '-', - cls->nameForLogging(), sel_getName(sel), imp); - } - else { - // Method resolver didn't add anything? - _objc_inform("RESOLVE: +[%s resolveInstanceMethod:%s] returned YES" - ", but no new implementation of %c[%s %s] was found", - cls->nameForLogging(), sel_getName(sel), - cls->isMetaClass() ? '+' : '-', - cls->nameForLogging(), sel_getName(sel)); - } - } -} -``` - -核心是 `bool resolved = msg(cls, SEL_resolveInstanceMethod, sel);` ,也就是动态方法解析阶段,如果是类对象,则会调用类方法 `+ (BOOL)resolveInstanceMethod:(SEL)sel` 方法。 - -元类对象走 `_class_resolveClassMethod` 逻辑 - -```c -static void _class_resolveClassMethod(Class cls, SEL sel, id inst) -{ - assert(cls->isMetaClass()); - - if (! lookUpImpOrNil(cls, SEL_resolveClassMethod, inst, - NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) - { - // Resolver not implemented. - return; - } - - BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend; - bool resolved = msg(_class_getNonMetaClass(cls, inst), - SEL_resolveClassMethod, sel); - - // Cache the result (good or bad) so the resolver doesn't fire next time. - // +resolveClassMethod adds to self->ISA() a.k.a. cls - IMP imp = lookUpImpOrNil(cls, sel, inst, - NO/*initialize*/, YES/*cache*/, NO/*resolver*/); - - if (resolved && PrintResolving) { - if (imp) { - _objc_inform("RESOLVE: method %c[%s %s] " - "dynamically resolved to %p", - cls->isMetaClass() ? '+' : '-', - cls->nameForLogging(), sel_getName(sel), imp); - } - else { - // Method resolver didn't add anything? - _objc_inform("RESOLVE: +[%s resolveClassMethod:%s] returned YES" - ", but no new implementation of %c[%s %s] was found", - cls->nameForLogging(), sel_getName(sel), - cls->isMetaClass() ? '+' : '-', - cls->nameForLogging(), sel_getName(sel)); - } - } -} -``` - -其实就是调用 `bool resolved = msg(_class_getNonMetaClass(cls, inst), -SEL_resolveClassMethod, sel);` - -最后还是走到了 `goto retry;` 继续走完整的消息发送流程(因为添加了方法,所以会按照方法查找再去执行的逻辑) - -完整流程如下 - - - - - -QA: - -方法动态解析后,为什么还要 `goto retry`? - -因为 API 设计阶段只是增加了口子,预留了能力,让开发者可以给类增加对象方法、类方法进行方法的动态解析。 - -如果我们实现了 `+ (BOOL)resolveInstanceMethod:(SEL)sel` 方法或者实现了 `+ (BOOL)resolveClassMethod:(SEL)sel`, - -则根据函数返回值,拿到 YES 标记为已解析,则执行 `goto retry`。此时在一开始查找的时候就可以找到 IMP,此时 `goto done` - -里面返回函数地址到汇编代码中。 - -如果没有实现动态解析方法,则执行 `goto retry` 的时候,一开始找不到 IMP,且在动态方法解析的 if 条件判断不成立 `if(resolver & !triedResolver)` 则不会继续动态方法解析,开始执行下面的逻辑,进入消息转发阶段 `_objc_msgForward_impcache` - - - - - -上述动态消息解析后,会缓存起来吗? - -第一次消息动态解析后,只是将方法增加到 class 或者 meta-class 的 class_rw_t 中。然后会继续执行 `goto retry`,在 `gto retry` 的流程中,会先在 class 的方法缓存中查找有没有缓存,如果没有缓存,则会在 class 的 class_rw_t 中查找方法,找到并执行,同时还会把方法保存到方法缓存中去。以便后续再次调用方法的时候更加高效。 - -所以这个“会”缓存起来,只不过缓存的时机不是同步的,而是再次调用 `goto retry` 的时候,发现没有缓存,则在 class_rw_t 找查找到后,再缓存起来 - - - -#### 动态消息解析 Demo - -##### 实例方法 - -```objectivec -Person *person = [[Person alloc] init]; -[person makeLiving]; -``` - -调用不存在方法则报错 `***** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[Person makeLiving]: unrecognized makeLiving sent to instance 0x101b2d900'**` - -因为调用对象不存在的方法,所以会 Crash - -知道 `objc_msgSend` 的流程,我们尝试给它修正下 - - - -方法1,增加一个兜底方法,然后利用 `class_addMethod` 动态增加方法实现 - -```objective-c -struct method_t { - SEL sel; - char *types; - IMP imp; -}; - -- (void)mockMakeLiving { - NSLog(@"方法动态解析拦截第一阶段"); -} - -+ (BOOL)resolveInstanceMethod:(SEL)sel { - if (sel == @selector(makeLiving)) { - struct method_t *method = (struct method_t *)class_getInstanceMethod(self, @selector(mockMakeLiving)); - class_addMethod(self, sel, method->imp, method->types); - return YES; - } - return [super resolveInstanceMethod:sel]; -} -``` - -方法2,不用自定义的 struct method_t 结构体,直接用 Method 去承接,只不过在传递 IMP、encoding 的时候用 runtime api 获取即可 - -```objective-c -Method method = class_getInstanceMethod(self, @selector(mockMakeLiving)); -class_addMethod(self, sel, method_getImplementation(method), method_getTypeEncoding(method)); -``` - -方法3,也可以添加 c 语言方法 - - - -c 函数即函数地址,只不过传递给 class_addMethod 的时候需要转换为函数参数加 `(IMP)cfuntionResolver`,手动添加方法签名。 - -```objective-c -void cfuntionResolver(id self, SEL _cmd) { - NSLog(@"cfuntionResolver %@ %@", self, NSStringFromSelector(_cmd)); -} -+ (BOOL)resolveInstanceMethod:(SEL)sel -{ - if (sel == @selector(eat)) { - // 对象方法,存在于对象上。 - class_addMethod(self, sel, (IMP)cfuntionResolver, "v16@0:8"); - return YES; - } - return [super resolveInstanceMethod:sel]; -} -``` - - - -##### 类方法动态解析 - -也可以给类方法做动态方法解析。需要注意的是类方法 - -- 调用 `-(BOOL)resolveClassMethod:(SEL)sel` - -- `class_addMethod` 方法中的第一个参数,需要加到类的元类对象中,所以是 `object_getClass` - -```objectivec -[Person drink]; -void mockDrink (id self, SEL _cmd) { - NSLog(@"假喝水"); -} - -+ (BOOL)resolveClassMethod:(SEL)sel -{ - if (sel == @selector(drink)) { - // 类方法,存在于元类对象上。 - class_addMethod(object_getClass(self), sel, (IMP)mockDrink, "v16@0:8"); - return YES; - } - return [super resolveClassMethod:sel]; -} -``` - - - -### 消息转发阶段 - -能走到消息转发,说明 - -1. 类自身没有该方法(`objc_msgSend` 的消息发送) - -2. `objc_msgSend` 动态方法解析失败或者没有做 - -说明:**类自身(自身方法缓存、自身没有方法实现、自身也没有动态增加方法)和父类没有可以处理该消息的能力,此时应该将该消息转发给其他对象**。 - -查看 objc4 的源码 - -```c -IMP lookUpImpOrForward(Class cls, SEL sel, id inst, - bool initialize, bool cache, bool resolver) -{ - //... - // No implementation found, and method resolver didn't help. - // Use forwarding. - imp = (IMP)_objc_msgForward_impcache; - cache_fill(cls, sel, imp, inst); - // ... -} -``` - -继续查找 `_objc_msgForward_impcache` - -```shell -STATIC_ENTRY __objc_msgForward_impcache - -MESSENGER_START -nop -MESSENGER_END_SLOW - -// No stret specialization. -b __objc_msgForward -END_ENTRY __objc_msgForward_impcache - -ENTRY __objc_msgForward - -adrp x17, __objc_forward_handler@PAGE -ldr x17, [x17, __objc_forward_handler@PAGEOFF] -br x1 - -END_ENTRY __objc_msgForward -``` - -查找 `__objc_forward_handler` 没有找到,可以猜想是一个 c 方法,去掉最前面的 `_`,按照 `_objc_forward_handler` 查找得到 - -```c -__attribute__((noreturn)) void -objc_defaultForwardHandler(id self, SEL sel) -{ - _objc_fatal("%c[%s %s]: unrecognized selector sent to instance %p " - "(no message forward handler is installed)", - class_isMetaClass(object_getClass(self)) ? '+' : '-', - object_getClassName(self), sel_getName(sel), self); -} -void *_objc_forward_handler = (void*)objc_defaultForwardHandler; -``` - -消息转发的代码是不开源的,查找资料找到一份靠谱的 `__forwarding `方法实现 - -为什么是 `__forwarding__` 方法。我们可以根据 Xcode 崩溃窥探一二 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-forwardingFailed.png) - -```c -int __forwarding__(void *frameStackPointer, int isStret) { - id receiver = *(id *)frameStackPointer; - SEL sel = *(SEL *)(frameStackPointer + 8); - const char *selName = sel_getName(sel); - Class receiverClass = object_getClass(receiver); - - // 调用 forwardingTargetForSelector: - if (class_respondsToSelector(receiverClass, @selector(forwardingTargetForSelector:))) { - id forwardingTarget = [receiver forwardingTargetForSelector:sel]; - if (forwardingTarget && forwardingTarget != receiver) { - return objc_msgSend(forwardingTarget, sel, ...); - } - } - - // 调用 methodSignatureForSelector 获取方法签名后再调用 forwardInvocation - if (class_respondsToSelector(receiverClass, @selector(methodSignatureForSelector:))) { - NSMethodSignature *methodSignature = [receiver methodSignatureForSelector:sel]; - if (methodSignature && class_respondsToSelector(receiverClass, @selector(forwardInvocation:))) { - NSInvocation *invocation = [NSInvocation _invocationWithMethodSignature:methodSignature frame:frameStackPointer]; - - [receiver forwardInvocation:invocation]; - - void *returnValue = NULL; - [invocation getReturnValue:&value]; - return returnValue; - } - } - - if (class_respondsToSelector(receiverClass,@selector(doesNotRecognizeSelector:))) { - [receiver doesNotRecognizeSelector:sel]; - } - - // The point of no return. - kill(getpid(), 9); -} -``` - -具体地址可以参考 [__frowarding](../assets/__forwarding__clean.c) - -完整流程如下 - - - - - - - -#### 方法签名的获取 - -方法1: 自己根据方法的返回值类型,方法2个基础参数参数:`id self`、`SEL _cdm`,其他参数类型按照 Encoding 自己拼。 类似 `v16@0:8` - -方法2 :根据某个类的对象,去调用 `methodSignatureForSelector ` 方法获取。 - - `[[[Person alloc] init] methodSignatureForSelector:@selector(makeLiving)];` - -方法1自己拼的缺点是不够灵活,修改原始方法,需要在方法签名处修改方法的参数、返回值信息,还需手动计算。效率低。如果某个类实现了方法,则可以通过 `[class methodSignatureForSelector:@selector(***)]` 的方式获取方法签名。 - -```objectivec -- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector -{ - if (aSelector == @selector(drink)) { - return [[[PersonHelper alloc] init] methodSignatureForSelector:@selector(makeLiving:)]; - } - return [super methodSignatureForSelector:aSelector]; -} -``` - - - -#### 消息转发 Demo - -##### 对象方法消息转发 Demo - -Person 类不存在对象方法 makeliving ,PersonHelper 类存在。 - - - -调用对象不存在的方法,则会抛出错误。同时在 Runtime 动态消息解析阶段,`resolveInstanceMethod` 没有处理对象方法,所以会报错 - -方法1:因为动态消息解析没有处理,则会开始走消息转发阶段。消息转发首先会调用 `- (id)forwardingTargetForSelector:(SEL)aSelector` 方法。(如果是对象方法则调用 `- (id)forwardingTargetForSelector:(SEL)aSelector`,如果是类方法则调用 `+ (id)forwardingTargetForSelector:(SEL)aSelector`) - - - -方法2:如果消息转发里,`forwardingTargetForSelector` 返回了 nil,则开始调用方法签名 `methodSignatureForSelector` 方法和 `forwardInvocation` 方法 - -```objective-c -- (id)forwardingTargetForSelector:(SEL)aSelector { - if (aSelector == @selector(makeLiving)) { -// return [[PersonHelper alloc] init]; - return nil; - } - return [super forwardingTargetForSelector:aSelector]; -} - -- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { - return [NSMethodSignature signatureWithObjCTypes:"v16@0:8"]; -} - -- (void)forwardInvocation:(NSInvocation *)anInvocation { - // anInvocation 是包含了函数调用的所有信息 - // anInvocation.target; 调用者 - // anInvocation.selector; 方法名 - // anInvocation getArgument: atIndex:; 方法参数 - // anInvocation.target = [[PersonHelper alloc] init]; - // [anInvocation invoke]; - [anInvocation invokeWithTarget:[[PersonHelper alloc] init] ]; -} -``` - -注意: - -1. `methodSignatureForSelector` 如果返回 nil,则 `forwardInvocation` 不会执行 -2. 方法签名 `methodSignatureForSelector` 必须正确,否则会获取参数 crash,报错 `Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSInvocation getArgument:atIndex:]: index (2) out of bounds [-1, 1]'` 的错误 - - - - - -上述方式不够优雅,针对方法签名的获取太被动,方法改变了,方法签名处需要调整,很麻烦。 - -通过 `NSInvocation` 获取参数一般是从2开始,因为第一个是 self,第二个是 _cmd,第三个是 cost - - - - - - - -##### 类方法消息转发 Demo - -上述是实例方法找不到的情况,我们也可以给类方法,增加消息转发的处理。 - -- 基本上的处理上对象方法转发所用到的方法,前面的 `-` 变为 `+` 即可。 -- 某些关于方法签名或者转发的对象换为类对象即可 - -```objective-c -+ (id)forwardingTargetForSelector:(SEL)aSelector { - return [PersonHelper class]; -} - -+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { - return [[PersonHelper class] methodSignatureForSelector:aSelector]; -} - -+ (void)forwardInvocation:(NSInvocation *)anInvocation { - [anInvocation invokeWithTarget:[PersonHelper class]]; -} -``` - - - - - - - -注意:**实例对象有消息转发,类方法也有消息转发机制**。但是在 Xcode 中只可以提示 `-(id)forwardingTargetForSelector:(SEL)aSelector` - -不提示 `+(id)forwardingTargetForSelector:(SEL)aSelector` 所以有人文章说 OC 不支持类方法的动态方法解析和转发,这是错误的 - - - -### OC 消息机制是什么样的? - -OC 中方法调用的本质就是利用 runtime 发消息,发消息也给 receiver(方法调用者)发消息(selector 方法名),就是 `objc_msgSend` 的工作流程,具体分为3个阶段: - -- 消息发送:objc_msgSend 的完整流程 -- 动态方法解析 -- 消息转发 - - - -### 消息传递流程 - -1. 先判断是否命中缓存,利用 sel 和 _mask,计算出哈希值,然后在 bucket_list 中查找方法缓存,找到则调用。没有找对则继续查找 - -2. 没有找到缓存,则根据对象的 isa 在类对象的方法列表中,进行方法查找 - - - 对于已经排序好的列表,采用二分查找算法,查找方法对应执行函数 - - 对于没有排序好的列表,采用一般遍历查找方法对应执行函数 - -3. 如果在当前类的方法列表中没有找对方法实现,则根据类对象的 superclass 在父类的类对象的方法列表中继续查找 - - - - 先根据 superclass 找到父类,然后在父类中也按照上面3点进行递归查找,即: - - - 先判断能否命中方法缓存,根据 sel 和 _mask 计算哈希,然后判断是否命中 - - 没有命中,则在父类的类对象方法列表中查找。对于已经排序好的列表,采用二分查找算法,查找方法对应执行函数;对于没有排序好的列表,采用一般遍历查找方法对应执行函数 - - 还是没有命中,则对父类的父类,继续上述流程 - - - -## Super 底层原理 - -```objectivec -@interface Person: NSObject - -@end -@implementation Person -@end - - -@implementation Student -- (instancetype)init -{ - if (self = [super init]) { - NSLog(@"%@", [self class]); // Student - NSLog(@"%@", [self superclass]); // Person - NSLog(@"%@", [super class]); // Student - NSLog(@"%@", [super superclass]); // Person - } - return self; -} -@end -``` - -后面2个的打印似乎不符合预期?转成 c++ 代码看看 - -```c -static instancetype _I_Student_init(Student * self, SEL _cmd) { - if (self = ((Student *(*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("Student"))}, sel_registerName("init"))) { - NSLog((NSString *)&__NSConstantStringImpl__var_folders_z5_ksvb7q252lbdfg78236t7tt00000gn_T_Student_91af5b_mi_0, ((Class (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("class"))); - NSLog((NSString *)&__NSConstantStringImpl__var_folders_z5_ksvb7q252lbdfg78236t7tt00000gn_T_Student_91af5b_mi_1, ((Class (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("superclass"))); - NSLog((NSString *)&__NSConstantStringImpl__var_folders_z5_ksvb7q252lbdfg78236t7tt00000gn_T_Student_91af5b_mi_2, ((Class (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("Student"))}, sel_registerName("class"))); - NSLog((NSString *)&__NSConstantStringImpl__var_folders_z5_ksvb7q252lbdfg78236t7tt00000gn_T_Student_91af5b_mi_3, ((Class (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("Student"))}, sel_registerName("superclass"))); - } - return self; -} -``` - -`[super class]` 这句代码底层实现为 `objc_msgSendSuper((__rw_objc_super){self, class_getSuperclass(objc_getClass("Student"))}, sel_registerName("class"));` - -`__rw_objc_super` 是什么? - -```c -struct objc_super { - __unsafe_unretained _Nonnull id receiver; - __unsafe_unretained _Nonnull Class super_class; -}; -``` - -`objc_msgSendSuper` 如下 - -```c -/** - * Sends a message with a simple return value to the superclass of an instance of a class. - * - * @param super A pointer to an \c objc_super data structure. Pass values identifying the - * context the message was sent to, including the instance of the class that is to receive the - * message and the superclass at which to start searching for the method implementation. - * @param op A pointer of type SEL. Pass the selector of the method that will handle the message. - * @param ... - * A variable argument list containing the arguments to the method. - * - * @return The return value of the method identified by \e op. - * - * @see objc_msgSend - */ -objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...) -``` - -所以 `objc_msgSendSuper((__rw_objc_super){self, class_getSuperclass(objc_getClass("Student"))}, sel_registerName("class"));` 等同于下面代码 - -```c -struct objc_super arg = {self, class_getSuperclass(self)}; -objc_msgSendSuper(arg, sel_registerName("class")) -``` - -也就是说 `[super class]` 中, super 调用的 receiver 还是 self。比如 `[super class]` 等价于 `objc_msgSendSuper({self, class_getSuperClass(objc_getClass("Student"))}, @selector(class))` - -调用 `class` 方法,其实是在基类对象 NSObject 中的。因为是类方法,所以先从 Student 的父类 Person 类的元类对象中查找有没有 `class` 方法实现。发现没有,则继续根据 Person 类 superclass 指针,找到 Person 的父类的元类对象的类方法列表中中查找,没找到,则继续向上找,最后找到 NSObject 对象的元类对象的类方法列表中找到了,因为方法调用者是 self,所以获取 class 得到的就是当前类,即 Student。 - -结构体的目的是为了在类对象查找的过程中,**直接从当前类的父类中查找,而不是本类**(比如 Student 类的 [super init] 会直接从 Person 的类对象中查找 init,找不到则通过 `superclass` 向上查找) - - - -大致推测系统的 class、superclass 方法实现如下 - -```objective-c -@implementation NSObject -- (Class)class{ - return object_getClass(self); -} -- (Class)superclass { // 先获取类对象,然后获取类对象的 superclass - return class_getSuperclass(object_getClass(self)); -} -@end -``` - -`class` 方法是在 NSObject 类对象的方法列表中的。所以 - - `[self class]` 等价于 `objc_msgSend(self, sel_registerName("class"))` - -`[super class]` 等价于 `objc_msgSendSuper({self, class_getSuperclass(self)}, sel_registerName("class"))` - -其实2个方法本质上消息 receiver 都是 self,也就是当前的 Student,所以打印都是 Student - - - -结论:`[super message]` 有2个特征 - -- super 消息的调用者还是 self - -- 方法查找是根据当前 self 的父类的类对象方法列表开始查找 - -通过将代码转为 c++ 发现,super 调用本质就是 `objc_msgSendSuper`,实际不然 - -我们对 iOS 项目`[super viewDidLoad]` 下符号断点,发现`objc_msgSendSuper2` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-super.png) - -查看 objc4 源代码发现是一段汇编实现。 - -```shell -ENTRY _objc_msgSendSuper2 -UNWIND _objc_msgSendSuper2, NoFrame -MESSENGER_START - -ldp x0, x16, [x0] // x0 = real receiver, x16 = class -ldr x16, [x16, #SUPERCLASS] // x16 = class->superclass -CacheLookup NORMAL - -END_ENTRY _objc_msgSendSuper2 -``` - -所以 `super viewDidLoad`本质上就是 - -```objectivec -struct objc_super arg = { - self, - [UIViewController class] -}; -objc_msgSendSuper2(arg, sel_registerName("viewDidLoad")); -``` - -objc_msgSendSuper2 和 objc_msgSendSuper 区别在于第二个参数 - -objc_msgSendSuper2 底层源码(汇编代码 objc-msg-arm64.s 422 行)会将第二个参数找到父类,然后进行方法缓存查找 - -objc_msgSendSuper 直接从第二个参数查找方法。 - -总结:clang 转 c++ 可以窥探系统实现,可以作为研究参考。super 本质上就是 `objc_msgSendSuper2`,传递2个参数,第一个参数为结构体,第二个参数是sel。 - -为什么转为 c++ 和真正实现不一样?思考下 - -源代码变为机器码之前,会经过 LLVM 编译器转换为中间代码(Intermediate Representation),最后转为汇编、机器码 - -我们来验证下 super 在中间码上是什么 - -```shell -clang -emit-llvm -S Student.m -``` - -llvm 中间码如下,可以看到确实内部是 `objc_msgSendSuper2` - -```shell -; Function Attrs: noinline optnone ssp uwtable -define internal void @"\01-[Student sayHi]"(%0* %0, i8* %1) #1 { - %3 = alloca %0*, align 8 - %4 = alloca i8*, align 8 - %5 = alloca %struct._objc_super, align 8 - store %0* %0, %0** %3, align 8 - store i8* %1, i8** %4, align 8 - %6 = load %0*, %0** %3, align 8 - %7 = bitcast %0* %6 to i8* - %8 = getelementptr inbounds %struct._objc_super, %struct._objc_super* %5, i32 0, i32 0 - store i8* %7, i8** %8, align 8 - %9 = load %struct._class_t*, %struct._class_t** @"OBJC_CLASSLIST_SUP_REFS_$_", align 8 - %10 = bitcast %struct._class_t* %9 to i8* - %11 = getelementptr inbounds %struct._objc_super, %struct._objc_super* %5, i32 0, i32 1 - store i8* %10, i8** %11, align 8 - %12 = load i8*, i8** @OBJC_SELECTOR_REFERENCES_.6, align 8, !invariant.load !12 - call void bitcast (i8* (%struct._objc_super*, i8*, ...)* @objc_msgSendSuper2 to void (%struct._objc_super*, i8*)*)(%struct._objc_super* %5, i8* %12) - notail call void (i8*, ...) @NSLog(i8* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_.8 to i8*)) - ret void -} -``` - -指令介绍 - -```shell -@ - 全局变量 -% - 局部变量 -alloca - 在当前执行的函数的堆栈帧中分配内存,当该函数返回到其调用者时,将自动释放内存 -i32 - 32位4字节的整数 -align - 对齐 -load - 读出,store 写入 -icmp - 两个整数值比较,返回布尔值 -br - 选择分支,根据条件来转向label,不根据条件跳转的话类似 goto -label - 代码标签 -call - 调用函数 -``` - - - -也可以在 Xcode 上可视化面板操作,路径为:菜单栏 `Product -> Perform Action -> Assemble "ViewController.m"` - - - - - - - -## 消息发送的边界情况 - -- objc_msgSend_stret: 如果待发送的消息要返回结构体,那么可交给此函数完成。只有当 CPU 的寄存器能够容纳得下消息返回类型时,这个函数才能处理此消息。如果是返回值无法容纳于 CPU 寄存器中(比如结构体太大了),那么就由另一个函数执行派发,此时函数会通过栈上的某个变量来处理消息所返回的结构体 -- objc_msgSend_fpret:如果消息返回的是浮点数,那么可交给此函数处理。在某些架构的 CPU 中调用函数时,需要对浮点数寄存器做特殊处理,通常采用的 objc_msgSend 在这种情况下不合适,这个函数是为了处理 x86 等架构 CPU 中某些特殊的情况的 -- objc_msgSendSuper: 要给超类发消息,就交给此函数处理。 - - - -## 编译器优化之尾调用优化 - -如果函数的最后一行是调用另一个函数,那么就可以采用“尾调用优化”技术。编译器会生成跳转至另一函数所需的指令码,而不会向调用堆栈中推入新的栈帧。 - -只有当函数的最后一个操作仅仅是调用其他函数而不会将其返回值另作他用时,才能执行尾调用优化。这项优化对 objc_msgSend 很有用。如果不这么做,那么每次调用 OC 方法之前,都需要为调用 objc_msgSend 函数准备栈帧。而且很容易出现 stack overflow 的问题。 - - - -## isKindOfClass、isMemberOfClass - -Demo1 - - - - - -上面的打印有没有有疑惑?2个判断都是调用对象方法的 `isMemberOfClass` 、`isKindOfClass` - -由于 objc4 是开源的,查看 `object.mm` - -```c -- (BOOL)isKindOfClass:(Class)cls { - for (Class tcls = [self class]; tcls; tcls = tcls->superclass) { - if (tcls == cls) return YES; - } - return NO; -} -- (BOOL)isMemberOfClass:(Class)cls { - return [self class] == cls; -} -``` - -- `isMemberOfClass` 判断当前对象是不是传递进来的对象 - -- `isKindOfClass` 内部是一个 for 循环,第一次循环先拿当前类的类对象,判断是不是和传递进来的对象一样,一样则 return YES,不一样则先给 tlcs 赋值当前类的父类,然后走第二次判断,直到 cls 不存在(NSObject 的父类为 nil)。所以 `isKindOfClass` 其实判断的是当前类是传递进来的类或子类 - - - -Demo2 - - - -下面面2个判断都是调用类方法的 `isMemberOfClass` 、`isKindOfClass` - -```c -+ (BOOL)isMemberOfClass:(Class)cls { - return object_getClass((id)self) == cls; -} -+ (BOOL)isKindOfClass:(Class)cls { - for (Class tcls = object_getClass((id)self); tcls; tcls = tcls->superclass) { - if (tcls == cls) return YES; - } - return NO; -} -``` - -分析: - -第一类:`isMemberOfClass` - -可以看到 `+(BOOL)isMemberOfClass:(Class)cls` 方法内部就是对当前 receiver 类获取类对象,然后与传递进来的 cls 判断是否相等。因为本身就是类方法,方法 receiver 就是一个类对象,对类对象调用 `object_getClass` 方法,获取到的就是 receiver 类的元类对象 - -- `[Student isMemberOfClass:[Student class]]` 等价于:先对 Student 类对象调用 `object_getClass` 方法,得到 Student 类的元类对象,等号左侧是 Student 的元类对象,等号右侧是 Student 类对象(cls 参数也就是 `[Student class]` 是一个类对象)元类对象等于类对象吗?显然不是显然不成立,输出0 -- `[Student isMemberOfClass:[NSObject class]]` 等价于:先对 Student 类对象调用 `object_getClass` 方法,得到 Student 类的元类对象,等号左侧是 Student 元类对象,等号右侧是 NSObject 类对象(cls 参数也就是 `[NSObject class]` 是一个类对象)元类对象等于类对象吗。显然不成立,输出0 - -想让判断成立,可以改为 `[Student isMemberOfClass:object_getClass([Student class])]` 或者 `[[Student class] isMemberOfClass:object_getClass([Student class])]` - -第二类:`+isKindOfClass` - -可以看到 `+(BOOL)isKindOfClass:(Class)cls` 方法内部就是对当前 receiver 调用 `object_getClass`,由于自身就是类方法,所以receiver 就是类对象,调用方法后返回元类对象。for 循环内判断,是否是右边传入对象的元类或者元类的子类。 - -- `[Student isKindOfClass:[Student class]]); ` 为什么会输出0?因为 `isKindOfClass` 底层是 for 循环对传入的 self 使用 `object_getClass((id)self)` 获取类对象,因为传入的已经是类对象,所以 `object_getClass((id)self)` 内部得到的是元类对象。右边是传入的类对象。等号左边的 Student 的元类对象,不等于等号右边的类对象。所以为 false,输出 0. - - 如何更改使得输出1?`[Student isKindOfClass:object_getClass([Student class])]`。右边也是元类对象,则为 True 输出1. - -- `NSLog(@"%hhd", [[Student class] isKindOfClass:[NSObject class]]); ` 为什么输出1?看右边的部分,调用 `isKindOfClass` 方法,本质上就是 Student 类的类对象,也就是 Student 元类,和传入的右边 `[NSObject class]` 判断是否想通过。 - - 第一次 for 循环当然不同,所以不能 return,会将 `tcls ` 走步长改变逻辑 `tcls = tcls->superclass`,也就是找到当前 Student 元类对象的父类。 - - 第二次 for 循环也一样不相等,Person 元类不等于 `[NSObject class]` 继续向上,直到 tcls = NSObject。此时还是不等,这时候 tcls  走步长改变逻辑,`tcls = tcls->superclass` NSObject 元类的 superclass 还是 NSObject。所以 for 循环内部的判断编委 ` [NSObject class] == [NSObject class]`,return YES。 - - - -**tips:基类的元类对象指向基类的类对象。** - -```c -+ (BOOL)isKindOfClass:(Class)cls { - for (Class tcls = object_getClass((id)self); tcls; tcls = tcls->superclass) { - if (tcls == cls) return YES; - } - return NO; -} -``` - -QA:`NSLog(@"%d", [Person isKindOfClass:[NSObject class]]);` 为什么输出1? - -其内部流程: - -- 先对 receiver 调用 `objcet_getClass` 方法,得到 Person 的元类对象 tcls -- 开启 for 循环,判断 tcls 是否等于方法参数(也就是 NSObject 的类对象) -- for 循环一开始不满足,则不断进行,令 tcls = tcls->superClass。for 循环结束的条件是 tcls 为 nil,所以最后找到 NSObject 的元类的时候,继续往上找,NSObject 元类对象的父类是 NSObject 的类对象,此时 tcls 就是 NSObject 的类对象,cls 也是 NSObject 类的类对象,相等,输出1. - - - -同理 `[NSObject isKindOfClass:[NSObject class]]` 也为 YES,工作流程和上面的类似。也就是 `[继承自 NSObject 及其继承自任何子类 isKindOfClass:[NSObject class]]` 都为 YES - -综合练习: - - - - - - - - - -## Runtime 刁钻题 - -### NSObject 的内存布局、isa、对象属性访问原理 - -> 这道题目设计:super 调用的本质、函数栈空间向下增长、runtime 消息调用本质(isa)、访问对象的成员变量(找到 isa,约过前面的8字节,按照成员变量的大小,去找成员) -> -> 因为实例对象里存的就是:isa + 各个成员变量的值 - -```objective-c -@interface Person : NSObject -@property (nonatomic, copy) NSString *name; -- (void)sayHi; -@end - -@implementation Person -- (void)sayHi { - NSLog(@"hi,my name is %@", self->_name); -} -@end - -- (void)viewDidLoad { - [super viewDidLoad]; - NSString *temp = @"杭城小刘"; - id obj = [Person class]; - void *p = &obj; - [(__bridge id)p sayHi]; -} -``` - -程序运行什么结果? - - - -为什么会方法调用成功?为什么 name 打印出为 @"杭城小刘"。我们来分析下: - -`[Person class]` 类对象是全局唯一的,存在于全局区。 - -第一、**方法调用本质就是寻找 isa 进行消息发送** - -```objective-c -Person *person = [[Person alloc] init]; -[person sayHi]; -``` - -`[[Person alloc] init]` 在内存中分配一块内存,然后 isa 指向这块内存,然后 person 指针,指向结构体,结构体的第一个成员。 - -`[p sayHi]` 编译后 `objc_msgSend(p, @selector(sayHi))` - - - -第二、**栈空间数据内存向下生长。第一个变量地址高,其次降低。且每个变量的内存地址是连续的。** - -这个流程其实和上面的代码一样的。所以可以正常调用 - -```c -void test () { - long long a = 4; // 0x7ff7bfeff2d8 - long long b = 5; // 0x7ff7bfeff2d0 - long long c = 6; // 0x7ff7bfeff2c8 - NSLog(@"%p %p %p", &a, &b, &c); -} -``` - -方法内的变量存储在栈上,堆向上增长,栈向下增长。 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-isa-demo.png) - -第三,id 的本质是 - -```objective-c -typedef struct objc_object *id; -``` - -那 `objc_object` 的核心结构是什么? - -```objective-c -struct objc_object { -private: - isa_t isa; - -public: - // ISA() assumes this is NOT a tagged pointer object - Class ISA(); - - // getIsa() allows this to be a tagged pointer object - Class getIsa(); - // ... -} -``` - -所以可以看到找到对象 id,也就可以找到 isa 信息,也就可以获取到类对象信息,进而查找方法列表。 - -第四、**实例对象的本质就是一个结构体,存储所有成员变量(isa 是一个特殊成员变量,其他的成员变量,这里就是 _name),`sayHi` 方法内部的 self 就是 obj,找成员变量的本质就是找内存地址的过程,属性的地址 = 对象地址基础地址 + 属性的Offset。此时就是 isa 地址 + 8字节偏移量)** - -上面代码可以类比类调用方法的流程。 obj 指针指向 Person 这块内存,给类对象发送 `sayHi` 消息也就是通过 obj 指针找到 isa,恰好 obj 指针指向的地址就是类对象的类结构体的地址,结构体成员变量第一个就是 isa 指针,结构体的其他成员变量就是类的其他属性,这里也就是 `_name`,所以我们给自定义的指针 `void *p` 调用 sayHi 方法,系统 runtime 在打印 name 的时候,会在 p 附近(下8个字节,因为 isa 是指针,长度为8)找 `_name` 属性,此时也就找到了 temp 字符串。 - -```c -struct Person_IMPL { - Class isa; // 8字节 - NSString *_name; // 8字节 -} -``` - -整体流程是: - -- 调用 `[p sayHi]` 方法的的流程是,编译后 `objc_msgSend(p, @selector(print))` -- Runtime 会认为 p 是对象,然后寻找 p 的 isa 信息,即使 isa 是 union 类型,但系统根据 isa 寻找类对象的时候,是调用 `p 地址 & ISA_MASK` 获取类对象地址的 -- 也就是获取到了 cls 指向的 [Person class] 类对象地址。然后 Person 内存在对象方法 print,发起调用 -- 但内部访问的 self.name 本质就是:「知道对象的地址,如何访问属性值」。属性值 = 实例对象 baseAddress + 属性 offsetAddress,对于 name 也就是基地址偏移8位,找到 name -- 由于方法内就是栈,由高地址向低地址增长(方法内从上到下,变量的地址依次降低) -- 因为 obj 本身占8位,然后找 name 也就是获取到上一个8位的对象值,此时拿到了 temp,所以 Runtime 会认为 temp 就是所需要的 self.name - - - -再看一个变体1 - - - -打印输出是因为 `*p` 类似 isa 指针。本身占用8字节空间,然后访问 `self->_name` 就是 `base + 8 = isa地址 + 8 ` 出的内存就是 name,`*p` 是在栈中,加8,就是向上声明的变量,当前情况下 `Address(*p) + 8` 就是 temp 变量。所以输出 `` - - - -再看一个变体2 - - - -分析: 搞懂的小伙伴不迷惑了。没搞懂其实就是没搞懂**栈地址由高到低,向下生长** 和 `super` 调用的本质。 - -再强调一句,根据指针寻找成员变量 _name 的过程其实就是根据内存偏移找对象的过程。在变体2中,isa 地址就是 class 的地址,所以按照`地址 +8` 的策略,其实前一个局部变量。 - -`[super viewDidLoad];` 本质就是 `objc_msgSendSuper({self, class_getSuperclass(self)}, sel_registerName("viewDidLoad"))` - -```c -struct objc_super arg = {self, class_getSuperclass(objc_getClass("ViewController"))}; -objc_msgSendSuper(arg, sel_registerName("viewDidLoad")); -``` - -所以此时的“前一个局部变量” 也就是结构体 `objc_super` 类型的 arg。arg 是一个结构体,结构体第一个成员变量就是 self,所以“前一个局部变量” 也就是 self(ViewController) - -结构体有2个成员变量,顺序越高的成员变量地址越高。所以在栈上,struct 中第一个成员变量地址更低。在通过 isa 内存偏移的时候,优先找到 self。所以会输出 ViewController - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-super-isa-demo.png) - -可能会疑问,知道了 super 调用本质,知道了会产生一个局部变量的结构体,但是结构体里面2个成员变量,找属性的时候,isa 下的8个字节会命中哪个? - -```c++ -struct objc_super { - __unsafe_unretained _Nonnull id receiver; - __unsafe_unretained _Nonnull Class super_class; -}; -``` - -结构体 `objc_super` 存在2个成员变量,self 是第一个,`class_getSuperclass(objc_getClass("ViewController"))` 是第二个,self 地址更低,会被认为是 Person 的 name 属性值(虽然栈内变量地址由高到低,但是结构体 objc_super 的2个成员变量顺序并不会改变,也就是相对位置不变。假设结构体地址为 structBase = 0x00007ff7bf961c28,也就是第一个成员变量 receiver 地址为 0x00007ff7bf961c28,由于 id 类型长度为8位,所以第二个成员变量 super_class 地址为 = 0x00007ff7bf961c28 + 8 = 0x7FF7BF961C30 )。 - -假设 obj 地址为 0x7FF7BF961C20,所以 name 地址为 0x7FF7BF961C20 + 8 = 0x7FF7BF961C28,命中结构体的地址,按照 name 自身长度为8,也就取到了结构体第一个成员变量 self 的值。此时为 ViewController 对象。 - -画图如下,有助于理解 - - - - - - - - - -### runtime 对象方法、类方法的查找过程熟悉吗? - -下面的代码会 crash 吗? - -```objective-c -id rs = [NSObject valueForKey:@"isa"]; -NSLog(@"%@", rs); -``` - - - -不会 Crash。输出的是 NSObject 的元类对象。因为 NSObject 调用的是类方法,先去元类对象的方法列表中查找,发现没有 `valueForKey` 方法。则继续从 NSObject 元类对象的父类对象,也就是 NSObject 的类对象上查找 `valueForKey` 方法。 - -因为分类 `NSObject(NSKeyValueCoding)` 实现了 `-valueForKey` 方法。所以不会 crash,在类对象方法中,访问 isa,也就是获取类对象的 isa,也就是 NSObject 的元类对象。 - - - -查看了 objc 源码,会发现很多 NSObject 的基础方法:`+ (id)init`、`- (id)init` 等均有 `+` 、`-` 方法。 - - - -为什么这么设计?猜测是为了代码的健壮。 - -- 因为存在继承关系,所有的对象的基类需要有个源头。即使是 NSObject 也是如此。 -- 调用对象方法的本质就是根据对象的 isa 找到类对象,然后从方法缓存中去查找方法实现,有就调用,没有就从类对象的方法列表中查找,找到则写入方法缓存并调用,没有则根据 superclass 的类对象继续查找...,一直找到 NSObject 还是找不到,则走 Runtime 消息转发流程。 -- 调用类方法的本质是根据对象的 isa 找到类对象,再根据类对象的 isa 找到元类对象,从元类对象的方法方法列表中查找方法实现,如果没有则继续向上查找,直到找到基类的元类对象,也就是 NSObject,如果找不到则走消息转发流程 -- 但是 Apple 的设计是为了 NSObject 元类对象的父类也要有个东西去接着,于是就让 NSObject 的类对象来充当 。 - -这也就是为什么 NSObject 子类对象调用 `+` 类方法不 crash 的原因。 - - - -## 应用场景 - -### 统计 App 中未响应的方法。给 NSObject 添加分类 `NSObject+ExceptionHunter.m`,利用 NSProxy 实现 - -```objective-c -#import "NSObject+ExceptionHunter.h" -#import - -@implementation NSObject (ExceptionHunter) - -- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { - if ([self respondsToSelector:aSelector]) { - return [self methodSignatureForSelector:aSelector]; - } - return [NSMethodSignature signatureWithObjCTypes:"v@:"]; -} - -- (void)forwardInvocation:(NSInvocation *)anInvocation { - id receiver = anInvocation.target; - NSString *methodName = NSStringFromSelector(anInvocation.selector); - // 收集上报... - NSLog(@"%@方法未调用成功", NSStringFromSelector(anInvocation.selector)); -} - -@end -``` - -### 修改类的 isa - -类似 KVO 的实现,就是更改 isa。 - -`object_setClass` 实现 - -```objectivec -Person *p = [Person new]; -object_setClass(p, [Student class]); -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-changeisa-demo.png) - - - -### 动态创建类 - -必须先创建类:`objc_allocateClassPair`、再注册类(用于向运行时系统注册一个新创建的类及其元类):`objc_registerClassPair` 成对存在 - -动态创建类、添加属性、方法 - -```objectivec -void study (id self, SEL _cmd) { - NSLog(@"在学习了"); -} - -void createClass (void) { - Class newClass = objc_allocateClassPair([NSObject class], "GoodStudent", 0); - class_addIvar(newClass, "_score", 4, 1, "i"); - class_addIvar(newClass, "_height", 4, 1, "i"); - class_addMethod(newClass, @selector(study), (IMP)study, "v16@0:8"); - objc_registerClassPair(newClass); - id student = [[newClass alloc] init]; - [student setValue:@100 forKey:@"_score"]; - [student setValue:@177 forKey:@"_height"]; - [student performSelector:@selector(study)]; - NSLog(@"%@ %@", [student valueForKey:@"_score"], [student valueForKey:@"_height"]); -} -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/runtime-dynamicCreateClass-demo.png) - -注意: - -- 运行时注册的类会在程序生命周期内持久存在(调用 copy、create 等出来的内存),不使用的时候需要手动释放`objc_disposeClassPair(newClass>)` -- 能否动态添加实例变量到已注册的类?**不能**。注册后类的内存布局已固定,修改会导致崩溃。 - - - -### 访问成员变量信息 - -```objectivec -void ivarInfo (void) { - Ivar nameIvar = class_getInstanceVariable([Person class], "_name"); - NSLog(@"%s %s", ivar_getName(nameIvar), ivar_getTypeEncoding(nameIvar)); //_name @"NSString" - // 设置、获取成员变量 - Person *p = [[Person alloc] init]; - Ivar ageIvar = class_getInstanceVariable([Person class], "_age"); - object_setIvar(p, ageIvar, (__bridge id)(void *)27); - NSLog(@"%d", p.age); -} -``` - -runtime 设置值 api `object_setIvar(id _Nullable obj, Ivar _Nonnull ivar, id _Nullable value)` 第三个参数要求为 id 类型,但是我们给 int 类型的属性设置值,怎么办?可以将27这个数字的地址传进去,同时需要类型转换为 id `(__bridge id)(void *)27)` - -KVC 可以根据具体的值,去取出 NSNumber ,然后调用 intValue - -`[p setValue:@27 forKey:@"_age"];` - - - -### 访问对象的所有成员变量信息-字典转模型 - -用到的 API 是 class_copyIvarList` - -```objectivec -@property (nonatomic, strong) NSString *name; -@property (nonatomic, assign) int age; -@end - -unsigned int count; -// 数组指针 -Ivar *properties = class_copyIvarList([Person class], &count); -for (int i =0 ; i - -不够健壮体现在: - -- 假设服务器返回的 json 有9个字段,本地对象有8个字段,怎么处理?可能报错 `[ setNilValueForKey:]` -- 服务器给的有名字为 id 的数据,OC 对象的属性名不能叫 id,如何处理? -- Person 中嵌套 Cat 呢?Person 对象有个 Cat 类型的属性 - -具体可以参考 YYModel - - - -### 替换方法实现 - -注意 - -- 类似 NSMutableArray 的时候,+load 方法进行方法替换的时候需要注意类簇的存在,比如 `__NSArrayM` - -- 方法交换一般写在类的 `+load` 方法中,且为了防止出问题,比如别人手动调用 load,代码需要加 `dispatch_once` - -```objectivec -void studentSayHi (void) { - NSLog(@"Student say hi"); -} -void changeMethodImpl (void){ - class_replaceMethod([Person class], @selector(sayHi), (IMP)studentSayHi, "v16@0:8"); - Person *p = [[Person alloc] init]; - [p sayHi]; -} -// Student say hi -``` - -上述代码可以换一种写法 - -```objectivec -class_replaceMethod([Person class], @selector(sayHi), imp_implementationWithBlock(^{ - NSLog(@"Student say hi"); -}), "v16@0:8"); -Person *p = [[Person alloc] init]; -[p sayHi]; -``` - -`imp_implementationWithBlock(id _Nonnull block)` 该方法将方法实现替换为包装好的 block - -```objectivec -Person *p = [[Person alloc] init]; -Method sleep = class_getInstanceMethod([Person class], @selector(sleep)); -Method sayHi = class_getInstanceMethod([Person class], @selector(sayHi)); -method_exchangeImplementations(sleep, sayHi); -[p sayHi]; // 人生无常,抓紧睡觉 -[p sleep]; // Person sayHi -``` - -runtime 方法交换的本质,就是交换类对象、元类对象的 class_rw_t 的 `method_array_t>` 里的 method_t 的 IMP。 - - - -### 无痕埋点 - -对 App 内所有的按钮点击事件进行监听并上报。发现 UIButton 继承自 UIControl,所以添加分类,在 load 方法内,替换方法实现。UIControl 存在方法 `sendAction:to:forEvent:` - -```objectivec -@implementation UIControl (Monitor) -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Method method1 = class_getInstanceMethod(self, @selector(sendAction:to:forEvent:)); - Method method2 = class_getInstanceMethod(self, @selector(lbp_sendAction:to:forEvent:)); - method_exchangeImplementations(method1, method2); - }); -} - -- (void)lbp_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { - NSLog(@"%@-%@-%@", self, target, NSStringFromSelector(action)); - // 调用系统原来的实现 - [self mj_sendAction:action to:target forEvent:event]; -// [target performSelector:action]; -} -@end -``` - -为了对业务代码无影响,在 hook 代码内部又要调用回去,所以需要调用原来的方法,此时因为交换方法实现,所以原来的方法应该是 `lbp_sendAction:to:forEvent:` - -`method_exchangeImplementations` 方法实现交换了,系统会清空缓存,调用 `flushCaches` 方法,内部调用 `cache_erase_nolock` 来清空方法缓存。 - -```c -void method_exchangeImplementations(Method m1, Method m2) -{ - if (!m1 || !m2) return; - rwlock_writer_t lock(runtimeLock); - IMP m1_imp = m1->imp; - m1->imp = m2->imp; - m2->imp = m1_imp; - - // RR/AWZ updates are slow because class is unknown - // Cache updates are slow because class is unknown - // fixme build list of classes whose Methods are known externally? - flushCaches(nil); - updateCustomRR_AWZ(nil, m1); - updateCustomRR_AWZ(nil, m2); -} - -static void flushCaches(Class cls) -{ - runtimeLock.assertWriting(); - mutex_locker_t lock(cacheUpdateLock); - if (cls) { - foreach_realized_class_and_subclass(cls, ^(Class c){ - cache_erase_nolock(c); - }); - } - else { - foreach_realized_class_and_metaclass(^(Class c){ - cache_erase_nolock(c); - }); - } -} -``` - - - -### 安全气垫 - -安全气垫就是对代码运行过程中出错的方法进行兜住,比如数组越界等。ROI 和带来的一些业务异常问题就见仁见智了。实现手段就是 runtime hook 然后更改方法实现。做一些安全判断。和网易大白的作者交流过,发现安全气垫的副作用,比如一个商品价格运算后异常,会 crash 这时候起码 crash 不能交易下单了。但是用了安全气垫,好处是不会 crash,缺点是给一个有问题的值,要么价格为0,要么为空。用户可以正常下单,这时候产生资损了。电商业务虾带来的业务异常问题比稳定性异常问题更严重。电商公司一般会给商家有资损保障,赔付率。 - -不写代码是因为没有什么难的地方,难点在于策略的选择。 - - - -### 单元测试的 mock - -单元测试框架很多都依赖了 Runtime 的能力,来 mock 方法返回值。比如 [OCMock](https://github.com/erikdoe/ocmock) 库。 - - - -### 热修复 - -动态替换方法实现修复线上 Bug。具体的可以看开源库 [JSPatch](https://github.com/bang590/JSPatch) - - - -### hook 库 - -比如 [Aspects](https://github.com/steipete/Aspects) 的实现。 - - - -## 总结 - -OC 是一门动态性很强的编程语言,允许很多操作推迟到程序运行时决定。OC 动态性其实就是由 Runtime 来实现的,Runtime 是一套 c 语言 api,封装了很多动态性相关函数。平时写的 oc 代码,底层大多都是转换为 Runtime api 进行调用的。 - -- 关联对象 - -- 遍历类的所有成员变量(可以访问私有变量,比如修改 UITextFiled 的 placeholder 颜色、字典转模型、自动归档解档) - -- 交换方法实现 - -- 扩大点击区域 - -- 利用消息转发机制,解决消息找不到的问题 - -- 无痕埋点 - -- 热修复 - -可以认为 oc 中对象上一个指向 CLassObject 地址的变量 `id obj = &ClassObject` - -而对象的实例变量 `void *ivar = &obj + offset(N*size)` 。根据 isa 也就是对象的基地址,然后偏移访问 ivar。 diff --git a/Chapter1 - iOS/1.83.md b/Chapter1 - iOS/1.83.md deleted file mode 100644 index b0aefea..0000000 --- a/Chapter1 - iOS/1.83.md +++ /dev/null @@ -1,418 +0,0 @@ -# NSURLProtocol 应用场景 - -> 在做 Hybrid 的时候就使用到 NSURLProtocol,对于网络监控依旧可以使用它,所以本文就总结下 NSURLProtocol 的应用场景和如何用 - -## 一、 NSURLProtocol 是什么 - -> An NSURLProtocol object handles the loading of protocol-specific URL data. The NSURLProtocol class itself is an abstract class that provides the infrastructure for processing URLs with a specific URL scheme. You create subclasses for any custom protocols or URL schemes that your app supports. - -![URL Loading System](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-18-URL-loading-system.png) - -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 进行网络请求,它好处多多,具体的自行查阅官方介绍。 - -## 五、 读源码,学习 NSURLProtocol - -iOS 中网络测试框架 [ OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs)的实现就是利用了 NSURLProtocol 实现的。 - -几个主要类及其功能:HTTPStubsProtocol 拦截网络请求;HTTPStubs 单例管理 HTTPStubsDescriptor 实例对象;HTTPStubsResponse 伪造 HTTP 请求。 - -HTTPStubsProtocol 继承自 NSURLProtocol,可以在 HTTP 请求发送之前对 request 进行过滤处理 - -```objective-c -+ (BOOL)canInitWithRequest:(NSURLRequest *)request -{ - BOOL found = ([HTTPStubs.sharedInstance firstStubPassingTestForRequest:request] != nil); - if (!found && HTTPStubs.sharedInstance.onStubMissingBlock) { - HTTPStubs.sharedInstance.onStubMissingBlock(request); - } - return found; -} -``` - -`firstStubPassingTestForRequest` 方法内部会判断请求是否需要被当前对象处理 - -紧接着开始发送网络请求。实际上在 `- (void)startLoading` 方法中可以用任何网络能力去完成请求,比如 NSURLSession、NSURLConnection、AFNetworking 或其他网络框架。OHHTTPStubs 的做法是获取 request、client 对象。如果 HTTPStubs 单例中包含 `onStubActivationBlock` 对象,则执行该 block,然后利用 responseBlock 对象返回一个 HTTPStubsResponse 响应对象。 - -## 六、 补充内容 - -### 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。 - -### 5. 拦截 WebView 内 Ajax 请求 - -其实上述的方法也是可行,不过使用私有 API 的方式不是很推荐,一般在穷途末路的时候才选择私有 API,所以另一种思路是 hook Web 端的 ajax 请求。在执行 hook 后的 ajax 请求的时候将 ajax 的请求相关信息(请求方式、header、body 等)以 messageHandler 的方式告诉 Native,然后起到监控的效果。 -参考: https://www.jianshu.com/p/7337ac624b8e;https://github.com/wendux/Ajax-hook - -1. 关于 WKWebview 的各种问题可以查看这篇[文章](https://www.tuicool.com/articles/QbE3Mb7)。 diff --git a/Chapter1 - iOS/1.84.md b/Chapter1 - iOS/1.84.md deleted file mode 100644 index f4fb414..0000000 --- a/Chapter1 - iOS/1.84.md +++ /dev/null @@ -1,6 +0,0 @@ -# WKWebView 技巧集 - -UIWebView 已经不被推荐了,所以工程中需要扫描有无使用 UIWebView。在工程终端命令行中执行下面代码 -```shell -find . -type f | grep -e ".a" -e ".framework" | xargs grep -s UIWebView -``` \ No newline at end of file diff --git a/Chapter1 - iOS/1.85.md b/Chapter1 - iOS/1.85.md deleted file mode 100644 index 59b0551..0000000 --- a/Chapter1 - iOS/1.85.md +++ /dev/null @@ -1,100 +0,0 @@ -# 统跳 - - -定义: - -使用统一的 URL 来描述特定的业务功能,并通过路由映射到任意资源目标,并基于此可实现 rewrite、埋点、监控、灰度、A/B 等功能 - - - -iOS非统跳接口调用检测设计方案 - -工作原理 - -由于ObjectC语言自身的限制,获取编译时的调用关系信息相对较为困难。因此,没有一个简便的方法获取到哪些接口调用了SDK中的方法。 - -ObjectC语言继承自C语言的头文件引用的机制,可以作为检测的点。即,如果App中调用了SDK中的某个方法,那么一定会直接或者间接引用了SDK中的某个头文件。通过检测引用头文件,引导iOS开发删除对应SDK的头文件,那么应该可以暴露出App中直接调用SDK接口的位置。从而达到辅助检测非统跳接口的目的。 - - -配置文件 -配置文件主要用于在检测过程中,提供工程的目录结构相关信息。在生成过程中,提供生成结果文件需要的额外信息。 - -```YAML -category: login_register_sdk -plist: Example/LoginRegisterSDK/LoginRegisterSDK-Info.plist -workspace: Example/LoginRegisterSDK.xcworkspace -xcodeproj: Example/LoginRegisterSDK.xcodeproj -scheme: LoginRegisterSDK-Example -source_directory: LoginRegisterSDK/ -pods_directory: Example/Pods -ignore: - - LoginRegisterSDK/**/*SDK.m -``` - - - - - -调用流程 - -```objective-c -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - // ... - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [[TNTRouter sharedRouter] manuallyFetchRoutingTableWithCompletion:^(BOOL succeeded) { - - }]; - }); - - return YES; -} -``` - -```objective-c -- (void)manuallyFetchRoutingTableWithCompletion:(TNTManualFetchRoutingTableCompletion)completion { - NSDictionary *params = [self getRouterRequestParams]; - [TMTrinityMGetTask manuallyFetchWithKey:TMNeutronRouterRewriteKey - params:params - moduleCallback:^(NSString * _Nullable result, NSError * _Nullable error) { - if (error) return ; - if (result.length == 0) return; - - // encryp & json - NSDictionary *routesAndWrites = [self routesAndRewriteForRemoteEncryptedString:result]; - - // routes is existed - if (routesAndWrites) { - // process routes - [self loadRoutesAndWritesFromRemote:routesAndWrites]; - - // save decrpy data string - [self saveRemoteRouteString:result]; - - if(completion) completion(true); - } - } callback:nil]; -} -``` - - - -# 参考资料 - -- [iOS performSelector传递两个以上参数](https://www.jianshu.com/p/4118793c88df) -- - - -之前看代码理解设计太慢了,不清楚设计背景,所以想请教你一下 - -1. 使用统一的 URL 来描述特定的业务功能,并通过路由映射到任意资源目标,并基于此可实现 rewrite、埋点、监控、灰度、A/B 等功能 -如何理解rewrite、埋点、监控、灰度、A/B 等功能。 - -用统跳 url 后,调用某 SDK ,区分 type,h5、RN、Native,native 则 runtime,performSelector, -H5、RN 没看到处理逻辑? - -2. 通跳的设计背景是什么?解决业务上什么问题?举个例子理解下工作流程 -- RoutingTableGenerator.rb 扫描工程 TNT_TARGET,写入 Targets.json 的目的是什么? -- DefaultRoutingTableFetcher.rb 调用接口,写入文件 -- ProjectConfigurator.rb 调用上述2个脚本,写入工程 script - -3. TrinityParams 和 TrinityConfigurator 2个 SDK 背景、作用分别是什么? diff --git a/Chapter1 - iOS/1.86.md b/Chapter1 - iOS/1.86.md deleted file mode 100644 index b7a043c..0000000 --- a/Chapter1 - iOS/1.86.md +++ /dev/null @@ -1,1290 +0,0 @@ -# GCD 源码探究 - -## 基础知识 - -Mach 是 XNU 的核心,被 BSD 层包装。XNU 由以下部分组成: - -- Mach 内核: - - 进程和线程抽象 - - 虚拟内存管理 - - 任务调度 - - 进程间通信和消息传递机制 -- BSD - - UNIX 进程模型 - - POSIX 线程模型 - - 网络协议栈 - - 文件系统访问 - - 设备访问 -- libKern -- I/O Kit - -Mach的独特之处在于选择了通过消息传递的方式实现对象与对象之间的通信。而其他架构一个对象要访问另一个对象需要通过一个大家都知道的接口,而Mach对象不能直接调用另一个对象,而是必须传递消息 - -一条消息就像网络包一样,定义为透明的 `blob(binary larger object`,二进制大对象),通过固定的包头进行分装 - -```c++ -typedef struct -{ - mach_msg_header_t header; - mach_msg_body_t body; -} mach_msg_base_t; - -typedef struct -{ - mach_msg_bits_t msgh_bits; // 消息头标志位 - mach_msg_size_t msgh_size; // 大小 - mach_port_t msgh_remote_port; // 目标(发消息)或源(接消息) - mach_port_t msgh_local_port; // 源(发消息)或目标(接消息) - mach_port_name_t msgh_voucher_port; - mach_msg_id_t msgh_id; // 唯一id -} mach_msg_header_t; -``` - - - -Mach消息的发送和接收都是通过同一个API函数`mach_msg()`进行的。这个函数在用户态和内核态都有实现。为了实现消息的发送和接收,`mach_msg()`函数调用了一个Mach陷阱(trap)。Mach陷阱就是Mach中和系统调用等同的概念。在用户态调用mach_msg_trap()会引发陷阱机制,切换到内核态,在内核态中,内核实现的`mach_msg()`会完成实际的工作。这个函数也将会在下面的源码分析中遇到。 - -每一个`BSD`进程都在底层关联一个`Mach`任务对象,因为`Mach`提供的都是非常底层的抽象,提供的API从设计上讲很基础且不完整,所以需要在这之上提供一个更高的层次以实现完整的功能。我们开发层遇到的进程和线程就是BSD层对`Mach`的任务和线程的复杂包装。 - -进程填充的是线程,而线程是二进制代码的实际执行单元。用户态的线程始于对`pthread_create`的调用。这个函数的又由`bsdthread_create`系统调用完成,而`bsdthread_create`又其实是`Mach`中的`thread_create`的复杂包装,说到底真正的线程创建还是有Mach层完成。 - -在`UNIX`中,进程不能被创建出来,都是通过fork()系统调用复制出来的。复制出来的进程都会被要加载的执行程序覆盖整个内存空间。 - -接着,了解下常用的宏和常用的数据结构 - - - -## 源码中常见的宏 - -### __builtin_expect - -这个其实是个函数,针对编译器优化的一个函数,后面几个宏是对这个函数的封装,所以提前拎出来说一下。写代码中我们经常会遇到条件判断语句 - -```objective-c -if (今天是工作日) { - printf("好好上班"); -} else { - printf("好好睡觉"); -} -``` - -CPU读取指令的时候并非一条一条的来读,而是多条一起加载进来,比如已经加载了if(今天是工作日) printf(“好好上班”);的指令,这时候条件式如果为非,也就是非工作日,那么CPU继续把printf(“好好睡觉”);这条指令加载进来,这样就造成了性能浪费的现象。 -`__builtin_expect`的第一个参数是实际值,第二个参数是预测值。使用这个目的是告诉编译器if条件式是不是有更大的可能被满足。 - - - -### likely、unlikely - -这个宏后其实是对 `__builtin_expect` 封装,likely表示更大可能成立,`unlikely`表示更大可能不成立。 - -```c++ -#define likely(x) __builtin_expect(!!(x), 1) -#define unlikely(x) __builtin_expect(!!(x), 0) -``` - -遇到 `if(likely(a == 0))` 理解成 `if(a==0)` 即可。 - - - -### fastpath、slowpath - -`fastpath` 表示更大可能成立,`slowpath` 表示更大可能不成立 - -```c++ -#define fastpath(x) ((typeof(x))__builtin_expect(_safe_cast_to_long(x), ~0l)) -#define slowpath(x) ((typeof(x))__builtin_expect(_safe_cast_to_long(x), 0l)) -``` - -### os_atomic_cmpxchg - -其内部就是 `atomic_compare_exchange_strong_explicit` 函数,这个函数的作用是:第二个参数与第一个参数值比较,如果相等,第三个参数的值替换第一个参数的值。如果不相等,把第一个参数的值赋值到第二个参数上 - -```c++ -#define os_atomic_cmpxchg(p, e, v, m) \ - ({ _os_atomic_basetypeof(p) _r = (e); \ - atomic_compare_exchange_strong_explicit(_os_atomic_c11_atomic(p), \ - &_r, v, memory_order_##m, memory_order_relaxed); }) -``` - - - -### os_atomic_store2o - -将第二个参数,保存到第一个参数 - -``` -#define os_atomic_store2o(p, f, v, m) os_atomic_store(&(p)->f, (v), m) -#define os_atomic_store(p, v, m) \ - atomic_store_explicit(_os_atomic_c11_atomic(p), v, memory_order_##m) -``` - - - -### os_atomic_inc_orig - -将1保存到第一个参数中 - -``` -#define os_atomic_inc_orig(p, m) os_atomic_add_orig((p), 1, m) -#define os_atomic_add_orig(p, v, m) _os_atomic_c11_op_orig((p), (v), m, add, +) -#define _os_atomic_c11_op_orig(p, v, m, o, op) \ - atomic_fetch_##o##_explicit(_os_atomic_c11_atomic(p), v, \ - memory_order_##m) -``` - - - - - -## 源码中的数据结构 - -### dispatch_queue_t - -```objective-c -typedef struct dispatch_queue_s *dispatch_queue_t; -``` - -`dispatch_queue_t` 是一个结构体指针 - -```objective-c -struct dispatch_queue_s { - _DISPATCH_QUEUE_HEADER(queue); - DISPATCH_QUEUE_CACHELINE_PADDING; -} DISPATCH_ATOMIC64_ALIGN; -``` - -解开 `_DISPATCH_QUEUE_HEADER` 后发现又一个 `DISPATCH_OBJECT_HEADER` 宏定义,继续拆解. - -还有一层宏 `_DISPATCH_OBJECT_HEADER` - -```c++ -#define _DISPATCH_QUEUE_HEADER(x) \ - struct os_mpsc_queue_s _as_oq[0]; \ - DISPATCH_OBJECT_HEADER(x); \ - _OS_MPSC_QUEUE_FIELDS(dq, dq_state); \ - uint32_t dq_side_suspend_cnt; \ - dispatch_unfair_lock_s dq_sidelock; \ - union { \ - dispatch_queue_t dq_specific_q; \ - struct dispatch_source_refs_s *ds_refs; \ - struct dispatch_timer_source_refs_s *ds_timer_refs; \ - struct dispatch_mach_recv_refs_s *dm_recv_refs; \ - }; \ - DISPATCH_UNION_LE(uint32_t volatile dq_atomic_flags, \ - const uint16_t dq_width, \ - const uint16_t __dq_opaque \ - ); \ - DISPATCH_INTROSPECTION_QUEUE_HEADER - - -#define DISPATCH_OBJECT_HEADER(x) \ - struct dispatch_object_s _as_do[0]; \ - _DISPATCH_OBJECT_HEADER(x) -``` - -`##` 可以理解为拼接成字符串,比如 x 为 group 的话,下面就会拼接为 dispatch_group - -```c++ -#define _DISPATCH_OBJECT_HEADER(x) \ - struct _os_object_s _as_os_obj[0]; \ - OS_OBJECT_STRUCT_HEADER(dispatch_##x); \ - struct dispatch_##x##_s *volatile do_next; \ - struct dispatch_queue_s *do_targetq; \ - void *do_ctxt; \ - void *do_finalizer -``` - - - -来到`OS_OBJECT_STRUCT_HEADER`之后,我们需要注意一个成员变量,记住这个成员变量名字叫做`do_vtable`。再继续拆解`_OS_OBJECT_HEADER`发现里面起就是一个`isa`指针和引用计数一些信息。 - -```objective-c -#define OS_OBJECT_STRUCT_HEADER(x) \ - _OS_OBJECT_HEADER(\ - const void *_objc_isa, \ - do_ref_cnt, \ - do_xref_cnt); \ - // 注意这个成员变量,后面将任务Push到队列就是通过这个变量 - const struct x##_vtable_s *do_vtable - -#define _OS_OBJECT_HEADER(isa, ref_cnt, xref_cnt) \ - isa; /* must be pointer-sized */ \ - int volatile ref_cnt; \ - int volatile xref_cnt -``` - - - -### dispatch_continuation_t - -结构体就是用来封装 block 对象的,保存 block的上下文环境和执行函数等。 - -```c++ -typedef struct dispatch_continuation_s { - struct dispatch_object_s _as_do[0]; - DISPATCH_CONTINUATION_HEADER(continuation); -} *dispatch_continuation_t; -``` - -看下里面的宏: `DISPATCH_CONTINUATION_HEADER` - -```c++ -#define DISPATCH_CONTINUATION_HEADER(x) \ - union { \ - const void *do_vtable; \ - uintptr_t dc_flags; \ - }; \ - union { \ - pthread_priority_t dc_priority; \ - int dc_cache_cnt; \ - uintptr_t dc_pad; \ - }; \ - struct dispatch_##x##_s *volatile do_next; \ - struct voucher_s *dc_voucher; \ - dispatch_function_t dc_func; \ - void *dc_ctxt; \ - void *dc_data; \ - void *dc_other -``` - -### dispatch_function_t - -`dispatch_function_t `只是一个函数指针 - -```c++ -typedef void (*dispatch_function_t)(void *_Nullable); -``` - - - -## 死锁 - -死锁堆栈报错:`_dispatch_sync_f_slow` - - - - - -## dispatch_queue_create - -创建队列,其内部又调用了 `_dispatch_queue_create_with_targe`t函数,`DISPATCH_TARGET_QUEUE_DEFAULT` 这个宏其实就是null - -```objective-c -dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr) - { // attr一般我们都是传DISPATCH_QUEUE_SERIAL、DISPATCH_QUEUE_CONCURRENT或者nil - // 而DISPATCH_QUEUE_SERIAL其实就是null - return _dispatch_queue_create_with_target(label, attr, - DISPATCH_TARGET_QUEUE_DEFAULT, true); - } -``` - -`_dispatch_queue_create_with_target`函数,这里会创建一个`root`队列,并将自己新建的队列绑定到所对应的`root`队列上。 - -``` -static dispatch_queue_t _dispatch_queue_create_with_target(const char *label, dispatch_queue_attr_t dqa, - dispatch_queue_t tq, bool legacy) -{ // 根据上文代码注释里提到的,作者认为调用者传入DISPATCH_QUEUE_SERIAL和nil的几率要大于传DISPATCH_QUEUE_CONCURRENT。所以这里设置个默认值。 - // 这里怎么理解呢?只要看做if(!dqa)即可 - if (!slowpath(dqa)) { - // _dispatch_get_default_queue_attr里面会将dqa的dqa_autorelease_frequency指定为DISPATCH_AUTORELEASE_FREQUENCY_INHERIT的,inactive也指定为false。这里就不展开了,只需要知道赋了哪些值。因为后面会用到。 - dqa = _dispatch_get_default_queue_attr(); - } else if (dqa->do_vtable != DISPATCH_VTABLE(queue_attr)) { - DISPATCH_CLIENT_CRASH(dqa->do_vtable, "Invalid queue attribute"); - } - - // 取出优先级 - dispatch_qos_t qos = _dispatch_priority_qos(dqa->dqa_qos_and_relpri); - - // overcommit单纯从英文理解表示过量使用的意思,那这里这个overcommit就是一个标识符,表示是不是就算负荷很高了,但还是得给我新开一个线程出来给我执行任务。 - _dispatch_queue_attr_overcommit_t overcommit = dqa->dqa_overcommit; - if (overcommit != _dispatch_queue_attr_overcommit_unspecified && tq) { - if (tq->do_targetq) { - DISPATCH_CLIENT_CRASH(tq, "Cannot specify both overcommit and " - "a non-global target queue"); - } - } - - // 如果overcommit没有被指定 - if (overcommit == _dispatch_queue_attr_overcommit_unspecified) { - // 所以对于overcommit,如果是串行的话默认是开启的,而并行是关闭的 - overcommit = dqa->dqa_concurrent ? - _dispatch_queue_attr_overcommit_disabled : - _dispatch_queue_attr_overcommit_enabled; - } - - // 之前说过初始化队列默认传了DISPATCH_TARGET_QUEUE_DEFAULT,也就是null,所以进入if语句。 - if (!tq) { - // 获取一个管理自己队列的root队列。 - tq = _dispatch_get_root_queue( - qos == DISPATCH_QOS_UNSPECIFIED ? DISPATCH_QOS_DEFAULT : qos, - overcommit == _dispatch_queue_attr_overcommit_enabled); - if (slowpath(!tq)) { - DISPATCH_CLIENT_CRASH(qos, "Invalid queue attribute"); - } - } - - // legacy默认是true的 - if (legacy) { - // 之前说过,默认是会给dqa_autorelease_frequency指定为DISPATCH_AUTORELEASE_FREQUENCY_INHERIT,所以这个判断式是成立的 - if (dqa->dqa_inactive || dqa->dqa_autorelease_frequency) { - legacy = false; - } - } - - // vtable变量很重要,之后会被赋值到之前说的dispatch_queue_t结构体里的do_vtable变量上 - const void *vtable; - dispatch_queue_flags_t dqf = 0; - - // legacy变为false了 - if (legacy) { - vtable = DISPATCH_VTABLE(queue); - } else if (dqa->dqa_concurrent) { - // 如果创建队列的时候传了DISPATCH_QUEUE_CONCURRENT,就是走这里 - vtable = DISPATCH_VTABLE(queue_concurrent); - } else { - // 如果创建线程没有指定为并行队列,无论你传DISPATCH_QUEUE_SERIAL还是nil,都会创建一个串行队列。 - vtable = DISPATCH_VTABLE(queue_serial); - } - - if (label) { - // 判断传进来的字符串是否可变的,如果可变的copy成一份不可变的 - const char *tmp = _dispatch_strdup_if_mutable(label); - if (tmp != label) { - dqf |= DQF_LABEL_NEEDS_FREE; - label = tmp; - } - } - - // _dispatch_object_alloc里面就将vtable赋值给do_vtable变量上了。 - dispatch_queue_t dq = _dispatch_object_alloc(vtable, - sizeof(struct dispatch_queue_s) - DISPATCH_QUEUE_CACHELINE_PAD); - // 第三个参数根据是否并行队列,如果不是则最多开一个线程,如果是则最多开0x1000 - 2个线程,这个数量很惊人了已经,换成十进制就是(4096 - 2)个。 - // dqa_inactive之前说串行是false的 - // DISPATCH_QUEUE_ROLE_INNER 也是0,所以这里串行队列的话dqa->dqa_state是0 - _dispatch_queue_init(dq, dqf, dqa->dqa_concurrent ? - DISPATCH_QUEUE_WIDTH_MAX : 1, DISPATCH_QUEUE_ROLE_INNER | - (dqa->dqa_inactive ? DISPATCH_QUEUE_INACTIVE : 0)); - - dq->dq_label = label; -#if HAVE_PTHREAD_WORKQUEUE_QOS - dq->dq_priority = dqa->dqa_qos_and_relpri; - if (overcommit == _dispatch_queue_attr_overcommit_enabled) { - dq->dq_priority |= DISPATCH_PRIORITY_FLAG_OVERCOMMIT; - } -#endif - _dispatch_retain(tq); - if (qos == QOS_CLASS_UNSPECIFIED) { - _dispatch_queue_priority_inherit_from_target(dq, tq); - } - if (!dqa->dqa_inactive) { - _dispatch_queue_inherit_wlh_from_target(dq, tq); - } - // 自定义的queue的目标队列是root队列 - dq->do_targetq = tq; - _dispatch_object_debug(dq, "%s", __func__); - return _dispatch_introspection_queue_create(dq); -} -``` - -这个函数里面还是有几个重要的地方拆出来看下: - -首先是创建一个 `root` 队列 `_dispatch_get_root_queue` 函数。取`root`队列,一般是从一个装有12个`root`队列数组里面取 - -```c++ -static inline dispatch_queue_t -_dispatch_get_root_queue(dispatch_qos_t qos, bool overcommit) -{ - if (unlikely(qos == DISPATCH_QOS_UNSPECIFIED || qos > DISPATCH_QOS_MAX)) { - DISPATCH_CLIENT_CRASH(qos, "Corrupted priority"); - } - return &_dispatch_root_queues[2 * (qos - 1) + overcommit]; -} -``` - -看下这个`_dispatch_root_queues`数组。我们可以看到,每一个优先级都有对应的`root`队列,每一个优先级又分为是不是可以过载的队列。 - -```c++ -struct dispatch_queue_s _dispatch_root_queues[] = { -#define _DISPATCH_ROOT_QUEUE_IDX(n, flags) \ - ((flags & DISPATCH_PRIORITY_FLAG_OVERCOMMIT) ? \ - DISPATCH_ROOT_QUEUE_IDX_##n##_QOS_OVERCOMMIT : \ - DISPATCH_ROOT_QUEUE_IDX_##n##_QOS) -#define _DISPATCH_ROOT_QUEUE_ENTRY(n, flags, ...) \ - [_DISPATCH_ROOT_QUEUE_IDX(n, flags)] = { \ - DISPATCH_GLOBAL_OBJECT_HEADER(queue_root), \ - .dq_state = DISPATCH_ROOT_QUEUE_STATE_INIT_VALUE, \ - .do_ctxt = &_dispatch_root_queue_contexts[ \ - _DISPATCH_ROOT_QUEUE_IDX(n, flags)], \ - .dq_atomic_flags = DQF_WIDTH(DISPATCH_QUEUE_WIDTH_POOL), \ - .dq_priority = _dispatch_priority_make(DISPATCH_QOS_##n, 0) | flags | \ - DISPATCH_PRIORITY_FLAG_ROOTQUEUE | \ - ((flags & DISPATCH_PRIORITY_FLAG_DEFAULTQUEUE) ? 0 : \ - DISPATCH_QOS_##n << DISPATCH_PRIORITY_OVERRIDE_SHIFT), \ - __VA_ARGS__ \ - } - _DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, 0, - .dq_label = "com.apple.root.maintenance-qos", - .dq_serialnum = 4, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.maintenance-qos.overcommit", - .dq_serialnum = 5, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, 0, - .dq_label = "com.apple.root.background-qos", - .dq_serialnum = 6, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.background-qos.overcommit", - .dq_serialnum = 7, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, 0, - .dq_label = "com.apple.root.utility-qos", - .dq_serialnum = 8, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.utility-qos.overcommit", - .dq_serialnum = 9, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT, DISPATCH_PRIORITY_FLAG_DEFAULTQUEUE, - .dq_label = "com.apple.root.default-qos", - .dq_serialnum = 10, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT, - DISPATCH_PRIORITY_FLAG_DEFAULTQUEUE | DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.default-qos.overcommit", - .dq_serialnum = 11, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, 0, - .dq_label = "com.apple.root.user-initiated-qos", - .dq_serialnum = 12, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.user-initiated-qos.overcommit", - .dq_serialnum = 13, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, 0, - .dq_label = "com.apple.root.user-interactive-qos", - .dq_serialnum = 14, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.user-interactive-qos.overcommit", - .dq_serialnum = 15, - ), -}; -``` - -其中 `DISPATCH_GLOBAL_OBJECT_HEADER(queue_root)`,解析到最后是 `OSdispatch##name##_class` 这样的,对应的实例对象是如下代码,指定了`root`队列各个操作对应的函数 - -```c++ -DISPATCH_VTABLE_SUBCLASS_INSTANCE(queue_root, queue, - .do_type = DISPATCH_QUEUE_GLOBAL_ROOT_TYPE, - .do_kind = "global-queue", - .do_dispose = _dispatch_pthread_root_queue_dispose, - .do_push = _dispatch_root_queue_push, - .do_invoke = NULL, - .do_wakeup = _dispatch_root_queue_wakeup, - .do_debug = dispatch_queue_debug, -); -``` - - - - - -## dispatch_group_t - -### dispatch_group_t 本质 - -```c++ -dispatch_queue_t group1 = dispatch_group_create(); -``` - -下断点后可以看到 `dispatch_group_create` 是位于 `libdispatch.dylib` 库中的。 - - - -打开 `libdispatch.dylib` 搜索 `dispatch_group_create`。可以看到源码实现。 - -```c++ -DISPATCH_ALWAYS_INLINE -static inline dispatch_group_t -_dispatch_group_create_with_count(uint32_t n) -{ - dispatch_group_t dg = _dispatch_object_alloc(DISPATCH_VTABLE(group), - sizeof(struct dispatch_group_s)); - dg->do_next = DISPATCH_OBJECT_LISTLESS; - dg->do_targetq = _dispatch_get_default_queue(false); - if (n) { - os_atomic_store(&dg->dg_bits, - (uint32_t)-n * DISPATCH_GROUP_VALUE_INTERVAL, relaxed); - _dispatch_retain(dg); // - } - return dg; -} -``` - -`dispatch_group_create` 调用 `_dispatch_group_create_with_count`, `_dispatch_group_create_with_count` 调用 `_dispatch_object_alloc`,有2个参数:`DISPATCH_VTABLE(group)` 和 `sizeof(struct dispatch_group_s)` - - - -在汇编调试模式下,对其打印输出,对于 x86 架构的汇编,第一个参数存放在 rdi,第二个参数存放在 rsi 中。 - -第一个参数值就是 `OS_dispatch_group`,用来声明当前类的名称。第二个参数其实就是 dispatch_group_t 的内存大小,为72字节。 - - - -汇编代码继续向下执行,输入 si,最后到 `_os_object_alloc_realized` 的地方。通过汇编可以看到内部调用了 `class_createInstance` 一看就是在做内存分配的事情。 - -然后顺着源码看看,左侧可以看到内部就是调用 `calloc` 分配的内存。 - - - -那 `dispatch_group_t` 是什么就很明显了。和 id 一样,是一个结构体指针。 - -- id 是 `objc_object` 结构体指针 - -- `dispatch_group_t` 是 `dispatch_group_s` 结构体指针 - -```objective-c -struct dispatch_group_s *dispatch_group_t - -typedef struct dispatch_group_s *dispatch_group_t; - -struct dispatch_group_s { - DISPATCH_OBJECT_HEADER(group); - DISPATCH_UNION_LE(uint64_t volatile dg_state, - uint32_t dg_bits, - uint32_t dg_gen - ) DISPATCH_ATOMIC64_ALIGN; - struct dispatch_continuation_s *volatile dg_notify_head; - struct dispatch_continuation_s *volatile dg_notify_tail; -}; -``` - - - -### dispatch_group_enter - -对 `dispach_group_enter` 下断点,可以看到如下图 - - - -一进断点就查看寄存器的值,因为 dispatch_group_enter 函数就一个参数,所以直接读取寄存器 rdi 的值,可以看到就是 `dispatch_group_t` 对象。其中 count 为0. - -```c++ -void -dispatch_group_enter(dispatch_group_t dg) -{ - // The value is decremented on a 32bits wide atomic so that the carry - // for the 0 -> -1 transition is not propagated to the upper 32bits. - uint32_t old_bits = os_atomic_sub_orig(&dg->dg_bits, - DISPATCH_GROUP_VALUE_INTERVAL, acquire); - uint32_t old_value = old_bits & DISPATCH_GROUP_VALUE_MASK; - if (unlikely(old_value == 0)) { - _dispatch_retain(dg); // - } - if (unlikely(old_value == DISPATCH_GROUP_VALUE_MAX)) { - DISPATCH_CLIENT_CRASH(old_bits, - "Too many nested calls to dispatch_group_enter()"); - } -} -``` - -当经过第6行汇编后继续查看,可以发现其 count 的值已经加1了。 - -`unlikely`是一个宏,用于指示编译器这个条件分支不太可能发生,从而优化代码。 - -这行代码检查操作前的引用计数是否为0。如果是0,说明这是第一次进入调度组,需要增加调度组的引用计数。 - - - -### dispatch_group_leave - -与 `dispatch_group_enter` 成对存在。一个 +1,一个-1 - -```c++ -void -dispatch_group_leave(dispatch_group_t dg) -{ - // The value is incremented on a 64bits wide atomic so that the carry for - // the -1 -> 0 transition increments the generation atomically. - uint64_t new_state, old_state = os_atomic_add_orig(&dg->dg_state, - DISPATCH_GROUP_VALUE_INTERVAL, release); - uint32_t old_value = (uint32_t)(old_state & DISPATCH_GROUP_VALUE_MASK); - - if (unlikely(old_value == DISPATCH_GROUP_VALUE_1)) { - old_state += DISPATCH_GROUP_VALUE_INTERVAL; - do { - new_state = old_state; - if ((old_state & DISPATCH_GROUP_VALUE_MASK) == 0) { - new_state &= ~DISPATCH_GROUP_HAS_WAITERS; - new_state &= ~DISPATCH_GROUP_HAS_NOTIFS; - } else { - // If the group was entered again since the atomic_add above, - // we can't clear the waiters bit anymore as we don't know for - // which generation the waiters are for - new_state &= ~DISPATCH_GROUP_HAS_NOTIFS; - } - if (old_state == new_state) break; - } while (unlikely(!os_atomic_cmpxchgv(&dg->dg_state, - old_state, new_state, &old_state, relaxed))); - return _dispatch_group_wake(dg, old_state, true); - } - - if (unlikely(old_value == 0)) { - DISPATCH_CLIENT_CRASH((uintptr_t)old_value, - "Unbalanced call to dispatch_group_leave()"); - } -} -``` - -``if (unlikely(old_value == DISPATCH_GROUP_VALUE_1))` :这行代码检查操作前的引用计数是否为1,这意味着如果这次调用后引用计数变为0,表示所有任务都已完成 - -可以看到内部,当所有任务都已完成,调用 `_dispatch_group_wake` 唤醒等待调度组的线程。 - - - -### dispatch_group_notify - -```c++ -DISPATCH_ALWAYS_INLINE -static inline void -_dispatch_group_notify(dispatch_group_t dg, dispatch_queue_t dq, - dispatch_continuation_t dsn) -{ - dsn->dc_data = dq; - dsn->do_next = NULL; - _dispatch_retain(dq); - // 更新链表,将新的任务放到链表尾部 - if (os_mpsc_push_update_tail(dg, dg_notify, dsn, do_next)) { - _dispatch_retain(dg); - os_atomic_store2o(dg, dg_notify_head, dsn, ordered); - // seq_cst with atomic store to notify_head - // 取出 dg_value,和0比较。等于0,则说明没有要执行的任务了,则唤醒 - if (os_atomic_load2o(dg, dg_value, ordered) == 0) { - _dispatch_group_wake(dg, false); - } - } -} -``` - -它用于设置一个通知,当一个调度组(`dispatch_group_t`)中的所有任务完成时,会执行 `_dispatch_group_wake` - -```c++ -DISPATCH_NOINLINE -static long -_dispatch_group_wake(dispatch_group_t dg, bool needs_release) -{ - dispatch_continuation_t next, head, tail = NULL; - long rval; - - // cannot use os_mpsc_capture_snapshot() because we can have concurrent - // _dispatch_group_wake() calls - // 原子性的存取操作,取出 head - head = os_atomic_xchg2o(dg, dg_notify_head, NULL, relaxed); - if (head) { - // 有 head,则取出尾部 - // snapshot before anything is notified/woken - tail = os_atomic_xchg2o(dg, dg_notify_tail, NULL, release); - } - // 调用 dispatch_group_wait 函数阻塞的任务 - rval = (long)os_atomic_xchg2o(dg, dg_waiters, 0, relaxed); - if (rval) { - // wake group waiters - _dispatch_sema4_create(&dg->dg_sema, _DSEMA4_POLICY_FIFO); - _dispatch_sema4_signal(&dg->dg_sema, rval); - } - uint16_t refs = needs_release ? 1 : 0; // - if (head) { - // async group notify blocks - do { - next = os_mpsc_pop_snapshot_head(head, tail, do_next); - dispatch_queue_t dsn_queue = (dispatch_queue_t)head->dc_data; - // 循环内,在目标队列执行任务 - _dispatch_continuation_async(dsn_queue, head); - _dispatch_release(dsn_queue); - } while ((head = next)); - refs++; - } - if (refs) _dispatch_release_n(dg, refs); - return 0; -} -``` - - - - - -### dispatch_group_wait - - - -```c++ -long -dispatch_group_wait(dispatch_group_t dg, dispatch_time_t timeout) -{ - if (dg->dg_value == 0) { - return 0; - } - if (timeout == 0) { - return _DSEMA4_TIMEOUT(); - } - return _dispatch_group_wait_slow(dg, timeout); -} - - -DISPATCH_NOINLINE -static long -_dispatch_group_wait_slow(dispatch_group_t dg, dispatch_time_t timeout) -{ - long value; - int orig_waiters; - - // check before we cause another signal to be sent by incrementing - // dg->dg_waiters - value = os_atomic_load2o(dg, dg_value, ordered); // 19296565 - if (value == 0) { - return _dispatch_group_wake(dg, false); - } - - (void)os_atomic_inc2o(dg, dg_waiters, relaxed); - // check the values again in case we need to wake any threads - value = os_atomic_load2o(dg, dg_value, ordered); // 19296565 - if (value == 0) { - _dispatch_group_wake(dg, false); - // Fall through to consume the extra signal, forcing timeout to avoid - // useless setups as it won't block - timeout = DISPATCH_TIME_FOREVER; - } - - _dispatch_sema4_create(&dg->dg_sema, _DSEMA4_POLICY_FIFO); - switch (timeout) { - default: - if (!_dispatch_sema4_timedwait(&dg->dg_sema, timeout)) { - break; - } - // Fall through and try to undo the earlier change to - // dg->dg_waiters - case DISPATCH_TIME_NOW: - orig_waiters = dg->dg_waiters; - while (orig_waiters) { - if (os_atomic_cmpxchgvw2o(dg, dg_waiters, orig_waiters, - orig_waiters - 1, &orig_waiters, relaxed)) { - return _DSEMA4_TIMEOUT(); - } - } - // Another thread is running _dispatch_group_wake() - // Fall through and drain the wakeup. - case DISPATCH_TIME_FOREVER: - _dispatch_sema4_wait(&dg->dg_sema); - break; - } - return 0; -} -``` - -主要是根据信号量来处理 wait 逻辑。 - - - - - -## 线程 - -线程的生命周期分为5种:新建 -> 就绪 -> 运行 -> 阻塞 -> 死亡 - - - -- 新建:使用 `new` 实例化一个线程对象,但该线程对象还未使用 `start()` 方法启动线程这个阶段,该阶段只在内存的堆中为该对象的实例变量分配了内存空间,但线程还**无法参与抢夺CPU的使用权** -- 就绪:一个线程对象调用 `start()` 方法将线程加入到 **可调度线程池**,同时也变成就绪状态,等待 CPU 来调度执行 -- 运行:当 CPU 开始调度处于就绪状态的线程时,此时线程才是真正执行,进入运行状态。线程要想进入运行状态,必须要进入就绪状态。运行状态和就绪状态会来回切换,是 CPU 调度的结果。 -- 阻塞:处于运行中的线程,由于某种原因(sleep、等待同步锁、从可调度线程池移除等),会进入阻塞状态。比如 `sleepUntilDate`、`synchronized` 等 api 可使线程进入阻塞状态 -- 死亡: - - 正常死亡:线程执行完毕 - - 非正常死亡:当线程因异常而退出,或调用 `exit` - - - -## 可调度线程池 - - - -有新任务过来,会先判断线程池是否都在执行任务: - -- 如果没有,则会创建线程执行任务 -- 如果都在执行,就会检查工作队列是否饱满: - - 如果没有饱满,则会将任务存储在工作队列 - - 如果饱满,则会判断线程是否处于执行状态 - - 如果没有,则安排非核心线程去执行 - - 如果都在执行状态,则交给**饱和策略**去处理 - - - -## 饱和策略 - -- `AbortPolicy`: 直接抛出 `RejectedExecutionExeception` 异常来阻止系统正常运行 -- `CallerRunsPolicy`:将任务回退到调用者 -- `DisOldestPolicy`:丢掉等待最久的任务 -- `DisCardPolicy`:直接丢弃任务 - - - -## 优先级 - -`IO密集型` 的线程特点是频繁等待,而 `CPU密集型` 则很少等待,所以`CPU密集型`的优先级要高,但优先级提高后也不一定执行,只是比低优先级的线程更可能运行。 - -可以通过 `NSThread` 中的 `setThreadPriority:`,或 `POSIX` 的 `pthread_setschedparam` 方法来设置优先级 - - - -## 队列 - -GCD 有12个队列。 - -```c++ -struct dispatch_queue_global_s _dispatch_root_queues[] = { -#define _DISPATCH_ROOT_QUEUE_IDX(n, flags) \ - ((flags & DISPATCH_PRIORITY_FLAG_OVERCOMMIT) ? \ - DISPATCH_ROOT_QUEUE_IDX_##n##_QOS_OVERCOMMIT : \ - DISPATCH_ROOT_QUEUE_IDX_##n##_QOS) -#define _DISPATCH_ROOT_QUEUE_ENTRY(n, flags, ...) \ - [_DISPATCH_ROOT_QUEUE_IDX(n, flags)] = { \ - DISPATCH_GLOBAL_OBJECT_HEADER(queue_global), \ - .dq_state = DISPATCH_ROOT_QUEUE_STATE_INIT_VALUE, \ - .do_ctxt = _dispatch_root_queue_ctxt(_DISPATCH_ROOT_QUEUE_IDX(n, flags)), \ - .dq_atomic_flags = DQF_WIDTH(DISPATCH_QUEUE_WIDTH_POOL), \ - .dq_priority = flags | ((flags & DISPATCH_PRIORITY_FLAG_FALLBACK) ? \ - _dispatch_priority_make_fallback(DISPATCH_QOS_##n) : \ - _dispatch_priority_make(DISPATCH_QOS_##n, 0)), \ - __VA_ARGS__ \ - } - _DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, 0, - .dq_label = "com.apple.root.maintenance-qos", - .dq_serialnum = 4, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.maintenance-qos.overcommit", - .dq_serialnum = 5, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, 0, - .dq_label = "com.apple.root.background-qos", - .dq_serialnum = 6, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.background-qos.overcommit", - .dq_serialnum = 7, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, 0, - .dq_label = "com.apple.root.utility-qos", - .dq_serialnum = 8, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.utility-qos.overcommit", - .dq_serialnum = 9, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT, DISPATCH_PRIORITY_FLAG_FALLBACK, - .dq_label = "com.apple.root.default-qos", - .dq_serialnum = 10, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT, - DISPATCH_PRIORITY_FLAG_FALLBACK | DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.default-qos.overcommit", - .dq_serialnum = 11, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, 0, - .dq_label = "com.apple.root.user-initiated-qos", - .dq_serialnum = 12, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.user-initiated-qos.overcommit", - .dq_serialnum = 13, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, 0, - .dq_label = "com.apple.root.user-interactive-qos", - .dq_serialnum = 14, - ), - _DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT, - .dq_label = "com.apple.root.user-interactive-qos.overcommit", - .dq_serialnum = 15, - ), -}; -``` - - - -- userInteractive、default、unspecified、userInitiated、utility 6个,他们的 overcommit 版本6个。 支持 overcommit 的队列在创建队列时无论系统是否有足够的资源都会重新开一个线程。 - - 串行队列和主队列是 overcommit 的,创建队列会创建1个新的线程。并行队列是非 overcommit 的,不一定会新建线程,会从线程池中的 64 个线程中获取并使用。 - -- 优先级 `userInteractive > default > unspecified > userInitiated > utility > background` - -- 全局队列是root队列。 - -一个队列最多64个线程同时工作。 - - - -其实我们平时用到的全局队列也是其中一个root队列,这个只要查看 `dispatch_get_global_queue` - -```c++ -dispatch_queue_global_t -dispatch_get_global_queue(intptr_t priority, uintptr_t flags) -{ - dispatch_assert(countof(_dispatch_root_queues) == - DISPATCH_ROOT_QUEUE_COUNT); - - if (flags & ~(unsigned long)DISPATCH_QUEUE_OVERCOMMIT) { - return DISPATCH_BAD_INPUT; - } - dispatch_qos_t qos = _dispatch_qos_from_queue_priority(priority); -#if !HAVE_PTHREAD_WORKQUEUE_QOS - if (qos == QOS_CLASS_MAINTENANCE) { - qos = DISPATCH_QOS_BACKGROUND; - } else if (qos == QOS_CLASS_USER_INTERACTIVE) { - qos = DISPATCH_QOS_USER_INITIATED; - } -#endif - if (qos == DISPATCH_QOS_UNSPECIFIED) { - return DISPATCH_BAD_INPUT; - } - return _dispatch_get_root_queue(qos, flags & DISPATCH_QUEUE_OVERCOMMIT); -} -``` - - - -## 线程池 - -共两个线程池。一个是主线程池,另一个是除了主线程池之外的线程池。 - - - -## 奇葩问题 - -### 滥用单例之dispatch_once死锁 - -遇到一个神奇的 crash,堆栈如下 - -```objective-c -Application Specific Information: -com.***.*** failed to scene-create in time - -Elapsed total CPU time (seconds): hhh秒 (user hhh, system 0.000), k% CPU -Elapsed application CPU time (seconds): 0.h秒, k% CPU - -Thread 0 name: Dispatch queue: com.apple.main-thread -Thread 0: -0 libsystem_kernel.dylib 0x36cb2540 semaphore_wait_trap + 8 -1 libsystem_platform.dylib 0x36d3d430 _os_semaphore_wait + 8 -2 libdispatch.dylib 0x36be04a6 dispatch_once_f + 250 -3 xxxx 偏移量 0x4000 + 947290 -// ... -``` - -重点关注 `com.xxx.yyy failed to scene-create in time`,这句话提示我们:我们的应用程序在:**规定的时间没能加载成功,无法显示。看起来这个原因是启动加载过长直接被干掉**。那么问题来了,原因具体是啥? - -复现 Demo - -```objective-c -@implementation ManagerA -+ (ManageA *)sharedInstance { - static ManagerA *manager = nil; - static dispatch_once_t token; - dispatch_once(&token, ^{ - manager = [[ManagerA alloc] init]; - }); - return manager; -} - -- (instancetype)init { - if (self = [super init]) { - [ManagerB sharedInstance]; - } - return self; -} -@end - -@implementation ManagerB -+ (ManageB *)sharedInstance { - static ManagerB *manager = nil; - static dispatch_once_t token; - dispatch_once(&token, ^{ - manager = [[ManagerB alloc] init]; - }); - return manager; -} - -- (instancetype)init { - if (self = [super init]) { - [ManagerA sharedInstance]; - } - return self; -} -``` - -crash 堆栈如下 - -```shell -#0 0x000000011054acd2 in semaphore_wait_trap () -#1 0x00000001101b1b1a in _dispatch_thread_semaphore_wait () -#2 0x00000001101b1d48 in dispatch_once_f () -#3 0x000000010d01c857 in _dispatch_once [inlined] at once.h:68 -#4 0x000000010d01c839 in +[ManageA sharedInstance] at ManageA.m:18 -#5 0x000000010d01cad8 in -[ManageB init] at ManageA.m:54 -#6 0x000000010d01ca42 in __25+[ManageB sharedInstance]_block_invoke at ManageA.m:44 -#7 0x00000001101c649b in _dispatch_client_callout () -#8 0x00000001101b1e28 in dispatch_once_f () -#9 0x000000010d01c9e7 in _dispatch_once [inlined] at once.h:68 -#10 0x000000010d01c9c9 in +[ManageB sharedInstance] at ManageA.m:43 -#11 0x000000010d01c948 in -[ManageA init] at ManageA.m:29 -#12 0x000000010d01c8b2 in __25+[ManageA sharedInstance]_block_invoke at ManageA.m:19 -#13 0x00000001101c649b in _dispatch_client_callout () -#14 0x00000001101b1e28 in dispatch_once_f () -#15 0x000000010d01c857 in _dispatch_once [inlined] at once.h:68 -#16 0x000000010d01c839 in +[ManageA sharedInstance] at /ManageA.m:18 -#17 0x000000010d01c5cc in -[AppDelegate application:didFinishLaunchingWithOptions:] at /AppDelegate.m:21 -``` - -嫌疑点: `sharedInstance` 和 `dispatch_once_f` 多次出现。查阅之后发现 `dispatch_once_f` 函数造成了信号量永久等待,从而死锁。为什么 dispatch_once 会死锁,难道单例写法不是安全的? - -查看 dispatch_once 源码(libdispatch,剔除注释部分) - -```c++ -#include "internal.h" - -#undef dispatch_once -#undef dispatch_once_f - -struct _dispatch_once_waiter_s { - volatile struct _dispatch_once_waiter_s *volatile dow_next; - _dispatch_thread_semaphore_t dow_sema; -}; - -#define DISPATCH_ONCE_DONE ((struct _dispatch_once_waiter_s *)~0l) - -#ifdef __BLOCKS__ -// 1. 我们的应用程序调用的入口 -void -dispatch_once(dispatch_once_t *val, dispatch_block_t block) -{ - struct Block_basic *bb = (void *)block; - - // 2. 内部逻辑 - dispatch_once_f(val, block, (void *)bb->Block_invoke); -} -#endif - -DISPATCH_NOINLINE -void -dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func) -{ -#if !DISPATCH_ONCE_INLINE_FASTPATH - if (likely(os_atomic_load(val, acquire) == DLOCK_ONCE_DONE)) { - return; - } -#endif // !DISPATCH_ONCE_INLINE_FASTPATH - return dispatch_once_f_slow(val, ctxt, func); -} - -// 通过包装token作为唯一标识,判断(v == DLOCK_ONCE_DONE)是否已经完成初始化,实际就是判断是否调用过block(func内部调用)。如果已经初始化完成,那么v == DLOCK_ONCE_DONE,直接返回;如果还未初始化完成则往下走。 - -DISPATCH_NOINLINE -void -dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func) -{ - struct _dispatch_once_waiter_s * volatile *vval = - (struct _dispatch_once_waiter_s**)val; - - // 3. 地址类似于简单的哨兵位 - struct _dispatch_once_waiter_s dow = { NULL, 0 }; - - // 4. 在Dispatch_Once的block执行期进入的dispatch_once_t更改请求的链表 - struct _dispatch_once_waiter_s *tail, *tmp; - - // 5.局部变量,用于在遍历链表过程中获取每一个在链表上的更改请求的信号量 - _dispatch_thread_semaphore_t sema; - - // 6. Compare and Swap(用于首次更改请求) - if (dispatch_atomic_cmpxchg(vval, NULL, &dow)) { - dispatch_atomic_acquire_barrier(); - - // 7.调用dispatch_once的block - _dispatch_client_callout(ctxt, func); - - dispatch_atomic_maximally_synchronizing_barrier(); - //dispatch_atomic_release_barrier(); // assumed contained in above - - // 8. 更改请求成为DISPATCH_ONCE_DONE(原子性的操作) - tmp = dispatch_atomic_xchg(vval, DISPATCH_ONCE_DONE); - tail = &dow; - - // 9. 发现还有更改请求,继续遍历 - while (tail != tmp) { - - // 10. 如果这个时候tmp的next指针还没更新完毕,等一会 - while (!tmp->dow_next) { - _dispatch_hardware_pause(); - } - - // 11. 取出当前的信号量,告诉等待者,我这次更改请求完成了,轮到下一个了 - sema = tmp->dow_sema; - tmp = (struct _dispatch_once_waiter_s*)tmp->dow_next; - _dispatch_thread_semaphore_signal(sema); - } - } else { - // 12. 非首次请求,进入这块逻辑块 - dow.dow_sema = _dispatch_get_thread_semaphore(); - for (;;) { - // 13. 遍历每一个后续请求,如果状态已经是Done,直接进行下一个 - // 同时该状态检测还用于避免在后续wait之前,信号量已经发出(signal)造成 - // 的死锁 - tmp = *vval; - if (tmp == DISPATCH_ONCE_DONE) { - break; - } - dispatch_atomic_store_barrier(); - // 14. 如果当前dispatch_once执行的block没有结束,那么就将这些 - // 后续请求添加到链表当中 - if (dispatch_atomic_cmpxchg(vval, tmp, &dow)) { - dow.dow_next = tmp; - _dispatch_thread_semaphore_wait(dow.dow_sema); - } - } - _dispatch_put_thread_semaphore(dow.dow_sema); - } -} -``` - -分析: - -- dispatch_once 并不是只执行1次那么简单 - -- dispatch_once 本质上可以接受多次请求,会对此维护一个请求链表 - -- 如果在 block 执行期间,多次进入调用同类的 dispatch_once 函数(即单例函数)会导致整体链表无限增长,造成永久性死锁。事实上只要进入2次就有问题,原因是 block_invoke 的完成依赖于第二次进入的请求的完成,而第二次请求的完成又必须依赖于之前信号量的出发。可是第一次 block 不结束,信号量就不会发出。(block调用完成之后通过_dispatch_once_gate_broadcast广播通知初始化完成,把v标记为DLOCK_ONCE_DONE,并释放锁。 - - 如果block正在调用,有其他线程进来,这时候它们尝试获取锁失败,进入等待状态。等到第一次进来初始化完成之后发出广播,它们收到消息立即返回。 - -- 有时候,启动耗时是因为占用了太多的CPU资源。但是从我们的 Crash Log 中可以发现,我们仅仅占用了**Elapsed application CPU time (seconds): 0.h秒, k% CPU**。通过这个,我们也可以发现,CPU 占用率高并不是导致启动阶段 App Crash 的唯一原因 - -在单例使用上: - -- 仅仅使用一次的模块,可以不使用单例,可以采用在对应的周期内维护成员实例变量进行替换 -- 和状态无关的模块,可以采用静态(类)方法直接替换 -- 可以通过页面跳转进行依赖注入的模块,可以采用依赖注入或者变量传递等方式解决 - -当然,的确有一些情况我们仍然需要使用单例。那在这种情况,也请将 `dispatch_once` 调用的 block 内减少尽可能多的任务,最好是仅仅负责初始化,剩下的配置、调用等等在后续进行。比如先 `sharedInstance`,得到对象后再调用 `defaultConfiguration` 方法。 - - - - - - - -## dispatch_after - -用来延迟执行代码。类似NSTimer。需要注意的是:dispatch_after 方法并不是在指定时间之后才开始执行任务,而是在指定时间之后将任务追加到主队列中。 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter1 - iOS/1.87.md b/Chapter1 - iOS/1.87.md deleted file mode 100644 index c99a044..0000000 --- a/Chapter1 - iOS/1.87.md +++ /dev/null @@ -1,157 +0,0 @@ -# Objective-C 底层 - -1. Objective-C 中对象、类主要是基于 C/C++ 中的结构体实现的。 - - 方法一: - - 可以用 clang 验证。`clang -rewrite-objc main.m -o main.cpp` - 转到指定平台代码。`xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp` - - ``` - struct NSObject_IMPL { - Class isa; - }; - - /// An opaque type that represents an Objective-C class. - typedef struct objc_class *Class; - ``` - - 所以 Class 是指向结构体的指针,所以在 64 位系统中占据8个字节,32位系统占据4个字节。 - - NSObject 就是结构体占据8个字节。 - - ![image-20200726130625217](/Users/lbp/Library/Application Support/typora-user-images/image-20200726130625217.png) - - ![image-20200726125926892](/Users/lbp/Library/Application Support/typora-user-images/image-20200726125926892.png) - - 发现 `class_getInstanceSize` 和 `malloc_size` 结果不一致。 - - ```c - // Class's ivar size rounded up to a pointer-size boundary. - uint32_t alignedInstanceSize() const { - return word_align(unalignedInstanceSize()); - } - ``` - - `class_getInstanceSize` 返回的是类的实例对象的成员变量的大小(四舍五入等于指针指向对象的大小,所以不精确) - - ```c - extern size_t malloc_size(const void *ptr); - /* Returns size of given ptr */ - ``` - - `malloc_size` 返回的就是指针指向的对象大小. - - ![image-20200726130920234](/Users/lbp/Library/Application Support/typora-user-images/image-20200726130920234.png) - - 结论: - - - 当某个类继承自 NSObject 的时候,如果没有其他属性,则这个类占据16个字节。`class_getInstanceSize` 占据 8 , `malloc_size` 占据 16 - - 当某个类继承自 NSObject 的时候,如果有其他属性,则这个类占据16个字节。`class_getInstanceSize` 占据 16 , `malloc_size` 占据 16 - - 方法二: 从源代码角度出发验证(从上到下) - - ```c++ - // NSObject.mm - // Replaced by ObjectAlloc - + (id)allocWithZone:(struct _NSZone *)zone { - return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone); - } - - // objc-class-old.mm - id - _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone) - { - id obj; - - if (fastpath(!zone)) { - obj = class_createInstance(cls, 0); - } else { - obj = class_createInstanceFromZone(cls, 0, zone); - } - - if (slowpath(!obj)) obj = _objc_callBadAllocHandler(cls); - return obj; - } - - // objc-class-old.mm - /*********************************************************************** - * _class_createInstance. Allocate an instance of the specified - * class with the specified number of bytes for indexed variables, in - * the default zone, using _class_createInstanceFromZone. - **********************************************************************/ - static id _class_createInstance(Class cls, size_t extraBytes) - { - return _class_createInstanceFromZone (cls, extraBytes, nil); - } - - - - static ALWAYS_INLINE id - _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, - int construct_flags = OBJECT_CONSTRUCT_NONE, - bool cxxConstruct = true, - size_t *outAllocatedSize = nil) - { - ASSERT(cls->isRealized()); - - // Read class's info bits all at once for performance - bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor(); - bool hasCxxDtor = cls->hasCxxDtor(); - bool fast = cls->canAllocNonpointer(); - size_t size; - - size = cls->instanceSize(extraBytes); - if (outAllocatedSize) *outAllocatedSize = size; - - id obj; - if (zone) { - obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size); - } else { - obj = (id)calloc(1, size); - } - if (slowpath(!obj)) { - if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) { - return _objc_callBadAllocHandler(cls); - } - return nil; - } - - if (!zone && fast) { - obj->initInstanceIsa(cls, hasCxxDtor); - } else { - // Use raw pointer isa on the assumption that they might be - // doing something weird with the zone or RR. - obj->initIsa(cls); - } - - if (fastpath(!hasCxxCtor)) { - return obj; - } - - construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE; - return object_cxxConstructFromClass(obj, cls, construct_flags); - } - - // objc-runtime-new.h - // Class's ivar size rounded up to a pointer-size boundary. - uint32_t alignedInstanceSize() const { - return word_align(unalignedInstanceSize()); - } - - // objc-runtime-new.h - size_t instanceSize(size_t extraBytes) const { - if (fastpath(cache.hasFastInstanceSize(extraBytes))) { - return cache.fastInstanceSize(extraBytes); - } - - size_t size = alignedInstanceSize() + extraBytes; - // CF requires all objects be at least 16 bytes. - if (size < 16) size = 16; - return size; - } - ``` - - `CF requires all objects be at least 16 bytes.` 系统为 NSObject 对象分配了至少16个字节大小的空间,但是它使用了 8 个字节大小的空间用来存放 ivars(64位系统) - -2. 某个类继承自 NSObject 的情况,内存如何分配 diff --git a/Chapter1 - iOS/1.88.md b/Chapter1 - iOS/1.88.md deleted file mode 100644 index 72dbe91..0000000 --- a/Chapter1 - iOS/1.88.md +++ /dev/null @@ -1,195 +0,0 @@ -# fishhook 原理 - -## hook 分类 - -- Method Swizzle:利用 OC runtime,动态改变 SEL(方法编号)、IMP(方法实现)的对应关系,达到 OC 方法调用流程改变的目的,主要用于 OC 方法 -- fishhook:是 Facebook 提供的一个动态修改链接 Mach-o 文件的工具,利用 Mach-O 文件加载原理,通过修改懒加载和非懒加载2个表的指针,达到 c 函数 hook 的目的。 -- Cydia Substrate: 原名为 Mobile Substrate,主要作用是针对 OC 方法、C 函数以及函数地址进行 hook,当然并不是仅针对 iOS 而设计,Android 也可使用。官方地址:http://www.cydiasubstrate.com - -hook只有二种: -- `inline hook`:直接修改函数入口代码或函数内某处代码跳转到自己代码 -- 地址替换:包含入口表地址替换、出口表地址替换、结构体内地址替换等。这类最简单但不一定有效,不通过地址表的调用 hook 不到 - - -## 应用 - -经常会遇到 hook oc 方法,但是遇到像 NSLog、objc_msgSend 等方法的时候 OC Runtime 就不满足了 ,有了 fishhook 神器,hook “c 函数”已不是难题。 - -为什么对 “c 函数”加了引号,带着问题往下看 - - - -### Hook 系统 c 函数 - -以 NSLog 为例。 - - - -可以看到 hook 成功了。 - -```c -struct rebinding { - const char *name; // 需要 hook 的函数名称,c 字符串 - void *replacement; // 新函数地址 - void **replaced; // 原始函数地址的指针 -}; -``` - - - -### Hook 自定义 c 函数 - -自定义一个 c 函数 `handleTouchAction` ,发现没有 hook 成功。 - - - -不禁令人好奇,同样是 c 函数,为什么系统 c 函数可以 hook,自定义的 c 函数无法 hook?带着问题继续探究 - - - -## 原理窥探 - -FishHook 是 FaceBook 提供的一个可以动态修改链接 Mach-O 文件的工具。利用 Mach-O 文件的加载原理,通过修改懒加载和非懒加载2个表的指针,达到 hook 系统 C 函数的目的。 - - - -### Mach-O 文件权限 - -Mach-O 分为代码段、数据段...: - -- 代码段:可读、可执行、不可写 -- 数据段:可读、可写、不可执行 - - - -### 系统共享缓存 - -我们知道 NSLog 的函数实现在 Foundation 库中,而我们开发自己写的其他函数则在自身可执行文件中,也就是 Mach-O。 - -iOS 共享缓存:在iOS 3.1及以后的版本中,Apple 将系统库文件打包合并成一个大的缓存文件,存放在 `/System/Library/Caches/com.apple.dyld/ ` 目录下,以减少冗余并优化内存使用 - -- 所有的进程均可访问 -- 缓存文件的架构特定性:共享缓存文件根据不同的处理器架构有不同的版本,例如 `dyld_shared_cache_arm64` 针对的是 ARM 64 位架构的处理器 -- 动态库的加载优化:通过共享缓存,iOS 系统可以在程序运行时更高效地加载动态库,因为不需要每个应用程序重复加载相同的库文件,从而加快了启动速度并提高了性能 - -App 对应的 Mach-O 被 dyld 装载进内存的时候,`NSLog ` 地址还不确定,其位于 `Foundation` 框架中,也就是位于共享缓存中。 - -这带来一个问题:代码在 Mach-O 文件上策马奔腾,在愉快的执行,当遇到 `NSLog` 的时候,该如何确定位于 `Foundation` 框架中 NSLog 真实函数地址呢?编译阶段,clang 可以知道任何设备(iPhone14、iPhone6s)任何架构(arm64、armv7)上 Foundation 真实的内存吗?显然不可能。 - - - -这里稍微展开谈谈静态链接和动态链接。 - -链接分为静态链接和动态链接。早期计算机都是采用静态链接这种方式的。静态链接存在缺点: - -- 对于计算机内存和磁盘浪费很严重。想象下,每个程序内部保留了 printf、scanf 等公用库函数,还有很多其他库函数和所需要的数据结构。 - -- 程序的开发和发布很不方便。比如应用 A,使用的 Lib.o 是一个第三方厂商提供的,当 lib.o 修复 bug 或者升级,开发都需要将应用 A 重新链接再发布,整个周期很不方便。 - -要解决空间浪费和更新困难最简单的办法就是把程序的模块拆分,形成独立文件,而不再将他们静态地链接在一起。而是等到程序运行起来才进行链接,也就是动态链接。 - -动态链接涉及运行时的链接以及多个文件的装载,必须有操作系统级别的支持。此时还有个角色叫做动态链接库。所有应用都可以在运行时使用它。 - -程序与 lib 动态库之间的链接工作是由动态链接器完成的,而不是静态链接器 ld 完成的。也就是动态链接是把链接这个过程由程序装载前被推迟到了装载的时候。 - -但也带来了坏处,因为都是程序每次装载的时候进行重新链接。有解决方案,叫做延迟绑定(Lazy binding),可使得动态链接对性能的影响减的最小。据估算,动态链接相比静态链接,存在大约5%的性能损失,但换来程序在空间上的节省和程序构建和升级的灵活性,是值得的。 - - - -如何解决? - -编译阶段给 NSLog 一个默认地址,在应用启动时,通过重定向,把真正的地址重新写入到 Mach-O 中,但效率很低。 后来诞生了 `PIC` 技术 - - - -### PIC 技术 - -装载时重定位是解决动态模块中有绝对地址引用的方案之一。但其存在一个很大缺点,是指令部分无法在多个进程间共享,这样就是失去动态链接节省内存这一优势。 - -我们还需要一种更优雅的方案,希望程序模块中的共享指令部分在装载时不需要因为装载地址改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这个方案就是 地址无关代码(Position Independent Code)PIC 技术。 - - - -Position-Independent Code,即位置无关代码。这是一种编译技术,允许生成的代码在内存中的任何位置执行,而不需要任何修改。这在动态链接库(dylib)中非常重要,因为它允许系统动态地将库加载到内存中的不同位置,而不需要重新链接。 - -优点是: - -- 共享代码:多个应用程序可以共享同一个动态库的实例,节省内存并减少启动时间。 -- 动态链接器(dyld)可以优化符号绑定过程,提高应用程序的启动速度 -- 支持代码重定位,使得应用程序更新和修补更加灵活 - - - -写的业务代码里面假如某一行调用了 `NSLog`,那么在编译阶段,使用 NSLog 只是 IDE 提供了功能,让你可以看到声明而已。编译后的可执行文件,还是不知道 NSLog 的具体函数地址。这个底层是如何工作的呢? - -有了 PIC 之后,工作流程为: - -- 编译期,在 Mach-O 中数据段生成一块区域,该区域叫符号表。数据段可读可写。 - - 工程中所有引用了动态库共享缓存区中的系统符号,其指向的地址设置成符号地址。比如工程中 NSLog,那么编译时就会在 Mach-O 中创建一个 NSLog 符号,工程中的 NSLog 就指向这个符号 - -- dyld 加载 Mach-O,做符号绑定。 - - 当 dyld 将 Mach-O 加载到内存中时,读取 header 中 load command 信息,找出需要加载哪些库文件,去做绑定的操作。比如 dyld 会找到 Foundation 中的 NSLog 的真实地址写到 _DATA 段的符号表中 NSLog 对应的符号上。 - - - -### 实践探索 - -做个实验验证下,完整流程(新版本的位于 ) - - - -第一步:可以看到 NSLog 位于 Lazy Symbol Pointers 里的第一个。lazy 说明只有在用到的时候才去绑定。下断点验证下 - - - - - -第二步:在 `NSLog` 处打断点,触发后 LLDB 模式下 `image list` 查看所有的 image。可以看到第一个 image 就是 App 主程序镜像,且 image 开始地址为 `0x0000000100da5000` - - - -第三步:进入 LLDB 模式,根据 image base 地址 + offset 计算 NSLog 的地址。即 `memory read 0x0000000102eec000+0xC000`,查看下内存信息 - - - -第四步:断点过下一行,即执行过一次 NSLog。然后再通过 LLDB 根据地址查看汇编代码,`dis -s addr` 查看 - - - -第五步:继续过断点,等执行完 `rebind_symbols` 再看看内存信息。可以看到再 rebind 之后,地址变了。然后根据地址查看汇编代码,发现已经是我们自定义的函数了。 - - - - - - - -第一步: 在 `Lazy Symbol Pointers` 懒加载符号表中看到第一个符号 `NSLog`,索引为1。 - - - -第二步:根据索引,在 `Dynamic Symbol Tables` 动态符号表中看到第一条数据,是 NSLog 相关的。其 Data 值 `00000084` 是十六进制的,换算为十进制就是 132。 - - - -第三步:根据第二步得到的角标,在 `Symbol Table` 符号表中查找第132个位置。可以看到其 Data 值 `000000AA` 是偏移值。 - - - -第四步:在 `String Table` 中,第一个位置 `0000CFE4` 加上偏移值 `0xAA` ,等于 `0xD08E`,如下图所示,就是 `NSLog` 符号真实的地址。 - - - - - -`NSLog`、`dispatch_once` 等,stub 代码指向 `lazy Symbol Pointers` 部分,`lazy Symbol Pointers` 中又指向 `stub_helper`,默认又到了 `dyld_stub_binder`,在首次调用时再绑定真实调用地址。 - -fishhook 正是利用上面这点,将 `lazy Symbol Pointer` 中的符号替换成自己的函数,从而实现 hook,这也是为什么 fishhook 不能 hook 二进制文件中自定义的 C 函数 - - - -fishhook 做的事情就是将系统的符号表,将符号表中的特定符号对应的地址,修改为自定义的函数地址。起到了 hook 作用。也就是说外部的 c 函数,在 iOS 中的调用属于**动态调用**。 - -知道了 fishhook 的工作原理,我们就知道 fishhook 是 hook 不了自己写的 c 函数了。因为自定义函数是没有通过符号去寻找函数真正地址的这个过程。而系统库是通过符号去绑定真实地址的。 diff --git a/Chapter1 - iOS/1.89.md b/Chapter1 - iOS/1.89.md deleted file mode 100644 index d29b761..0000000 --- a/Chapter1 - iOS/1.89.md +++ /dev/null @@ -1,2247 +0,0 @@ -# block 底层原理 - -> 大家写 OC 肯定用过不少 block。有这样4个问题: -> -> - block 原理是什么,系统是如何实现的? -> - __block 的作用是什么? -> - block 作为属性时,为什么用 copy 修饰? -> - block 在修改 NSMutableArray 的时候,需要加 __block 吗? -> -> 带着问题探究本文。 - - - -## 一、block 本质探索 - -### 1. 实验探索 - -Demo - -```objective-c -NSInteger age = 27; -void(^block)(NSInteger, NSInteger) = ^(NSInteger a, NSInteger b) { - NSLog(@"age is %zd", age); - NSLog(@"a is %zd, b is %zd", a, b); -}; -block(1, 2); -``` - -用指令`xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转为 c++ - - - -`ViewController.m` 的 `viewDidLoad` 函数为 `_I_ViewController_viewDidLoad` - -```c -static void _I_ViewController_viewDidLoad(ViewController * self, SEL _cmd) { - ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}, sel_registerName("viewDidLoad")); - NSInteger age = 27; - void(*block)(NSInteger, NSInteger) = ((void (*)(NSInteger, NSInteger))&__ViewController__viewDidLoad_block_impl_0((void *)__ViewController__viewDidLoad_block_func_0, &__ViewController__viewDidLoad_block_desc_0_DATA, age)); - ((void (*)(__block_impl *, NSInteger, NSInteger))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 1, 2); -} -``` - -block 被定义为一个叫做 `__ViewController__viewDidLoad_block_impl_0` 的结构体 - -```c++ -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - NSInteger age; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, NSInteger _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; - -struct __block_impl { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; -}; -``` - -因为 `__block_impl` 结构体位于 `__ViewController__viewDidLoad_block_impl_0` 结构体的第一个成员,所以上述代码等价于 - -```c++ -struct __ViewController__viewDidLoad_block_impl_0 { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - NSInteger age; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, NSInteger _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -block 内部的 NSLog 语句,被封装为 `__ViewController__viewDidLoad_block_func_0` 结构体 - -```c++ -static void __ViewController__viewDidLoad_block_func_0(struct __ViewController__viewDidLoad_block_impl_0 *__cself, NSInteger a, NSInteger b) { - NSInteger age = __cself->age; // bound by copy - - NSLog((NSString *)&__NSConstantStringImpl__var_folders_7x_g921y52j5yb_w5hsn3fb3m8r0000gn_T_ViewController_95a9e6_mi_0, age); - NSLog((NSString *)&__NSConstantStringImpl__var_folders_7x_g921y52j5yb_w5hsn3fb3m8r0000gn_T_ViewController_95a9e6_mi_1, a, b); - } - -__ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, NSInteger _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -``` - -可以看到构造函数 `__ViewController__viewDidLoad_block_impl_0` 有4个参数。 - -第一个参数: - -在 `viewDidLoad` 中,`__ViewController__viewDidLoad_block_func_0` 当作构造函数 `__ViewController__viewDidLoad_block_impl_0` 的参数,传递给了参数 fp,构造函数内部将 fp 赋值给了 impl 的 FuncPtr。在 - -```c++ -((void (*)(__block_impl *, NSInteger, NSInteger))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 1, 2); -``` - -简化为 - -```c++ -block->FuncPtr(block, 1, 2); -``` - -最后在 viewDidLoad 函数中通过结构体 impl 的成员 FuncPtr,调用了函数。 - -第二个参数: - -`__ViewController__viewDidLoad_block_desc_0_DATA` 可以看成是一个 block 信息的描述,占用了 `sizeof(struct __ViewController__viewDidLoad_block_impl_0)` 大小的空间。 - -```c++ -static struct __ViewController__viewDidLoad_block_desc_0 { - size_t reserved; - size_t Block_size; -} __ViewController__viewDidLoad_block_desc_0_DATA = { 0, sizeof(struct __ViewController__viewDidLoad_block_impl_0) -``` - - - -QA: - -```c++ -((void (*)(__block_impl *, NSInteger, NSInteger))((__block_impl *)block)->FuncPtr)((__block_impl *)block, 1, 2); -``` - -为什么 `block->FuncPtr` 可以直接访问,而不是通过 block 先访问 impl,再访问 FuncPtr?因为 `__block_impl` 就是 `__main_block_impl_0` 这个结构体的第一个变量地址(结构体特性),然后访问第一个结构体变量内的成员变量,等价于,直接访问结构体第一个成员变量的结构体变量 - -举个例子 - -```c++ -struct B { int x; }; -struct A { struct B b; int y; }; - -struct A a; -// 以下两个指针指向同一地址: -struct B *b_ptr1 = &(a.b); -struct B *b_ptr2 = (struct B *)&a; // 强制类型转换 -``` - -`b_ptr1` 和 `b_ptr2` 是等价的,因为 `a` 的起始地址就是 `a.b` 的起始地址。 - -因为 `__block_impl` 是 `__ViewController__viewDidLoad_block_impl_0` 结构起的第一个成员变量,所以 `block->FuncPtr` 会被转换为 `block->imp.FuncPtr` - -结构体访问成员变量的原理就是:**基地址 + 偏移量**(baseAddress + Offset) - -- `__block_impl` 的 `FuncPtr` 成员在其内部的偏移量是固定的(例如,假设 `isa` 占 8 字节,`Flags` 和 `Reserved` 各占 4 字节,则 `FuncPtr` 的偏移量为 `8 + 4 + 4 = 16` 字节)。 -- 无论通过 `__main_block_impl_0` 还是 `__block_impl` 的指针访问 `FuncPtr`,最终都是基于同一基地址(`block` 的起始地址)加上相同的偏移量(16 字节)来获取值。 - -```c++ -struct block { // baseAddress - struct first { - isa; - Flags; - FuncPtr; // offset - Desc; - } - struct second; - // ... -} -``` - -上述的代码,等价于 `block->impl.FuncPtr` - -```c -struct __block_impl { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; -}; -``` - -类似于下面代码 - -```c++ -struct __main_block_impl_0 { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; - struct __main_block_desc_0* Desc; - int age; - __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -通过 clang 转为 c++ 分析后,知道了 block 的本质,然后自定义结构体,mock 对象去承载 block 信息,然后查看 - -```objective-c -struct __block_impl { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; -}; - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - NSInteger age; -}; - -struct __ViewController__viewDidLoad_block_desc_0 { - size_t reserved; - size_t Block_size; -}; - -- (void)viewDidLoad { - [super viewDidLoad]; - - NSInteger age = 27; - void(^block)(NSInteger, NSInteger) = ^(NSInteger a, NSInteger b) { - NSLog(@"age is %zd", age); - NSLog(@"a is %zd, b is %zd", a, b); - }; - block(1, 2); - - struct __ViewController__viewDidLoad_block_impl_0 *mockBlock = (__bridge struct __ViewController__viewDidLoad_block_impl_0 *)block; - NSLog(@""); -} -``` - - - - - -### 2. 结论 - -通过探索发现: - -- block 本质上就是一个 oc 对象,也有 isa 指针 - -- block 是封装了函数调用和函数调用环境的 OC 对象 - - - - - - - - - -## 二、block 变量捕获 - -### 1. auto 变量捕获 - -> 在 C 和 Objective-C 编程语言中,`auto` 关键字用于声明自动存储期的变量。自动存储期的变量会在定义它们的块(block)或作用域(scope)中自动创建,并在退出该作用域时自动销毁。这是变量存储期的默认行为,因此 `auto` 关键字实际上是可选的,但有时候为了清晰起见,开发者可能会显式使用它。 - -Demo1: - -一个最简单的 block,参数和返回值都是 void,内部仅一条打印语句。 - -```objective-c -void(^printBlock)(void) = ^ { - NSLog(@"Hello block"); -}; -printBlock(); -``` - -用 `xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转换为 c++ - - - -概括如下: - - - - - -Demo2: 捕获外部变量 - -```objective-c -age = 27; -void(^printAgeBlock)(void) = ^ { - NSLog(@"age is %zd", age); -}; -age = 28; -printAgeBlock(); -// 27 -``` - -用指令 `xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转换为 c++ 代码 - - - -代码分析: - -- 可以看到我们编写的 block 被声明为一个 `__ViewController__viewDidLoad_block_impl_0` 类型的结构体 -- 结构体内有个构造函数,见50773行代码。 -- c++ 中,构造方法中 `age(_age) `的写法,表明传入的 `_age` 会被赋值给结构体内的 age -- 50794行代码,调用结构体的构造方法,传入参数。结构体构造方法内部将 参数 age 的值保存到结构体内部的 age 中。 -- 因为是值传递。所以即使在 50795 行代码对 age 进行了修改,结构体内部的 age 值不变 -- 所以执行 block,输出 age 依旧为27 - - - -block 内部多了一个变量来存储外部变量,这个现象叫做 block 捕获了外部变量。 - -c++ 中,在函数内部定义的变量,默认用 **auto** 修饰,叫做自动变量,离开作用域后自动销毁。上述 age 等价于 `auto NSInterge age = 27;` - -所以上述的情况,叫做 block 的 auto 变量捕获。 - - - -### 2. static 变量捕获 - -```objective-c -auto NSInteger age = 27; -static NSInteger height = 175; -void(^printInfoBlock)(void) = ^ { - NSLog(@"age is %zd, height is %zd", age, height); -}; -age = 28; -height = 176; -printInfoBlock(); -// age is 27, height is 176 -``` - -用指令 `xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转换为 c++ 代码 - - - - - -对代码进行分析: - -- 可以看到我们编写的 block 被声明为一个 `__ViewController__viewDidLoad_block_impl_0` 类型的结构体 -- 结构体内有个构造函数,见50774行代码。 -- c++ 中,构造方法中 `age(_age) `的写法,表明传入的 `_age` 会被赋值给结构体内的 age,`NSInteger _age` 则 age 为值传递;`height(_height)` 写法,表明传入的 `_height` 会被复制给结构体内的 height,`NSInteger *_height` 则 height 为引用传递 -- 50797行代码,调用结构体的构造方法,age 以值传递的方式传入参数,结构体构造方法内部将 参数 age 的值保存到结构体内部的 age 中。height 以引用传递的方式传入参数,结构体构造方法内,将参数 height 的引用保存起来 -- 因为 age 是值传递。所以即使在 50798 行代码对 age 进行了修改,结构体内部的 age 值不变 -- 因为 height 是引用传递。所以在 50799 行代码对 height 进行了修改,结构体内部的 height 值跟着改变 -- 所以执行 block,输出 age 依旧为27,输出 height 的时候,根据保存地址,找到 height,也就是最新的 height 会被输出 - - - -### 3. 全局变量捕获 - -```objective-c -NSInteger age = 27; -static NSInteger height = 175; - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - void(^printInfoBlock)(void) = ^ { - NSLog(@"age is %zd, height is %zd", age, height); - }; - age = 28; - height = 176; - printInfoBlock(); -} -@end -// console -age is 28, height is 176 -``` - -用指令 `xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转换为 c++ 代码 - - - - - -代码分析: - -- 可以看到我们编写的 block 被声明为一个 `__ViewController__viewDidLoad_block_impl_0` 类型的结构体 -- 结构体内有个构造函数,见50774行代码。可见针对全局变量,结构体内部不会捕获全局变量 -- block 内部的指令,被封装为一个叫做 `__ViewController__viewDidLoad_block_func_0` 的结构体,打印的时候直接访问全局变量 -- 针对全局变量的修改会实时生效 -- 所以执行 block,输出 age 和 height 的时候,直接输出全局变量的值 - - - -QA:为什么局部变量存在捕获,全局变量不需要捕获? - -全局变量到哪都可以访问,所以没必要捕获。局部变量因为作用域的问题,所以需要捕获到哪步,以便后续使用。 - -理解 block 的本质和意义: - -- block 本质上就是一个 oc 对象,也有 isa 指针 - -- block 是封装了函数调用和函数调用环境的 OC 对象 - - - -### 4. 变量捕获总结 - -block 截获变量可以分为: - -- 局部变量 - - 基本数据类型:对于基本数据类型的局部变量,截获其值 - - 对象类型:对于对象类型的局部变量,连同所有权修饰符一起截获 -- 静态局部变量:以指针形式进行截获的 -- 全局变量:不截获 -- 静态全局变量:不截获 - -变量分为:static、auto、register。 - -- static:表示作为静态变量存储在数据区。 - -- auto:一般的变量不加修饰词则默认为 auto,auto 表示作为自动变量存储在栈上。意味着离开作用域变量会自动销毁。 - -- register:这个关键字告诉编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率。是尽可能,不是绝对。如果定义了很多 register 变量,可能会超过CPU 的寄存器个数,超过容量。所以只是可能。 - -| 作用域 | 捕获到 block 内部 | 访问方式 | -| ---------- | ------------ | ---- | -| 局部变量 auto | YES | 值传递 | -| 局部变量static | YES | 指针传递 | -| 全局变量 | NO | 直接访问 | - - - -来一个开发中常见的 case: - -下面的例子中 的 block,self 会被捕获吗? - -```objective-c -#import "Person.h" - -@implementation Person - -- (instancetype)initWithName:(NSString *)name { - if (self = [super init]) { - _name = name; - } - return self; -} - -- (void)play { - void(^playBlock)(void) = ^{ - NSLog(@"%@ is playing", self); - }; - playBlock(); -} - -@end -``` - -用指令 `xcrun --sdk iphoneos clang -arch arm64 Person.m -rewrite-objc -o Person-arm64.cpp` 转换为 c++ 代码 - - - - - -代码分析: - -- 可以看到我们编写的 block 被声明为一个 `__Person__play_block_impl_0` 类型的结构体 - -- 结构体内有个构造函数,见22893行代码。 - -- 因为 objective-c 的方法,默认会携带2个参数,`self` 和 ` _cmd`,等价于 `void play(Person *self, SEL _cmd)`,所以22917行代码 调用构造函数的时候,self 会被传递进去。查看 c++ 代码,可以看到 OC 的 play 方法被转换为 - - ```c++ - static void _I_Person_play(Person * self, SEL _cmd) { - void(*playBlock)(void) = ((void (*)())&__Person__play_block_impl_0((void *)__Person__play_block_func_0, &__Person__play_block_desc_0_DATA, self, 570425344)); - ((void (*)(__block_impl *))((__block_impl *)playBlock)->FuncPtr)((__block_impl *)playBlock); - } - ``` - -- `play` 方法等价于 - - ```c++ - - (void)play:(id)self selector:(SEL)_cmd { - //... - } - ``` - - 所以 self 是局部变量,block 为了访问局部变量,会发生变量捕获。 - -- block 为了局部变量 self 的将来访问,结构体内部也增加了一个 Person 类型的 self,所以存在 self 的变量捕获。 - -所以,答案是会,因为 self 就是局部变量。 一个 oc 方法转换为 `void test(Person *self, SEL _cmd)` 形式,所以 self 也是局部变量,会被捕获。 - -Tips:判断会不会捕获的本质就是判断某个对象、变量是不是局部变量,是局部变量则会捕获,否则不会捕获。 - -举个例子: - -```objective-c -// Person.h -@interface Person : NSObject { - @public - NSString *_hobby; -} -- (void)testBlockCapture; -@end - -@implementation Person - - (void)testBlockCapture { - void (^test)(void) = ^(void) { -// NSLog(@"%@", _hobby); - }; - test(); -} -@end -``` - -利用 clang 转为 c++ `xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc Person.m -o Person-arm64.cpp` - -```c++ -struct __Person__testBlockCapture_block_impl_0 { - struct __block_impl impl; - struct __Person__testBlockCapture_block_desc_0* Desc; - __Person__testBlockCapture_block_impl_0(void *fp, struct __Person__testBlockCapture_block_desc_0 *desc, int flags=0) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; - - -static void __Person__testBlockCapture_block_func_0(struct __Person__testBlockCapture_block_impl_0 *__cself) { - -} -``` - -可以看到没有捕获任何变量。但对上述代码进行调整 - -```objective-c -// Person.m -- (void)testBlockCapture { - void (^test)(void) = ^(void) { - NSLog(@"%@", _hobby); - }; - test(); -} - -``` - -继续调为 C++ - -```c++ -struct __Person__testBlockCapture_block_impl_0 { - struct __block_impl impl; - struct __Person__testBlockCapture_block_desc_0* Desc; - Person *self; - __Person__testBlockCapture_block_impl_0(void *fp, struct __Person__testBlockCapture_block_desc_0 *desc, Person *_self, int flags=0) : self(_self) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; - - -static void __Person__testBlockCapture_block_func_0(struct __Person__testBlockCapture_block_impl_0 *__cself) { - Person *self = __cself->self; // bound by copy - - NSLog((NSString *)&__NSConstantStringImpl__var_folders_vf_05htlrfx42qck4yh6bsnh3yr0000gn_T_Person_28fd90_mi_4, (*(NSString **)((char *)self + OBJC_IVAR_$_Person$_hobby))); - } -``` - -观察可以看到 Person 的 self 被捕获进去了,本质上是因为 `testBlockCapture` 方法内的 block 访问了成员变量 `_hobby`,本质上还是访问了 `self->hobby` ,也就是对局部变量 self 进行了访问,所以存在变量捕获。 - - - -## 三、block 类型 - -### 1. 类型划分 - -我们知道 block 可以看成是一个 oc 对象,所以它有类型,写个 Demo1 验证下 - - - -也就是说: `__NSGlobalBlock__` -> `NSBlock` -> `NSObject`。 - -继续验证,Demo2 - - - -同时利用指令 `xcrun --sdk iphoneos clang -arch arm64 ViewController.m -rewrite-objc -o ViewController-arm64.cpp` 转换为 c++ - -梳理分析下: - -- 通过打印发现,block1 为 `__NSGlobalBlock__` 类型,但是 clang 转为 c++ 后为 `_NSConcreteStackBlock` 类型 -- block1 为 `__NSMallocBlock__` 类型,但是 clang 转为 c++ 后为 `_NSConcreteStackBlock` 类型 -- block2 为 `__NSStackBlock__` 类型,但是 clang 转为 c++ 后为 `_NSConcreteStackBlock` 类型 -- 虽然可以用 clang 将 OC 转换为 c++ 来分析问题,但是 OC 最强大的是运行时,所以编译期转换为 c++ 看到的信息不一定是准确的,还是以运行时的信息为准 - - - -简单结论: - -block 的类型可以通过 isa 或者 class 方法查看,最终都是继承自 NSBlock 类型,共存在3种类型的 block: - -- `__NSGlobalBlock__` (`_NSConcreteGlobalBlock`):程序的数据区域(.data 区) - -- `__NSStackBlock__` (`_NSConcreteStackBlock`),会自动销毁 - -- `__NSMallocBlock__`(`_NSConcreteMallockBlock`),需要程序员自己管理内存 - -这3种 block 在内存中的排布如下图: - - - - - - - -### 2. 如何判断 block 属于什么类型 - -Demo: - -由于 ARC 默认会做一些优化,为了彻底的研究 block,我们将工程的 ARC 关掉(Build Setting 里 Automatic Reference Counting 设置为 No) - - - -分析: - -- block1 是 `__NSGlobalBlock__` ,此**类型的 block 用结构体实例的内容不依赖于执行时的状态,所以整个程序只需要1个实例。因此存放于全局变量相同的数据区域即可。** - -- block2 是 `__NSStackBlock__`. - -- 但为什么执行 block2 的时候发生了 crash? - - 猜测由于在 test 方法内给 block2 赋值,也就是在栈上定义和捕获了栈上的变量 age。等 test 方法结束,可能栈上的数据消失或者内存指向某个不可预知的地方,所以这个情况下调用 block2 会 crash。 - -如何解决该问题? - -这类问题概括就是:`__NSStackBlock__` 栈上的 block 及其捕获的数据出了栈后,内存不稳定,将来某个时间调用 block,可能会发生 crash。需要把数据和内存稳定下来,不要交由栈自动处理。想办法把栈上的数据移动到堆上,由程序员自己管理内存。 - -当 `__NSStackBlock__` 调用 `copy` 方法后会变为 `__NSMallocBlock__`。如下图: - - - - - -Demo 也同时发现,当对 `__NSGlobalBlock__` 调用 copy ,不会变为 `__NSMallocBlock__` 。 - - - -### 3. 总结 - -| block 类型 | 环境 | -| ------------------- | ------------------------------ | -| `__NSGlobalBlock__` | 没有访问 auto 变量 | -| `__NSStackBlock__` | 访问了 auto 变量 | -| `__NSMallocBlock__` | `__NSStackBlock__` 调用了 copy 方法 | - -调用 `copy` 方法 - -| Block 类 | 原本位置 | 复制效果 | -| --------------------------- | ------------ | ---------- | -| `__NSConcreteStackBlock__` | 栈 | 栈复制到堆 | -| `__NSConcreteGlobalBlock__` | 程序的数据段 | 什么也不做 | -| `__NSConcreteMallocBlock__` | 堆 | 引用计数+1 | - -Demo - -```objective-c -int age = 30; -int main(int argc, const char * argv[]) { - @autoreleasepool { - int height = 175; - NSLog(@"数据段:%p", &age); // 数据段:0x100008938 - NSLog(@"栈区:%p", &height); // 栈区:0x7ff7bfeff400 - NSLog(@"堆区:%p", [[NSObject alloc] init]); // 0x600000014000 - NSLog(@"数据段:%p", [Person class]); // 0x1000088c8 - } -} -``` - -可以看到: - -- age 是全局变量,在数据段区 -- height 是局部变量,在栈区 -- `[[NSObject alloc] init]` 是 alloc 的对象,在堆区 -- `[Person class]` 看到地址接近数据段,所以也在数据段区 - - - -## 四、内存管理 - -### 1. ARC 针对 block 的优化 - -#### 1. block 作为函数返回值,并且捕获了 auto 变量 - -MRC 下 block 作为函数的返回值是比较危险的。在方法内部,也就是栈上定义的 block,函数调用结束后可能一些相关数据就释放了,存在潜在风险。 - - - - - - - -MRC 下如果函数返回值是 block,且 block 内做了 auto 变量捕获的逻辑,编译器会报错:`Returning block that lives on the local stack`。此时 block 应该为 `__NSStackBlock__` - - - -即使编译器不报错,也存在很大风险,因为 block 创建在栈上,函数返回后栈内存可能被回收,导致后续访问野指针。 - -需要: - -- 复制到堆:使用 `copy` 方法将栈 block 复制到堆,延长生命周期 -- 自动释放:返回堆 block 时,通常用 `autorelease` 标记,遵循 MRC 的命名规则(非 `alloc/new/copy` 方法返回自动释放对象) - -改正: - -```objective-c -HelloBlock generateBlock(void) { - int age = 28; - HelloBlock block = ^(void) { - NSLog(@"Hello block, age is %d", age); - }; - return [[block copy] autorelease]; -} -``` - -内存管理总结: - -- 返回 block 前:务必使用 `[[block copy] autorelease]` -- 调用者:若需长期持有返回的 block,应手动 `retain` 并在不再使用时 `release` -- 命名规范:若方法名包含 `alloc/new/copy`,返回对象由调用者释放;否则返回 `autorelease` 对象。 - - - -另外的改法是,在 Build Setting 中改为 ARC,看看 - - - -也就是说,ARC 模式下,当 block 捕获了 auto 变量,并且作为函数返回值的时候,ARC 会自动调用 copy 方法,将 `__NSStackBlock__` 变为 `__NSMallocBlock__` - - - - - -Demo1: - -```objective-c -MyBlock block; -{ - Person *person = [[Person alloc] init]; - block = ^{ - NSLog(@"block called"); - }; - NSLog(@"%@", [block class]); -}; -``` - -MRC 环境: 如果 block 不访问外部局部变量,则`__NSGlobalBlock__` - -ARC 环境:如果 block 不访问外部局部变量,则`__NSGlobalBlock__` - -Demo2: - -```objectivec -typedef void(^MyBlock)(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - MyBlock block; - { - auto Person *person = [[Person alloc] init]; - person.age = 10; - block = ^{ - NSLog(@"age:%zd", person.age); - }; - NSLog(@"%@", [block class]); // __NSStackBlock__ - }; - } - return 0; -} -``` - -MRC 环境下:如果访问了 auto 变量,则为 `__NSStackBlock__` - -ARC 环境下:**ARC 下面比较特殊,默认局部变量对象都是强指针,存放在堆里面。所以 block 为 `__NSMallocStack__`** - -Demo3: - -```objectivec -typedef void(^MyBlock)(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - MyBlock block; - { - auto Person *person = [[Person alloc] init]; - person.age = 10; - block = [^{ - NSLog(@"age:%zd", person.age); - } copy]; - NSLog(@"%@", [block class]); // __NSMallocBlock__ - }; - } - return 0; -} -``` - -MRC 下:如果 block 调用 copy 方法,则 block 为 `__NSMallocStck__` - -ARC 下:如果 block 调用 copy 方法,则 block 仍旧为 `__NSMallocBlock__`。`__NSMallocBlock__` 调用 copy 仍旧为 `__NSMallocBlock__` - -在 ARC 下,如果有一个强指针引用 block,则 block 会被拷贝到堆上,成为 `__NSMallocStck` - - - -#### 2. 将 block 赋值给强指针的时候会调用 copy(ARC 针对强指针指向的 block 会调用 copy) - -MRC 下,栈内捕获了 auto 变量的 block 为 `__NSStackBlock__` - - - -改为 ARC - - - - - -说明:ARC 模式下,如果 block 被强指针指向,则会自动调用 copy 方法。 - -- 捕获了 auto 变量的 `__NSStackBlock__`,ARC 下调用 copy 会变为 `__NSMallocBlock__` -- 没有捕获变量的 `__NSGlobalBlock__`,ARC 下调用 copy 依旧为 `__NSGlobalBlock__` - -#### 3. block 作为 Cocoa API 中的方法名含有 usingBlock 的方法参数时 - -```objective-c -NSArray *array = @[]; -[array enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - -}]; -``` - -#### 4. block 作为 GCD API 的方法参数时 - -```objective-c -dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - -}); -``` - - - -#### 总结 - -在 ARC 下,编译器会根据情况,自动将栈上的 block 复制到堆上,比如: - -- block 作为函数返回值时 -- 将 block 赋值给 `__strong` 指针时 -- block 传递给 Cocoa API 中名字含有 usingBlock 的方法参数时 -- block 传递给 GCD 的方法参数时 - -ARC 下:block 对象捕获了 auto 外部变量,是一种 `__NSMallocBlock__`,捕获的对象将会在 block 销毁后销毁 - - - - - -MRC 下:block 对象捕获了 auto 外部变量,是一种 `__NSMallocBlock__`,因为是 MRC,所以需要手动管理内存。会发现对象将在离开作用域后立马销毁,不会被 block 所捕获。 - - - - - -MRC,对 block 加 copy,变为 `__NSMallocBlock__` 呢? - - - -ARC 下对 block 引用的对象加 `__weak` 修饰呢? - - - -用指令 `xcrun --sdk iphoneos clang -arch arm64 main.m -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 -o main-arm64.cpp` 转换为 c++ 进行分析看看。注意,因为 weak 涉及运行时,需要在 clang 后添加 runtime 参数 - - - -如果对 Person 不加 `__weak` 修饰,block 结构体内部将会是`__strong`。 - - - - - -思考:发现生成的 c++ 代码中,block_desc 里面多了2个成员变量。 - -````c++ -static struct __main_block_desc_0 { - size_t reserved; - size_t Block_size; - void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*); - void (*dispose)(struct __main_block_impl_0*); -} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0}; -```` - -仔细想想,查看 block 编译成 c++ 代码的源码可以发现 `__main_block_desc_0` 结构体内部是变化的。什么意思呢?reserved、Block_size 是一直有的,`void (*copy)`、`void (*dispose)` 只有在修饰对象的时候才有。为什么这么设计? - - - -### 2. block 的 copy、dispose - -```c++ -static struct __main_block_desc_0 { - size_t reserved; - size_t Block_size; - void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*); - void (*dispose)(struct __main_block_impl_0*); -} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0}; -``` - -因为 block 会对变量进行内存管理。`void *copy`、`void *dispose` 都是内存管理的方法。 - -如果 block 访问的不是对象,则结构体没有 `void *copy`、`void *dispose` - -```c++ -static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->p, (void*)src->p, 3/*BLOCK_FIELD_IS_OBJECT*/);} - -static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->p, 3/*BLOCK_FIELD_IS_OBJECT*/);} -``` - -其中 `_Block_object_assign` 会根据要不要拥有对象,内部决定要不要给对象调用 retain 方法。 - -Demo1: - - - -说明:ARC 环境下,GCD 的 block 会自动被拷贝到堆上 `__NSMallocBlock__`,堆上的 block 会对使用的对象进行 copy,所以 person 引用计数+1,则在 GCD block 执行完毕后才 release - -Demo2 - - - -- 栈空间上的 block 是不会对对象进行保命的(不管是 ARC 还是 MRC,都不调用 retain、copy 方法)。 -- ARC 下, block 如果访问了 auto, static 变量,则属于 `__NSStackBlock__`,ARC 下用强指针指向,则会变为 `__NSMallocBlock__ 堆上的 block, 是会对对象进行保命的。GCD 的 block 会自动拷贝到堆上,属于 `\__NSMallocBlock__`,也会对对象进 行 copy 保命。 -- 栈空间的 block 调用 copy 方法会变为堆空间的 block,会对 block 内使用的对象调用 retain、copy 方法进行保命。 - -马上执行了 Person 的 dealloc 方法。因为 `__weak` 修饰,block 内部的 `_Block_object_assign` 会根据 `__strong` 为对象引用计数 +1,`__weak` 则引用计数不变。所以是 `__weak` 修饰,出离作用域则立马会释放 Person 对象。 - -`_Block_object_assign` 会根据内存修饰符来对内存进行操作。 - -- `_block_object_assign` 函数会根据 auto 变量的修饰符(`__strong`、`__weak`、`__unsafe_unretained`)做出相应的操作,类似 retain(形成强、弱引用) - -Demo3 - - - -因为 GCD 是给 MainQueue 添加任务的,所以是串行,2个任务前后按照3s、1s 后打印。由于最晚的一个任务是访问强指针对象,所以不会释放。等到 GCD 全部执行完后,Person 才释放。 - -Demo4 - - - - - -在给 MainQueue 提交同步任务的时候,第一个任务是一个 block,访问了强指针指向的 Person(内部会调用 `_Block_object_assign`,发现是强引用,对 p 的引用计数 +1,当 block 执行完后,调用 `_Block_object_dispose` 对 p 的引用计数 -1),第二个任务是弱指针指向的 Person,引用计数不做操作。当1s 后第一个任务执行后,Person 被释放。第二个任务执行的时候,访问 name 属性就是给 nil 发消息,不会 crash,但是为 null。 - - - - -block 嵌套。多个 block 存在先后关系时 -- 看看最晚的一个 block 是什么修饰的。如果是 strong,早期的是 weak,则也不会释放。 -- 看看最晚的一个 block 是什么修饰的。如果是 weak,早起是 strong,则第一个 block 内部的可以正常访问,之后调用对象的 dealloc 方法,最后的 block 访问因为对象释放了,所以访问为 null - - -```objective-c -Person *p = [[Person alloc] init]; -p.name = @"杭城小刘"; -__weak Person *weakPerson = p; -dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - NSLog(@"%@", weakPerson.name); - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - NSLog(@"%@", p.name); - }); -}); -NSLog(@"-touchesBegan:withEvent:"); - -2024-08-13 20:58:46.500553+0800 BlockExplore[29848:967516] -touchesBegan:withEvent: -2024-08-13 20:58:47.549486+0800 BlockExplore[29848:967516] 杭城小刘 -2024-08-13 20:58:49.550015+0800 BlockExplore[29848:967516] 杭城小刘 -2024-08-13 20:58:49.550315+0800 BlockExplore[29848:967516] -[Person dealloc] - -Person *p = [[Person alloc] init]; -p.name = @"杭城小刘"; -__weak Person *weakPerson = p; -dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - NSLog(@"%@", p.name); - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - NSLog(@"%@", weakPerson.name); - }); -}); -NSLog(@"-touchesBegan:withEvent:"); - -2024-08-13 20:59:51.265796+0800 BlockExplore[29889:968688] -touchesBegan:withEvent: -2024-08-13 20:59:52.313063+0800 BlockExplore[29889:968688] 杭城小刘 -2024-08-13 20:59:52.313265+0800 BlockExplore[29889:968688] -[Person dealloc] -2024-08-13 20:59:54.313367+0800 BlockExplore[29889:968688] (null) -``` - -### 3. _Block_release 和 _Block_copy - -Demo1 - -```objective-c -void test(void) { - - int a = 0; - void(^__weak weakBlock)(void) = nil; - { - int b = 2; - void(^ __weak weakInnerBlock)(void) = ^{ - NSLog(@"%d", b); - }; - a = b; - weakBlock = weakInnerBlock; - } - weakBlock(); -} -``` - -- 定义了一个局部变量 weakBlock - -- 在 `{}` 内定义了一个栈上的 block **__NSStackBlock__** ,block 内访问了定义在 block 外部的 b - -- 将 weakInnerBlock 赋值给 weakBlock - -- 出了 `{}`,也意味着栈上的 block 作用域结束了。block 会调用 block_release 方法 - -- 由于出了局部作用域,栈上的 block 被释放了。所以在 `{}` 外调用 weakBlock 则存在野指针风险。编译器也会报警告:`Assigning block literal to a weak variable; object will be released after assignment` 。但可能不崩溃:栈内存虽回收但未被新数据覆盖 - - - -我们来看看 **block_release** 源码 - -```c++ -void -_Block_release(const void *src) -{ - struct StackBlockClass *self = (struct StackBlockClass *)src; - extern const void _NSConcreteStackBlock; - - if (self->isa == &_NSConcreteStackBlock // 必须是栈块类型 - // A Global block doesn't need to be released - && self->flags & BLOCK_HAS_DESCRIPTOR // 必须有描述符结构 - // Should always be true... - && self->reserved > 0) // 引用计数大于0 - // If false, then it's not allocated on the heap, we won't release auto memory ! - { - self->reserved--; - if (self->reserved == 0) - { - if (self->flags & BLOCK_HAS_COPY_DISPOSE) - self->descriptor->dispose_helper(self); - free(self); - } - } -} -``` - -说明: - -- `StackBlockClass` 表示栈 block 结构,包含: - - isa:指向块类型的指针(栈块为 `__NSConcreteStackBlock`) - - flag: 标志位( BLOCK_HAS_DESCRIPTOR 表示有描述符) - - reserved:引用计数器 - - descriptor:包含析构函数 dispose_helper。当 block 捕获了 OC、C++ 对象后,编译器会自动设置此标志 -- 释放条件: - - 排除全局 block 和堆 block:全局block `__NSConcreteGlobalBlock` 和堆上的 block 无需释放。 - - **栈块**: - 存储在栈内存中,生命周期与函数作用域绑定。 - **需要手动管理**:通过 `_Block_copy()` 复制到堆时,需用 `_Block_release()` 平衡引用计数。 - - **堆块**: - 由 `_Block_copy()` 动态分配在堆内存,受 ARC 管理。 - **自动管理**:ARC 会自动插入 `objc_release()` 调用,使用标准 Objective-C 对象释放机制。 - - 有效性检验:确保块结构完整且引用计数有效 -- 引用计数管理: - - `self->reserved--` 引用计数减 1 - - 引用计数归0的时候,调用 dispose_helper 方法。释放 block 捕获的对象、内存 - - 释放 block 结构体内存 -- 一般来说 block 由栈管理,但是被 copy、strong 等强引用作为属性或者参数,则会调用 **_Block_copy** 拷贝到堆上。本函数管理堆化后栈 block 的引用计数 - - - -Demo2 - -```c++ -void test2(void) { - - int a = 0; - void(^__weak weakBlock)(void) = nil; - { - int b = 2; - // 栈 block 赋值给一个 strong或copy 修饰的强引用变量,则会调用 _Block_copy 方法拷贝到堆上,变成堆 block - void(^ __strong strongInnerBlock)(void) = ^{ - NSLog(@"%d", b); - }; - a = b; - // 结构体赋值,也就是此时 a 是一个堆上的 block - weakBlock = strongInnerBlock; - } // 离开作用域,堆上的 block 会自动调用 _Block_release 方法,内部会 free 掉,此时再去调用释放的内存,系统机制会为已经释放的内存填充 0xDEADBEEF 标记,MMU 也会触发缺页异常,发送 crash - weakBlock(); -} -``` - -现象:上面的代码会稳定 crash。报错:`Thread 1: EXC_BAD_ACCESS (code=1, address=0x10)` - -分析: - -- 在 `{}` 内定义初始化了一个栈 block -- 赋值给一个由强指针指向的 strongInnerBlock 变量时,自动触发 `_Block_copy` 方法,复制到堆上,变为堆 block **__NSConcreteMallocBlock** -- 然后通过结构体赋值的方式,赋值给 `weakBlock` -- 要出 `{}` 作用域时,则会调用 `_block_release` 方法。堆块的引用计数清零。释放后,堆内存表记为不可访问 -- 此时调用 `weakBlock()` 则会 crash。访问已经释放的堆内存。系统在释放的堆内存上添加保护(如 `EXC_BAD_ACCESS`) - -test 和 test2 的本质区别 - -| 函数 | 内存类型 | 内存回收机制 | 崩溃原因 | -| :------ | :------- | :---------------------- | :--------------- | -| `test` | 栈内存 | 系统自动回收(无保护) | 内存可能未被覆盖 | -| `test2` | 堆内存 | `free()` + 内存保护标记 | 强制触发访问异常 | - - - -Demo3 - - - -为什么上面的代码会 crash? - -- weakBlock 是一个栈 block(__NSConcreteStackBlock) -- GCD 代码以 block 的形式将延迟任务添加到主队列中 -- `dispatch_block_t` 内部捕获了 block,但捕获的是原始指针,未复制 block -- GCD 不会阻塞,函数会结束,同时栈 block 会被回收,栈帧销毁。此时 `dispatch_block_t` 内持有其悬挂指针。 -- 3s 后开始执行 dispatch_block_t 所指向的 weakBlock -- 发现 weakBlock 所指向的栈内存被回收,调用无效指针导致 **EXC_BAD_ACCESS** 崩溃(访问野指针)。 - - - -解决方案: - -1. 将栈 block 改为堆 block。block 修饰改为 strong - - ````objective-c - void test3(void) { - int a = 10; - void(^__strong block)(void) = ^ { - NSLog(@"%d", a); - }; - - dispatch_block_t dispatch_block = ^{ - block(); - }; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), dispatch_block); - } - ```` - -2. 复制 block 到堆 - - ```objective-c - void test3(void) { - int a = 10; - void(^__weak weakBlock)(void) = ^ { - NSLog(@"%d", a); - }; - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), [weakBlock copy]); - } - ``` - -3. 栈帧不要回收。开启一个 RunLoop 晚退出3秒 - - ```objective-c - void test3(void) { - int a = 10; - void(^__weak weakBlock)(void) = ^ { - NSLog(@"%d", a); - }; - - dispatch_block_t dispatch_block = ^{ - weakBlock(); - }; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), dispatch_block); - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3]]; - } - ``` - - - -Demo4 - - - -分析: - -- block 本质就是结构体 - -- `void (^__strong strongBlock)(void) = weakBlock;` 看上去左侧是一个 `__strong` 修饰,其实就是结构体,所以不会像对象一样,底层调用 setter 方法。所以 block 的赋值本就是按照结构体的成员变量一个个字段简单赋值而已。 - - ```c++ - struct __block_impl { - void *isa; // 指向 Block 类 (栈上) - int flags; // 标志位 (栈上) - int reserved; // 保留字段 (栈上) - void (*invoke)(void); // 函数指针 (代码段) - struct __block_descriptor *descriptor; // 描述符指针 (栈上) - }; - - struct __block_descriptor { - unsigned long reserved; // 保留字段 (栈上) - unsigned long size; // Block 大小 (栈上) - void (*copy)(void); // copy 辅助函数 (代码段) - void (*dispose)(void); // dispose 辅助函数 (代码段) - }; - ``` - -- 当把结构体 block 的 invoke 设为 nil,由于是简单赋值,所以原来的 weakblock 的 invoke 也为 nil - -- 所以 strongblock 的 invoke 也为 nil,所以执行 block 底层就是先判断 block 的 isa、然后根据 invoke 指针,找到代码段的函数去执行。此时已经为 nil,再去执行就会 crash - -解决方案:将 weakblock copy 一下,即 `void (^__strong strongBlock)(void) = [weakBlock copy];` - - - -### 4. 栈内存、堆内存保护机制 - -通过上面 test 和 test2 知道,栈内存、堆内存在释放后继续访问存在不同的表现 - -#### 1. 栈内存 - -##### 1. 栈内存的本质上是: - -- **线性结构**:栈是连续的内存区域,通过栈指针(SP)管理 -- **自动回收**:函数退出时,只需移动栈指针即可"释放"内存 -- **物理内存不变**:移动栈指针不会立即清除数据,原内存内容保持不变 - -##### 2. 访问已释放的内存为何可能不崩溃? - -- 无硬件保护:CPU 和 MMU(内存管理单元)不跟踪栈帧生命周期 -- 内存数据保留:除非被新的栈帧覆盖,否则数据仍可被读取 - -##### 3. 不崩溃的深层原因: - -- 无页表标记:栈内存页始终标记为:可读写 (PROT_READ|PORT_WRITE) -- 无隔离机制:不同函数的栈帧共享同一内存页 -- 延迟覆盖:新栈帧写入前,元数据保持有效 - - - - - -#### 2. 堆内存 - -##### 1. 堆内存管理的核心机制: - -- malloc -> 向内核申请内存页 -> 设置页表属性 -> 分配内存块 -- free -> 标记内存页属性 -> 加入空闲链表 -> 触发内存页回收 - -##### 2. free 后的关键操作 - -内存标记 - -````objective-c -// 典型内存分配器实现 -void free(void *ptr) { - // 1. 在内存块头部写入魔数(如0xDEADBEEF) - *(uint32_t*)(ptr - 4) = 0xDEADBEEF; - - // 2. 通过 mprotect 设置内存页不可访问 - mprotect(ALIGN_PAGE(ptr), PAGE_SIZE, PROT_NONE); -} -```` - -页表更新 - -| 状态 | 页表项标志位 | 访问后果 | -| :--- | :-------------- | :----------- | -| 正常 | PRESENT+RW+USER | 允许访问 | -| 释放 | ~PRESENT | 触发缺页异常 | - -稳定崩溃的硬件基础: - -MMU 介入:当访问 `PROT_NONE` 内存页时: - -1. 触发缺页一场(Page Fault) -2. 内核检查异常地址 -3. 发送 `SIGSEGV` 信号 -4. 进程终止(崩溃) - -#### 3. iOS/Macos 优化 - -##### 1. 堆内存保护优化 - -- Malloc Scribble:释放后填充`0x55`(调试模式默认启用) - -- Zone-based Protection: - - ```objective-c - malloc_zone_t *zone = malloc_create_zone(0, 0); - malloc_set_zone_name(zone, "Protected Zone"); - malloc_zone_protect(zone, 1); // 启用保护 - ``` - -##### 2. 栈内存的刻意放松 - -- **性能优先**:避免栈操作时的权限检查 -- **安全边界**:仅防止栈溢出,不保护栈帧间访问 - - - -### 5. block 如何修改变量 - -#### 1. __block 修饰基本数据类型 - -```objectivec -typedef void(^MyBlock)(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - int age = 27; - MyBlock block = ^{ - age = 28; - }; - NSLog(@"%zd", age); - } - return 0; -} -``` - -编译会报错 `// Variable is not assignable (missing __block type specifier)` 为什么不能修改? - -把 block 内修改的那行代码注释了,转成 c++ 看看 - -```c -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - int age; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, int _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -static void __ViewController__viewDidLoad_block_func_0(struct __ViewController__viewDidLoad_block_impl_0 *__cself) { - int age = __cself->age; // bound by copy - - NSLog((NSString *)&__NSConstantStringImpl__var_folders_7x_g921y52j5yb_w5hsn3fb3m8r0000gn_T_ViewController_3ceae4_mi_0, age); - } - -static struct __ViewController__viewDidLoad_block_desc_0 { - size_t reserved; - size_t Block_size; -} __ViewController__viewDidLoad_block_desc_0_DATA = { 0, sizeof(struct __ViewController__viewDidLoad_block_impl_0)}; - -static void _I_ViewController_viewDidLoad(ViewController * self, SEL _cmd) { - ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}, sel_registerName("viewDidLoad")); - int age = 27; - void(*ChangeValueBlock)(void) = ((void (*)())&__ViewController__viewDidLoad_block_impl_0((void *)__ViewController__viewDidLoad_block_func_0, &__ViewController__viewDidLoad_block_desc_0_DATA, age)); -} -``` - -可以看到, block 内部的逻辑被包装成一个新的 `__ViewController__viewDidLoad_block_func_0`方法,然而 age 定义在 `_I_ViewController_viewDidLoad` 方法中。没有传递引用,也没任何特殊处理,所以没办法修改。 - - - -思考:那如何实现修改一个变量? - -全局变量、static 变量、`__block`修饰的变量在 block 内部可以修改。 - -- `__block` 用于解决 block 内部无法修改 auto 变量的问题。 - -- `__block` 不能修饰 static、全局变量 - -- 编译器会将 `__block` 修饰的变量包装为一个对象(后续修改则通过指针找到结构体对象,结构体对象再修改里面的值) - -Demo - -```objectivec -__block int age = 27; -MyBlock block = ^{ - age = 28; -}; -``` - -转为 C++ - - - -```c++ -struct __Block_byref_age_0 { - void *__isa; -__Block_byref_age_0 *__forwarding; - int __flags; - int __size; - int age; -}; - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - __Block_byref_age_0 *age; // by ref - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, __Block_byref_age_0 *_age, int flags=0) : age(_age->__forwarding) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -static void __ViewController__viewDidLoad_block_func_0(struct __ViewController__viewDidLoad_block_impl_0 *__cself) { - __Block_byref_age_0 *age = __cself->age; // bound by ref - - (age->__forwarding->age) = 28; - NSLog((NSString *)&__NSConstantStringImpl__var_folders_7x_g921y52j5yb_w5hsn3fb3m8r0000gn_T_ViewController_7fbccf_mi_0, (age->__forwarding->age)); - } - -static void _I_ViewController_viewDidLoad(ViewController * self, SEL _cmd) { - ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}, sel_registerName("viewDidLoad")); - __attribute__((__blocks__(byref))) __Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 27}; - void(*ChangeValueBlock)(void) = ((void (*)())&__ViewController__viewDidLoad_block_impl_0((void *)__ViewController__viewDidLoad_block_func_0, &__ViewController__viewDidLoad_block_desc_0_DATA, (__Block_byref_age_0 *)&age, 570425344)); -} -``` - -可以看到 `__block int age = 27;` 变为了 `__Block_byref_age_0` 结构体,给结构体赋值的时候,第二个成员变量的值就是结构体自身地址。 - -`__ViewController__viewDidLoad_block_impl_0` 结构体构造函数 `age(_age->__forwarding)` 就是把外面传递进来结构体的指针,所指向的结构体的 `__forwarding` 成员变量赋值给 block 结构体内的 age 成员变量。 - -block 内部的函数在修改 age 的时候其实就是通过 `__main_block_impl_0` 结构体的 age 找到 `__Block_byref_age_0`,然后访问 `__Block_byref_age_0` 中的成员变量 `__forwarding` 访问成员变量 age,并修改值。 - - - - - - - -QA:为什么`__block` 变量的 `__Block_byref_age_0` 结构体并不在 block 结构体 `__main_block_impl_0` 中? - -因为这样做可以在多个 block 中使用 `__block` 变量。 - - - -看个有趣的例子,验证下 __block 的效果 - - - -转换成 c++ 可以看到 - -```c++ -struct __ViewController__viewDidLoad_block_impl_1 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_1* Desc; - __Block_byref_num2_0 *num2; // by ref - __ViewController__viewDidLoad_block_impl_1(void *fp, struct __ViewController__viewDidLoad_block_desc_1 *desc, __Block_byref_num2_0 *_num2, int flags=0) : num2(_num2->__forwarding) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -static long __ViewController__viewDidLoad_block_func_1(struct __ViewController__viewDidLoad_block_impl_1 *__cself, NSInteger num3) { - __Block_byref_num2_0 *num2 = __cself->num2; // bound by ref - - return (num2->__forwarding->num2) + num3; - } -static void __ViewController__viewDidLoad_block_copy_1(struct __ViewController__viewDidLoad_block_impl_1*dst, struct __ViewController__viewDidLoad_block_impl_1*src) {_Block_object_assign((void*)&dst->num2, (void*)src->num2, 8/*BLOCK_FIELD_IS_BYREF*/);} - -static void __ViewController__viewDidLoad_block_dispose_1(struct __ViewController__viewDidLoad_block_impl_1*src) {_Block_object_dispose((void*)src->num2, 8/*BLOCK_FIELD_IS_BYREF*/);} - -static struct __ViewController__viewDidLoad_block_desc_1 { - size_t reserved; - size_t Block_size; - void (*copy)(struct __ViewController__viewDidLoad_block_impl_1*, struct __ViewController__viewDidLoad_block_impl_1*); - void (*dispose)(struct __ViewController__viewDidLoad_block_impl_1*); -} __ViewController__viewDidLoad_block_desc_1_DATA = { 0, sizeof(struct __ViewController__viewDidLoad_block_impl_1), __ViewController__viewDidLoad_block_copy_1, __ViewController__viewDidLoad_block_dispose_1}; - -__attribute__((__blocks__(byref))) __Block_byref_num2_0 num2 = {(void*)0,(__Block_byref_num2_0 *)&num2, 0, sizeof(__Block_byref_num2_0), 22}; - NSInteger(*testBlock2)(NSInteger num3) = ((long (*)(NSInteger))&__ViewController__viewDidLoad_block_impl_1((void *)__ViewController__viewDidLoad_block_func_1, &__ViewController__viewDidLoad_block_desc_1_DATA, (__Block_byref_num2_0 *)&num2, 570425344)); - (num2.__forwarding->num2) = 24; -``` - -外面的 num2 被 `__block` 修饰后变为了对象。通过 num2 对象的 `__forwarding` 指针,再访问 num2 即可修改值。 - - - -#### 2. __block 修饰对象 - -对` __block` 修饰的对象,clang 转换为 c++ 后如下: - - - - - -分析发现: - -- 对于 `__block` 修饰对象数据,对于生成的结构体也不一样。`__Block_byref_obj_0` 中含有2个操作内存的成员变量`__Block_byref_id_object_copy`、`__Block_byref_id_object_dispose` -- 其他的逻辑和 `__block` 修饰基本数据类型一致 - - - -注意: - - - - - -- block 外定义的 NSMutableArray,block 内只是使用数组则不需要` __block` - -- 如果在 block 里操作指针,则需要加 `__block` - -注意:`__weak` 只可以用来修饰对象,(终端用 clang 处理)否则 clang 会报错 `warning: 'objc_ownership' only applies to Objective-C object or block pointer types; type here is 'int' [-Wignored-attributes]` - - - -Demo:知道 `__block` 的本质之后,下面打印的 age 的地址是 struct 里面哪个的值? - -```objectivec -__block int age = 27; -MyBlock block = ^{ - age = 28; -}; -NSLog(@"%p", &age); -``` - -知道转换为c++后的效果,我们可以在代码中按照结构体,自己定义并转接到 block - -```objectivec -struct __Block_byref_age_0 { - void *__isa; // 0x0000000105231f70 +8 - struct __Block_byref_age_0 *__forwarding; // 0x0000000105231f78 + 8 - int __flags; // 0x0000000105231f80 +4 - int __size; // 0x0000000105231f84 + 4 - int age; // 0x0000000105231f88 -}; - -struct __block_impl { - void *isa; - int Flags; - int Reserved; - void *FuncPtr; -}; - -struct __main_block_desc_0 { - size_t reserved; - size_t Block_size; - void (*copy)(void); - void (*dispose)(void); -}; - -struct __main_block_impl_0 { - struct __block_impl impl; - struct __main_block_desc_0* Desc; - struct __Block_byref_age_0 *age; // by ref -}; - - -typedef void(^MyBlock)(void); -int main(int argc, const char * argv[]) { - @autoreleasepool { - __block int age = 27; - MyBlock block = ^{ - age = 28; - }; - struct __main_block_impl_0 *blockImpl = (__bridge struct __main_block_impl_0 *)block; - NSLog(@"%p", &age); - } - return 0; -} -``` - -我们将断点设置到 NSLog 这里,打印出自定义结构体 `__main_block_impl_0` 中的 age 。 - - - -```c -// 0x0000000105231f70 -struct __Block_byref_age_0 { - void *__isa; // 地址:0x0000000105231f70 长度:+8 - struct __Block_byref_age_0 *__forwarding; // 地址:0x0000000105231f78 长度:+8 - int __flags; // 地址:0x0000000105231f80 长度:+4 - int __size; // 地址:0x0000000105231f84 长度:+4 - int age; // 地址:0x0000000105231f88 -}; - -``` - -将地址打印出来。该地址就是 `__Block_byref_age_0` 结构体的地址,也就是结构体内第一个 `isa` 的地址。我们计算下,规则如下: - -- 指针长度8个字节 - -- int 长度4个字节 - -算出来 age 的地址为 `0x0000000105231f88` ,此时 Xcode 打印出的地址也是 `0x105231f88`。其实也就是 `blockImple->age->age` 的地址 - -block 内部对变量的值修改其实就是对 block 内部自定义结构体内部的变量修改。 - -当 block 被 copy 到堆上 - -- 会调用 block 内部的 copy 函数 - -- copy 函数内部会调用 `_Block_object_assign` 函数 - -- `_Block_object_assign` 函数会根据所指向对象的修饰符(__strong、__weak、__unsafe_unretained)做出相应的操作,形成强引用(retain)或者弱引用(注意:这里仅限于ARC时会retain,MRC时不会retain) - -当 block 从堆中移除 - -- 会调用 block 内部的 dispose 函数 - -- dispose 函数会调用 `_Block_object_dispose` 函数 - -- `_Block_object_dispose` 函数会自动释放引用的 auto 变量,类似于 release - - - -#### 3. 什么情况下需要 __block - -局部变量:基本数据类型、对象数据类型 - - - -#### 4. 什么情况下不需要 __block - -- 全局变量(不截获) -- 静态全局变量(不截获) -- 静态局部变量(截获指针) - - - - - -## 五、`__forwarding` 的设计 - -Demo1 - -```objective-c -__block int age = 27; -NSLog(@"1: age is %d, address is %p", age, &age); - -age = 30; -NSLog(@"4: age is %d, address is %p", age, &age); -// console -1: age is 27, address is 0x7ff7bb181c28 -4: age is 30, address is 0x7ff7bb181c28 -``` - -利用 `xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc -fobjc-runtime=ios-8.0.0 ViewController.m -o ViewController-arm64.cpp` 转成 c++ 研究下 - -```c++ -struct __Block_byref_age_0 { - void *__isa; - __Block_byref_age_0 *__forwarding; - int __flags; - int __size; - int age; -}; - -static void _I_ViewController_viewDidLoad(ViewController * self, SEL _cmd) { - ((void (*)(__rw_objc_super *, SEL))(void *)objc_msgSendSuper)((__rw_objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}, sel_registerName("viewDidLoad")); - int hegith = 175; - __attribute__((__blocks__(byref))) __Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 27}; - NSLog((NSString *)&__NSConstantStringImpl__var_folders_vf_05htlrfx42qck4yh6bsnh3yr0000gn_T_ViewController_450701_mi_0, (age.__forwarding->age), &(age.__forwarding->age)); - (age.__forwarding->age) = 30; - NSLog((NSString *)&__NSConstantStringImpl__var_folders_vf_05htlrfx42qck4yh6bsnh3yr0000gn_T_ViewController_450701_mi_1, (age.__forwarding->age), &(age.__forwarding->age)); -} -``` - -可以看到: - -- age 没有在任何一个 block 中访问(也就不存在变量捕获的问题),仅仅是被 `__block` 修饰,编译器也会转为结构体 `struct __Block_byref_age_0` - -- 通过 `__Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 27};` 可以看出此时 `__Block_byref_age_0` 结构体中的 `__forwarding` 成员变量指针指向栈空间上 age 的地址(也就是结构体 `__Block_byref_age_0` 自身的地址) - - 大概如下 - - ```c++ - // 栈上创建 __Block_byref_age_0 结构体实例 - __Block_byref_age_0 age = { - .__isa = NULL, - .__forwarding = &age, // 指向自己(栈地址) - .__flags = 0, - .__size = sizeof(__Block_byref_age_0), - .age = 27 - } - ``` - -- NSLog 打印 age 的值和地址的时候,都是 `(age.__forwarding->age), &(age.__forwarding->age))`通过结构体变量的成员变量 `__forwarding` 指针指向的自身,再去访问成员变量的 -- `(age.__forwarding->age) = 30;` 赋值的时候也是通过结构体变量的成员变量 `__forwarding` 指针指向的自身去赋值的 - -Tips: - -在 C 和 Objective-C 中,结构体位于栈还是堆上,取决于声明方式。 - -- 在栈上创建:当结构体作为局部变量在函数内部声明时,它会直接分配在栈上 -- 在堆上创建:当使用动态内存分配函数(如`malloc`、`calloc`或Objective-C的`alloc`)时,结构体会在堆上分配 - -``` objective-c -- (void)viewDidLoad { - [super viewDidLoad]; - // 栈上 - struct Person { - char name[20]; - int age; - }; - struct Person p1; // p1 分配在栈上 - p1.age = 25; - NSLog(@"栈地址:%p", &p1); // 输出栈地址 - - // 堆上 - struct Student { - char name[20]; - int age; - }; - - struct Student *st = malloc(sizeof(struct Student)); // p2 指向堆内存 - st->age = 30; - NSLog(@"堆地址:%p", st); // 输出堆地址 - free(st); // 需要手动释放 -} -``` - -Demo2 : `__block` 如何修改外部变量 - -```objective-c -- (void)viewDidLoad { - [super viewDidLoad]; - __block int age = 27; - NSLog(@"1: age = %d, address is %p", age, &age); - void(^block1)(void) = ^{ - age = 28; - NSLog(@"in block: age = %d, address is %p", age, &age); - }; - NSLog(@"2: age = %d, address is %p", age, &age); - block1(); - NSLog(@"3: age = %d, address is %p", age, &age); - age = 29; - NSLog(@"4: age = %d, address is %p", age, &age); -} -// console -1: age = 27, address is 0x7ff7b0faebf8 -2: age = 27, address is 0x600000464938 -in block: age = 28, address is 0x600000464938 -3: age = 28, address is 0x600000464938 -4: age = 29, address is 0x600000464938 -``` - - - - - -分析: - -- 被 `__block` 修饰的 int age 将会被封装为一个结构体,该结构体内有一个 `__forwarding` 成员变量 - - ```c++ - struct __Block_byref_age_0 { - void *__isa; - __Block_byref_age_0 *__forwarding; - int __flags; - int __size; - int age; - }; - ``` - -- 在给 block 赋值的时候,其成员变量 `__forwarding`的值是由当前结构体对象的地址赋值的 - - ````c++ - __attribute__((__blocks__(byref))) __Block_byref_age_0 age = {(void*)0,(__Block_byref_age_0 *)&age, 0, sizeof(__Block_byref_age_0), 27}; - ```` - -- block 通过指针持有栈上的 `__Block_byref_age_0` 结构体 - -- 当发现 block 是函数返回值或者被一个强指针指向的时候,编译器生成 `_Block_copy()` 函数调用,将 block 从栈复制到堆。 - -- 如果 block 捕获了 `__block` 变量,编译器会调用 `_Block_object_assign()`,将 `__Block_byref_age_0` 结构体从栈复制到堆。同时修改 `__Block_byref_age_0` 结构体的 `__forwarding` 指针,指向堆上的结构体地址。 - -- block 内部代码,将被封装为一个新的函数 `__ViewController__viewDidLoad_block_func_0`,其内部通过结构体指针` _cself` 的 age 成员变量,获取到 `__Block_byref_age_0` 指针,该指针命名为 age。然后通过 age 指针访问到结构体的 `__forwarding` 成员变量,该成员变量指向的是结构体自己,然后再访问 age 拿到真正的 age 进行修改。 - - ```c++ - static void __ViewController__viewDidLoad_block_func_0(struct __ViewController__viewDidLoad_block_impl_0 *__cself) { - __Block_byref_age_0 *age = __cself->age; // bound by ref - (age->__forwarding->age) = 28; - NSLog((NSString *)&__NSConstantStringImpl__var_folders_7x_g921y52j5yb_w5hsn3fb3m8r0000gn_T_ViewController_bccea7_mi_1, (age->__forwarding->age), &(age->__forwarding->age)); - } - ``` - -- 第一行输出 `1: age = 27, address is 0x7ff7b0faebf8` 是因为此时 age 在栈上,高地址 `0x7ff7b0faebf8` - -- 第二行输出 `2: age = 27, address is 0x600000464938` 是因为 block 被拷贝到堆上,内部对于使用到的` __block` 变量也会拷贝到堆上,是通过一个结构体对象来实现的。由于在栈上,地址变为 `0x600000464938`,相较于栈上的地址,地址变低了。 - -- 将 block 从栈拷贝到堆上时,block 所捕获的 `__block` 变量也会从栈拷贝到堆上,但是此时我们在该函数的作用域内(即 block 外)仍然是可以对 age 变量进行修改的 - -- 第三行输出 `in block: age = 28, address is 0x600000464938` 是因为此时 age 在堆上,低地址 `0x600000464938`。通过结构体 `__ViewController__viewDidLoad_block_impl_0`的 age 成员变量指向的 `__Block_byref_age_0` 指针,再通过指针指向的 `__forwarding` 指向自己,再访问 age 来修改值 - -- 为了将上述修改进行同步,在将 `__block` 变量从栈拷贝到堆上时,栈上的 `__Block_byref_val_0` 结构体的 `__forwarding` 指针将会指向堆上的 `__Block_byref_val_0` 结构体。所以此时,`age` 变量(即`age.__forwarding->age`变量)的地址改变了 - -- 第四行输出 `3: age = 28, address is 0x600000464938` 是因为此时 age 在堆上,低地址 `0x600000464938`,且值为28 - -- 第五行输出 `4: age = 29, address is 0x600000464938` 是因为此时通过栈上的 age 结构体,通过成员变量 `__forwarding` 指向对上的结构体地址,然后再通过指向堆上的结构体的 age 成员变量已经被修改为 29 了 - -通过 Demo1 和 Demo2 总结下:` __forwarding` 的作用是什么?为什么这么设计 - - - - - -- 当 block在栈中时,栈上的 `__Block_byref_age_0` 结构体内的 `__forwarding` 指针指向栈上的结构体自己 - -- 当 block 被复制到堆中时,栈中的`__Block_byref_age_0` 结构体也会被复制到堆中一份,而此时栈中的 `__Block_byref_age_0` 结构体的成员变量 `__forwarding` 指针指向的就是堆中的 `__Block_byref_age_0`结构体,堆中 `__Block_byref_age_0`结构体内的 `__forwarding` 指针依然指向自己,此时再访问成员变量 age 就可以修改堆上的值 - - - -完整流程: - -- 初始阶段:`__block` 变量(结构体)在栈上,`__forwarding` 指向栈地址 -- block 复制到堆时:`__block` 结构体被复制到堆,原来栈上结构体变量 `__forwarding` 指针被重定向到堆副本(指向堆上的地址) -- 最终结果:所有代码(无论 block 内外)通过 `__forwarding` 访问堆上的副本,因此地址看似“不变”,实际是 `__forwarding` 指针在幕后重定向 - -这种设计既保证了性能(避免不必要的堆分配),又确保了 Block 内外对 `__block` 变量修改的同步性。 - -所以存在2方面的优点: - -- 性能优化:如果 Block 未被复制到堆(例如仅在栈上使用),则无需为 `__block` 变量分配堆内存。`__forwarding` 指针的初始设计允许“按需复制”。 -- 统一访问逻辑:无论 `__block` 变量在栈还是堆,代码中访问 `age` 时,始终通过 `age.__forwarding->age`,编译器隐藏了实际存储位置的差异 - -`__forwarding` 指针的设计是为了解决 **Block 在栈(Stack)和堆(Heap)之间迁移时的内存访问一致性问题**,并确保对 Block 及其捕获变量的操作始终安全可靠 - -**一言以蔽之,`__forwarding` 指针是为了在 `__block` 变量从栈复制到堆上后,在 block 外对 `__block` 变量的修改也可以同步到堆上实际存储的 `__block` 变量的结构体上。也就是抹平栈、堆上对变量操作的差异。** - - - -Tips:实现 block 拷贝及其捕获对象的函数是 `_Block_copy`,工作流程如下: - -- 检查 block 类型 - - 首先通过 Block 的 `flags` 字段判断其当前存储位置: - - - 全局 Block(`BLOCK_IS_GLOBAL`):直接返回原 Block,无需复制(全局 Block 存储在数据区,生命周期与程序一致)。 - - 堆 Block(`BLOCK_NEEDS_FREE`):增加引用计数后返回原 Block(堆 Block 已由 ARC 管理)。 - - 栈 Block:需要复制到堆上,进入下一步流程。 - - ```c++ - void *_Block_copy(const void *arg) { - struct Block_layout *src = (struct Block_layout *)arg; - if (src == NULL) return NULL; - - // 全局 Block 直接返回 - if (src->flags & BLOCK_IS_GLOBAL) { - return src; - } - - // 堆 Block 增加引用计数 - if (src->flags & BLOCK_NEEDS_FREE) { - src->flags |= BLOCK_REFCOUNT_MASK; - return src; - } - - // 栈 Block 复制到堆 - // ... - } - ``` - -- 分配堆内存并复制结构体值 - - 为栈 Block 分配堆内存,并复制其内容: - - - 内存分配:使用 malloc 分配与栈上结构体一样大的堆空间 - - 结构体复制:将栈上的 block_layout 包含函数指针等都复制到堆上 - - ```c++ - struct Block_layout *dest = malloc(src->descriptor->size); - memmove(dest, src, src->descriptor->size); // 复制结构体 - ``` - -- 更新 block 标志位 - - 复制后设置 block 的标志位: - - - 标记为堆 block:`BLOCK_NEEDS_FREE` 表示需要手动释放 - - 初始化引用计数:引用计数设为 1(后续通过 `_Block_release` 管理) - - ```c++ - dest->flags &= ~BLOCK_REFCOUNT_MASK; // 清除原有引用计数 - dest->flags |= BLOCK_NEEDS_FREE | 1; // 标记为堆 Block,引用计数=1 - ``` - -- 处理捕获的变量。调用 `descriptor` 中的 `copy` 助手函数,处理捕获的变量 - - - 对象类型变量:对 `__strong` 修饰的对象调用 `retain`(符合 ARC 规则) - - `__block` 变量:将栈上的 `__Block_byref_xxx` 结构体复制到堆,并更新 `__forwarding` 指针 - -- 返回堆上的 block:最终返回堆上的 block,供后续使用 - - - -## 六、Block 内存引用 - -对于` __block` 修饰的变量进行研究 - -Demo0 - - - -可以看到: - -- block 访问了外部的变量,则会在 block 的底层实现即 `__ViewController__viewDidLoad_block_impl_0` 内增加一个 `__Block_byref_age_0 *age` 成员变量 -- 被 `__block` 修饰的基础数据类型 int 会被编译器自动创建为一个结构体 `__Block_byref_age_0` ,其内部的成员变量 age 存储真实的值 - -Demo1 - - - - - - - -Demo2 - - - - - - - -Test0: - -```c++ -int age = 20; -void (^block)(void) = ^(void) { - NSLog(@"%p", &age); -}; - - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - int age; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, int _age, int flags=0) : age(_age) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -Test1: - -```c++ -__strong NSObject *obj = [[NSObject alloc] init]; -NSLog(@"1 %p", obj); -void (^block)(void) = ^(void) { - NSLog(@"%p", obj); -}; -block(); - - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - NSObject *__strong obj; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, NSObject *__strong _obj, int flags=0) : obj(_obj) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -Test2: - -```c++ -_block NSObject *obj = [[NSObject alloc] init]; -__weak NSObject *weakObj = obj; -NSLog(@"1 %p", weakObj); -void (^block)(void) = ^(void) { - NSLog(@"%p", weakObj); -}; -block(); - - -struct __Block_byref_obj_0 { - void *__isa; -__Block_byref_obj_0 *__forwarding; - int __flags; - int __size; - void (*__Block_byref_id_object_copy)(void*, void*); - void (*__Block_byref_id_object_dispose)(void*); - NSObject *__strong obj; -}; - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - NSObject *__weak weakObj; - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, NSObject *__weak _weakObj, int flags=0) : weakObj(_weakObj) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -Test3: - -```objective-c -NSObject *obj = [[NSObject alloc] init]; -__block __weak NSObject *weakObj = obj; - -NSLog(@"1 %p", weakObj); -void (^block)(void) = ^(void) { - NSLog(@"%p", weakObj); -}; - -struct __Block_byref_weakObj_0 { - void *__isa; -__Block_byref_weakObj_0 *__forwarding; - int __flags; - int __size; - void (*__Block_byref_id_object_copy)(void*, void*); - void (*__Block_byref_id_object_dispose)(void*); - NSObject *__weak weakObj; -}; - -struct __ViewController__viewDidLoad_block_impl_0 { - struct __block_impl impl; - struct __ViewController__viewDidLoad_block_desc_0* Desc; - __Block_byref_weakObj_0 *weakObj; // by ref - __ViewController__viewDidLoad_block_impl_0(void *fp, struct __ViewController__viewDidLoad_block_desc_0 *desc, __Block_byref_weakObj_0 *_weakObj, int flags=0) : weakObj(_weakObj->__forwarding) { - impl.isa = &_NSConcreteStackBlock; - impl.Flags = flags; - impl.FuncPtr = fp; - Desc = desc; - } -}; -``` - -可以看出: - -- Test0:如果基础数据类型,没有用 `__block` 修饰,则 block 底层的结构体 `__ViewController__viewDidLoad_block_impl_0` 内,只会增加一个基础成员变量 `int age` - -- Test1:如果是一个 strong 指针指向的对象,则会在 block 底层的结构体 `__ViewController__viewDidLoad_block_impl_0` 内,只会增加一个 strong 对象类的成员变量 `NSObject *__strong obj;` - -- Test2: 如果一个对象被 `__block` 修饰,但是是强指针类型的,那么生成的被 `__block` 修饰的结构体 `__Block_byref_obj_0` 内只会有一个强指针指向的成员变量;如果一个仅仅是 weak 指针的对象(没有被 `__block` 修饰),那么在 block 底层的结构体 `__ViewController__viewDidLoad_block_impl_0` 内,只会存在一个 weak 指针的成员变量 ` NSObject *__weak weakObj;` - -- Test3: 如果一个对象被 `__block` 修饰,同时是弱指针类型的,则在 block 底层的结构体 `__ViewController__viewDidLoad_block_impl_0` 内,仅仅会存在一个强指针指向的成员变量 ` __Block_byref_weakObj_0 *weakObj` ,指向被 `__block` 底层生成的结构体 `__Block_byref_weakObj_0`, `__Block_byref_weakObj_0` 结构体内会存在一个 weak 指针指向的成员变量,指向堆上的 weakObj - - - -通过上面例子的源码可以看到: - -- block 结构体里面的针对变量生成的结构体新对象,都是用 strong 指针指向的 -- block 所捕获的对象是` __weak` 还是` __strong` 决定了新生成结构体对象里面的对象内存访问修饰符。 - -```c -int main(int argc, const char * argv[]) { - /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; - __attribute__((__blocks__(byref))) __Block_byref_p_0 p = {(void*)0,(__Block_byref_p_0 *)&p, 33554432, sizeof(__Block_byref_p_0), __Block_byref_id_object_copy_131, __Block_byref_id_object_dispose_131, ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("alloc")), sel_registerName("init"))}; - void(*block)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_p_0 *)&p, 570425344)); - } - return 0; -} - - -static void __Block_byref_id_object_copy_131(void *dst, void *src) { - _Block_object_assign((char*)dst + 40, *(void * *) ((char*)src + 40), 131); -} - -static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) { - _Block_object_assign((void*)&dst->p, (void*)src->p, 8/*BLOCK_FIELD_IS_BYREF*/); -} -``` - -如果 `__block` 修饰 `__strong` 则表示 block_impl 结构体中的 person 成员变量指向一个新的结构体 `__Block_byref_person_0`。这个线是强引用。 - -`__Block_byref_person_0` 结构体成员变量 person 真正的 Person 对象的引用关系要看 block 外部 person 的修饰是 `__strong` 还是 `__weak`,因为从栈上拷贝到堆上,会调用 block 的 desc 的 `__main_block_copy_0`,本质上调用的是 `_Block_object_assign` - -`__Block_byref_id_object_copy_131` 方法里的 40 代表什么? - -```c - -struct __Block_byref_p_0 { - void *__isa; 8 -__Block_byref_p_0 *__forwarding; 8 - int __flags; 4 - int __size; 4 - void (*__Block_byref_id_object_copy)(void*, void*); 8 - void (*__Block_byref_id_object_dispose)(void*); 8 - Person *p; 8 -}; - -__attribute__((__blocks__(byref))) __Block_byref_p_0 p = { - 0, - &p, - 33554432, - sizeof(__Block_byref_p_0), - __Block_byref_id_object_copy_131, - __Block_byref_id_object_dispose_131, - ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("alloc")), sel_registerName("init")) - -}; -``` - -`__Block_byref_p_0` 结构体地址上偏移40就是 p 对象。 - - - - - -## 七. 循环引用 - - self 是一个局部变量,block 访问 self,即存在捕获变量的效果。 - -为什么会存在循环引用?block 会对截获的变量是对象类型,会把所有权也进行捕获。为什么 strong 类型的对象,会造成对象和 block 的循环引用 - -看个 Demo - - - -可以看到 block 放在堆上的时候(被抢指针指向、作为返回值)的时候,如果 block 内部访问了强指针指向的对象,则会发生循环引用。 - -可以看到 Person 对象的 dealloc 方法没有执行,里面的打印信息没有输出。 - -### 1. ARC 下 - - `__weak`、`__unsafe_unretained` 修饰 `__block` 所修饰的变量。区别在于: - -- `__weak` 不会产生强引用,指向的对象销毁时,会自动给指针置为 nil - -- `__unsafe_retained` 不会产生强引用,不安全。当指向的对象销毁时,指针地址值不变。 - -```objectivec -@interface Person : NSObject -@property (nonatomic, assign) NSInteger age; -@property (nonatomic, copy) void (^block)(void); -- (void)test; -@end - -@implementation Person -- (void)dealloc -{ - NSLog(@"%s", __func__); -} -- (void)test -{ - __weak typeof(self) weakself = self; - self.block = ^{ - weakself.age = 23; - }; - self.block(); - NSLog(@"age:%ld", (long)self.age); -} -@end - -Person *p = [[Person alloc] init]; -[p test]; -``` - - - - - - - -方法1: `__weak` 修饰。`__weak typeof(self) weakself = self;` - -方法2: `__unsafe_retained` 修饰。`__unsafe_unretained typeof(self) weakself = self;` - -方法3: `__block` 修饰。因为此时会构成3角关系。所以需要调用 block,block 内部需要将对象设置为 nil。虽然` __block` 方案也可以解决循环引用的问题,但是缺点是该 block 需要执行,方案会有限制。 - -```objectivec -__block Person *weakself = [[Person alloc] init]; -p.block = ^{ - weakself.age = 23; - NSLog(@"%ld", weakself.age); - weakself = nil; -}; -p.block(); -``` - -`__unsafe_retained` 因为不安全所以不推荐,`__block` 因为使用繁琐,且必须等到调用 block 才会释放内存,所以不推荐。ARC 下最佳用 `__weak` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/block_object_cycle.png) - - - -### 2. MRC 下 - -方法1: `__unsafe_retained` 修饰。`__unsafe_unretained typeof(self) weakself = self;` - -方法2: `__block` 修饰。MRC 下不会对 block 内部的对象引用计数 +1 - - - -## 八、为什么加 weakself、strongself - -weakSelf 是为了使 block 不持有 self,避免 Retain Circle 循环引用。在 Block 内如果需要访问 self 的方法、变量,建议使用 weakSelf。 - -strongSelf 的目的是因为一旦进入 block 执行,假设不允许 self 在这个执行过程中释放,就需要加入 strongSelf。 - -block 执行完后这个 strongSelf 会自动释放,没有不会存在循环引用问题。如果在 Block 内需要多次 访问 self,则需要使用 strongSelf。 - -1. **防止对象在 block 执行过程中被释** - - - 弱引用的风险:`weakself`是对`self`的弱引用,不会增加其引用计数。如果在Block开始执行后,`self`被其他部分释放,后续对`weakself`的访问将指向无效内存,导致崩溃。 - - 强引用的保障:通过`__strong`修饰的`strongSelf`,在 block 内部临时强引用`self`,确保在 block 执行期间`self`不会被释放。即使外部已经不再持有`self`,只要 block 在执行,`strongSelf`会保持`self`存活。 - -2. ### **避免悬垂指针(Dangling Pointer)** - - ```objective-c - // bad - __weak typeof(self) weakself = self; - dispatch_async(dispatch_get_main_queue(), ^{ - // 假设此时 weakself 已被释放 - [weakself doSomething]; // 访问已释放对象,崩溃! - }); - - // good - __weak typeof(self) weakself = self; - dispatch_async(dispatch_get_main_queue(), ^{ - // 假设此时 weakself 已被释放 - __strong typeof(self) strongSelf = weakself; - if (strongSelf) { - [strongSelf doSomething]; // 访问已释放对象,崩溃! - } - }); - ``` - - - -## 九、总结 - -1. block 本质是什么?封装了函数调用及其调用环境的 OC 对象。本质实现是一个结构体。 -2. `__block` 的作用是什么?可以对 block 外部的变量进行捕获,可以修改。但是需要注意内存管理相关问题。比如`__weak`、`__unsafe_unretained`、`__block` -3. 修改 NSMutableArray 不需要加 `__block`? 是的,如果修改 NSMutableArray 指针比如`array = nil` 则需要加`__block ` -4. block 属性修饰词为什么是 copy?没有进行 copy 操作的时候,block 就不会在堆上,对于 block 生命周期以及所使用到的内存,没办法灵活控制(由栈控制,出栈就死)。因为 block 的高频使用场景就是作为方法参数传递、作为类的属性值,所以最常见的场景是:赋值的地方不是使用的地方,所以要捕获周围环境参数和管理所捕获的内存、以及自身内存。 -5. 为什么会产生循环引用? - 1. 如果当前当前 block 对于某个变量进行捕获,变量也是强引用类型的,block 捕获变量后,block 对变量是强引用关系,当前对象(VC)对 block 是强引用关系,变量也是 VC 强引用的,就产生了循环引用。 - 2. 用 __block 修饰: - - MRC 下不会产生循环引用 - - ARC 下会产生循环引用,可以采用断环的方式解决。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.9.md b/Chapter1 - iOS/1.9.md deleted file mode 100644 index 9891bc2..0000000 --- a/Chapter1 - iOS/1.9.md +++ /dev/null @@ -1,180 +0,0 @@ -# 事件响应者判定原理之 hittest 方法 - -## 一、 hittest 介绍 -* 就是用来寻找最合适的view -* 当一个事件传递给一个控件,就会调用这个控件的hitTest方法 -* 点击了白色的view: 触摸事件 -> UIApplication -> UIWindow 调用 \[UIWindow hitTest\] -> 白色view \[WhteView hitTest\] - - -## 二、 实验 - -### 2.1 实验一 - -定义 BaseView,在里面实现方法touchBegan,监听当前哪个类调用了该方法。 - -定义KeyWindow,在里面实现hitTest方法,监听哪个类调用了该方法,用来追踪判断哪个view是最合适的view - -在控制器的界面上加5个颜色不同的view,每个view自定义view去实现,因此在不同的view上的手势就可以由不同的view拦截到。 - -![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); - return view; -} -``` - -点击了白色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->> -2017-10-11 16:48:59.647575+0800 主流App框架[16295:358790] BrownView--hitTest withEvent -2017-10-11 16:48:59.647702+0800 主流App框架[16295:358790] GreenView--hitTest withEvent -2017-10-11 16:48:59.647880+0800 主流App框架[16295:358790] fittest->> -``` - -点击了蓝色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 -2017-10-11 16:49:56.331968+0800 主流App框架[16295:358790] YellowView--hitTest withEvent -2017-10-11 16:49:56.333206+0800 主流App框架[16295:358790] fittest->> -2017-10-11 16:49:56.333633+0800 主流App框架[16295:358790] BrownView--hitTest withEvent -2017-10-11 16:49:56.333762+0800 主流App框架[16295:358790] BView--hitTest withEvent -2017-10-11 16:49:56.333893+0800 主流App框架[16295:358790] BlueView--hitTest withEvent -2017-10-11 16:49:56.334005+0800 主流App框架[16295:358790] YellowView--hitTest withEvent -2017-10-11 16:49:56.334185+0800 主流App框架[16295:358790] fittest->> -2017-10-11 16:49:56.334644+0800 主流App框架[16295:358790] BlueView -``` - -那么看出来hitTest方法的作用就是找出最合适的view,那么我们可以指定任何事情的最合适的view为特定的view - -实验2: - -在KeyWindow中hitTest方法中返回BlueView,那么点击任何色块的view那么都会交给BlueView去处理事件。 - -``` -//KeyWindow --(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ - return self.subviews.firstObject.subviews.firstObject; -} -``` - -结果: - -``` -2017-10-11 22:48:46.102793+0800 主流App框架[21498:749663] GreenView -2017-10-11 22:48:46.668595+0800 主流App框架[21498:749663] GreenView -``` - -因为事件的响应者链条就是当用户操作屏幕会产生一个事件,该事件被系统加入到事件队列中去,UIApplication对象会将事件队列中最早加入进去的事件传递给window,然后window找到最合适的view去处理事件。因此任何事件都会先通过KeyWindow对象去判断并找到最合适的view - -## 2个重要的方法 - -* -\(BOOL\)pointInside:\(CGPoint\)point withEvent:\(UIEvent \*\)event: 用来判断触摸点是否在控件上 - -* -\(UIView \*\)hitTest:\(CGPoint\)point withEvent:\(UIEvent \*\)event: 用来判断控件是否接受事件以及找到最合适的view - -## 模仿系统实现找出最合适的view - -``` -//KeyWindow - -/** -模仿系统实现寻找最合适的view步骤 -1、控件接收事件 -2、触摸点在自己身上 -3、从后往前遍历子控件,重复前面2个步骤 -4、如果没有符合条件的子控件,那么就自己最合适 - -*/ - --(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ - if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) { - return nil; - } - - if (![self pointInside:point withEvent:event]) { - return nil; - } - - for (NSUInteger index = self.subviews.count - 1; index >= 0; index--) { - CGPoint childViewPoint = [self convertPoint:point toView:self.subviews[index]]; - UIView *fitestView = [self.subviews[index] hitTest:childViewPoint withEvent:event]; - if (fitestView) { - return fitestView; - } - return nil; - } - - return self; -} -``` - -给出 一个Demo地址:[https://github.com/FantasticLBP/BlogDemos/tree/master/模仿系统找出事件的最佳响应者](https://github.com/FantasticLBP/BlogDemos/tree/master/模仿系统找出事件的最佳响应者 "模仿系统找出事件的最佳响应者") - -实验: - -在控制器(ViewController)的view上先添加一个UIButton,再添加一个自定义的UIView\(ShelterView\),盖在button的上面。 - -需求:点击ShelterView上的点,如果点也在UIButton范围上则交给UIButton处理事件,如果不在UIButton上则交给ShelterView处理,如果点击屏幕上除了ShelterView之外的点则交给控制器的view处理。 - -``` -//ViewController --(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - NSLog(@"viewController->%s",__func__); -} - - -//ShelterView -#import "ShelterView.h" - -@implementation ShelterView - --(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - NSLog(@"%s",__func__); -} - --(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ - NSLog(@"%s",__func__); - /** - 需求:不管点击按钮还是view都交给button处理 - 思路:在view的hitTest方法中寻找最合适的view,那么返回nil告诉系统view不是最合适的view,那么系统则认为按钮是最合适的view - return nil; - */ - - //需求,在view上点击,如果点击范围在button上则由button进行处理事件;否则交给view处理事件 - - UIView *button = nil; - for (UIView *subView in self.superview.subviews) { - //判断事件的点是否在按钮上 - if ([subView isKindOfClass:[UIButton class]]) { - button =subView; - } - - - CGPoint btnPoint = [self convertPoint:point toView:button]; - if ([button pointInside:btnPoint withEvent:event]) { - return button; - }else{ - //此时代表事件触摸点不在button上,但是也不能写nil,写nil的话点击屏幕上的其他地方系统会寻找最合适的view,此时返回nil( return nil;),则代表view不是最合适的view,那么此时点击屏幕上除了按钮之外的区域,最合适的view就是控制器上面的view - return [super hitTest:point withEvent:event]; - } - } - return nil; -} - -@end - -``` - -要看完整Demo,地址为:[https://github.com/FantasticLBP/BlogDemos/tree/master/hitTest的神奇效果(一)](https://github.com/FantasticLBP/BlogDemos/tree/master/hitTest的神奇效果(一) "hitTest的神奇效果") - diff --git a/Chapter1 - iOS/1.90.md b/Chapter1 - iOS/1.90.md deleted file mode 100644 index fec5db7..0000000 --- a/Chapter1 - iOS/1.90.md +++ /dev/null @@ -1,53 +0,0 @@ -# YYImage 框架原理,探索图片高效加载原理 - -## 图片显示流程 - -![image-20200813130942777](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-ImageRenderProcess.png) - -```objective-c -UIImage *image = [UIImage imageNamed:@"test"]; -_imageView.image = image; -``` - -上述的代码叫做“隐形解码”。 代码测试一张图片从磁盘读取到内存中,通过 Instrucments 中的 Time Profiler 分析得到,从磁盘调用 ImageIO 中方法加载到内存中,这个过程比较耗时。 - -图片大,则需要更大的空间去将 Data Buffer 计算得到 Image Buffer - -所以将图片解码过程,放到异步线程中去。 - - - -## YYImage 源码 - -![image-20200813131944130](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2020-08-13-YYImageClassLevel.png) - - -很多框架使用锁都是 pthread_mutex_lock,分析原因 - -pthread_mutex_lock - -pthread_mutex_unlock - - -## 内存方面 -图片相关小建议 -目前对于大图片没有特别好的方法进行内存峰值的控制 -内存峰值:解码一瞬间带来的内存压力,无关最终图片可能缩放的大小 -1. 下发图片不要太大,字节内部大多数直接接入 ImageX 可以直接控制, -图片下发尽可能不要超大类型的图片,这样对客户端下载和解码带来的压力都不低 -( 字节ImageX 外部也可接入服务 ) -2. 大部分主流图片库支持 Force Redraw 概念,默认都是开启的 -ForceRedraw 开启:3倍内存峰值,提升FPS -ForceRedraw 关闭:2倍内存峰值,直到需要图片才进行渲染,降低 FPS -( 字节图片库可以指定是否开启 ) -3. 如果是用户上传的图片,可以选择显示图片大小有一定范围,比如最大不超过 1920x1080, -否则本地进行图片缩小后上传 -4. 部分低端机可以增加更多的解码限制,举例: -iPhone6 内存大小为 1GB,iPhone6s 内存大小为 2GB -对于 iPhone6 而言,一张 4000x6000,或着三张 1920x1080同时解码,就是压力的极限 -对于 iPhone6s 而言,两张 4000x6000,或着五张 1920x1080同时解码,就是压力的极限 -( 字节图片库支持提前获取图片大小,然后让选择是否解码图片的功能 ) -5. 图片解码后可以进行降采样 -虽然无法控制解码当时的内存峰值,但是对于解码后的图库可以进行缩放, -例如在一个 100x100 的UIImageView 里展示一张 4000x6000的图片,显然非常浪费 -( 字节的图片库也支持自动降采样操作 ) \ No newline at end of file diff --git a/Chapter1 - iOS/1.91.md b/Chapter1 - iOS/1.91.md deleted file mode 100644 index 5683ce7..0000000 --- a/Chapter1 - iOS/1.91.md +++ /dev/null @@ -1,6337 +0,0 @@ -# DYLD 及 Mach-O - -> 链接动态库还是静态库对 App l包体积影响大? -> -> 动态库、静态库编译链接都做了哪些事情?链接的要素是什么? -> -> Module 是什么? -> -> 如果这些问题不是很清楚,可以带着问题看看本文。 - - - -什么是 DYLD?dynamic loader,动态加载器。在 MacOS/iOS 中,是使用 `/usr/lib/dyld` 程序来加载动态库的。 - - - -## 一、工程多环境配置 - -- Project:包含了项目的所有代码、资源文件、所有信息 - -- Target:对指定代码和资源文件的具体(特定)构建方式 - -- Scheme:对指定 Target 的环境配置 - -- xcconfig:便捷化的方式管理编译、链接等配置 - -传统的在 GUI 面板上操作不在本文研究范畴。对于 Build Settings 各个 key 不知道是什么作用的,可以查看 [Xcode Build Settings Reference](https://help.apple.com/xcode/mac/current/#/itcaec37c2a6) 和 [Xcode Project Management Guide](https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/XcodeBuildSystem/) - - - -### 1. 多 Target 的方式 - -举个例子,我们经常会遇到在不同环境下,根据环境的不同,网络接口请求不同的 url。常见的一幕是:给 QA 测试不同环境的 App,手动更改 baseUrl 地址,然后打包让下载。太低效了。 - -所以需要做: - -1. 在 Xcode 中创建多个 Target。选择 TARGETS 模块,选择原有的 TARGET,点击 **Duplicate**,即可创建新的 TARGET 和对应的 plist 文件 - -2. 为了区分不同环境,还需要设置 OC 和 Swift 各自的编译宏。 - - - OC:需要在 `Build Settings` 中的 `Preprocessor Macros` 中添加,可以区分不同的 **Configuration**(工程自带的是 Debug、Release。可以在 PROJECT 中添加新的 Configuration。假设在 Configuration 里再增加一个 Beta。那就可以在这里设置)。 - - OC 自带 `DEBUG` 宏定义来区分是否是 Debug 环境。只有在 Beta 下 BETA 宏为1,其他为0。 - - 要增加新的参数,选中对应的 Configuration,双击后在弹出的面板后,按照 **DEBUG=1** 的格式添加 - - - - - 对于 Swift 则有不同的编译器。在 Build Settings 中的 **Other Swift Flags** 里选择对应的 Configuration,按照 **-DDEV** 的格式添加。**-D** 是必须要存在的,后面要加的 Flag,就紧跟在后面 - - - - - - - -操作了一番下来,我们可以发现一些缺点: - -1. 操作繁琐。需要添加 OC、Swift 的宏定义。 -2. 资源重复,存在多份 plist 文件 - - - -### 2. 多 Scheme 方式 - -要在不同的环境设置不同的 BASE_URL 在多 Scheme 方案下如何实现? - -1. 多 Scheme 可以选中 PROJECT,然后点击左下方的 **+**,点击「Duplicate "Debug" Configuration」,增加一列后,重命名为 Beta - - - -2. 选择 TARGETS,点击左上角的 **+** 号,在弹出面板中选择 "Add User-Defined Setting"。然后区分不同的 Configuration,设置不同的值 - -3. 然后新添加的值要在项目中使用,需要添加到 plist 中。 - -4. 添加后便可以按照正常使用 plist 的方式进行使用 - -5. Xcode 选择项目进行 Manage Scheme。添加了 `LDExploreDemoBeta`、`LDExploreDemoRelease` 2个 **Scheme**。 - -6. 分别选择不同的 Scheme,点击 Edit Scheme。**让不同的 Scheme 对应不同的 Configuration** - - - -7. 选择 Debug Scheme,Debug Scheme 的名称就是 `LDExploreDemo`,点击 Run 验证 - - - - - -优点:不用像方式1一样,要配置某个值,需要切换不同的 TARGETS。通过多 Scheme 的方式,可以在同一个 Build Settings 中对不同的值进行配置,不会很分散。到时候只需要切换不同的 Scheme 即可。 - - - -### 3. .xcconfig 方式 - -#### 1. 基础使用 - - Cocoapods 管理的项目在 install 之后便可以看到2份 `.xcconfig` 文件。命名格式为:`Pods-{ProjectName}.debug.xcconfig` 和 `Pods-{ProjectName}.release.xcconfig` - -所以我们也可以使用 `.xcconfig` 的方式管理工程。命名格式为:`Pods-{ProjectName}.${ConfigurationName}.xcconfig` - -- ProjectName: 就是工程项目名 -- ConfigurationName:工程设置的 Configuration 名称。比如 Debug、Beta、Release - -另外 `.xcconfig` 可以给 PROJECT 设置,也可以给 TARGETS 设置。 - - - -需求:设置不同环境对应不同的 BASE_URL 的值,在项目中正确读取。 - -1. 在 PROJECT 中创建不同的 Configuration,比如:Debug、Beta、Release - -2. Manage Scheme。创建不同的 Scheme。比如:`${ProjectName}Debug` 、`${ProjectName}Beta` 、`${ProjectName}Release - -3. Edit Scheme。让不同的 Scheme 选择对应的 Build Configuration - - - -4. 创建不同的 `.xcconfig` 文件,命名格式为:`Pods-{ProjectName}.${ConfigurationName}.xcconfig`。比如:Config-LDExploreDemo.Debug.xcconfig、Config-LDExploreDemo.Beta.xcconfig、Config-LDExploreDemo.Release.xcconfig。并编辑里面的内容 - -5. 将 xcconfig 文件中的变量,在 plist 文件中声明一下 - -6. 业务代码中按照读取 plist 文件的逻辑,正常编写业务代码 - -7. Xccode 选中工程的不同 Scheme 运行即可 - -效果如下: - - - -同样 Cocoapods 管理的工程,也会根据 Configuration 生成不同的 xcconfig 文件,我们可以修改或者创新新的 xcconfig 文件,但引入使用。 - - - -#### 2. xcconfig 编写规范 - -xcconfig (Xcode Configuration Settings File) 文件是用于管理 Xcode 项目构建设置的纯文本配置文件。遵循正确的编写规范可以确保项目配置的可维护性和一致性。 - -1. 键值对声明 - - ```shell - // 基本键值对 - BUILD_SETTING_NAME = value - - // 带引号的值(包含空格时必需) - OTHER_LDFLAGS = -ObjC -all_load - FRAMEWORK_SEARCH_PATHS = "$(inherited)" "$(PROJECT_DIR)/Frameworks" - ``` - -2. 包含其他文件 - - ```shell - // 包含其他配置文件 - #include "Base.xcconfig" - #include "../Configurations/SharedSettings.xcconfig" - ``` - -3. 注释规范 - - ```shell - // 单行注释 - /* - 多行注释 - 用于详细说明 - */ - - // 设置分组标题 - //////////////////////////// - // MARK: - 路径设置 - //////////////////////////// - ``` - -4. 条件设置 - - SDK 条件 - - ```shell - // iOS 真机配置 - LIBRARY_SEARCH_PATHS[sdk=iphoneos*] = $(inherited) "$(SRCROOT)/iOS/Libs" - - // iOS 模拟器配置 - LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited) "$(SRCROOT)/Simulator/Libs" - - // macOS 配置 - OTHER_CFLAGS[sdk=macosx*] = -DMAC_ENV - ``` - - 架构条件 - - ```shell - // ARM64 架构 - GCC_PREPROCESSOR_DEFINITIONS[arch=arm64] = ARM_OPTIMIZED=1 - - // x86_64 架构 - SWIFT_COMPILATION_MODE[arch=x86_64] = wholemodule - ``` - - 构建配置条件 - - ```shell - // Debug 配置 - OTHER_SWIFT_FLAGS[config=Debug] = -D DEBUG -enable-testing - - // Release 配置 - CODE_SIGN_IDENTITY[config=Release] = "Apple Distribution" - ``` - - 组合条件(上述条件可以自由组合) - - ```shell - // 仅对 iOS 模拟器的 Debug 配置生效 - ENABLE_UI_TESTS[sdk=iphonesimulator*][config=Debug] = YES - ``` - - 设置条件后,如果在不符合的条件下,编译会报错 - - - -5. 值引用和继承 - - ```shell - // 引用其他设置的值 - NEW_SETTING = $(EXISTING_SETTING)/subpath - - // 继承上级设置(重要!) - OTHER_LDFLAGS = $(inherited) -framework CryptoKit - ``` - -6. 文件组织结构 - - ```shell - Project/ - ├── Configurations/ - │ ├── Base.xcconfig - │ ├── Debug.xcconfig - │ ├── Release.xcconfig - │ ├── Development.xcconfig - │ └── Production.xcconfig - ``` - -7. 分层配置 - - ```shell - // Base.xcconfig - 通用基础设置 - SDKROOT = iphoneos - IPHONEOS_DEPLOYMENT_TARGET = 15.0 - - // Debug.xcconfig - #include "Base.xcconfig" - GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 COCOAPODS=1 - SWIFT_OPTIMIZATION_LEVEL = -Onone - - // Release.xcconfig - #include "Base.xcconfig" - SWIFT_OPTIMIZATION_LEVEL = -O - GCC_OPTIMIZATION_LEVEL = 3 - ``` - -8. 路径管理规范 - - ```shell - // 使用 PROJECT_DIR 作为根目录 - PROJECT_DIR = $(SRCROOT) - - // 框架搜索路径 - FRAMEWORK_SEARCH_PATHS = $(inherited) \ - "$(PROJECT_DIR)/ThirdParty" \ - "$(PROJECT_DIR)/Frameworks" - - // 头文件搜索路径 - HEADER_SEARCH_PATHS = $(inherited) \ - "$(PROJECT_DIR)/Includes" \ - "$(PROJECT_DIR)/Generated" - ``` - -9. 多平台支持 - - ```shell - // 通用设置 - COMMON_SETTINGS = -DCOMMON_FEATURE - - // iOS 特定 - BUILD_SETTING[sdk=iphoneos*] = $(COMMON_SETTINGS) -DIOS - BUILD_SETTING[sdk=iphonesimulator*] = $(COMMON_SETTINGS) -DIOS_SIMULATOR - - // macOS 特定 - BUILD_SETTING[sdk=macosx*] = $(COMMON_SETTINGS) -DMACOS - ``` - -10. 条件包含 - - ```shell - // 根据配置包含不同文件 - #if $(CONFIGURATION) == Debug - #include "DebugOverrides.xcconfig" - #elif $(CONFIGURATION) == Release - #include "ReleaseOverrides.xcconfig" - #endif - ``` - -11. 命令行验证 - - ```shell - # 检查语法 - plutil -lint Config.xcconfig - - # 查看最终设置 - xcodebuild -project YourProject.xcodeproj -showBuildSettings - ``` - -#### 3. 编译链接日志输出重定向 - - - -终端输入 `tty` 敲回车,然后在 Xcode 脚本中,就可以将 echo 输出的结果重定向到终端 `echo "message" > /dev/ttys000` - -因为要编写脚本,研究 MachO 文件,但每次编译后的 MachO 还要 show in Finder,之后再切换到终端,然后执行脚本很繁琐。所以可以直接在 Xcode 的 Build Phases 中,增加脚本解决该问题。 - -但 Build Phases 中的 Run Script 长度有限,不能写很多脚本,所以还是需要结合 Xcconfig。 - -Xcconfig 中定义的变量在 Run Script 中是可以访问的到。 - -Demo 验证如下: - - - -所以编写了一个脚本,进行输出重定向 [xcode_run_cmd](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/xcode_run_cmd.sh) - -使用的时候,需要搭配 `.xcconfig` 文件,需要设置3个参数: - -- CMD:运行到命令 -- CMD_FLAG :运行到命令参数 -- TTY:需要打开终端,查看终端编号配置进去。 - - - - - -#### 4. LLVM Strip 调试 - -符号的处理都是利用 LLVM Strip 的能力,对这部分好奇的,可以在源码中对 Strip 进行调试。 - -调试的时候要想更有意义,类似:真的在 strip 一个项目的符号,可以把某个可执行文件的路径配置到 LLVM-Strip 的启动项中 - -步骤:Edit Scheme - Arguments - 添加2个参数:-pa 和 需要脱符号的路径 - - - -关于 LLVM 工程如何编译、运行的具体步骤,可以查看 [LLVM](./1.102.md) - - - - - -## 二、Xcode 配置层级机制 - -上面已经看到了 Xcconfig 文件的身影。在 Xcode 中使用 `.xcconfig` 文件设置 `OTHER_LDFLAGS = -framework "AFNetworking"` 后,该值会出现在项目的 **Build Settings** 界面中,这是由 Xcode 配置系统的设计机制决定的 - -### 1. Xcode 配置层级机制 - -Xcode 的 Build Settings 是一个**多层叠加系统**,优先级从高到低如下: - -```mermaid -graph LR -A[Xcode 默认值] --> B[.xcconfig 文件] -B --> C[Project 设置] -C --> D[Target 设置] -D --> E[最终生效值] -``` - -这是因为: - -- `.xcconfig` 配置是比较通用的,比较基础的 -- **Project 是通用容器**,存放多个 Target 的共享配置。只能选择 Configuration 下的某个 Configuration Set。并指定对应的 `.xcconfig` -- **Target 是具体构建目标**(如 App、Framework),可以在拥有从 Project 继承而来的配置后,进行独立配置。比如管理 Project 中的某几个文件、资源的编译、链接配置方式。 -- Xcode 默认值:优先级最低。啥都不设置才是默认值 - -最终生效的意思是:Xcode 的 Build Settings 界面本质是一个**实时计算的合并视图**,它会展示: - -- 所有直接通过 GUI 设置的值 -- 从 `.xcconfig` 导入的值 -- 继承的默认值(如 `$(inherited)`) - -举个例子: - -假设在以下位置设置 `OTHER_LDFLAGS`: - -| 配置位置 | 设置的值 | -| :------------- | :-------------------------- | -| Project 层 | `-framework "CoreData"` | -| Target 层 | `-framework "AFNetworking"` | -| .xcconfig 文件 | `-framework "OpenSSL"` | - -最终生效值将是:`OTHER_LDFLAGS = -framework "AFNetworking"` - - - -### 2. 验证 Xcode 的配置层级系统 - -1. 新创建一个 iOS 项目,不引入任何库 - -2. 创建2个 `.xcconfig` 文件 - - - `Base.xcconfig`:只链接一个 UI 基础库 WantUIKit - - `Dev.xcconfig`:引入 `Base.xcconfig` 文件,同时在此基础上,引入:SDWebImage、AFNetworking、PrismClient 3个库 - - - -3. 配置工程的 PROJECT 对应的 Configuration。让 Debug 模式,选择 `Dev.xcconfig` - -编译后,查看如下: - - - -所以可以看到:Xcode 的配置是遵循层级关系和继承的。 - -当配置完 Xcconfig 和 Xcode GUI 面板上操作完后,可以用 **xcodebuild -showBuildSettings -configuration Debug** 查看最终的结果是否符合预期 - - - - - -## 三、符号的导入与导出 - -### 1. 从源文件到可执行文件做了什么? - -iOS 应用的构建过程本质上是将高级语言代码转化为设备可执行的 Mach-O 文件的过程,其中**编译阶段**和**链接阶段**是核心技术环节 - - - -#### 1. 编译阶段(Compilation) - 将源代码转化为机器码的中间形态 - -前端编译: - -1. 词法分析(Lexical Analysis):将源代码拆分为 token 流(关键字、标识符、运算符等) - - ```mermaid - graph LR - Source["int a = 10;"] --> Lexer - Lexer --> Tokens[["'int'
'a'
'='
'10'
';'"]] - ``` - -2. 语法分析(**Syntax Analysis**):生成抽象语法树(AST) - - ````c - int main() { - return add(2, 3); - } - - -> - FunctionDecl: main - └─ CompoundStmt - └─ ReturnStmt - └─ CallExpr: add - ├─ IntegerLiteral: 2 - └─ IntegerLiteral: 3 - ```` - -3. 语义分析(Semantic Analysis): - - - 类型检查(确保 `add` 函数存在且参数匹配) - - 作用域验证(变量声明周期检查) - - 生成带类型信息的 AST - - 中间代码的生成与优化: - -1. LLVM IR 的生成:平台无关的中间表示(Objective-C 通过 Clang,Swift 通过 SILGen) - - ```ruby - ; add 函数的 IR 表示 - define i32 @add(i32 %a, i32 %b) { - %1 = add i32 %a, %b - ret i32 %1 - } - ``` - -2. 机器无关的优化。经历一系列 Pass。关键方面 - - - 常量传播(Constant Propagation) - - 死代码消除(DCE) - - 函数内联(Function Inlining) - - 循环展开(Loop Unrolling) - - ```assembly - - 优化前: - %1 = mul i32 %a, 1 ; a*1 - %2 = add i32 %1, 0 ; +0 - - + 优化后: - %result = add i32 %a, %b - ``` - -3. 目标代码生成:包含机器码 + 符号表 + 重定位信息 - - - 汇编代码生成:将 IR 转换为目标架构的汇编代码(.s 文件) - - **指令选择**:映射 IR 到机器指令 - - **寄存器分配**:管理有限寄存器资源 - - **指令调度**:优化指令顺序 - - **代码发射**:生成汇编文本 - - 汇编器阶段:将汇编文本转为机器码。输入 `.s` 文件,输出 `.o` 文件 - - - - - - - - - - - - - -#### 2. 链接阶段(Linking)- 构建完整可执行文件 - - 1. 符号解析与重定位 - - - - ```c++ - // main.c - extern int add(int, int); // 外部符号声明 - - int main() { - return add(2, 3); - } - ``` - - 符号解析过程: - - ```mermaid - graph LR - A[main.o] -- 寻找 add 符号 --> B[math.o] - B -- 返回符号地址 --> A - ``` - - 重定位操作: - - ```assembly - - 链接前: call 0x00000000 ; 未知地址 - + 链接后: call 0x1000F2A0 ; 解析后的add函数地址 - ``` - - 2. 静态链接处理 - - | 文件类型 | 处理方式 | 示例 | - | :----------- | :----------- | :-------------------- | - | 目标文件(.o) | 直接合并 | main.o + utils.o | - | 静态库(.a) | 按需提取 | libMath.a 中的 add.o | - | Swift 模块 | 消除重复符号 | __TEXT.__swift5_types | - - 合并过程: - - ```assembly - 0x1000: _main (来自 main.o) - 0x2000: _add (来自 math.o) - ``` - - 3. 动态链接处理 - - ```mermaid - sequenceDiagram - participant App as 应用程序 - participant PLT as PLT(NSLog桩代码) - participant GOT as GOT(NSLog指针) - participant Binder as dyld_stub_binder - participant Dyld as dyld(动态链接器) - participant Lib as libSystem (NSLog实现) - - App->>+PLT: 首次调用 NSLog - PLT->>+GOT: 读取NSLog指针 - Note right of GOT: 初始指向绑定器 - GOT-->>-PLT: 返回dyld_stub_binder地址 - PLT->>+Binder: 跳转到绑定器 - Binder->>+Dyld: 请求解析NSLog - Dyld->>+Lib: 查找NSLog实现 - Lib-->>-Dyld: 返回0x7FFFE4567890 - Dyld-->>-Binder: 返回真实地址 - Binder->>+GOT: 更新指针地址 - Note right of GOT: 指向真实NSLog实现 - Binder->>+Lib: 跳转到NSLog - Lib-->>-App: 执行NSLog功能 - - Note over App,Lib: 后续调用 - App->>PLT: 再次调用NSLog - PLT->>GOT: 读取指针 - GOT-->>PLT: 返回真实地址(0x7FFFE4567890) - PLT->>Lib: 直接跳转 - Lib-->>App: 执行NSLog - ``` - - 当 App 编译完成的时候,Mach-O 文件中会存在一些关键结构: - - - `__TEXT.__stubs` section(PLT 桩代码) - - ```assembly - ; NSLog 的桩代码 - _NSLog_stub: - jmp qword ptr [rip + _NSLog_ptr] ; 跳转到GOT中的指针 - nop - ``` - - - `__DATA._la_symbol_ptr` section(GOT 懒符号指针) - - ```assembly - _NSLog_ptr: - .quad _dyld_stub_binder ; 初始指向绑定器 - ``` - - - `__DATA._nl_symbol_ptr` section(非懒加载指针) - - ````assembly - ; 启动时立即绑定的符号 - _malloc_ptr: .quad _malloc_real - ```` - - 首次调用 NSLog 过程 - - - App 调用桩代码 `NSLog(@"hello world");` 编译后调用的是 `_NSLog_stub` - - - 桩代码读取 GOT 指针 - - ```assembly - jmp qword ptr [rip + _NSLog_ptr] - ``` - - 此时 `_NSLog_ptr` 指向 `dyld_stub_binder` - - - 执行动态绑定器 - - dyld_stub_binder 工作流程: - - - 通过栈帧找到调用者信息 - - 计算目标符号在重定位表中的偏移量 - - 调用 dyld 的 bindLazySymbol 函数 - - - dyld 解析符号地址 - - dyld 执行下面步骤: - - - 遍历加载的镜像(libSystem.dylib、Foundation.framework 等) - - - 在导出符号表中查找 `_NSLog` - - - 计算 ASLR 偏移后的实际地址。 - - ```shell - 基地址: 0x7FFFE4500000 - 偏移: 0x0000000000004a30 - 实际地址: 0x7FFFE4504a30 - ``` - - - 更新 GOT 并跳转 - - ```assembly - ; 更新GOT指针 - mov [rip + _NSLog_ptr], rax ; rax=0x7FFFE4504a30 - - ; 跳转到真实实现 - jmp rax - ``` - - 后续调用 NSLog 流程 - - ```mermaid - graph LR - A[App调用NSLog] --> B[桩代码] - B --> C[读取GOT指针] - C --> D{指针已绑定?} - D -->|是| E[直接跳转实现] - D -->|否| F[触发绑定流程] - ``` - - - - 4. 生成 Mach-O 可执行文件 - - ```shell - Mach-O Header - ┌───────────────────────┐ - │ Load Commands │ → 描述段信息 - ├───────────────────────┤ - │ __TEXT (代码段) │ → 只读机器码 - │ __text: 主程序代码 │ - │ __stubs: PLT存根 │ - ├───────────────────────┤ - │ __DATA (数据段) │ → 可读写数据 - │ __data: 全局变量 │ - │ __la_symbol_ptr: │ → 延迟绑定指针 - ├───────────────────────┤ - │ Dynamic Loader Info │ → dyld 所需信息 - └───────────────────────┘ - ``` - - - - 编译、链接的对比 - - | 阶段 | 核心任务 | 关键技术 | 输出 | - | :------- | :------- | :------------------------ | :------------ | - | **编译** | 代码转换 | AST 生成 IR 优化 指令选择 | `.o` 目标文件 | - | **链接** | 模块集成 | 符号解析 地址绑定 重定位 | Mach-O 文件 | - - - - - -那看上去链接器做的事情很简单,不就是个打包工具?**链接器最重要的工作就是决定符号(变量名、函数名)的定义** - -```c++ -#include - -int main() { - NSLog(@"Hello world"); - return 0; -} -``` - -例如上面的代码 `main.m`。编译器在编译 `main.m` 时遇到 NSLog,根本不知道这个 `NSLog` 符号定义在哪里,这不是编译器该关心的事情。因此,编译器只能看到局部,只聚焦关心一个当前的源文件。到底谁来关心这个 NSLog 符号定义在哪呢?这就是链接器。 - -链接器打包所有的目标文件,因为链接器可以看到全局,具有上帝视角,因此链接器从依赖的库中去查找 NSLog 这个符号,如果找不到则会报经典的错误 `undefined reference to ***` - -编译器只能将 NSLog 这个函数的跳转地址暂时设置为0,随后在链接的时候再去修正它。 - - - -通过一个例子一步步验证下上面的过程: - -第一步,将 main.m 编译为 main.o 文件。指令如下 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c main.m -o main.o -``` - -第二步,已经得到了 main.o 目标文件,也就是二进制文件,利用指令 `objdump -r main.o` 查看目标文件中的内容 - - - -可以看到 main 函数中,callq 就是调用 NSLog 函数。后面的地址写为了 0,这里的0会在后面链接的过程中被修正。 - -第三步,另外为了能让链接器能够定位到这些需要被修正的地址,在代码块中可以看到一个重定位表。指令为 `objdump -r main.o` - - - -NSLog 位于偏移量为19的位置, - -第四步,链接目标文件到可执行文件,指令为 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - main.o -o main -``` - -因为目前 Foundation 已经存在于系统目录,所以不需要额外指定动态库/静态库路径了。 - - - -### 2. 符号 - -符号可以理解为程序中各种元素的抽象表示。它就像是一个标识符,用来代表函数、变量、类等编程元素 - -#### 1. 符号的种类 - -| Symbol Type | **说明** | -| ----------- | ------------------------------------------------------------ | -| **U** | undefined(未定义) | -| **A** | absolute(绝对符号) | -| **T** | text section symbol(__TEXT.__text) | -| **D** | data section symbol(__DATA.__data) | -| **B** | bss section symbol(__DATA.__bss) | -| **C** | common symbol(只能出现在`MH_OBJECT` 类型的`Mach-O`⽂件中) | -| **-** | debugger symbol table | -| **S** | 除了上⾯所述的,存放在其他`section`的内容,例如未初始化的全局变量存放在(__DATA,__common)中 | -| **I** | indirect symbol(符号信息相同,代表同⼀符号) | -| **u** | 动态共享库中的⼩写u表示⼀个未定义引⽤对同⼀库中另⼀个模块中私有外部符号 | - -编译 `main.m` 到 `main.o` - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c main.m -o main.o -``` - -使用 `nm -pa .o文件路径` 命令来查看符号 - - - -#### 2. 符号的大分类 - -##### 1. 全局符号(Global Symbols) - -定义:在目标文件中显式导出的符号,可被其他模块引用。 - -特点: - -- 对其他目标文件或库**可见** -- 链接时若存在多个同名全局符号,会引发 **`duplicate symbol` 错误**(除非使用弱符号)。 - -```c++ -// C/C++/Objective-C 中未加 static 的函数/全局变量 -int globalVar = 10; -void publicFunction() { ... } -``` - -Mach-O 标记:`N_EXT`(外部符号)。 - -针对全局符号: - -- 当存在2个同名的全局符号,一个初始化了,一个未初始化,不会报错。在编译链接阶段,当找到定义之后,未定义的会被删掉。 -- 链接器默认会把未初始化的全局符号,给强制初始化掉。比如 `int global_age;` 初始化为 `int global_age = 0;` - -##### Common Symbol - -Common Symbol:**Common Symbol 是全局符号的“临时状态”** - -编译器将未初始化的全局变量(如 `int x;`)暂存为 Common Symbol,**链接时**再决定其最终形态: - -- 若全局唯一 → 转为标准全局符号(位于 `.bss` 段) -- 若多个同名 → 合并为一个全局符号 -- 若存在强定义 → 被强定义覆盖 - -Common Symbol(公共符号)和全局符号(Global Symbol)本质上是**同一符号在不同编译阶段的表现形式**,二者既有紧密联系又有关键区别 - -编译期与链接期的形态转换 - -| 阶段 | Common Symbol | 全局符号 (Global Symbol) | -| :--------- | :----------------------------------------- | :------------------------- | -| **编译期** | ✅ 未初始化的全局变量被标记为 Common Symbol | ❌ 此时未形成强定义 | -| **链接期** | ❌ 被转化或合并 | ✅ 最终成为强定义的全局符号 | - - - -链接器设置: - -- -d:强制定义 Common Symbol -- -commons:指定对待 Common Symbol 如何响应 - -有趣的 feature: - -```c++ -int global_int_age = 28; -int global_int_age; - -void main() { - print("Hello world"); -} -``` - -上面的代码不会编译报错。当存在2个同名的全局符号,一个初始化了,一个未初始化,不会报错。在编译链接阶段,当找到定义之后,未定义的会被删掉。 - -##### 2. 本地符号(Local Symbols / Static Symbols) - -定义:仅在当前目标文件中可见,不会暴露给其他模块 - -特点: - -- 链接时不会被其他目标文件引用,避免了命名冲突 -- 通常用于内部工具函数或私有状态 -- Static 修饰的符号,只对定义所在的文件可见。 - -```c++ -static int localVar = 5; // 静态全局变量 -static void privateFunc() { ... } // 静态函数 -``` - -Mach-O 标志:无 `N_EXT` 标志 - -##### 3. 未定义符号(Undefined Symbol) - -定义:当前目标文件声明但未定义的符号,需由链接器在其他目标文件或库中解析。 - -特点:链接时若找不到符号定义,则会报错:Undefined Symbol - -```c++ -extern int externalVar; // 声明外部变量 -void undefinedFunc(); // 声明未实现的函数 -``` - -##### 4. Weak Symbol - -**Weak Reference Symbol**:表示此 未定义符号是弱引用。如果动态链接器找不到该符号的定义,则将其设置为0。链接器会将该符号设置弱链接标志 - -**Weak definition Symbol**:表示此符号为弱定义符号。如果静态链接器或动态链接器为此符号找到另一个(非弱)定义,则该弱定义将被忽略。只能将合并部分中的符号标记为弱定义 - - - -当把其中一个符号通过 **\__attribute__((weak))** 改为 weak symbol 的时候,再次编译,发现没有问题。 - - - -- 当用 **\__attribute__((weak, visibility("hidden")))** 把弱定义的全局符号设置为隐藏的时候,本来是全局符号的弱定义符号,就会变成局部弱定义符号 - -- 弱引用符号。`__attribute__((weak_import))` 是 Clang/GCC 编译器的一个属性,用于在 iOS/macOS 开发中实现**弱链接(Weak Linking)**。它的核心作用是:**允许代码引用高版本 SDK 中的符号(函数/变量/类),同时在低版本系统中运行时安全地处理这些符号的缺失**,避免崩溃 - - 主要作用: - - 1. 向后兼容性 - - - 当您的 App **部署目标(Deployment Target)设置为较低版本(如 iOS 12)**,但**编译时使用的高版本 SDK(如 iOS 13 SDK)** 时,您可以在代码中使用高版本新增的 API(如 iOS 13 新增的类),但必须通过弱导入确保在低版本系统上运行时不会崩溃 - - 编译器不会阻止你使用高版本符号,但运行时如果符号不存在,其地址会被设为 `NULL`。 - - 2. 运行时安全检查。使用前需显式检查符号是否可用: - - ```objective-c - if (&NewFunction != NULL) { // 检查弱导入函数指针 - NewFunction(); // 安全调用 - } - if ([NewClass class] != nil) { // 检查弱导入类 - // 安全使用 NewClass - } - ``` - - 3. 避免链接错误 - - - 未使用 `weak_import` 时:低版本系统因找不到符号会直接崩溃 - - 使用后:**\__attribute__((weak_import))** 系统动态加载器(dyld)会将缺失符号置为 `NULL`,由开发者处理 - - 实验不方便模拟动态库和 App 的情况。就看同一个 Mach-O 中,只有函数声明,没有实现的情况 - - - - 但告诉编译器该符号是弱引用符号,就可以编译链接成功 - - - - - - - -生成目标文件分过程中,只需要头文件信息(头文件路径),只需要重定位符号表,知道哪些符号需要重定位,链接的时候,会自动将符号重定位。 - - - -### 3. 脱符号 - -符号在 Mach-O 中占一定的体积,所以需要脱符号。那么哪些符号不能脱? - -动态库的全局符号不做处理,默认就是导出符号。链接之后,这些导出符号可能就是间接符号表中的后续经由 dyld_stub_binder 去查找所需的符号。所以动态库的全局符号不能 strip 掉。 - -看个 Demo:OC 对象默认就是全局符号。 - - - -链接器也提供了能力,将导出符号变为不导出的符号:**-Xlinker -unexported_symbol -Xlinker _OBJC_CLASS_$_Person** - - - -如果有一批符号需要隐藏,链接器提供了更方便的参数: **-Xlinker -unexported_symbols_list -Xlinker ${PROJECT_DIR}/LDExploreDemo/hidden_symbols.list** - - - - - -### 4. 符号可见性 - -按照先后顺序 - -- 生成目标文件阶段:`-O1、O2、O3、Os、Oz` -- 链接:死代码剥离 dead code strip -- 编译后的产物 mach-o:strip 剥离符号 - - - -## 四、编译阶段做了什么 - -### 1. 预处理 - -- **输入:** 源代码文件(`.m`, `.mm`, `.c`, `.cpp`, `.swift` 等)。 -- **处理:** 由预处理器执行。 -- **操作:** - - **宏展开:** 替换所有 `#define` 定义的宏。 - - **头文件包含:** 将 `#import` 或 `#include` 指令替换为对应头文件的内容(递归处理嵌套包含)。 - - **条件编译:** 根据 `#if`, `#ifdef`, `#ifndef`, `#else`, `#elif`, `#endif` 等指令决定哪些代码块被包含或排除(常用于区分 Debug/Release 版本、不同平台、功能开关)。 - - **特殊指令:** 处理 `#pragma` 等特殊指令。 -- **输出:** 一个“纯净”的、宏已展开、头文件已包含、条件编译已处理完毕的“翻译单元”文本文件。这个文件是后续编译步骤的输入 - -### 2. 词法分析 - -- **输入:** 预处理后的翻译单元。 -- **处理:** 由编译器前端执行。 -- **操作:** 将源代码字符流分解成一系列有意义的 **词素**。例如,将 `int result = a + b;` 分解成 `int` (关键字), `result` (标识符), `=` (运算符), `a` (标识符), `+` (运算符), `b` (标识符), `;` (符号)。 -- **输出:** 一个 **Token 序列** - -### 3. 语法分析 - -- **输入:** Token 序列。 -- **处理:** 由编译器前端执行。 -- **操作:** 根据语言的语法规则,将 Token 序列组织成具有层次结构的 **抽象语法树**。AST 代表了代码的结构(函数、语句、表达式、操作符、操作数等),但不包含语义信息(如变量类型)。 -- **输出:** **抽象语法树** - -### 4. 语义分析 - -- **输入:** AST。 -- **处理:** 由编译器前端执行。 -- **操作:** - - **符号表管理:** 收集标识符(变量名、函数名、类名等)及其属性(类型、作用域、存储类别等),建立符号表。 - - **类型检查:** 验证表达式和操作的类型是否兼容(如 `int` + `float` 是允许的,`int` + `NSString*` 是不允许的)。 - - **类型推导:** (特别是 Swift) 推断未明确声明类型的变量或表达式的类型。 - - **常量表达式求值:** 计算编译时可确定的常量表达式(如 `const int size = 10 * 20;`)。 - - **检查语言规则:** 如变量是否声明后再使用、函数调用参数个数和类型是否匹配、类是否实现了协议要求的方法等。 -- **输出:** 经过语义验证和修饰的 **AST**(节点上附加了类型等语义信息),以及符号表。如果发现错误(类型不匹配、未定义符号等),会在此阶段报告 - -### 5. 生成中间代码 - -- **输入:** 经过语义分析的 AST。 -- **处理:** 由编译器前端执行。 -- **操作:** 将 AST 转换成一种与具体硬件架构无关的、更低级的表示形式,称为 **中间代码**。LLVM 使用的中间代码是 **LLVM IR**。 - - **LLVM IR (Intermediate Representation):** 一种类似 RISC 指令集的低级语言,具有强类型化、静态单赋值形式等特点。它是编译流程的核心枢纽。 -- **输出:** **LLVM IR 代码**(通常存储在 `.ll` 文本文件或 `.bc` 二进制文件中)。这是**优化发生的主要阶段** - -### 6. IR 优化 - -- **输入:** LLVM IR。 -- **处理:** 由 **LLVM 优化器**执行。 -- **操作:** 应用一系列优化通道对 LLVM IR 进行转换,目标是在不改变程序行为的前提下,提升性能、减小代码体积。常见优化包括: - - **死代码消除:** 删除永远不会被执行的代码。 - - **常量传播:** 将使用常量值的变量直接替换为常量。 - - **循环优化:** 展开、不变代码外提、归纳变量优化等。 - - **内联:** 将小函数调用直接替换为函数体,减少调用开销(特别对于 Swift 的泛型函数和 `@inlinable` 函数)。 - - **公共子表达式消除:** 避免重复计算相同的表达式。 - - **内存优化:** 如提升堆栈分配、优化访问模式。 - - **Tail Call Optimization:** 优化尾递归调用。 - - **特定于 Objective-C/Swift 的优化:** 如 ARC 优化(消除不必要的 `retain`/`release`)、Swift 的泛型特化等。 -- **输出:** 优化后的 **LLVM IR**。Xcode 的编译设置(如 `-O0`/`-Onone` - 无优化, `-O1` - 基础优化, `-O2`/`-O` - 常用优化, `-O3` - 激进优化, `-Os`/`-Osize` - 优化大小)控制优化的级别和类型 - -### 7. 生成目标汇编代码 - -- **输入:** 优化后的 LLVM IR。 -- **处理:** 由 **LLVM 后端**执行。 -- **操作:** 将平台无关的 LLVM IR 代码转换成特定目标 CPU 架构(iOS 设备主要是 **ARM64**)的 **汇编语言**。 -- **输出:** **目标架构的汇编代码文件**(`.s` 文件)。 - -### 8. 生成 .o 目标文件 - -- **输入:** 目标架构的汇编代码文件(`.s`)。 -- **处理:** 由 **汇编器**执行。 -- **操作:** 将人类可读的汇编语言代码转换成机器可以直接执行的 **目标文件**。目标文件包含机器指令、数据以及符号表、重定位信息等元数据(通常是 **Mach-O 格式**的 `.o` 文件)。 -- **输出:** **目标文件**(`.o` 文件)。 - -### 9. 链接 - -- **输入:** - - 编译生成的所有目标文件(`.o`)。 - - 项目引用的静态库(`.a` 文件,本质是 `.o` 文件的集合)。 - - iOS SDK 提供的动态库框架(如 `UIKit.framework`, `Foundation.framework`, `libSystem.dylib` 等)的导入信息(头文件声明和链接库 stub)。 - - 链接器脚本(通常由 Xcode/LLD 管理)。 -- **处理:** 由 **链接器**执行(iOS 主要使用 `ld64`)。 -- **操作:** - - **符号解析:** 将所有目标文件和库中的符号引用与符号定义关联起来。确保每个被引用的函数或变量都有唯一且明确的定义。 - - **地址和空间分配:** 给程序中的各个段(如代码段 `__TEXT`, 数据段 `__DATA`)以及符号分配最终的内存地址(虚拟地址)。此时地址是相对的或基于段的起始地址。 - - **重定位:** 修改目标文件和库中的代码和数据引用,将符号引用替换为链接器分配的最终地址(或地址偏移量)。这是链接器最核心的工作之一。 - - **合并段:** 将所有输入目标文件的相同段(如 `.text`, `.data`, `.rodata`, `.bss`)合并到输出文件的对应段中。 - - **处理静态库:** 链接器从静态库中只提取那些被目标文件引用了符号所在的 `.o` 文件,避免包含未使用的代码。 - - **处理动态库:** 记录程序依赖的动态库(如 `UIKit`)及其版本信息(`LC_LOAD_DYLIB` 加载命令),并设置符号绑定信息(延迟绑定通过 `__DATA,__la_symbol_ptr`),但不将动态库代码复制到最终可执行文件中。动态库在运行时由 `dyld` 加载。 - - **生成入口点:** 设置程序的入口点(通常是 `start` 函数,最终调用 `main` 或 `UIApplicationMain`)。 - - **生成 Mach-O 头部和加载命令:** 构建最终的 **Mach-O 文件**结构,包含描述文件类型、目标架构、入口点、段信息、符号表、动态库依赖等信息的头部和一系列加载命令。 -- **输出:** **可执行的 Mach-O 文件**(通常是应用程序的二进制文件,如 `YourApp`)和/或 **动态库**(`.dylib`)、**Bundle**(`.bundle`, `.framework` 内部) - -Tips: 后续有很多终端使用指令的场景,为了查找方便高效,分享一个技巧 - -**man 指令**,比如 `man nm`: - - - -进入 vim 模式了,看到左下角有 `:` 光标,如果想查看当前 nm 命令的参数,可以快速查找,输入 `/ + 具体参数`,敲回车即可跳转到要匹配到的位置,如果有多个结果,且当前自动跳转到的不是正确的位置,vim 模式下可以输入 `n` 跳转到下一个匹配到的位置(n 即 next),输入 `N` 则跳转到上一个匹配到的位置。 - -比如查找 `-p`,则输入 `/-p`,敲回车的效果如下 - - - - - -### 符号的导入导出及 App 瘦身 - -代码中使用了 Foundation 库的 NSLog,NSLog 对于业务代码来说,就是一个导入的符号,对于 Foundation 库来说,就是一个导出的符号。 - -什么符号可以是导出的符号?全局符号可以是导出符号。 - -App 或者一个 MachO 中,所有使用到的动态库的符号,都保存在间接符号表中,这些间接符号表中的数据,来自于动态库中。 - -动态库,全局符号 -> 导出符号 - -间接符号表 -> 动态库符号 - -所以,Strip 符号的时候,可能不能 Strip 全局符号。 - - - -OC 代码,默认都是导出的全局符号,所以容易占空间,想让体积变小,就可以尽量不想暴露的符号,使用链接器的能力,将不需要暴露的符号不暴露出去。 - -```shell -OTHER_LDFLAGS=$(inherited) -Xinker -unexported_symbol -Xlinker _OBJC_CLASS_$_Person -``` - - - -静态库 = `.o` 文件的合集 + 重定位符号表。但重定位符号表中的符号不能 strip,所以只能 strip `.o` 文件的调试符号。 - - - -QA:从符号角度出发,动态库还是静态库对于 App 瘦身较好(更有抓手)?使用动态库还是静态库会提及更小? - -- App 在链接静态库的时候,静态库就是 .o 文件的合集,会把 `.o` 中的符号(包括可以重定位的符号),都放到 App 自身的符号表中,也就意味着可能是:本地符号、全局符号、导出符号。根据 Strip 的原理,Strip 可以脱离除了间接符号表之外的所有符号。 - - 静态库链接的时候,除了间接符号表,其他区域都有可能放。所以链接静态库占用体积更小。 - -- App 在链接动态库的时候,正好相反,App 链接的动态库的符号都放到了间接符号表中,即使 Strip 所有符号,也不可能脱掉间接符号表中的符号。 - -所以大家在写 SDK 的时候,可以从符号角度出发想想,是选择静态库还是动态库。针对动态库,可以 strip 导出符号。默认 OC 的符号都是导出的。 - - - -## 五、静态库 - -写在前面: - -`.a` 静态库,`.dylib` 动态库使用有问题,一般是: - -- header search path -- library search path -- other link flags - -这3个的一个或者多个造成的问题,着手去排查问题即可。 - - - -### 1. clang 指令 - -clang 编译、链接的参数解释如下: - -```shell - clang命令参数: - -x: 指定编译文件语言类型 - -g: 生成调试信息 - -c: 生成目标文件,只运行preprocess,compile,assemble,不链接 - -o: 输出文件 - -isysroot: 使用的SDK路径 - 1. -I 在指定目录寻找头文件 header search path - 2. -L 指定库文件路径(.a\.dylib库文件) library search path - 3. -l 指定链接的库文件名称(.a\.dylib库文件)other link flags -lAFNetworking - -F 在指定目录寻找framework framework search path - -framework 指定链接的framework名称 other link flags -framework AFNetworking -``` - -说明: - -- `-I` 参数 - - ```shell - # Xcode - Header Search Paths: /path/Headers - # 等价于 Clang - clang -I/path/Headers - ``` - -- `-I` + 通配符: - - ```shell - # Xcode - Header Search Paths: /path/libs/** - # 等价于 Clang - clang -I/path/libs/subdir1 -I/path/libs/subdir2 ... - ``` - -- 系统头文件参数:`-isystem` - - ```shell - # Xcode - Header Search Paths: system /path/system_headers - # 等价于 Clang - clang -isystem /path/system_headers ... - ``` - -- `-framework` 参数 - - ```shell - # Xcode - Framework Search Paths: /path/framework/Headers - # 等价于 Clang - clang -F /path/framework/Headers - ``` - - - -### 2. 静态库就是 .o 文件的合集 - -做个实验,验证下静态库其实就是 `.o` 文件的合集。 - -第一步,编写 oc 代码,就一个 Person 类,写一个类方法,编译为静态库。`Person.m` 编译为 `Person.o` - - - -第二步,将 `Person.o` 重命名为 `Person.dylib` - -其实,这里就已经可以验证「静态库就是 .o 文件的合集」。 - -利用 `objdump --macho --private-header Person.dylib` 查看静态库依旧是 `Object File` - - - -第三步,编写代码 `main.m` 代码,导入静态库 `` - -```objective-c -#import -#import - -int main(int argc, char * argv[]) { - Person *p = [[Person alloc] init]; - [p sayHi]; - NSLog(@"%@", p); -} -``` - -第四步,利用 clang 将 `main.m` 编译为 `main.o` 文件。注意,因为用到了 NSLog 和导入了静态库的头文件,所以需要加参数指定 NSLog 该符号从哪确定,也需要指定静态库所需的信息。 - -```shell -clang -x objective-c \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./StaticLibrary \ - -c main.m -o main.o -``` - -第五步,将第四步得到的 `main.o` 文件和前面编译好的 `Person` 静态库 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - > -L./StaticLibrary \ - > -lPerson \ - > main.o -o main -``` - -注意:上面第二步,得到的 `Person` 静态库需要重命名下,因为 clang 指令 `-l` 参数的 `Person`,其实就是去找 `libPerson` 的动态库或者静态库。 - -查找规则:先找 `lib+` 的动态库,找不到,再去找 `lib+` 的静态库,还找不到,就报错 - -第六步,查看第五步得到的可执行文件,然后执行,看看是否正常? - -- 成功,则说明 静态库就是`.o` 文件的集合,单个 `main.o` 文件,修改拓展名就可以变为静态库 -- 不成功,则相反 - - - - - -### 3. 静态库的合并 - -**静态库本质就是一堆 `.o` 的合集**,那么2个或者2个以上的静态库是可以合并的。 - -1. 创建2个类:Person、Cat 用于创建2个静态库 - -2. 利用 Clang 指令将 .m 编译为 .o 目标文件 - - ```shell - clang -x objective-c \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./CatStaticLibray \ - -c ./CatStaticLibrary/Cat.m -o Cat.o - ``` - -3. 再利用 clang 指令将 .o 目标文件,链接为静态库 - - - -4. 利用 libtool 合并2个静态库 - - ```shell - libtool -static \ - -o libPersonCat \ - libPerson libCat - ``` - -5. 用 **ar -t libPersonCat** 指令,验证合并后的静态库所包含的 .o 目标文件 - - - -### 4. Auto-Link - -链接器有一个特性,Auto-Link,启动这个特性后,当我们 **import <模块>** 不需要我们再去往链接器配置这个链接参数。比如 `import ` 我们在代码里使用的是 *.framework 这个目标文件时,就**自动在目标文件的 `Mach-O` 中插入一个 Load Command 格式是 `LC_LINKER_OPTION`,存储这样一个链接器参数 `-framework FrameworkName`** - - - -### 5. duplicate symbol - -静态库(`.a` 文件)在链接阶段出现 **`duplicate symbol`** 错误,本质是链接器在合并多个目标文件时发现了重复的全局符号定义 - -静态库的本质是**目标文件(`.o`)的集合**。当链接器将主工程和静态库的代码合并时,如果发现: - -1. 同一个符号(函数/变量)在多个地方被定义 -2. 链接器无法确定使用哪个定义 - -就会抛出 `duplicate symbol` 错误 - -其实上述存在前提,链接的时候需要指定为 `all_load\-Objc` 时才会存在 - - - -### 6. 静态库冲突 - -#### 1. 情景模拟 - -##### 1. Demo1 -一个 cocoapods 组织的工程,以 pod 的形式引入了 AFNetworking 库。再将另一个 `libAFNetworking.a` 以静态库的形式引入了 AFNetworking。 - - - - -现象:工程编译成功。编译链接后,运行输出打印信息。 -问题:为什么2个同样的静态库,没有链接失败? -分析: -- 直接在 Xcode 中手动拖入一个静态库/动态库,本质是将库的路径写到 OTHER_LDFLAGS 后面。Xcode 会按照指定的路径去加载静态库/动态库。 - ```shell - OTHER_LDFLAGS = $(inherited) -ObjC -l"AFNetworking" "/Users/unix_kernel/Desktop/编译链接/LDAndFramework/StaticLibConflictsSolution/StaticLibConflictsDemo/AFNetworking"` - ``` -- 但链接器很聪明,当发现同名的库的时候,会优先链接找到的第一个库库,第二个库不会被链接。 -- 即使将 `OTHER_LDFLAGS = $(inherited) -ObjC -l"AFNetworking"` 中的 `-ObjC` 改为 `-all_load` 也不会存在链接问题。本质是链接器对于同名的库,找到第一个后,后面的就停止查找,不会被链接了,所以不存在符号重复相关的问题 - - - -##### 2. Demo2 - -一个 cocoapods 组织的工程,以 pod 的形式引入了 AFNetworking 库,再将另一个 `libAFNetworking.a` 静态库改名为 `libAFNetworkingCopy.a`,然后引入 Xcode 工程。 - - - - -现象:工程链接失败。报错:`Issues223 duplicate symbols for architecture x86_64` -问题:为什么上次的链接成功,这次却链接失败了,报符号重复的错误? -分析: -- 不同名称的静态库,就不存在「链接器对于同名的库,找到第一个后,后面的就停止查找,不会被链接了,所以不存在符号重复相关的问题」,也就是会发生冲突 - - - -##### 3. Demo3 - -- 一个 cocoapods 组织的工程,以 pod 的形式引入了 AFNetworking 库 -- 然后 Xcode 新创建一个静态库,选择 `Static Library` -- 新创建的静态库就1个 `AFNetworkingMock` 文件。不过类里面还存在一个类 `AFURLSessionManager` 和一个全局函数 `global_function` - -现象:工程链接失败。报错:`error: duplicate interface definition for class 'AFURLSessionManager'` - - -问题:为什么上次的链接成功,这次却链接失败了,报符号重复的错误? -分析: - -- 不同名称的静态库,就不存在「链接器对于同名的库,找到第一个后,后面的就停止查找,不会被链接了,所以不存在符号重复相关的问题」,也就是会发生冲突 - - - -#### 2. 解决方案 - -2个静态库有符号冲突怎么办?且一个静态库没有源码。[llvm-objcopy](https://llvm.org/docs/CommandGuide/llvm-objcopy.html) 这个工具刚好有能力在静态库基础上直接修改符号名称。 - -##### 1. 收集需要重命名的符号 -通过 **objdump --macho -t ${MACH_PATH}** 就可以输出符号信息。Demo 中需要重命名的符号如下: - - - - - - -##### 2. 编译 llvm-objcopy 产出工具 - -关于 LLVM 如何编译运行,或者开发 pass 可以查看 [LLVM 编译运行](./1.102.md#编写-xcode-插件) - -打开 LLVM 工程,Scheme 切换为 `llvm-objcopy` 然后编译运行,从 Products 目录下将产物拷贝到个人电脑的 CustomTools 文件夹,后续就可以在终端访问 llvm-objcopy 指令。(因为在 `.zshrc` 文件里配置过 `export PATH=~/CustomTools:$PATH`) - -这样就说明已经成功了 - - - -然后改造 Demo 工程静态库的脚本为 **llvm-objcopy --prefix-symbols=FantasticLBP_ ${MACH_PATH}** 去给冲突的符号加前缀 `FantasticLBP_`(当然这个前缀名字可以是 SDK 名称简写、业务线简写,我这里是 Github ID ,验证问题而已)。 - -结果:但发现运行报错,提示 `llvm-objcopy: command not found` - - -改进:将 `llvm-objcopy` 移动到源码根目录下,现在 `Command not found` 的问题解决了,但是报错: **error: option is not supported for MachO** - -说明 `--prefix-symbols` 并不支持 MachO 格式。而 `--redefine-sym` 符合要求。 - -打开 llvm 源码进行调试,配置启动参数。 - -发现修改成功 - - -上面演示了单个修改符号名称的过程。llvm-objcopy 可以批量修改。 -- 指令改为 `--redefine-syms`。 -- 用一个文件,格式为空格和换行来区别。 - ``` - _OBJC_CLASS_$_AFURLSessionManager FantasticLBP__OBJC_CLASS_$_AFURLSessionManager - _OBJC_METACLASS_$_AFURLSessionManager FantasticLBP__OBJC_METACLASS_$_AFURLSessionManager - _OBJC_IVAR_$_AFURLSessionManager._session FantasticLBP__OBJC_IVAR_$_AFURLSessionManager._session - ``` - 修改后运行,发现批量符号修改成功。 - - - - -##### 3. 使用工具修改符号名称 - -利用 llvm-objcopy 的能力,修改重名的符号后测试下。 - - -可以看到: - -- 符号冲突的问题解决了 -- 但同一个符号存在2处实现,当两个同名类被加载时,运行时随机选择一个 - - - -##### 4. 为什么存在2处类定义? - -```mermaid -graph LR -A[AFURLSessionManager] --> B[_OBJC_CLASS_$_AFURLSessionManager] -A --> C[_OBJC_METACLASS_$_AFURLSessionManager] -A --> D[“__objc_classname” 段中的字符串] -``` - -**通过 llvm-objcopy 只是修改了 `_OBJC_CLASS_$` 符号而忽略 `_OBJC_METACLASS_$` 和类名字符串,运行时仍会看到两个类定义,导致冲突未解决** -而且和运行时系统相关的所有功能都会受影响,比如:Category、KVC/KVO 内部使用类名字符串查找、Archive/Unarchive 崩溃等。 - -所以这种方式存在巨大风险,这里只是从技术研究角度出发,证明可以这么做,但是影响面太大。 -另外一种方式:2个不同名的静态库,但存在相同符号。可以通过 cocoapods 的配置,将某个静态库改为动态库。因为动态库存在二级命名空间,就可以避免符号重复的问题。 -关于动态库二级命名空间的细节,可以查看[这里](#twoLevelNamespace) - -方案 1:单个库转为动态库 -```ruby -# Podfile -use_frameworks! # 启用框架支持 - -target 'YourApp' do - pod 'AFNetworking', '~> 4.0' # 默认静态库 - - # 将冲突库转为动态框架 - pod 'ConflictingLib', :modular_headers => true, :linkage => :dynamic -end -``` -方案 2:指定模块为动态框架 -```ruby -dynamic_frameworks = ['ConflictingLib'] - -target 'YourApp' do - # 先声明所有库 - pods = [ - 'AFNetworking', - 'ConflictingLib', - 'OtherLib' - ] - - # 动态处理 - pods.each do |pod| - if dynamic_frameworks.include?(pod) - pod pod, :modular_headers => true, :linkage => :dynamic - else - pod pod - end - end -end -``` -实验:我的电脑 cocoapods 版本较低,采用下面写法 -```ruby -platform :ios, '9.0' -use_frameworks! # 确保启用框架支持 - -target 'StaticLibConflictsDemo' do - # 动态链接 AFNetworking(兼容所有版本) - pod 'AFNetworking', :modular_headers => true - - # 添加后安装钩子脚本 - post_install do |installer| - # 将 AFNetworking 设为动态框架 - installer.pods_project.targets.each do |target| - if target.name == 'AFNetworking' - # 设置 Mach-O 类型为动态库 - target.build_configurations.each do |config| - config.build_settings['MACH_O_TYPE'] = 'mh_dylib' - config.build_settings['DYLIB_INSTALL_NAME_BASE'] = '@rpath' - config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = ['$(FRAMEWORK_SEARCH_PATHS)'] - end - end - end - - # 确保框架被正确嵌入 - installer.pods_project.build_configurations.each do |config| - config.build_settings['EMBEDDED_CONTENT_CONTAINS_SWIFT'] = 'YES' - end - end -end -``` -效果: - -可以看到不光是解决了符号重复的问题,也解决了同一个类,存在2处实现的问题。 - - -```mermaid -graph LR -A[主可执行文件] --> B[静态库A] -A --> C[动态库B] -C --> D[私有符号空间] -``` - -- 静态库:符号直接合并到主可执行文件的全局符号表 -- 动态库:拥有独立的符号空间(LC_ID_DYLIB 标识) - - 动态库内部符号相互可见 - - 外部通过导出符号表(LC_DYSYMTAB)访问 - - -##### 5. 符号冲突总结 -###### 1. 最佳实践方案:动态库隔离 - -利用动态库的二级命名空间特性,将冲突的静态库转换为动态库,实现符号隔离 - -```ruby -# Podfile -use_frameworks! # 关键:启用框架支持 - -target 'StaticLibConflictsDemo' do - # 将冲突库转为动态 framework - pod 'ConflictingLib', :modular_headers => true, :linkage => :dynamic - - # 其他静态库 - pod 'NonConflictingLib' -end -``` - -静态库的 xcconfig 为 - - - -动态库的 xcconfig 为 - - - -###### 2. 源码级解决方案:添加类前缀 - -```objective-c -// 原始类名 -@interface AFURLSessionManager : NSObject - -// 修改后 -@interface ABC_AFURLSessionManager : NSObject -``` - -###### 3. 构建系统级解决方案:Bazel 命名空间 - -```shell -objc_library( - name = "AFNetworking", - namespace = "ABC", # 自动添加前缀 - srcs = glob(["*.m"]), - hdrs = glob(["*.h"]), -) -``` - - - -## 六、Strip 流程 - -静态库/ .o 文件 Strip 流程: 处理 `_DWARF` 段 - - - -Strip 的过程,就是在修改 Mach-O 文件中的内容。 - - - -动态库 - - - -All Symbols - - - - - -Non-Global Symbols(非全局符号): - - - - - - - - - -## 七、Framework - -### 1. 定义 - -**Mac OS/iOS 平台还可以使用 Framework,Framework 实际上是一种打包方式,将库的:二进制文件、头文件、和有关资源打包到一起,方便管理和分发**。 - -Framework 和系统 UIKit.Framework 还是有很大区别的。 - -- 系统的 Framework 不需要拷贝到目标程序中 -- 我们自己的 Framework 不管是静态还是动态的,都需要拷贝到 App 中(App 和 Extension 的 Bundle 是共享的) - -因此,Apple 把这种 Framework 又叫做 `Embedded Framework`。开发中使用的动态库会被放到 ipa 的 framework 目录下,基于沙盒运行。 - -不同的 App 使用相拥的动态库,并不会只在系统中存在一份。而是在各个 App 中各自打包、签名、加载一份。 - - - -根据 Apple 审核要求,上传到 App Store 的 ipa 可执行文件有大小限制。这里的大小不是指二进制(Mach-O)文件大小,而是指去其中 `__TEXT` 段的大小。 - -- iOS 7 之前,二进制文件中所有 `__TEXT` 部分总和不得超过 80M -- iOS7.x 至 iOS8.x,二进制文件中,每个架构中的 `__TEXT` 部分不得超过 60M -- iOS9.0 之后,二进制文件中所有 `__TEXT` 部分的总和不超过 500M - -为了实现该效果,很多公司在组件化或者开源库,都采用动态链接的方式。因为动态链接的部分,不算在当前 Mach-O 的 `__TEXT` 段。 - - - -### 2. Framework 2种结构 - -- **动态库:Header + `.dylib` + 签名 + 资源文件** -- **静态库:Header + `.a` + 签名 + 资源文件** - - - -### 3. 静态库转 Framework - -继续做个实验,验证下 Framework 的结构(上面做了静态库),所以我们可以沿用上面的成果,将静态库包装成 Framework - -第一步,新建一个文件夹 `Framworks`,下面创建一个 `Person.Framework` 文件夹,把之前得到的静态库 `Person` 移动到该目录下。并创建一个 `test.m` 移动过去。 - -```objective-c -#import -#import "Person.h" - -int main () { - Person *person = [[Person alloc] init]; - NSLog(@"Person is %@", person); -} -``` - -第二步,模仿 Framwork 文件结构目录。因为 Framework 的结构里有 Header 信息,所以创建 `Headers` 文件夹,把 `Person.h` 文件放进去。Headers 同层目录,把 Person 静态库放进去。 - -第三步,根据 `test.m` 和 framework 信息,编译成 `test.o` - -```shell -clang -x objective-c \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I ./Frameworks/Person.framework/Headers \ - -c test.m -o test.o -``` - -第四步,再根据 `test.o` 和 framework 去完成链接。链接三要素:库的头文件、库所在目录、库的名称。只不过在处理 Framework 的时候,参数不一样 - -```shell -clang \ --target x86_64-apple-macos13.1 \ --fobjc-arc \ --isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -F ./Frameworks \ --framework Person \ -test.o -o test -``` - -说明:test.o 链接 Person.framework 生成 main 可执行文件 - -- `-F./Frameworks` 在当前目录的子目录 Frameworks 查找需要的库文件。类似 Xcode 里的 **Framwork search path** - -- `-framework Person ` 链接的名称为 `Person.framework` 的动态库或者静态库。类似 **Other linker flags -framework** - - 查找规则:先找 `Person.framework` 的动态库,找不到,再去找 `Person.framework` 的静态库,还找不到,就报错 - -第五步:成功得到了 test 可执行文件,说明模仿 Framwork 搭建的静态库 Framwork 成功了。然后测试下可执行文件运行结果。进行 double check - - - - - -### 4. QA:通常情况下,**同一份代码,一个库制作成动态库体积会比静态库小**。为什么? - -静态库是一堆 `.o` 文件的集合。假设 AFNetworking 有15个 `.m` 文件,编译后产生 15个 `.o` 文件。每个 `.o` 文件的都存在下面3部分: - -- Mach header -- Segment -- Section - -Mach header 包括一些基础信息,所以 Mach header 存在冗余(大小端序、CPU 类型等),这也就是为什么静态库比动态库体积大的原因之一。 - - - -```shell -Unix_Kernel  ~/Desktop/OCExplore/OCExplore  file Person.o -Person.o: Mach-O 64-bit object x86_64 - Unix_Kernel  ~/Desktop/OCExplore/OCExplore  otool -h Person.o -Person.o: -Mach header - magic cputype cpusubtype caps filetype ncmds sizeofcmds flags - 0xfeedfacf 16777223 3 0x00 1 4 1880 0x00002000 -``` - - - -动态库在 Mach header 这里有改进,AFNetworking 动态库格式如下: - - - -将公共的信息放到一起,公用一个 Mach header。 - - - -但「同一份代码,一个库制作成动态库体积会比静态库小」不绝对。 - -对于 iOS9,Load Commands Segment vmsize 默认是一个内存页,也就是16k - -对于 iOS10,Load Commands Segment vmsize 默认是 32k - -- vmsize:此 segment 占用的虚拟内存的字节数 -- filesize:此 segment 在磁盘上占用的内存数 - -假设项目只有1个源文件,可能打包后的静态库要比动态库体积大。 - - - -### 5. dead strip - -dead strip 触发条件: - -- 没有被入口点使用 -> 脱掉 -- 没有被导出符号使用 -> 脱掉 - -由于 OC 是动态性语言,比如 Category 是在运行时创建的,所以即使开启了 dead strip 也没办法脱掉 OC 符号。 - -链接器很方便,提供了 **-why_live** 参数,来查看为什么某个符号没有被脱掉。用法:**-Xlinker -why_live -Xlinker _global_Function ** - -``` shell -clang -target x86_64-apple-macos13.1 \ --fobjc-arc \ --isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ --Xlinker -dead_strip \ --Xlinker -all_load \ --Xlinker -why_live -Xlinker _global_Function \ --L ./StaticLibrary \ --l Person \ -test.o -o test -``` - -dead code strip 和 xlinker 提供的4个参数: - -- `-noall_load`: 完全不加载、直接去优化。`OTHER_LDFLAGS=-Xlinker -noall_load` - -- `-all_load`: 完全加载、不要去优化掉。`OTHER_LDFLAGS=-Xlinker -all_load` -- `-ObjC` : 排除ObjC的代码、其他的都优化掉。`OTHER_LDFLAGS=-Xlinker -ObjC ` -- `-force_load` : 指定哪些静态库不要优化掉; - - - -## 八、动态库 - -### 1. tbd - -tbd 全称是 text-based stub libraries,本质就是一个 YAML 描述的文本文件。 - -作用:用于描述动态库的链接信息,包括导出的符号、动态库的架构信息、动态库的依赖信息 - -用于避免在真机开发过程中直接使用传统的 dylib。 - -对于真机来说,由于动态库都是在设备上的,在 Xcode 上使用基于 tbd 格式的伪 framework,可以大大减少 Xcode 的大小 - -一个典型的 TBD 文件(如 `UIKit.tbd`)包含以下部分: - -```yaml ---- !tapi-tbd-v3 -archs: [ arm64, arm64e ] -platform: ios -install-name: /System/Library/Frameworks/UIKit.framework/UIKit -current-version: 61000 -compatibility-version: 1.0 -exports: - - archs: [ arm64, arm64e ] - symbols: [ _UIApplicationMain, _UIViewSetFrame ] - weak-symbols: [ _UISomeOptionalAPI ] - objc-classes: [ UIViewController, UIView ] - objc-ivars: [ UIViewController.view ] -... -``` - -| 字段 | 描述 | -| :---------------------- | :----------------------------------- | -| `archs` | 支持的架构 (arm64, armv7, x86_64 等) | -| `platform` | 目标平台 (ios, ios-simulator, macos) | -| `install-name` | 运行时加载路径 | -| `current-version` | 库的当前版本 | -| `compatibility-version` | 兼容版本 | -| `exports` | 导出的符号信息 | -| `symbols` | 导出的 C 函数和全局变量 | -| `objc-classes` | 导出的 Objective-C 类 | -| `objc-ivars` | 导出的实例变量 | -| `objc-eh-types` | Objective-C 异常类型 | -| `re-exports` | 重新导出的其他库 | - -### 2. 直接链接动态库 - -继续通过小实验来研究动态库的创建与使用 - -第一步:创建 dylib 文件夹,下面创建 `Person.h` `Person.m` 类。在 dylib 同层目录创建 main.m 文件。代码如下 - - - -第二步:对 main.m 编译成 main.o 文件,指令为 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./dylib \ - -c main.m -o main.o -``` - -第三步:到 dylib 文件夹下,对 Person 编译为 Person.o 文件,指令为 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c Person.m -o Person.o -``` - -第四步:将 Person.o 编译为动态库,指令为 - -```shell -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - Person.o -o LibPerson.dylib -``` - -第五步,将 main.o 和 LibPerson.dylib 链接,成为 main 可执行文件 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -L./dylib \ - -lPerson \ - main.o -o main -``` - -每次针对动态库的操作都是这些差不多的指令,就是一些参数的不同,写个 Shell 脚本,命名为 `build.sh` - -```shell -echo "---------------- start --------------" - -echo "第一步:先对 main.m 编译为 main.o" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./dylib \ - -c main.m -o main.o - -echo "第二步,再对 Person 编译为 Person.o" - -pushd ./dylib -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c Person.m -o Person.o - -echo "第三步,将 Person.o 编译为 libPerson.dylib 动态库" - -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - Person.o -o libPerson.dylib - -echo "第四步,将 main.o 和 libPerson.dylib 链接为可执行文件 main" - -popd -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -L./dylib \ - -lPerson \ - main.o -o main - -echo "---------------- Done --------------" -``` - -结果如下: - - - - - -第六步:对生成的 main 可执行文件进行调试运行,使用 lldb 指令 `lldb -file 可执行文件`,然后输入 r 进行运行: - - - -咦,为什么我用动态库链接后还是无法使用???带着问题研究下 - - - -### 3. 静态库链接成动态库 - -因为: - -- `.o` 文件可以链接成静态库 -- `.o` 文件可以链接成动态库 - -所以:能不能推导出这样一个结论:静态库也可以链接成动态库。 - -做个小实验验证看看: - -和上面的材料没有差别,区别在于脚本,其中一步是将静态库链接为动态库。 - -使用链接器 LD 能力,链接静态库为动态库指令如下 - -```shell -ld -dylib -arch x86_64 \ - -macosx_version_min 13.1 \ - -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -lsystem -framework Foundation \ - libPerson.a -o libPerson.dylib -``` - -`build.sh` 完整脚本如下 - -```shell -echo "---------------- start --------------" - -echo "第一步:先对 main.m 编译为 main.o" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./dylib \ - -c main.m -o main.o - -echo "第二步,再对 Person 编译为 Person.o" - -pushd ./dylib -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c Person.m -o Person.o - -echo "第三步,对 Person.o 编译为 LibPerson.a 静态态库" - -libtool -static -arch_only x86_64 Person.o -o libPerson.a - -echo "第四步,LD 链接器将 libPerson.a 链接为 libPerson.dylib 动态库" - -ld -dylib -arch x86_64 \ - -macosx_version_min 13.1 \ - -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -lsystem -framework Foundation \ - libPerson.a -o libPerson.dylib - -echo "第五步,将 main.o 和 libPerson.dylib 链接为可执行文件 main" - -popd -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -L./dylib \ - -lPerson \ - main.o -o main - -echo "---------------- Done --------------" -``` - -执行完脚本,又出现了奇怪的现象: - - - - - -发现,在**利用动态库链接成可执行文件时报错了 `undefined symbols for **`,然后利用 `objdump --macho --exports-trie libPerson.dylib` 查看动态库的导出符号,居然是空**。为什么? - -链接器在链接阶段,默认使用 `-noall_load` 参数。共4个参数: - -- `-noall_load` :默认是 `-noall_load`,顾名思义就是不会所有符号的加载,而是链接器链接一个静态库之前去扫描静态库文件,找到需要的代码再进行链接 -- `-all_load`:链接所有符号,不管代码有没有使用 -- `-force_load`: 可以指定要载入所有方法的库,后面必须跟一个只想静态库的路径 -- `-ObjC`:告诉链接器把库中定义的 Objective-C 类和 Category 都加载进来,这样编译之后的可执行文件会变大(因为加载了其他的 OC 代码进来)。由于 OC语言符号链接的基本单位是类,静态库链接时首先会链接本类,而 Category 是运行时才会被加载的,因此会被静态链接器直接忽略掉,通过 `-ObjC` 命令是告知链接器链接所有的 OC 代码 - -知道具体原因那就好办了,修改指令 - -```shell -ld -dylib -arch x86_64 \ - -macosx_version_min 13.1 \ - -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -lsystem -framework Foundation \ - -all_load \ - libPerson.a -o libPerson.dylib -``` - -再次运行 build 脚本,然后对可执行文件执行,还是报错 😂 - - - - - -通过上面的实验可以得出结论: - -- **静态库是 `.o` 文件的合集** -- **动态库是 `.o` 文件链接后的产物** -- **静态库可以链接成动态库** -- **动态库是最终链接产物。动态库比静态库多走一次链接的过程** - - - -### 4. 动态库 Library not loaded? - -为什么动态库链接后的可执行文件运行,会报 `Library not loaded: 'libPerson.dylib'` 错误? - -不得不聊聊动态库加载原理 - - - -也就是说:**当 dyld 加载一个可执行文件(main) 的时候,在 Mach-O 中有一些名字叫 `LC_LOAD_DYLIB` 的 Load Command,里面保存了所使用到的动态库的路径。可执行文件所依赖的动态库,就是靠 dyld 根据动态库路径进行加载的**。 - -用 MachOView 打开另一个 App **看看** - - - -对于我们自己链接的可执行文件 main 进行查看,利用 `otool -l main | grep 'DYLIB' -A 5` 指令 - - - -可以发现: - -- **name 好像就是动态库的路径** -- 链接的其他几个动态库的路径都没问题,就是 LibPerson.dylib 路径有问题。 - - - -如何解决? - -**需要在编译链接生成动态库的时候,有个东西保存动态库路径,这个就是 Mach-O 文件中的另一个 Load Command,即 `LC_ID_DYLIB`**。 - - - -#### 方式一:通过 `install_name_tool` 指令 - - - -通过改变动态库 name 来修改动态库的路径。具体指令为: `install_name_tool -id 动态库路径 动态库名称`。即:`install_name_tool -id /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary/dylib/libPerson.dylib libPerson.dylib` - -修改动态库的 name 之后,再次 `otool -l libPerson.dylib | grep 'DYLIB' -A 5` 查看路径信息 - - - -动态库有了正确的 name 后,再重新链接生成可执行文件。可执行文件可以正确运行,查看所以来的动态库路径,均正确加载。 - - - -方式一有明显**缺点,因为路径是绝对路径,因此没办法迁移**。 - - - -#### 方式二:`rpath` - -`rpath`,Runpath search paths,dyld 搜索路径。运行时,`@rpath` 指示 dyld 按顺序搜索路径列表,以找到动态库。 - -`@rpath` 保存一个或多个路径的变量。 - -关键步骤:**谁链接动态库,谁来提供 rpath** - -- 动态库需要 rpath 信息,用来加载访问动态库真正的实现 -- 宿主(App)提供 rpath 信息,用于提供路径信息给动态库 - - - - - -前提说明:模拟下 App 真实环境。创建一个文件夹 `Frameworks`,内部继续创建 `Person.framework` 文件夹,其内部继续创建 `Headers` - -文件夹,将 dylib 文件夹下的文件复制过去。结构如下 - -```shell -// tree -L 4 -. -├── Frameworks -│   └── Person.framework -│   ├── Headers -│   │   └── Person.h -│   ├── Person.h -│   ├── Person.m -├── build.sh -├── dylib -│   ├── Person.h -│   ├── Person.m -│   ├── Person.o -│   ├── libPerson.a -│   └── libPerson.dylib -├── main.m -``` - -第一步,在 ` Frameworks/Person.framework` 下面执行命令,将 `Person.m` 编译为 `Person.o` - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c Person.m -o Person.o -``` - -第二步,将 `Person.o` 链接为动态库 - -```shell -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - Person.o -o Person -``` - -第三步,给动态库利用 `install_name_tool` 修改 id,id 指定 `@rpath` 信息(在做这么一件事:动态库需要 rpath 信息,用来加载访问动态库真正的实现) - -```shell -install_name_tool -id @rpath/Frameworks/Person.framework/Person /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary/Frameworks/Person.framework/Person -``` - -第四步,利用 `otool` 查看动态库的 name 是否修改好了 `@rpath` 信息 - -``` -otool -l Person | grep 'ID' -A 5 -``` - -第五步,回到根目录,将 `main.m` 编译为 `main.o` - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Frameworks/Person.framework/Headers \ - -c main.m -o main.o -``` - -第六步,将 `main.o` 和 `Person.framework` 链接为可执行文件 - -```shell -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \-isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -F./Frameworks \ - -framework Person \ - main.o -o main -``` - -此时的可执行文件虽然链接成功了,但是可执行文件需要用到动态库的功能,直接运行会报错 `Library not loaded: '@rpath/Frameworks/Person.framework/Person'`。所以需要给可执行文件添加 `rpath` 信息 - -第七步,给可执行文件 `main` 添加 `rpath` 信息(在做这么一件事:宿主(App)提供 rpath 信息,用于提供路径信息给动态库) - -```shell -install_name_tool -add_rpath /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary main -``` - -添加后验证是否添加成功,指令 `otool -l main | grep 'RPATH' -A 5` - -第八步,链接成功后 `main` 可执行文件即可执行 - - - -下面是上面全部步骤的截图说明。 - - - - - - - -给动态库添加 `rpath` 信息。 - -核心:谁链接动态库,`rpath` 谁来提供,比如一个 Person.framework 路径为: `/usr/meiying/desktop/DynamicExplore/Person.framework` - -- 可执行文件中 `Load Command ` 中存在 `LC_RPATH` ,值为 `/usr/meiying/desktop/DynamicExplore/Person.framework` -- 动态库 Mach-O 中也存在 值为 `@rpath/Frameworks/Person.framework/Person` - -反思:上面的方案还是有缺点的,因为可执行文件提供的 `rpath` 还是一个绝对路径。 - - - -#### 方式三:@execute_path、@loader_path - -`@executable_path`:表示可执⾏程序所在的⽬录,解析为可执⾏⽂件的绝对路径。 - -`@loader_path`:表示被加载的`Mach-O` 所在的⽬录,每次加载时,都可能被设置为不同的路径,由上层指定 - -可以将可执行文件的 **rpath 修改为灵活的,而不是写死的路径**,指令格式为:**install_name_tool -rpath oldPath newPath** - -```shell -install_name_tool -rpath /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary @executable_path main -``` - - - -这个可不是花里胡哨的烧操作,Cocoapods 也是这么干的 - -``` -LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' -``` - - - - - - - -注意:`@loader_path` 还是比较抽象的,举一个实际场景例子来看看 - -假设背景是: - -- 动态库 Cat 具有 `[cat sleep]` 能力 -- 动态库 Person 具有 `[person sayHi]` 能力,动态库 Person 使用了动态库 Cat -- 可执行文件,导入了动态库 Person - -这样一个场景,代码模拟下,文件目录如下 - - - -1. 在 Cat.framework 文件夹下运行 build.sh -2. 在 Person.framework 文件夹下运行 build.sh -3. 在 main.m 根目录下运行 build.sh - -得到 main 可执行文件,运行报错。 - -```shell -lldb -file main -(lldb) target create "main" -Current executable set to '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' (x86_64). -(lldb) r -Process 54157 launched: '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' (x86_64) -dyld[54157]: Library not loaded: 'Person' - Referenced from: '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' - Reason: tried: 'Person' (no such file), '/usr/local/lib/Person' (no such file), '/usr/lib/Person' (no such file), '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/Person' (no such file), '/usr/local/lib/Person' (no such file), '/usr/lib/Person' (no such file) -Process 54157 stopped -* thread #1, stop reason = signal SIGABRT - frame #0: 0x000000010005698e dyld`__abort_with_payload + 10 -dyld`: --> 0x10005698e <+10>: jae 0x100056998 ; <+20> - 0x100056990 <+12>: movq %rax, %rdi - 0x100056993 <+15>: jmp 0x100013150 ; cerror_nocancel - 0x100056998 <+20>: retq -Target 0: (main) stopped. -``` - -得到新的命题:**可执行文件中引入动态库 A,动态库的功能实现依赖动态库 B,链接器该如何链接呢?** - - - -第一种尝试: - -对 Cat.framework 下的 build.sh 修改脚本,指定 `@rpath` 信息, `-Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat` - -```shell -echo "1: 编译 Cat.m 为 Cat.o 可执行文件" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -c Cat.m -o Cat.o - -echo "2: 链接 Cat.o 为 Cat 动态库" - -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat \ - Cat.o -o Cat - -echo "3: 输出动态库的 name(路径)信息" -otool -l Cat | grep 'ID' -A 5 -``` - -对 Person.framework 下的 build.sh 修改脚本,指定 `@rpath` 信息,` -Xlinker -install_name -Xlinker @rpath/Person.framework/Person` - -```shell -echo "1:编译 Person.m 为 Person.o 可执行文件" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Headers \ - -I./Frameworks/Cat.framework/Headers \ - -c Person.m -o Person.o - -echo "2: 链接 Person.o 为 Person 动态库" - -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ - -F./Frameworks/ \ - -framework Cat \ - Person.o -o Person - - -echo "3: 输出动态库的 dylib 信息" -otool -l Person | grep 'DYLIB' -A 5 - -echo "-----------" -echo "4: 输出动态库的 name 信息" -otool -l Person | grep 'ID' -A 5 - -``` - -对可执行文件 main 根目录,指定 `@executable_path` 信息,`-Xlinker -rpath -Xlinker @executable_path/Frameworks` - -```shell -echo "---------------- start --------------" - -echo "第一步:先对 main.m 编译为 main.o" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Frameworks/Person.framework/Headers \ - -c main.m -o main.o - -echo "第二步,将 main.o 和 Person.dylib 链接为可执行文件 main" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -rpath -Xlinker @executable_path/Frameworks \ - -F./Frameworks \ - -framework Person \ - main.o -o main - -echo "第三步,输出信息" -otool -l main | grep 'RPATH' -A 3 -otool -l main | grep 'DYLIB' -A 3 - -echo "---------------- Done --------------" -``` - -运行报错如下: - - - -为什么还错误了?都已经给可执行文件添加了 `@executable_path`,给2个动态库都添加了 `@rpath`,怎么办? - -思路:因为报错是说动态库 Person 找不到动态库 Cat,那是不是聚焦下 Person,在 Person 的链接指令中,给 Person 添加 `@rpath` 就可以了? - -动手实践下,修改 Person 动态库的 build.sh 脚本 - -```shell -echo "1:编译 Person.m 为 Person.o 可执行文件" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Headers \ - -I./Frameworks/Cat.framework/Headers \ - -c Person.m -o Person.o - -echo "2: 链接 Person.o 为 Person 动态库" - -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ - -Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks \ - -F./Frameworks/ \ - -framework Cat \ - Person.o -o Person - - -echo "3: 输出动态库的 dylib 信息" -otool -l Person | grep 'DYLIB' -A 5 - -echo "-----------" -echo "4: 输出动态库的 name 信息" -otool -l Person | grep 'ID' -A 5 - -``` - -在 Person.framework运行下 build.sh,然后在根目录下运行 build.sh,得到新的可执行文件,然后可以成功运行 - - - -反思:可执行文件依赖动态库 A,动态库 A 依赖动态库 B,上面的配置很繁琐: - -- 可执行文件提供 `rpath`,指令为: `-Xlinker -rpath -Xlinker @executable_path/Frameworks` -- 动态库 A 指定 name 为 `-Xlinker -install_name -Xlinker @rpath/Person.framework/Person`,同时又因为依赖了动态库 B,所以同时又要提供 `rpath`,指令为 `-Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks ` -- 动态库 B 指定 name,指令为 `-Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat` - -好繁琐啊,有没有简化的方法。此时 **`@loader_path`** 呼之欲出了。 - -在当前场景下,Person 动态库的指令可以简化下。`-Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks ` 可以替换为 `-Xlinker -rpath -Xlinker @loader_path/Frameworks ` - -修改脚本后,分别在 Person.framework 和 main 可执行文件根目录下执行 build.sh 脚本,然后验证可执行文件是否可以正确运行,加载所需动态库 - -注:为了方便看清楚脚本执行情况和可执行文件执行结果,这次运行注释了 otool 的打印脚本。 - - - - - -`loader_path` 是标准解决方案。随便打开 AFNetworking 工程看看 - - - - - - - -链接器可真强大啊,同时 Mach-O 开的口子也多,也就是可以随意修改已经编译好的可执行文件的 `rpath` ,自己也可以创建一个同名的动态库(name)把原有的动态库替换了,这也是为什么 MacOS 那些破解软件的工作原理。(当然,这些也要结合逆向技术) - -上述的操作,其实本质就是修改 Mach-O 文件的 Load Command,按照 Apple 的机制,Mach-O 修改后,必须重新签名才可以运行使用破解软件时,总是要求我们重新签名,背后的命令如下: - -```shell -sudo codesign --force --deep --sign - (应用路径) -``` - - - -### 5. 动态库如何导出所引用的动态库的符号 - -- 主工程 -> Person 动态库 -- Person 动态库 -> Cat 动态库 - -那么主工程可以直接调用 Cat 动态库的能力吗? - -正常写代码肯定可以,但是从链接器角度分析下,如何实现 - -**调用的本质就是符号的发现。也就是 Cat 的符号有没有导出?可执行文件 mian 使用的能力就是动态库导出后,自己导入的**。 - -因为 main 引入了 `import ` ,查看下 Person 动态库的导出符号,使用指令 `objdump --macho --exports-trie Person` - -进入 Cat.framework 也查看下 Cat 动态库的导出符号,使用指令 `objdump --macho --exports-trie Cat` - -发现 Person 没有导出 Cat 的符号。那在可执行文件中调用不了 Cat 的能力了。 - - - -怎么办呢?链接器 LD 已经是很成熟的东西了,对于处理动态库依赖了动态库,且需要将被依赖动态库的符号导出,这样的需求早已满足了。具体是什么参数?终端输入 `man ld` 查看下指令 - - - -其中,我们需要用的是 **-reexport_framework** 这个参数。指令为 ` -Xlinker -reexport_framework -Xlinker Cat` - -对此,修改 Person.framework 的 build.sh 脚本 - -```shell -echo "1:编译 Person.m 为 Person.o 可执行文件" - -clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Headers \ - -I./Frameworks/Cat.framework/Headers \ - -c Person.m -o Person.o - -echo "2: 链接 Person.o 为 Person 动态库" - -clang -dynamiclib \ - -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ - -Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks \ - -Xlinker -reexport_framework -Xlinker Cat \ - -F./Frameworks/ \ - -framework Cat \ - Person.o -o Person - - -echo "3: 输出动态库的 dylib 信息" -otool -l Person | grep 'DYLIB' -A 5 - -echo "-----------" -echo "4: 输出动态库的 name 信息" -otool -l Person | grep 'ID' -A 5 - -``` - -执行脚本输出如下: - - - -结构如下: - - - -理论上来讲,Person.framework 把 Cat.framework 导出了,实现方式是通过给 Mach-O 的一个叫做 `LC_REEXPORT_DYLIB` 的 Load Command。也就是可执行文件,通过 Person.framework 的 `LC_REEXPORT_DYLIB` load Command 可以实现访问 Cat.framework 的符号。 - -完整验证下,还需要做2件事情: - -- 修改 main.m 的代码,因为要访问 Cat.framework 的能力,测试能否正常运行 - - ```objective-c - #import - #import "Person.h" - #import - - int main(int argc, char * argv[]) { - Person *p = [[Person alloc] init]; - [p sayHi]; - Cat *cat = [[Cat alloc] init]; - [cat sleep]; - return 0; - } - ``` - -- 修改 main.m 的 build.sh 脚本,因为 Person.framework 已经暴露了 Cat.framework 的能力, `mian.m` 中引入了 `Cat.Framework` 的符号,LD 链接指令需要加 Cat.framework 头文件的参数 - - ```shell - echo "---------------- start --------------" - - echo "第一步:先对 main.m 编译为 main.o" - - clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -I./Frameworks/Person.framework/Headers \ - -I./Frameworks/Person.framework/Frameworks/Cat.framework/Headers \ - -c main.m -o main.o - - echo "第二步,将 main.o 和 Person.dylib 链接为可执行文件 main" - - clang -target x86_64-apple-macos13.1 \ - -fobjc-arc \ - -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ - -Xlinker -rpath -Xlinker @executable_path/Frameworks \ - -F./Frameworks \ - -framework Person \ - main.o -o main - - # echo "第三步,输出信息" - # otool -l main | grep 'RPATH' -A 3 - # otool -l main | grep 'DYLIB' -A 3 - - echo "---------------- Done --------------" - ``` - -修改完从里到外一次性执行 build.sh,得到 main 可执行文件。一切顺利,输出如下: - - - - -### 6. 二级命名空间 -在 iOS 系统中,动态库的二级命名空间是一种关键的符号管理机制,它解决了不同库之间可能发生的符号冲突问题。这个机制是 macOS 和 iOS 平台动态链接的核心特性。 - - -#### 1. 核心原理 -二级命名空间通过2个维度来唯一标识符号: -- 符号名称(Symbol Name) -- 动态库名称(Dylib Identity) -``` -graph TD - A[符号解析] --> B{是否在二级命名空间?} - B -->|是| C[符号名称 + 库标识符] - B -->|否| D[仅符号名称] - C --> E[唯一符号] - D --> F[可能冲突] -``` - -##### 1. 库标识符(Dylib Identity) -每个动态库都有一个唯一标识符,通常包含: -- 安装路径(Install Name) -- 当前版本号(Current Version) -- 兼容版本号(Compatibility Version) -```shell -# 查看库标识符 -otool -l libExample.dylib | grep -A 3 LC_ID_DYLIB - -# 输出示例: -# cmd LC_ID_DYLIB -# cmdsize 56 -# name @rpath/libExample.dylib (offset 24) -# time stamp 1 Wed Dec 31 19:00:01 1969 -# current version 1.0.0 -# compatibility version 1.0.0 -``` - -##### 2. 使用过程 -当动态链接器(dyld)加载符号的时候: -- 解析符号引用 -- 确定定义该符号的动态库 -- 使用(符号名、动态库ID)作为唯一键 -- 在符号表中查找唯一匹配 -```mermaid -sequenceDiagram - participant App as 应用程序 - participant dyld as 动态链接器 - participant LibA as 库A - participant LibB as 库B - - App->>dyld: 请求符号 foo - dyld->>LibA: 检查 foo@LibA - dyld->>LibB: 检查 foo@LibB - dyld-->>App: 返回 foo@LibA -``` - -##### 3. 符号表结构 -在二级命名空间下,符号表使用分层结构 -```shell -Symbol Table -├── LibA Symbols -│ ├── foo -│ ├── bar -│ └── baz -├── LibB Symbols -│ ├── foo -│ ├── xyz -│ └── abc -└── ... -``` - -#### 2. 应用场景 -##### 1. 解决符号冲突 -当两个库定义同名符号时: -```shell -// libNetwork.dylib -void process_data() { - // 网络处理实现 -} - -// libAudio.dylib -void process_data() { - // 音频处理实现 -} -``` -在二级命名空间作用下: -- process_data@libNetwork -- process_data@libAudio -所以,可以安全的使用2个库,而不会发生符号冲突问题 - - - -##### 2. Xcode 配置 - -- 启用二级命名空间(默认):`OTHER_LDFLAGS = $(inherited) -twolevel_namespace` -- 禁用二级命名空间:`OTHER_LDFLAGS = $(inherited) -flat_namespace` -查看命名空间状态: -```shell -# 检查二进制是否使用二级命名空间 -otool -lv YourApp | grep TWOLEVEL - -# 输出示例: -# flags TWOLEVEL -``` - - - -#### 3. 动态库开发 tips - -##### 1. 动态库设计原则 -- 使用唯一前缀命名符号 - ```c++ - // 良好的命名实践 - void TaoBaoNetwork_processData() { - // 实现 - } - ``` -- 限制导出符号数量 -- 明确声明依赖关系 -- 符号可见性控制 - ```c++ - // 只导出必要的符号 - __attribute__((visibility("default"))) - void public_api(); - - // 隐藏内部实现 - __attribute__((visibility("hidden"))) - void internal_function(); - ``` - -##### 2. 运行时路径管理 -使用适当的路径变量: -- `@executable_path`: 可执行文件所在目录 -- `@loader_path`: 当前加载模块所在目录 -- `@rpath`: 运行时搜索路径 - - - -## 九、xcframework - -### 1. 诞生背景 - -XCFramework:是苹果官⽅推荐的、⽀持的,可以更⽅便的表示⼀个多平台和架构的分发⼆进制库的格式。专⻔在 2019 年提出的framework 的另⼀种先进格式。 - -需要 Xcode11 以上⽀持。是为了更好的⽀持 Mac Catalyst 机制和 ARM 芯⽚的 macOS。 - -### 2. 优势 - -胖二进制:Fat Binary,通用二进制格式(Universal Binary)。通用二进制文件实际上就是将支持不同架构的二进制文件打包成一个文件,系统在加载运行时,会根据通用二进制文件中提供的架构,选择和当前系统匹配的二进制文件。 - -动态库是可以合并的。前提是不同的 CPU 架构。合并之后还是多个不同的动态库,只不过 mach-header 是挨在一起的,所有的库文件也是挨着的。可以理解成是“压缩”。 - -lipo 指令的缺点: - -- 不能合并相同架构 -- 需要手动处理头文件、资源文件的内容 -- 设计优秀的 SDK 还需对外提供 DSYM文件,用于后续卡顿、Crash 的堆栈还原。对不同的库处理后,还要处理 `dSYM` 文件 -- 如果库开启了 bitcode,还会生成 `BCSymbolMaps` - -文件。所以使用 lipo 处理动态库的合并、拆分,都需要管理 `dSYM`、`BCSymbolMaps`、`库文件`,较为繁琐。 - -基于此,Apple 在 2019 诞生了 `xcframework` 技术。 - -和传统的 framework 相⽐: - -- **可以⽤单个`.xcframework` ⽂件提供多个平台的分发⼆进制⽂件** - -- **与 `Fat Header` 相⽐,可以按照平台划分,可以包含相同架构的不同平台的⽂件** - -- **在使⽤时,不需要再通过脚本去剥离不需要的架构体系(比如默认包含3种架构,armv7、arm64、x86_64 上架前为了包大小,还会用 lipo 指令剔除不需要的架构)** - - - -### 3. 如何制作 xcframework - -第一步:先创建一个动态库 `pod lib create Person`。里面就一个 Person 类, 包含2个方法。 - -第二步:利用 `xcodebuild` 指令打包。`xcodebuild` 指令执行的是打包当前的工程目录,会读取 `-workspace` 参数指定的项目配置,然后根据指定的 `-scheme` 和 `-configuration` 模式去打包工程。 - -先打出模拟器的包,指令如下: - -```shell -xcodebuild -workspace Person.xcworkspace \ - -scheme 'Person-Example' \ - -configuration Release \ - -destination 'generic/platform=iOS Simulator' \ - -archivePath '../archives/Person.framework-iphonesimulator.xcarchive' \ - SKIP_INSTALL=NO \ - archive -``` - -再打出真机的包,指令如下 - -```shell -xcodebuild -workspace Person.xcworkspace \ - -scheme 'Person-Example' \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath '../archives/Person.framework-iphoneos.xcarchive' \ - SKIP_INSTALL=NO \ - archive -``` - -打包成功的输出如下: - - - -实体目录如下: - - - -注意:我们打包归档后生成的符号表只有 `.dSYM` 文件,没有 `BCSymbolMaps` 文件,是因为工程没有开启 bitcode,当开启 bitcode 后的,打包会生成 `BCSymbolMaps` 文件,作用也是用于 Crash 后解析堆栈用的。 - -因为 `.dSYM` 文件是默认生成的,但是 `bitcode` 需要分别开启(Pod 和 Demo 工程)。开启 bitcode 后重新打包,看看生成的产物里有没有 `BCSymbolMaps` 和相关的 `.bcsymbolmap` 文件 - - - - - -第三步:利用 `xcodebuild` 打包成 xcframework - -```shell -xcodebuild -create-xcframework \ - -framework 'archives/Person.framework-iphoneos.xcarchive/Products/Library/Frameworks/Person.framework' \ - -framework 'archives/Person.framework-iphonesimulator.xcarchive/Products/Library/Frameworks/Person.framework' \ - -output 'https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/xcframework/Person.xcframework' -``` - -结果如下: - - - -可以看到打包后的 xcframework 自动处理了文件夹。但是缺点是我们提供的 framework,别人使用可能会 crash,所以为了一些使用场景需要在 xcframework 中提供 `.dSYM` 文件或者 `.bcsymbolmap` 文件。 - -第四步:加入 `.dSYM` 和 `.BCSymbolMaps` 文件,重新生成 xcframework,其参数为 `-debug-symbols`,后面路径必须是绝对路径。 - -```shell -xcodebuild -create-xcframework \ - -framework 'archives/Person.framework-iphoneos.xcarchive/Products/Library/Frameworks/Person.framework' \ - -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/BCSymbolMaps/3C61A3F4-4398-322F-8AC9-F078B196C381.bcsymbolmap' \ - -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/BCSymbolMaps/B34138DB-2F8F-372C-93D3-7ADDDFC7BDA1.bcsymbolmap' \ - -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/dSYMs/Person.framework.dSYM' \ - -framework 'archives/Person.framework-iphonesimulator.xcarchive/Products/Library/Frameworks/Person.framework' \ - -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphonesimulator.xcarchive/dSYMs/Person.framework.dSYM' \ - -output 'https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/xcfrmaework/Person.xcframework' -``` - -结果如下 - - - -可以看到 xcframework 里面不只有不同的动态库,还携带了对应的 `.dSYM` 和 `bcsymbolmap` 文件,用于堆栈、符号还原。 - - - -第五步:制作好的 xcframework 接入使用下。 - -- 新建一个叫做 `XCFrameworkUsageDemo` 的 Xcode iOS App 工程 - -- 导入创建的 `Person.xcframework` - -- import 头文件并使用 - - - -- 编译运行,查看 products 下面的产物,因为选择的是模拟器运行,所以验证 `Person.framework` 里面的动态库文件大小,是否和 `Person.xcframework` 里面模拟器目录下 Person 动态库的大小一致 - - - -可以看到我们打包的 `Person.xcframework` 可以正常使用,除此之外,`Person.xcframework` 包含了模拟器和真机的动态库文件和对应的 `.dSYM` 和 `.bcsymbolmap` 文件,当导入到项目中的时候,Xcode 会根据当前编译的架构,自动从里面选择合适的架构文件。 - -好处有三: - -- 不需要处理头文件 -- 我们不需要关心上线前处理,重复架构(lipo 剔除) -- 调试符号也方便的给到 - -注意:该部分代码,在 `LDAndFramework/XCFramework` 目录下。 - -### 4. 弱链接(weakly linked) - -先做个小实验 - -第一步:创建一个 iOS App 工程,然后把上面生成的 `Person.framework` 拖到工程根目录下 - -第二步:不引入动态库,而是通过 xcconfig 文件,告诉链接器,关于动态库的三要素:头文件位置、动态库名称。 - -第三步:编译运行。 - - - -结论:编译正常,但是运行会报错 `Library not loaded: @rpath/Person.framework/Person` - -第一种解决方案是给 xcconfig 添加 rpath 的具体路径。 - - - -**第二种解决方案是将库声明为“弱链接”**。输入 `man ld` 查看具体的参数和说明: - -```shell - -weak_framework name[,suffix] - This is the same as the -framework name[,suffix] but forces the framework and all references to it to be marked as weak imports. Note: due to a - clang optimizations, if functions are not marked weak, the compiler will optimize out any checks if the function address is NULL. -``` - -运行时行为: - -1. **dyld 加载**: - - 弱链接框架不作为启动依赖 - - 不检查框架是否存在 -2. **符号解析**: - - 首次访问符号时尝试解析 - - 失败则置为 `NULL` -3. **错误处理**: - - 不触发崩溃 - - 无异常抛出 - -主要功能: - -1. **运行时可选依赖**:允许程序在框架不存在时仍能运行 -2. **版本兼容**:支持调用新版框架功能,同时保持旧系统兼容 -3. **避免崩溃**:框架缺失时不会导致 `EXC_BAD_ACCESS` -4. **动态功能检测**:可安全检查框架/API 可用性 - -修改 xcconfig 文件为 - -```shell -// 引用一个动态库,3要素:头文件、动态库名称、动态库路径 -// 知道了链接的原理之后,就知道可以不用把动态库托到项目中去,指定了3要素就可以链接了。 -// 1. -I:头文件信息 -HEADER_SEARCH_PATHS = $(inherited) ${SRCROOT}/Person.framework/Headers -LD_RUNPATH_SEARCH_PATHS = $(inherited) -// 2. -F:framework -FRAMEWORK_SEARCH_PATHS = $(inherited) ${SRCROOT} -// 3. -weak_framework: 允许该库在运行时消失 -OTHER_LDFLAGS = $(inherited) -Xlinker -weak_framework -Xlinker "Person" -``` - -修改后编译运行,发现没有报 `Library not loaded: ` 这样的错。 - -我们对添加了 `_weak-framework` 这个链接器参数的可执行文件查看下,指令为 `otool -l WeakImportDemo` - - - -查看 Mach-O 发现,**被 `-weak-framework` 声明后,cmd 从 `LC_LOAD_DYLIB` 变为 `LC_LOAD_WEAK_DYLIB`** - - - -通常,当链接一个库时,链接器会尝试解析所有从该框架中引用的符号。如果某个符号在库不存在或者没有导出,链接器会报错,因为这是一个强链接(strong linking)的要求。 - -使用 `-weak_framework` 选项,可以告诉链接器,即使框架中缺少某些符号,也不要报错,而是允许链接继续进行。 - -这样,应用程序在运行时可以优雅地处理缺少的符号,例如,某个特性可能因为缺少实现而不可用,但应用程序的其他部分仍然可以正常工作。 - - - - - -### 5. 静态库符号冲突原因及其解法 - -探索下静态库符号冲突的情况下,怎么解决? - -来个简单的小实验。 - -第一步:准备2个 AFNetworking 的静态库,符号一模一样,但是静态库名称不同。 - -第二步:创建 Demo 工程,将静态库放到根目录下。 - -第三步:创建 xcconfig 文件。配置参数用于配制一些编译、链接信息。 - -```json -//// -I -HEADER_SEARCH_PATHS = $(inherited) "${SRCROOT}/AFNetworking" "$(SRCROOT)/AFNetworking2" -//// -L -LIBRARY_SEARCH_PATHS = $(inherited) "${SRCROOT}/AFNetworking" "$(SRCROOT)/AFNetworking2" -//// -l -OTHER_LDFLAGS = $(inherited) -l"AFNetworking" -l"AFNetworking2" -``` - -第四步:尝试编译,编译成功。 - - - -QA:**为什么链接2个同名同符号的静态库,编译不报错?** - -会有二级命名空间吗?不是的。静态库本质就是一堆 `.o` 文件,所有的 `.o` 最后都会和主工程的可执行文件进行合并,所以不存在二级命名空间的问题。 - -核心原因是因为,**链接器针对静态库,在 deac code strip 专门为静态库,设置为 `-noall_load`,意思是:完全不加载、直接去优化** - -**可以验证下,在链接的时候修改链接参数为 `-all_load` 就会报错** - - - - - -如何解决?? - -LD 提供了 ` -load_hidden` 参数。 - -```shell - -load_hidden path_to_archive - Uses specified static library as usual, but treats all global symbols from the static library to as if they are visibility hidden. - Useful when building a dynamic library that uses a static library but does not want to export anything from that static library. -``` - -修改 LD 链接参数 - -```shell -OTHER_LDFLAGS = $(inherited) - -l"AFNetworking" - -l"AFNetworking2" - -Xlinker -force_load // 强制加载静态库中所有目标文件(即使未使用) - -Xlinker "${SRCROOT}/AFNetworking/libAFNetworking.a" - -Xlinker -load_hidden // 隐藏静态库符号 - -Xlinker "${SRCROOT}/AFNetworking2/libAFNetworking2.a" -``` - - - -具体代码见: `LDAndFramework/StaticLibConflictsSolution/StaticLibConflictsDemo` - - - -## 十、动态库/静态库的搭配使用 - -### 1. 动动 - -第一步,创建一个名字叫 `NetworkManager` 的 Framework,勾选测试。里面就添加一个 `NetworkDetector` 类,有1个类方法 `+sharedManager` - -第二步,给动态库添加 AFNetworking 依赖。 - -- 项目根目录下执行 `pod init` ,初始化工程 -- 添加 `pod 'AFNetworking'` - -第三步:目的是为了研究 App -> NetworkManager 动态库 -> AFNetworking 动态库的情况,所以 App 可以用测试工程代替,测试工程就是一个可执行文件,类似一个 App。 - -发现编译通过,运行报错。 - - - -为什么?原因 - -因为 `Pods-NetworkManager.debug.xcconfig` 里面的配置信息告诉链接器关于编译的3要素:头文件位置、动态库名称、rpath 信息。所以可以链接成功, - -运行报错是因为 dyld 找不到 AFNetworking 动态库所在的位置。 - -使用的 AFNetworking 动态库,所以`NetworkManager.Framework` 的 Load Command `LC_LOAD_DYLIB` 中的 name 就告诉外部,AFNetworking 将会在 `@rpath/AFNetworking.framework/AFNetworking` 下面查找。 - - - -遵循原则是谁链接库,谁就提供库所需要的 rpath 信息。所以 `NetworkManager.Framework` 提供 rpath 信息。xcconfig 文件的 `LD_RUNPATH_SEARCH_PATHS` 是 Xcode 项目中的一个设置,它在编译时告诉链接器在生成的可执行文件的运行时路径(rpath)中包含特定的目录( `install_name_tool -add_rpath` 是在二进制文件生成后对其进行修改) - -```shell -LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/.https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/Frameworks' -``` - -dyld 在运行起来后,会根据 `LD_RUNPATH_SEARCH_PATHS` 提供的 rpath 信息,和 AFNetworking 的 `name` 拼接去查找。但我们面前的例子,路径下没有 AFNetworking。 - -如何解决? - -方式一:low 一点,直接给测试工程也 `pod AFNetworking`。这是在探究原理,简单解决问题可以这么做。 - - - -方式二:不是找不到 AFNetworking 吗?因为 xcconfig 提供的路径找不到,那直接给 `LD_RUNPATH_SEARCH_PATHS` 配置一个可以找到的地方。然后运行成功。这只是为了研究定位问题后,简单解决问题的方案。 - - - -方式三:观察标准做法,比如一个 App 使用了 AFNetworking,这个 case Cocoapods 是怎么处理的? - - - -是通过 shell 脚本来处理的。该脚本肯定是 Cocoapods 生成的。属于工程化范畴。核心代码如下 - - - -具体怎么做呢?编写 shell 脚本,编译 Person 动态库、AFNetworking 动态库,然后将产物复制到 Frameworks 文件夹下。 - -Tips - -> 往一个 Framework 里通过 Cocoapods 导入一个库,并不会真的导入,而是生成链接器链接所需的参数,并不会把依赖的库文件放到自身的 Framework 中。 - - - -来个有趣的操作:NetworkManager.Framework 如何使用主工程的功能?也就是反向依赖 - -**功能的本质就是符号,dyld 在链接的时候,会把所有的导出符号放到一起,只要运行的时候找到所有的符号,就可以动态链接。** - - 那怎么做呢? - -第一步:在 App(我们的实验中就是测试工程)创建 NetworkObject 类(OC 类默认就是全局符号,反向依赖后,可供外部使用) - -第二步:在 framework 的 HEADER_SEARCH_PATHS 中增加 App 的头文件查找路径 - -第三步:framework 中增加实现代码,使用 App 中的符号 - - - - - -发现编译报错?符号找不到 - -- 链接的时候会去找符号的地址 -- 但 App 和动态库的符号原则是,链接成功,App 运行起来,动态库自然可以访问到 App 中的符号 - -所以,如何链接成功?如何处理这个未定义的符号? - -LD 链接器支持符号的处理。 - -方法一:**`-Xlinker -undefined -Xlinker dynamic_lookup` 修改动态库 xcconfig 添加未定义的符号为动态查找。但风险较大,所有未定义的都不会报错误伤较大**(比如随便敲的符号,也检测不出问题) - -```shell -OTHER_LDFLAGS = $(inherited) -framework "AFNetworking" -Xlinker -undefined -Xlinker dynamic_lookup -``` - -```mermaid -graph LR - A[链接器 ld] --> B{遇到未定义符号} - B -->|默认行为| C[立即报错] - B -->|dynamic_lookup| D[延迟到运行时] - D --> E[dyld 动态解析] - E -->|成功| F[正常执行] - E -->|失败| G[运行时崩溃] -``` - - - -方法二:**`-Xlinker -U -Xlinker _OBJC_CLASS_$_NetworkObject` 只对特定的未定义的符号采用动态查找** - -```shell -OTHER_LDFLAGS = $(inherited) -framework "AFNetworking" -Xlinker -U -Xlinker _OBJC_CLASS_$_NetworkObject -``` - - - -### 2. 动静 - -模拟:App -> NetworkManager 动态库 -> AFNetworking 静态库。将上面的工程中,NetworkManager 中的 podfile `# use_frameworks!` 注释掉,就会以静态库的形式链接 AFNetworking。 - -动态库依赖静态库(动态库作为宿主),则会把静态库中所有导出符号链接到动态库中。同时动态库的符号都被保留了(静态库被引用到的符号 + 动态库的符号,存放于动态库的导出符号表中)所以工程可以正常编译、链接。 - -如何在 App 中引入静态库 AFNetworking 中的符号?因为链接后,动态库中已经包含了静态库中的符号,所以只需要让 Xcode 编译通过即可。符号查找无需关心。 - -打开 NetworkManager 动态库的 Mach-O 查看下符号,可以看到动态库 NetworkManager 中已经包含了静态库 AFNetworking 的符号。 - - - -如何成功编译?告诉链接器 HEADER_SEARCH_PATH 信息即可。 - - - - - -思考:动态库链接静态库后,静态库中暴露的导出符号,在动态库中也是导出符号。假设动态库不想把静态库的符号暴露出来,该怎么做? - -LD 链接器提供了能力,将静态库的符号不暴露出来。指令为: **-Xlinker -hidden-l"AFNetworking"** - -```shell -OTHER_LDFLAGS = $(inherited) -ObjC -Xlinker -hidden-l"AFNetworking" -``` - - - -### 3. 静静 - -模拟:App -> NetworkManager 静态库 -> AFNetworking 静态库 - -- 将上面的工程中,NetworkManager 中的 `Build Settings` -> `Mach-OType` 改为 `Static Library` -- 将 Podfile 中 `use_frameworks!` 注释掉,就会以静态库的形式链接 AF - -之后编译报错 - - - -静态库链接的本质是:链接器只提取被直接引用的目标文件。此时 NetworkManager 静态库中只有自己的符号,没有所依赖的 AFNetworking 中的符号。 - -App 链接 NetworkManager 静态库,需要告诉链接器:头文件、库名称、库所在位置。 - -但 NetworkManager 静态库链接 AFNetworking 静态库,没有配置信息告诉 App。所以需要额外配置。App 直接依赖的静态库,静态库所依赖的静态库没有对 App 可见。 - -方法一:Build Settings -> 查找 Other Linker Flags,添加 `-lAFNetworking` 指明链接哪个库;查找 Libarary Search Path,设置库的查找路径 `${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/AFNetworking` - - - - - -方法二:直接给 App install 静态库。Cocoapods 处理这些依赖关系。 - -具体代码见:`LDAndFramework/StaticLibUseStaticLib` - - - -### 4. 静动 - -模拟:App -> NetworkManager 静态库 -> AFNetworking 动态库。效果等价于:App + 动态库 - -- Xcode 中将 NetworkManager 项目的 Build Settings 中的 Mach-O Type 设置为 `Static Library` -- Podfile 中将 `use_frameworks!` 注释打开 - -编译报错,符号未定义 。 - - - -App 调用静态库,静态库中被 App 引用的符号最终会被链接到 App 里。所以问题演变为:App 如何使用动态库里面的符号? - -上面代码运行,NetworkManager 静态库调用 AFNetworking 动态库的能力,就相当于 App 直接访问 AFNetworking 动态库的符号一样。 - -App 没有配置关于 AFNetworking 的链接三要素,所以找不到 AFNetworking。 - -- AutoLink:当在代码中 `import ` 会自动在目标文件的 Mach-O 中 `OTHER_LDFLAGS` 拼接 `-framework` 参数 -- 所以只需要告诉 App framework search path 即可。参考debug.xcconfig 中的 `FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking"`,展开为 `"${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/AFNetworking"`。将其设置到 Build Settings 的 framework search path 中。 - -修改后编译没问题,运行报错 `image not found`,因为编译后的 AFNetworking 没有拷贝到 App 所需目录下。(通过配置,链接器会在 `build/Debug-iphoneos/AFNetworking` 的位置查找 AFNetworking,目前找不到) - - - -怎么处理? - -- 参考其他使用 AFNetworking 的项目,Cocoapods 工程化模版写好了脚本。 - - 工程根目录下新建 `Scripts` 文件夹 - - 直接拷贝一份到 `Scripts` 目录下,重命名为 `handle-frameworks.sh` - -- Xcode Targets 选择 “NetworkManagerTests” -> Build Phases,添加 “Run script”,内容为 `"${SRCROOT}/Scripts/handle-frameworks.sh"` - -运行成功。同时查看测试过程的 Product 里 framework 目录下确实存在了 AFNetworking - - - -具体代码见:`LDAndFramework/StaticLibUseDynamicLib` - -### 5. 同时依赖静态库、动态库 - -```shell -platform :ios, '14.1' - -target 'InstallDyanmicAndStaticFramework' do - use_frameworks! - $static_framework = ['AFNetworking'] - - pre_install do |install| - puts install - install.pod_targets.each {| pod | - if $static_framework.include?(pod.name) - def pod.build_type; - Pod::BuildType.static_framework # 使用静态库 - end - end - } - end - - # Pods for InstallDyanmicAndStaticFramework - pod 'SDWebImage' - pod 'AFNetworking' -end -``` - -代码见:`LDAndFramework/InstallDyanmicAndStaticFramework` - - - - -## 十一、Module - -### 1. 定义 - -在 iOS/macOS 开发领域的编译链接体系中,Module 是一项由 Clang/LLVM 提供的**语义化头文件封装**技术,其核心目标是**通过强隔离的预编译单元解决传统 `#include` 机制的头文件污染与低效问题** - -编译器实现: - -```mermaid -graph LR - A[Module] --> B[Clang Module] - A --> C[Swift Module] - B --> D[.modulemap 描述文件] - B --> E[.pcm 预编译二进制] - C --> F[.swiftmodule 接口文件] -``` - -特征: - -- **原子性**:头文件集合的编译边界(不可部分导入) -- **自包含性**:显式声明导出符号(`export`)与依赖(`requires`) -- **隔离性**:宏定义(`#define`)不泄漏到导入方 -- **预编译能力**:可序列化为 `.pcm`(Precompiled Module)加速编译 - - - -一个 Module 是机器代码和数据的最小单位,可以独立于其他代码单元进行链接。 - -通常,Module 是通过编译单个源文件生成的目标文件。例如,当前的 `test.m` 被编译成目标文件 `test.o` 时,当前的目标文件就代表了一个 Module 。 - -但是,Module 在调用的时候会产生**开销**,比如我们在使用一个**静态库**的时候。 - - - -### 2. .pcm 文件 - -#### 1. 结构 - -Module 生成的 `.pcm`(Precompiled Module)文件是 Clang 对 C++ Module 的**预编译二进制表示**,其内容远非简单的头文件文本封装,而是包含编译器所需的完整语义信息。 - -`.pcm` 文件包含: - -- 模块元数据 - - - **模块标识**:模块名、导出符号列表(`export` 声明的函数/类) - - - **编译环境指纹**: - - ```shell - Target: x86_64-apple-darwin22.4.0 - SDK: MacOSX13.3.sdk - Language: C++17 - ``` - - - **时间戳与版本号**:确保与源码版本一致 - -- AST 抽象语法树:**完整语法树序列化**:将头文件解析后的 AST 以二进制形式存储,通常占 `.pcm` 文件的 **60-70%**(主要膨胀源) - -- 语义信息: - - - 符号可见性 - - 内联函数代码生成 - - ... - -- 依赖关系图 - - - **显式依赖**:通过 `import` 直接导入的其他模块(如 `import std.core;`) - - **隐式依赖**:递归包含的所有头文件(如 `` 依赖 ``) - - **依赖树序列化**:以邻接表存储,支持快速增量编译检查 - -- 编译器配置快照 - - - **编译标志**:`-D` 宏定义、`-I` 包含路径、`-std=c++20` 等 - - **ABI 配置**:`-mabi=sysv`、`-fPIC` 等影响二进制兼容性的参数 - - **诊断设置**:`-Werror`、`-fno-exceptions` 等策略 - - - -#### 2. 技术特性 - -- 平台强耦合性:`.pcm` 文件与以下环境绑定,**不可跨平台/编译器/配置复用**: - - ```shell - // 不同配置生成不同 .pcm - -arch arm64 ≠ -arch x86_64 - -O2 ≠ -O0 - -DDEBUG ≠ -DRELEASE - ``` - -- 内容敏感性 - - - **头文件内容哈希**:即使注释修改也会使 `.pcm` 失效(因 AST 变化) - - **编译参数敏感性**:`-I` 路径顺序变化即触发重新生成 - -- 反序列化开销:加载 `.pcm` 本质是 AST 的反序列化过程 - - - - - -### 3. 场景 - -说人话,平时什么情况下使用的最多?导入库文件的时候,就是 module 的主战场。 - -当在 App 里导入一个库的时候,发生了什么事情?`.h` 文件也是需要编译的(里面也会承载一些方法信息)。 - -先谈谈导入方式,导入方式有2种: - -- `include`: - - 假设没有开启 module 能力,当使用 `#include "*.h"` 的时候, 头文件 A、B 在 `use.c ` `another-use.c` 中,include 几次就要编译几次。 - - - - 当编译 `use.c` 的时候,就要编译 include 进来的 `A.h`、`B.h`。编译 `another-use.c` 同样要再编译一次 `A.h`、`B.h`。被 include 了几次,就要编译几次,重复编译,效率低下。 - - - `#include` 是纯粹的文本替换指令 - - 预处理器将头文件内容原样复制到包含位置 - - 每个 `.c` 文件都获得头文件的完整副本 - -- `import`:与 `include` 相对应,`import ` 语句用于导入模块,而不是简单的文本包含。使用模块可以减少编译时间,因为编译器只需要编译模块的接口而不是整个模块的实现 - - - -**`clang -fmodules -fmodule-map-file=mo dule.modulemap -fmodules-cache-path=./moduleCache -c use.c -o use.o` **: - -- `-fmodules` 用于告诉 clang 启用 module 编译 -- 编译后 module 缓存保存到 `-fmodules-cache-path` 后面的路径中可以看到 A、B2个头文件,编译缓存也存在2个,分别以 A、B 开头 -- `-fmodule-map-file` 指明 modulemap 文件路径 - -module 是 clang 提供的能力。可以把头文件编译成二进制文件,缓存到系统目录中。这样的好处是,在使用某个 `.h` 的多个 `.m` 中,就不会因为多处引入 `.h` 而编译多次 `.h` - -当开启 module 能力后,下面3种写法都会被转换为 moudle 的写法 - -```c -#import -#include "AFNetworking/AFNetworking.h" -@import AFNetworking.AFNetworking; -// 转换为 -@import AFNetworking.AFNetworking; -``` - - - -### 4. modulemap 编写规范 - -- 开启 module 能力:Xcode 默认开启了 module 能力,开启或者关闭步骤:选择项目中的 target,进入 Build Settings 页面,搜索 “Enable Modules (C and Objective-C)”,将其设置为 NO,即可关闭 Module -- 配置 Module Map File 路径:Xcode -> Build Settings -> Module Map File 中配置 moduleMapFile 路径。 - - - -#### case 1:上例中的 modulemap - -```shell -/* module.modulemap */ -module A { // 定义了一个名字叫 A 的模块 - header "A.h" //模块 A 的公共头文件为 A.h。这意味着任何想要使用模块 A 中定义的类或函数的代码,都需要导入 A.h 文件 -} - -module B { // 定义了一个名为 B 的模块。。 - header "B.h" // 模块 B 的公共头文件是 B.h - export A // 模块 B 向外暴露模块 A。这意味着任何导入模块 B 的代码,也可以使用模块 A 中定义的类或函数。export A 将模块 A 作为模块 B 的一部分公开,以便在使用模块 B 时,可以隐式使用模块 A 中的内容。 -} -``` - -假设存在一个 c.h 的文件,需求是想使用模块 A 和模块 B 中定义的内容。 - -方法一:直接 import 模块对应的头文件 - -```objective-c -#import "A.h" -#import "B.h" -``` - -方法二:因为模块 B 已经暴露了模块 A,所以导入 B 就可以使用模块 A 中定义的类和函数 - -```objective-c -#import "B.h" -``` - - - -#### case2:AFNetworking Demo 中的 modulemap - -AFNetworking 的 [Framework/module.modulemap](https://github.com/AFNetworking/AFNetworking/blob/master/Framework/module.modulemap) - -```json -framework module AFNetworking { // 定义一个名为 AFNetworking 的框架模块 - umbrella header "AFNetworking.h" // 指定了框架的伞头文件,这个文件是包含了所有公共头文件的文件,方便外部调用 - export * // 表示框架中的所有公共接口(类、结构体、枚举、协议等)都被导出, - module * { export * } // 意味着框架内部的所有子模块(即 AFNetworking.h 中头文件代表的子模块都会被匹配到),子模块的名称就是 .h 文件的名称,都将被导出,可以被外部所访问 -} -``` - - - -#### case3:AsyncDisplayKit 的 modulemap - -```json -framework module AsyncDisplayKit { // 定义了一个名为 AsyncDisplayKit 的框架模块 - umbrella header "AsyncDisplayKit.h" // 指定了框架的伞头文件,这个文件是包含所有公共头文件的文件,方便外部调用 - - export * // 表示框架中所有公共借口(类、结构体、枚举、协议等)都被导出,可以被外部代码访问 - module * { // 意味着框架内部的所有子模块(即 AsyncDisplayKit.h 中头文件代表的子模块都会被匹配到),子模块的名称就是 .h 文件的名称,都将被导出,可以被外部所访问 - export * - } - - explicit module ASControlNode_Subclasses { // 如果需要显示指名,则必须使用 explicit。本行定义了一个名为 ASControlNode_Subclasses 的子模块,它包含了与 ASControlNode 相关的子类 - header "ASControlNode+Subclasses.h" // 指定了子模块的头文件,这个头文件包含了该子模块中所有需要对外暴露的头文件的定义 - export * // 将 ASControlNode+Subclasses.h 中的 .h 头文件,名称不变,导出出去 - } - - explicit module ASDisplayNode_Subclasses { // 定义了一个名为 ASDisplayNode_Subclasses 的子模块,它包含了所有与 ASDisplayNode 相关的子类 - header "ASDisplayNode+Subclasses.h" // 指定了子模块的头文件,这个头文件包含了子类的定义 - export * - } - -} -``` - -看一个例子,AsyncDisplayKit 中 `umbrella header` 伞头文件,即 `AsyncDisplayKit.h` 的内容 - -```c++ -#import -#import -#import -#import - -#import -#import -#import -#import -#import -#import -#import -#import - -#import -#import -#import -#import -#import -#import - -#import -#import -#import -#import -#import -#import -#import -#import - -#import -#import -#import -#import - -#import -#import - -#if AS_IG_LIST_KIT -#import -#import -#endif - -#import - -#import -#import - -#import -#import -#import -#import -#import - -#import - -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -#import -#import -#import -#import -#import -#import -#import -#import - -#import -#import - -#import -``` - -使用:子模块可以通过大模块访问到,比如 `@AsyncDisplayKit.ASEventLog;` - -注意: umbrella 有2种用法: - -- umbrella header "Header.h":用于指定一个头文件作为模块的伞头文件,该头文件会包含模块中所有需要暴露的头文件。 - -- umbrella "文件夹目录":用于将指定目录中的所有头文件都包含在模块中,这种方式适用于目录中有很多头文件需要暴露的情况。简化了模块中多个头文件的管理和导入,不需要在 `modulemap` 文件中逐个列出文件名,提高了开发的灵活性和效率。 - - - -更多关于 Module 的信息,可以查看 [Clang::Modules](https://clang.llvm.org/docs/Modules.html) - - - -### 5. 实战:Framework 使用 modulemap - -第一步:创建动态库 `PersonFramework`,创建一个 iOS App Demo 工程。 - -第二步:给动态库添加 `PersonFramework.modulemap` 文件 - -第三步:给主工程、动态库设置在同一个 Xcode 项目中编译调试。 - - - - - -结论: - -- 工程中 `PersonFramework.modulemap` 命名的 modulemap,在编译后被 Xcode 重命名为 `module.modulemap`,系统只认这个 - -- `.modulemap` 文件有多种写法,就拿上面的举例,存在3种写法: - - - 写法1 - - ```json - framework module PersonFramework { - // umbrella 不加 Header,则说明后面要跟文件夹路径。 - umbrella "Headers" - export * - module * { - export * - } - } - ``` - - `nubrella` 后不加 Header,说明跟得是文件夹,文件夹中里面的内容和 `Build Phases` 中 `Header` public 部分的头文件描述的一致。会将 public 的头文件,最后放到 `Headers` 文件夹中。 - - - 写法2 - - ```json - framework module PersonFramework { - umbrella header "PersonFramework.h" - export * - module * { - export * - } - } - ``` - - `umbrella header + 伞头文件` 意味着伞头文件里所有的 `.h` 将会被放到 `Headers` 文件夹中。且给外部访问 - - - 写法3 - - ```json - framework module PersonFramework { - umbrella header "PersonFramework.h" - explicit module Worker { - header "Worker.h" - export * - } - explicit module Student { - header "Student.h" - export * - } - } - ``` - - 写法3是将需要暴露的头文件,挨个显示声明子模块,指明头文件,然后导出。 - - - -### 6. Swift Framework modulemap - -背景:探索 Swift Framework 中,没有桥接文件,Swift 如何访问 OC?如何处理 modulemap 导出文件? - -问题1:Swift Framework 中 Swift 里如何访问 OC? - -利用 modulemap 解决。 - - - -但是,上面的做法有副作用。虽然 Framework 内部 Swift 可以访问 OC 了,但是使用 Framework 的地方,也可以访问到 OCStudent 类。如何解决该问题? - - - - - -如何实现只在 Swift Framework 内 Swift 代码可以访问 OC 类,在使用 framework 的地方,访问不到 oc 类? - -module 提供 private modle 的能力。 - - - -说明: - -- **private module 文件必须命名为 `PersonSwiftFramework.private.modulemap` 的 `private.modulemap` 格式** - -- **`private.modulemap` 模块名必须为 `PersonSwiftFramework_Private`** - - ```json - /* module.modulemap */ - framework module PersonSwiftFramework_Private { - module OCStudent { - header "OCStudent.h" - export * - } - } - ``` - - 文件内容是不希望通过正常预设模块暴露出去的子模块。 - - 什么是正常预设模块?比如 `PersonSwiftFramework` 是正常预设模块,通过 `@import PersonSwiftFramework.` 访问均符合预期。 - -- private module 是规范,但是还是可以在使用 framework 的地方通过 `@import PersonSwiftFramework_Private.OCStudent;`访问到的 - -具体代码见: `LDAndFramework/module-collections/ModulePractice` - - - -### 7. Swift module - Swift 静态库的合并 - -#### 1. Swift module 概念 - -Xcode 9 之后,Swift 开始支持静态库。 - -Swift 没有头文件的概念,Swift 要用 public 修饰的类和函数怎么办? - -Swift 库引入了一个全新的文件 `.swiftmodule`,其包含序列化过的 AST,也包含 SIL(Swift Intermediate Language,Swift 中间语言) - -#### 2. 实战:Swift 静态库合并 - -第一步:准备2个 Swift 静态库。其中 FLSwiftWorker 2个类完全一样,FLSwiftA、FLSwiftB 同名方法,方法实现不一样。 - -编写脚本: `cp -Rv -- "${BUILT_PRODUCTS_DIR}/" "${SOURCE_ROOT}/../Products"`,把产物拷贝到 products 目录下 - - - -第二步,打算用 libtool 指令 `libtool -static FLSwiftLibA.framework/FLSwiftLibA FLSwiftLibB.framework/FLSwiftLibB -o libFLSwiftC.a` 进行合并,发现报错 - - - -因为存在同名符号,但是又不存在2级命名空间,所以如何处理符号问题?? - -需要找 Headers 头文件信息和 Modules 文件夹里面的信息。 - -```shell -. -├── FLSwiftLibA -├── Headers -│   ├── FLSwiftLibA-Swift.h -│   └── FLSwiftLibA.h -├── Info.plist -├── Modules -│   ├── FLSwiftLibA.swiftmodule -│   │   ├── Project -│   │   ├── x86_64-apple-ios-simulator.abi.json -│   │   ├── x86_64-apple-ios-simulator.swiftdoc -│   │   └── x86_64-apple-ios-simulator.swiftmodule -│   └── module.modulemap -└── _CodeSignature - ├── CodeDirectory - ├── CodeRequirements - ├── CodeRequirements-1 - ├── CodeResources - └── CodeSignature -``` - -第三步,新建一个 iOS App 工程,引入合并后的静态库 libSwiftC.a 然后一步步解决符号冲突问题 - -- 把静态库拖入到工程中 - -- 新建 xcconfig 文件,配置静态库的头文件等信息 - -- 编辑 xcconfig 配置头文件信息,不然编译会报错(引入了头文件)。 - - ```shell - // 头文件信息 - HEADER_SEARCH_PATHS = $(inherited) "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibA.framework/Headers" "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibB.framework/Headers" - ``` - - **为什么 App 工程中的 OC 类中导入头文件编译成功,但还是无法访问里面的类?** - - - - 同样,需要一个新的参数,告诉 LD modulemap 的信息,然后系统根据 module.modulemap 去关联查找 `FLSwiftLibA.swiftmodule` 里面的 `x86_64-apple-ios-simulator.swiftmodule` swiftmodule 文件信息。 - - 这个时候就可以在 App oc 代码中访问静态库里 Swift 类了。 - - - - **为什么 App 工程中的 Swift 类中导入头文件报错?** - - 因为上面的配置是 Swift 编译后产生的 modulemap 和 swiftmodule 是配置 `OTHER_CFLAGS`,其实就是配置 c、oc 编译器也就是 clang 的关于 swift 的信息。 - - 而 Swift 编译器是 swiftc,需要额外配置。`SWIFT_INCLUDE_PATHS = "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibA.framework/Modules/" "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibB.framework/Modules/"` - - - - 但是配置后还是报错,因为看上去文件路径是对的,Framework 生成的时候,就是这么划分文件夹的。但实际编译的时候 Headers和 `module.modulemap` 、`.swiftmodule` 文件夹在同一层目录。所以我们手动编译静态库之后,需要处理静态库产物的文件目录信息。 - - - - - -#### 3. 为什么 Swift 静态库在链接的时候需要配置2个信息? - -- Header Search Path -- Swift module 相关信息 - -**Swift 静态库编译后确实会生成两个核心目录:`Headers` (包含 Objective-C/C 头文件) 和 `.swiftmodule` (包含 Swift 的模块接口信息)。** 要让使用方(宿主 App 或其他模块)正确链接和使用这个静态库,**必须同时处理好这两个部分的路径配置** - -- 配置 Header Search Path:告诉编译器(主要是 Clang 前端,处理 Objective-C/C 和 Swift 与 C 的交互)在哪里可以找到静态库暴露的 **Objective-C/C 头文件** (通常位于 `Headers/` 目录下) -- 配置 Swift Module 信息: - - -fmodule-map-file: `OTHER_CFLAGS = "-fmodule-map-file={path}"` - - Swift Import Paths:对应 xcconfig 文件中,就是 SWIFT_INCLUDE_PATHS = {path} - - - -### 8. 静态库使用 Module 带来的问题 - -#### 1. 存储与 I/O 开销显著增加 - -- **`.pcm` 文件体积膨胀** - - Module 预编译生成的 `.pcm` 文件包含头文件的完整 AST(抽象语法树)及依赖元数据,其体积通常达原始头文件的 **30–40 倍**(例如 `` 头文件 20 KB → `.pcm` 800 KB)。大型项目累积 `.pcm` 可达 GB 级(如 LLVM 编译超 10 GB),加剧存储压力与 I/O 延迟(机械硬盘随机读写性能骤降)。 - -- 缓存管理复杂度上升 - - 需维护全局 `.pcm` 缓存路径(`-fmodules-cache-path`),多版本编译环境易因缓存失效导致重复生成,消耗额外磁盘与计算资源 - -#### 2. 编译效率降低 - - - -## 十二、hmap文件与 Header Maps - -### 1. 头文件导入技术发展历史 - -#### 1. 头文件查找流程 - -引入头文件的方式: - -- 路径:头文件所在目录 + `具体文件.h` -- module:一堆 `.h` 放到 module,采用预编译的方式,产出二进制,节省编译时间。比如 `#import ` - -思考:平时 Xcode 写代码的时候 `#import "ViewController.h"` 背后是怎么工作的?是如何找到具体的文件内容的 - -##### 阶段1:基础路径搜索 - -1. **本地目录优先搜索**: - - 当你使用双引号 `"ViewController.h"` 进行导入时,编译器会首先在当前源文件所在的本地目录(即当前 `.m` 或 `.mm` 文件所在的目录)查找头文件 - - 示例:`AppDelegate.m` 中导入 `ViewController.h` 时,优先在 `AppDelegate.m` 同级目录查找 -2. **递归子目录搜索** - - 如果在当前目录找不到 `ViewController.h`,编译器会自动递归地搜索所有子目录 - - 搜索深度由 Xcode 设置决定(默认无限制) - - 可通过 `-I-` 编译选项禁用递归 - -##### 阶段2:工程配置路径搜索 - -3. **Header Search Paths 遍历** - - 如果本地目录中没有找到文件,编译器会根据 Xcode 项目的设置中的 "Header Search Paths" 来确定接下来搜索的目录。这些路径通常包括: - - - 项目的其他部分,如其他目标的目录 - - 项目依赖的库或框架的路径 - - 用户或系统级别的额外头文件目录 - - 路径类型: - - - **相对路径**:在 "Header Search Paths" 中设置的路径可以是相对路径,Xcode 会将其相对于项目文件(`.xcodeproj`)的位置来解析 - - **绝对路径**:也可以使用绝对路径指定头文件的位置 - - **环境变量**:有时 Header Search Paths 会包含环境变量,这些变量在编译时会被系统的实际路径替换。 - -4. **Framework Search Paths 遍历** - - 如果 `ViewController.h` 是某个框架或库的一部分,编译器还会在 "Framework Search Paths" 中指定的目录下查找。 - -##### 阶段3:高级优化机制 - -5. **Header Maps(hmap) 加速** - - 为了提高查找效率,项目可能使用 Header Maps。这些是预先生成的文件,列出了目录中所有头文件的索引,帮助编译器快速定位。 - -6. **编译器缓存优化** - - 编译器可能会缓存头文件的查找结果,以避免在后续编译中重复搜索 - -##### 阶段4:错误处理机制 - -7. **文件未找到处理** - - 如果编译器在所有指定的搜索路径中都没有找到 `ViewController.h`,它会报错指出找不到文件。 - - ```objective-c - fatal error: 'ViewController.h' file not found - #import "ViewController.h" - ^~~~~~~~~~~~~~~~~~ - 1 error generated. - ``` - - - - - -#### 2. 早期的 import - -最早期是 include,再到后来的 import,区别是什么? - -- `include`: 是基础引入,编译过程中会被直接展开,其内容会插入到 `#include` 指令的位置。将 `目标.h` 文件中的内容一字不落地拷贝到当前文件中,并替换掉这句 `#include` -- `import`: 只引入一次。用于导入模块化的头文件,它遵循模块化的结构,可以提供更好的封装性。会加入缓存,判断 import 的内容之前已经引入过则不再引入。 - -这段缓存相关逻辑,可以在 [LLVM:HeaderSearch.cpp](https://github.com/llvm/llvm-project/blob/main/clang/lib/Lex/HeaderSearch.cpp) 中查看 - -```c++ -// Cache all of the lookups performed by this method. Many headers are -// multiply included, and the "pragma once" optimization prevents them from -// being relex/pp'd, but they would still have to search through a -// (potentially huge) series of SearchDirs to find it. -LookupFileCacheInfo &CacheLookup = LookupFileCache[Filename]; -``` - -import 举个例子吧。在 `Person.m` - -```objective-c -#import -@implementation Person -- (void)eat { - NSLog(@"Person eat"); -} -@end -``` - -`DynamicLibA.h` 内容如下 - -```objective-c -#import -#import -#import -``` - -在找到上面的内容后,编译器将其复制粘贴到 `Person.m` 中 - -```objective-c -#import -#import -#import -@implementation Person -- (void)eat { - NSLog(@"Person eat"); -} -@end -``` - -编译器发现存在3个 import,则继续查找其内容 - -```objective-c -// Student.h -@interface Student: NSObject -- (void)study; -@end -``` - -编译器会把其内容复制到 `Person.m` 中。 - -```objective-c -@interface Student: NSObject -- (void)study; -@end - -#import -#import -@implementation Person -- (void)eat { - NSLog(@"Person eat"); -} -@end -``` - -这样的步骤,直到整个文件(Perosn.m)中的所有 import 被替换掉。同时 `.m` 可能会变得非常长。 - -存在2个问题:健壮性、拓展性。 - -我们大多数情况下,经常需要引入一些头文件来助力实现某些功能,假设某个类只有一个方法,方法本身10行。但是因为导入了某些头文件,最后这个文件按照上述 import 的查找替换过程,最后该文件可能存在10万行代码(import 了 Foundation、UIKit、MapKit 等库)。太浪费、太不合理了 - - - -#### 3. PCH(PreCompiled Header) - -为了优化前面提到的问题,一种折中的技术方案诞生了,它就是 `PreCompiled Header`。早期做 iOS 开发的都看过 pch 文件。 - -日常开发中,我们经常可以看到某些组件的头文件会频繁的出现,例如 UIKit,而这很容易让人联想到一个优化点,我们是不是可以通过某种手段,避免重复编译相同的内容呢? - -而这就是 PCH 为预编译流程带来的改进点。大体原理就是,在我们编译任意 `.m` 文件前, 编译器会先对 `.pch` 里的内容进行预编译,将其变为一种二进制的中间格式缓存起来,便于后续的使用。当开始编译 `.m` 文件时,如果需要 PCH 里已经编译过的内容,直接读取即可,无须再次编译。 - -虽然这种技术有一定的优势,但实际应用起来,还存在不少的问题。 - -首先,它的维护是有一定的成本的: - -- 对于大部分历史包袱沉重的组件来说,将项目中的引用关系梳理清楚就十分麻烦。因此不知道需要将哪些头文件放到 `.pch` 文件中 -- 随着版本的不断迭代,哪些头文件需要移出 PCH,哪些头文件需要移进 PCH 将会变得越来越麻烦 - - - -#### 4. clang module - -为了解决上面的问题,Clang Module 技术诞生了。 - -一个 Module 是机器代码和数据的最小单位,可以独立于其他代码单元进行链接。 - -通常,Module 是通过编译单个源文件生成的目标文件。例如,当前的 `test.m` 被编译成目标文件 `test.o` 时,当前的目标文件就代表了一个 Module 。 - -在实际编译之时,编译器会创建一个全新的目录,用它来存放已经编译过的 Module 产物。如果在编译的文件中引用到某个 Module 的话,系统将优先在这个列表内查找是否存在对应的中间产物,如果能找到,则说明该文件已经被编译过,则直接使用该中间产物,如果没找到,则把引用到的头文件进行编译,并将产物添加到相应的空间中以备重复使用。 - -相关的一些使用,可以查看上面的 section。 - -还是存在问题,因此诞生了 Use Header Maps 技术 - - - -### 2. 诞生背景 - -但存在一个问题,如果同时存在多个 `header search path` 的时候,假设一个工作项目有10000个文件,一个类使用了10个头文件,要去10000个文件中分别查找10个头文件具体位置,这个查找过程是发生在编译阶段的。无疑会增加编译耗时。 - -基于此,诞生了 Header Maps 技术,通过 hmap 文件,让 Xcode 在查找头文件的时候更快速。 - -编译器 -I 参数可以跟 Header Search Paths 也可以跟 hmap 文件路径。 - - - -QA:Xcode 自己会生成 hmap 文件,为什么我们还需要自己生成? - -- **Xcode默认行为**:虽然 Xcode 可以自己生成 hmap 文件。首次编译时动态生成hmap(耗时),后续编译复用。但清理工程后需重新生成。 -- **优化策略**:通过 CocoaPods 插件或自定义脚本在 pod install 阶段提前生成好 hmap 文件,避免动态生成的开销。工程化阶段,修改 xcconfig 文件,给编译器 `-I` 参数提供 hmap 文件路径。来享受编译加速带来的红利。 - - - -### 3. hmap 结构 - -创建一个 iOS App,默认模版的基础上添加一个 Person 类,一个继承自 Person 的 Student 类。然后编译 - - - - - -从 Xcode Compile log 可以看到通过 `-I` 参数来指定 hmap 文件。复制目录路径查看所有的 hmap 文件,可以看到, - -**iOS hmap 文件会根据文件分类和使用方式主要与项目结构和编译需求有关。可以分为项目级别 Header Maps、组件级别 Header Maps、公共与私有 Header Maps等等**。 - - - -`hmap` 文件不可读,必须用对应的工具解析,按照指定的格式解析。因为属于编译器的 scope,所以查看 LLVM 源码窥探下。 - - - -可以看到 hmap 结构类似 Mach-O ,顶部 HMapHeader 告诉系统,当前有几个 Bucket,下面是 Bucket 信息。然后最下方是 string 区域。 - -在 HMapBucket 中: - -- Key: 一个 `uint32_t` 类型的成员,表示 bucket 中键的哈希值或者是一个特殊值表示 bucket 的状态(例如,是否为空或者是一个冲突的bucket) - -- prefix: 一个 `uint32_t` 类型的成员,通常用于存储键的前缀偏移量,它与 `Suffix` 一起定义了键在整个 Header Map 中的完整字符串 -- Suffix: 一个 `uint32_t` 类型的成员,用于存储键的后缀长度,与 `Prefix` 结合使用可以定位到键的具体字符串 - - - -### 4. why hmap? - -不就是1个文件依赖4个头文件吗?为什么设计成 hMap 这么复杂的结构?要表示路径的话,可以有很多种方式,比如最基础的 Map。hMap 有啥优点? - -```json -{ - "ViewController.m": ['a.h', 'b.h', 'c.h', 'd.h'] -} -``` - -#### 1. 文本映射方案的致命缺陷 - -内存占用对比(10,000 头文件场景) - -| 方案 | 存储量 | 内存占用 | 问题 | -| :-------------- | :-------- | :------- | :---------------- | -| 文本映射 (JSON) | 约 5 MB | 50+ MB | 解析耗时 + 碎片化 | -| HMap 二进制 | 约 800 KB | 2 MB | **直接内存映射** | - -查找性能对比(单次查找) - -| 操作 | JSON 方案 | HMap 方案 | -| :------- | :----------------- | :---------------------- | -| 加载文件 | 5 ms (读5MB) | 0.01 ms (mmap) | -| 解析数据 | 15 ms (JSON parse) | **0 ms** (直接访问) | -| 查找键值 | 0.5 ms (遍历) | **0.001 ms** (哈希计算) | -| 总耗时 | ~20 ms | **~0.01 ms** | - -> **2000倍差距**:当 ViewController.m 引入 4 个头文件时,仅查找就相差 **80ms vs 0.04ms** - -#### 2. HMap 的优点 - -##### 1. 零解析内存映射 - -```mermaid -graph LR - A[磁盘 HMap] --> |mmap 系统调用|B[虚拟内存] - B --> C[CPU 直接访问] -``` - -- **传统文本方案**:读取 → 解析 → 构建哈希表(三重开销) -- **HMap 方案**:操作系统自动映射,编译器直接访问。HMap 利用操作系统提供的 **内存映射文件 (mmap)** 技术,让编译器能够像访问内存一样直接操作 HMap 文件,无需传统的数据加载和解析过程。这是 HMap 高性能的核心秘密。 - -##### 2. 路径压缩艺术 - -假设 100 个头文件在相同目录: - -```shell -// 文本方案存储: -{"File1.h": "/path/to/project/Sources/File1.h", ...} // 重复100次"/path/to/project/Sources/" - -// HMap 存储: -前缀: "/path/to/project/Sources/" (存储1次) -后缀: "File1.h", "File2.h" ... (只存文件名) -``` - -有事:**空间节省**,目录路径节省 **99%** 存储。类似 Trie 树。 - -##### 3. CPU 缓存友好 - -- HMap 的桶数组是**连续内存块**(12字节/桶) -- 现代 CPU 缓存行(通常 64 字节)可一次加载 **5个桶** - - - -### 5. 编写工具分析 hmap 文件 - -#### 1. 读取 HMap 文件的代码 - -其结构、工作原理都类似 Mach-O 文件。读取思路为: - -``` -二进制数据 -sizeOf(HMapHeader) - -从二进制数据中读取出:header -----> 根据 Header 信息,按照 Bucket 格式,读取出 Bucket 信息 ------> 再根据 Bucket 的 Key、prefix、suffix 去 String Payload 区域读取数据 -``` - -参考 Mach-O 文件结构和 LLVM 源码中对于 HMap 的处理 [LLVM:HeaderMap.cpp](https://github.com/llvm/llvm-project/blob/main/clang/lib/Lex/HeaderMap.cpp) 的实现,编写一个读取 hmap 文件的代码。 - -具体可以运行的代码可以在 github 这个 Repo 中查看并运行 [BlogDemos:HMapDump](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapDump) - -运行调试的时候,把需要读取的 HMapFile 拷贝到项目根目录。然后 Edit Scheme - Run - Arguments Passed On Launch - -这里我把 [github Textture](https://github.com/texturegroup/texture/) 编译日志中的 hmap 拖到项目的根目录下了 - - - - - -#### 2. 变成终端可使用的能力 - -QA:如何做到该工具做到像系统自带的 `objdump` 一样,在终端命令行使用: - -- 在电脑根目录新建 `CustomTools` 文件夹 -- 将上面编译后的产物复制进去 -- 在 `.zshrc` 文件里将路径添加进去。`export PATH=~/CustomTools:$PATH` -- 编辑 `.zshrc` 文件后,在终端执行 `souce .zshrc` -- 即可使用,在终端输入: **hmapdump ${hmapFileFullPath}** - -效果如下: - - - - - - - -### 5. 编写工具生成 hmap 文件 - -#### 1. 为什么要生成 hmap 文件 - -如果2个 `.m` 文件有相同的头文件代码,造成编译浪费。 - - - -clang 可以使用 `-I` 来指定 Header Search Path 信息。`-I` 后面可以跟:`目录文件夹`、`.hmap` 文件 - -iOS 导入方式: - -- `import <> ` :本质上就是 LD 编译参数 `HEADERS_SEARCH_PATHS -I` -- `import ""` :本质上就是 LD 编译参数 `USER_HEADER_SEARCH_PATHS -iquote` - -Tips:Xcode 设置 Search Path 有三处 `Header Search Path`、`System Header Search Path`、`User Header Search Path`,区别是什么? - -- System Header Search Path 是针对系统头文件的设置,通常代指 `<>` 方式引入的文件 -- User Header Search Path 则是针对非系统头文件的设置,通常代指 `""` 方式引入的文件 -- Header Search Path 并不会有任何限制,它普适于任何方式的头文件引用 - - - -Xcode 会主动生成 `.hmap` 文件,那为什么还需要研究生成 `hmap` 文件? - -有必要。下面来个例子进行说明。虽然平时开发中经常以二进制组件的方式构建 App,但在某些场景下(精准测试、覆盖率统计等),打包构建还是需要以全源码编译的方式进行。而且在实际开发过程中,大多是以源码的方式进行开发,所以我们将实验对象设置为基于全源码编译的流程。 - - - -创建一个静态库 `HMapStaticLib` 里面就包含2个类文件 `Person` 类、继承自 `Person` 类的 `Student` 类。再创建一个使用 iOS App,使用该静态库。编译该 App,查看编译日志。 - -然后查看编译日志中的 ` HMapStaticLibApp-project-headers.hmap` 文件。利用上面制作的 `HMapDump` 工具。 - - - -分析:在 App 使用 Static Library 的情况下,假设开启了 `Use Header Map`,静态库中所有头文件类型为 `Project`(只有 Project、Private、Public 3种类型,public 就是字面意思的公开,private 则代表 In Progress, project 才是通常意义上的 Private 含义)的情况,最终生成的 `.hmap` 文件中只会包含类似 `#import "Student.h"` 的键值引用。也就是说使用的地方,只有 `#import "Student.h"` 的这种方式才会走 hmap 策略,否则还是走 `Header Search Path` 来寻找头文件路径。 - -组件、库使用 `#import ` 是访问的标准做法。好处有3点: -1. 明确头文件的由来,避免歧义 -2. 可以让我们在是否开启 clang module 中随意切换 -3. Apple 在 WWDC 里曾经不止一次建议开发者使用这种方式来引入头文件 - -所以可以回答上面的问题了。虽然一个静态库、动态库项目中,`Build Setting` 中虽然 `Use Header Maps` 为 YES,但是某些情况下 Header Maps 带来的加速福利没有享受到。 - - - - - -一个大型项目有300个 Pod,每个 Pod 有100个头文件,也就是共 30000个头文件,在30000个头文件中,如果没有享受 `Header Maps` 带来的福利,老老实实依靠 `Header Search Path` 中提供的头文件所在的文件夹信息查找,或者递归查找。可能存在 n*m 的循环查找过程,效率很低,涉及的 IO 影响大型项目的编译构建时间,影响分发和发生问题时候的及时热修复。 - -既然知道某些情况下会存在 hmap 文件没有命中的情况,那有必要修改吗?让静态库的情况,也可以使用 hmap 带来的编译红利。 - - - -#### 2. 如何生成 HMap 文件 -LLVM 真是好东西,把 Xcode 编译、链接等一些幕后的事情变成白盒,有迹可循,感兴趣的可以去查看源码并带着一些关键词来搜索源码进行查看,可能会发现一些平时了解不到的细节。 - -可以查看 [HeaderMapTest.cpp](https://github.com/llvm/llvm-project/blob/main/clang/unittests/Lex/HeaderMapTest.cpp) 和 [HeaderMapTestUtils.h](https://github.com/llvm/llvm-project/blob/main/clang/unittests/Lex/HeaderMapTestUtils.h) 编写 hmap 的生成代码。编写后的具体代码可以查看 [BlogDemos:HMapWritor](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapWritor) - -为了方便平时使用,按照上面的方式,也将二进制放到 `/Users/unix_kernel/CustomTools` 目录下,同时设置 `.zshrc` 可访问性。 - - - -### 6. hmap 助力提升 iOS 项目编译速度 - -本节讨论:**如何为静态库自定义 hmap 开启编译加速**? - -为了方便观察,开启2组对照实验。 - -#### 1. 实验一:静态库 + Header Search Paths + App 编译 - -##### 1. 静态库设置 - -- 第一步:Xcode 新建项目,选择 **Static Library **。为了方便对照,命名为:**HMapStaticLib-HeaderSearchPath** -- 第二步:根目录下创建 **Sources** 文件夹,多创建几个存在继承关系的类 -- 第三步:把这几个类的头文件导入到静态库头文件中 -- 第四步:**Build Phases - Headers** 中设置静态库头文件为 **Public**,允许外部直接访问。其他几个头文件设为 **Private** -- 第五步:为了方便操作,不用每次创建文件夹,手动拖 **.a** 文件和 **.h** 文件很低效。编写 shell 脚本处理。指定到 **Build Phases - Run Script** 中 - -整体目录结构和设置如下: - - - - - -##### 2. App 以 Header Search Paths 方式编译链接静态库 - -- 新建 App 工程。在工程目录下创建 **StaticLib** 文件夹 -- 将上面脚本得到静态库产物 **HMapStaticLib-HeaderSearchPath** 拷贝到 **StaticLib** 文件夹下 -- App 工程打开,Build Settings - Header Search Paths** 中配置:`${SRCROOT}/HMapBenchMark-HeaderSearchPath/StaticLib/HMapStaticLib-HeaderSearchPath/Headers` -- App 工程的 `ViewController.m` 中,`#import "HMapStaticLib_HeaderSearchPath.h"` 引入静态库的公共头文件,然后使用里面的类 -- 编译运行,导出编译日志。查看编译时间. - -注意:因为我的 Demo 都是在一个 Xcode 工程,不同的 **TARGETS**,所以即使打包好的静态库产物,复制到 App 工程 **StaticLib** 文件夹下,系统在编译的时候还是会以源码的形式编译。 -会看到以下的编译日志。 - - -解决办法: -- 方法1: 创建不同的 Xcode 工程 -- 方法2: 还是同一个 Project,不同的 Targets,选中当前的 Target,**Edit Scheme**,在 **Build** 下取消勾选 **Find Implicit Depedencies** - - -问题修复完,最终编译链接效果如下: - -编译日志里时间先放着。等以 HeaderMap 编译链接实现结束一起对比看看时间节省了多少。 - - - - -#### 2. 实验二:静态库 + HeaderMap + App 编译 - -##### 1. 静态库设置 - -- 第一步:Xcode 新建项目,选择 **Static Library **。为了方便对照,命名为:**HMapStaticLib-HeaderMap** -- 第二步:根目录下创建 **Sources** 文件夹,多创建几个存在继承关系的类 -- 第三步:把这几个类的头文件导入到静态库头文件中 -- 第四步:**Build Phases - Headers** 中设置静态库头文件为 **Public**,允许外部直接访问。其他几个头文件设为 **Private** -- 第五步:根据源码中头文件目录,创建一个 **HMapFile.json** 文件。以数组的形式写入信息,3个元素,分别为:Key、Prefix、Suffix。利用 LLVM 开发的 hmapmaker 能力,将 json 转换为 hmap 文件 - -- 第六步:**Build Settings - Use Header Maps** 中设为 **NO**,**Header Search Paths** 设为:`${SRCROOT}/HMapStaticLib-HeaderMap/hmap`(hmap 文件所在目录) -- 第七步:**Build Phases - Headers** 中设置静态库头文件为 **Public**,允许外部直接访问。其他几个头文件设为 **Private** -- 第八步:为了方便操作,不用每次创建文件夹,手动拖 **.a** 文件和 **.h** 文件很低效。编写 shell 脚本处理。指定到 **Build Phases - Run Script** 中 - -整体目录结构和设置如下: - - - - - -##### 2. App 以 HeaderMap 方式编译链接静态库 - -- 新建 App 工程。在工程目录下创建 **StaticLib** 文件夹 -- 将上面脚本得到静态库产物 **HMapStaticLib-HeaderMap** 拷贝到 **StaticLib** 文件夹下 -- App 工程打开,Build Settings - Header Search Paths** 中配置:`${SRCROOT}/HMapBenchMark-HeaderMap/StaticLib/HMapStaticLib-HeaderMap/Headers` -- App 工程的 `ViewController.m` 中,`#import "HMapStaticLib_HeaderMap.h"` 引入静态库的公共头文件,然后使用里面的类 -- 编译运行,导出编译日志。查看编译时间. - -注意:因为我的 Demo 都是在一个 Xcode 工程,不同的 **TARGETS**,所以即使打包好的静态库产物,复制到 App 工程 **StaticLib** 文件夹下,系统在编译的时候还是会以源码的形式编译。 -会看到以下的编译日志。 - - -解决办法:同一个 Project,不同的 Targets,选中当前的 Target,**Edit Scheme**,在 **Build** 下取消勾选 **Find Implicit Depedencies** - - -最终编译链接效果如下: - - - - -#### 3. 数据分析及结论 - -**可以看到静态库先用 Header Search Paths 的方式,App 编译耗时6.5s。使用了自定义的 Header Map 文件后,App 编译耗时6.1s。编译耗时减少了0.4s,节省了6.6%。这是只有1个静态库且只有7个头文件的情况下测试得到的数据。真实项目中,如果静态库数量越多、项目文件目录长且嵌套复杂、头文件数量越多,以自定义 hmap 的方式将会在编译阶段节省更多的时间** - - - -源码查看: -- 静态库 + HeaderSearchPath:[HMapStaticLib-HeaderSearchPath](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapStaticLib-HeaderSearchPath) 和 [HMapBenchMark-HeaderSearchPath](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapBenchMark-HeaderSearchPath) - -- 静态库 + hmap:[HMapStaticLib-HeaderMap](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapStaticLib-HeaderMap) 和 [HMapBenchMark-HeaderMap](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapBenchMark-HeaderMap) - - -### 7. 工程化问题 - -上面是理论上分析并思考了2个问题: -- 为什么需要介入自己生成 hmap 文件 -- 生成后的 hmap 文件如何使用 -如果每个工程项目都这么做,效率有点低,所以需要站在工程化的角度去设计,如何优化? - -完整流程如下: -```mermaid -graph TD - A[Podfile] --> B[post_install Hook] - B --> C[遍历所有 Pod] - C --> D[收集头文件映射] - D --> E[生成 hmap 文件] - E --> F[修改 xcconfig] - F --> G[关闭默认 hmap] - G --> H[App 编译加速] -``` - -Cocoapods 提供了很多钩子,可以自定义编写 Ruby 脚本。 -- HooksManager 注册 cocoapods 的 `post_install` 钩子 -- 通过 `header_mappings_by_file_accessor` 遍历所有头文件和 `header_dir`,由header_dir/header.h和header.h为key,以头文件搜索路径为value,组装成一个Hash,生成所有组件pod头文件的json文件,再通过hmap工具将json文件转成hmap文件。 -- 再修改各 pod 中 `.xcconfig` 文件的 `HEADER_SEARCH_PATHS` 值,仅指向生成的新生成的 `.hmap` 文件,删除原来添加的搜索目录; -- 修改各 pod 的 `USE_HEADERMAP` 值,关闭对默认的 `.hmap` 文件的访问 - - - -完整步骤: - -1. Hook `pod install` 执行过程; -2. 扫描所有Pod组件的头文件路径; -3. 为**每个Pod**生成专属hmap文件(如`Pods-MainApp-prebuilt.hmap`); -4. 修改xcconfig:删除原始头文件搜索路径,替换为hmap路径(`HEADER_SEARCH_PATHS = ${PODS_ROOT}/Headers/HMap/Pods-MainApp-prebuilt.hmap`); -5. 关闭Xcode默认hmap生成(`USE_HEADERMAP = NO`)。 - - - -## 十一、dyld 及其工作流程 - -### 1. DYLD(dyld shared cache) 动态库共享缓存 - -UIKit、CoreGraphics 等。从 iOS13 开始,为了提高性能,绝大部分的系统动态库文件都被打包存放到了一个缓存文件中(dyld shared cache)中。 -存放路径为:`/System/Libarey/Caches/com.apple.dyld/dyld_shared_cache_armX` -其中,`X` 代表 ARM 处理器的指令集架构。V6、V7、V7s、arm64、arm64e。不同架构,对应的动态库缓存不一致。 - -所有指令集原则是向下兼容的。动态库共享缓存一个非常明显的好处是节省内存。 - -具体底层源码可以查看,dyld 源码中 `dyld2.cpp` 文件,函数入口为 load 方法。 - -```c -ImageLoader* load(const char* path, const LoadContext& context, unsigned& cacheIndex); -``` - -某个 App 被第一次打开时候, dyld 根据动态库的依赖,循环加载动态库到动态库共享缓存中(内存),后续其他 App 第一次打开发现使用了动态库A已经在动态库共享缓存中存在,则不需要加载。这一步调用方法为 `findInSharedCacheImage` - -### 2. ASLR - -#### 1. 虚拟内存、物理内存、内存分页、ASLR - -早期:应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题: - -- 当前进程只需要在合适的地址基础上,加减一些地址,就能访问到下一个进程所使用的内存,安全性很低 - -- 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。 - -基于上述2个问题,诞生了虚拟内存技术。App 进程通过内存管理单元(Memory Management Unit, MMU)来管理的。MMU 使用一张特殊的表来跟踪虚拟内存地址和物理内存地址之间的映射关系。这张表通常被称为页表(Page Table) - -内存分页: - -- 页表结构:页表包含了一系列的表项,每个表项对应虚拟内存中的一个页(通常是 4KB)。每个表项包含了该虚拟页对应的物理页的信息,包括物理页的起始地址和一些状态信息。 -- 虚拟地址到物理地址的转换:当程序访问一个虚拟地址时,MMU 查看页表来找到对应的表项,然后根据表项中的信息将虚拟地址转换为物理地址。 -- 页表缓存(TLB - Translation Lookaside Buffer):为了提高地址转换的速度,MMU 通常会有一个 TLB,它缓存了最近访问的页表项。这样,对于频繁访问的地址,转换过程可以更快地完成 -- 页错误处理:如果一个虚拟地址在页表中没有对应的条目,就会发生页错误。操作系统的页错误处理程序会被调用,它负责加载缺失的页到物理内存中,并更新页表。 -- 内存分配:当应用程序请求内存时,操作系统会分配虚拟地址空间,并在页表中创建相应的条目。如果需要,操作系统还会分配物理内存页,并将其与虚拟页关联起来。 - -当物理内存满的时候,会发生覆盖。用户使用的活跃的数据,覆盖内存中最不活跃的数据那一页。对应现实的表现就是:iPhone 上永远可以较好的打开一个 App,比如打开了 App1、App2、App3...,等打开使用了一阵子后,内存紧张,我们尝试切换打开最早的 App1,会发现 App1 重新启动了,之前用的功能 A 的页面1,已经不见了,经历一个新的启动流程。 - - - -但虚拟内存方案带来一个问题。比如黑客不断探索发现,某个重要的功能位于第3页,是不是完全可以通过固定的地址去访问?? - -因为早期物理内存方案下,App 启动后位于什么地址是不确定的。有了虚拟内存后,App 内符号的地址都是从0到4G,都是相对地址。 - -为了解决该问题,Apple 又推出了 ASLR 技术。核心就是为了解决虚拟内存地址固定不变的问题。就不能通过固定的地址访问内存或者数据了。 - -#### 2. 未使用 ASLR 的问题 - -- 函数代码存放在 `__TEXT` 段 - -- 全局变量存放在 `__DATA` 段 - -- 可执行文件的内存地址为 `0x0` - -- 可执行文件 Header 的内存地址,就是 `LC_SEGMENT(__TEXT)` 中的 VM Address - - - arm64 :`0x100000000` - - - 非 arm64:`0x4000` - -也可以使用 `size -l -m -x DDD` 指令来查看 Mach-O 的内存分布 - - - -利用 MachOView 查看如下: - - - - - - - -- _PAGEZERO - - VM Address:0x0 - - VM Size:0x100000000 -- _TEXT - - VM Address:0x100000000 - - VM Size:0x4000 -- _DATA_CONST: - - VM Address:0x10004000 - - VM Size:0x4000 -- _DATA - - VM Address:0x10008000 - - VM Size:0x4000 -- _LINKEDIT - - VM Address:0x1000C000 - - VM Size:0x8000 - - - - - - - - - -File Offset:在 Mach-O 文件中的位置 - -File Size:在 Mach-O 文件中的占据的大小 - -从下面的图可以看出,`_PAGEZERO` 在真实的 Mach-O 文件中不存在,不占据大小。只在虚拟内存中存在。 - - - - - - - -我们会发现根据 Mach-O 文件中的信息,File Size、File Offset、VM Address、VM Size 可以判断出 `__TEXT` 段内函数信息,这样子不够安全 - -#### 3. ASLR 诞生 - -Address Space Layout Randomization,地址空间布局随机化。是一种针对缓冲区溢出的安全保护技术,通过堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码的位置,达到阻止溢出攻击目的的一种技术。在 iOS 4.3 引入。 - - - - - -- LC_SEGMENT (__TEXT) 的 VM Address `0x10005000` - -- ASLR 随机偏移 0x5000,也就是可执行文件的内存地址 - -在有 ASLR 的时候:__TEXT 代码段地址 = __`PAGEZEROR 地址` - -在 Mach-O 文件中的地址是原始地址 - -```shell -代码运行起来函数真实地址 = ASLR-Offset + __PAGEZERO(arm64:0x100000000,其他:0x4000)+ 函数基于 Mach-O 的地址 -``` - - - -### 3. 概念 - -dyld 是一个动态链接程序。是 iOS 和 macOS 系统中的**动态链接器**(Dynamic Loader)。它负责在程序运行时加载和链接动态库。简单来说,dyld 就如同一个「中间人」,将你的程序代码和它所依赖的所有动态库整合在一起,最终让你的程序能够正常运行。 - -`libdyld.dylib` 给我们的程序提供在 runtime 期间能使用动态链接功能。比如 dlopen 能力,又或者是系统 lazy-binding 符号表,在运行之后,动态修正符号地址。 - -懒加载的符号表项通常包括一个或多个指向符号的间接符号(Indirect Symbols),这些间接符号在首次访问时会被动态链接器替换为实际的内存地址。这个过程称为符号绑定(Symbol Binding)。由于懒加载的符号在首次使用前不会被绑定,因此可以减少应用程序的启动时间,并按需加载所需的资源 - - - -```shell -objdump --macho --private-headers DSYMDemo -``` - - - -- **启动阶段**:应用程序启动时,iOS 系统首先解析 `Info.plist` 文件,加载相关信息,例如启动画面等,并建立沙盒环境以及进行权限检查 - -- **Mach-O 加载**:系统加载应用程序的可执行文件(Mach-O 格式),这是一个包含程序指令和数据的文件。加载时会包括 dylib(动态库)的加载时间以及偏移修正(rebase)和符号绑定(binding)的时间 - -- **dyld 的初始化**:在系统内核完成初始化后,从 Mach-O 的 `LC_LOAD_DYLINKER` load command 中,根据 name 路径信息,然后加载,dyld 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库 - - ```shell - Load command 14 - cmd LC_LOAD_DYLIB - cmdsize 88 - name /System/Library/Frameworks/Foundation.framework/Foundation (offset 24) - time stamp 2 Thu Jan 1 08:00:02 1970 - current version 1953.255.0 - compatibility version 300.0.0 - ``` - -- **依赖分析**:dyld 会分析 Mach-O 文件,找出程序所依赖的所有库,并递归地解析所有依赖关系,形成动态库的依赖图 - -- **内存映射**:dyld 将匹配 Mach-O 文件到内存空间,确保每个依赖库都被映射到正确的地址 - - - 解析 Mach Header,判断当前 Mach-O 文件是否可用 - - 比如上面的 Mach header 中 - - - cputype 是 `X86_64`,dyld 会判断该架构能否在当前系统上运行 - - filetype 是 `EXECUTE`,是不是一个可执行文件 - - 有 ncmds 16个 Load commands,占 sizeofcmds 2848大小的空间 - - - 根据 Mach Header,解析 load commands,根据解析的结果,将程序各个部分加载程序到指定的地址空间,同时设置保护标志 - - ```js - Load command 1 - cmd LC_SEGMENT_64 // 指定加载命令的类型是 LC_SEGMENT_64,这代表一个 64 位的内存段命令。 - cmdsize 792 // 表示这个加载命令的总大小是 792 字节。 - segname __TEXT // 段名称,这里是 __TEXT 段,通常包含程序的指令和只读数据。 - vmaddr 0x0000000100000000 // 指定了该段在内存中的虚拟地址 - vmsize 0x0000000000004000 // 定义了该段在内存中的大小,这里是 16 KiB(4000 十六进制转换为十进制是 16384)。 - fileoff 0 // 表示该段在文件中的偏移量,这里是文件的起始位置 - filesize 16384 // 表示该段在文件中的实际大小,以字节为单位 - maxprot r-x // 定义了该段(代码段)的最大保护级别,这里是 r-x(读和执行),表示该段可以被读取和执行,但不能写入。如果是数据段,则可读可写 rw- - initprot r-x // 定义了该段在加载到内存时的初始保护级别,与最大保护级别相同 - nsects 9 // 表示该段包含的节(sections)数量,这里是 9 个 - flags (none) // 该段没有设置任何特殊标志 - ``` - - - -- **符号查找和绑定**:进行符号查找,处理程序中的符号引用,并进行符号绑定,确保所有的函数调用和全局变量引用都能正确地指向它们的实现 - -- **rebase 操作**:由于 ASLR(地址空间布局随机化)的需要,dyld 会对 Mach-O 文件进行 rebase 操作,调整代码和数据的地址以适应随机化的内存布局 - -- **初始化程序执行**:在链接操作完成后,dyld 会执行初始化程序,包括 Objective-C 的 `+load` 方法和 C 的构造函数,以初始化静态变量 - -- **main 函数调用**:最后,dyld 读取 Mach-O 文件的 `LC_MAIN` 命令,获取程序的入口地址,并调用 `main` 函数,启动应用程序的主执行流程 - -- **dyld 3 的优化**:从 iOS 11 开始,引入了 dyld 3,它通过进程外的 Mach-O 分析器/编译器以及进程内的执行引擎,将许多耗时的操作提前处理好,并缓存结果,从而极大提升了启动速度 - - - -### 4. dyld 到底做了什么 - -1. 执行自身初始化配置加载环境。 `LC_DYLD_INFO_ONLY` - - ```shell - Load command 4 - cmd LC_DYLD_INFO_ONLY // 表示这是一个只包含 dyld 信息的加载命令,它通常不包含实际的 rebase、bind 等信息,而是提供给 dyld 用于优化链接过程的信息 - cmdsize 48 // 这个命令的总大小是 48 字节 - rebase_off 0 // 指示 rebase 信息在文件中的位置 - rebase_size 0 // 指示 rebase 信息在文件中的大小 - bind_off 0 // 指示 bind 信息的位置 - bind_size 0 // 指示 bind 信息的大小 - weak_bind_off 0 // 指示弱绑定信息的位置 - weak_bind_size 0 // 指示弱绑定信息的大小 - lazy_bind_off 0 // 指示延迟绑定信息的位置 - lazy_bind_size 0 // 指示延迟绑定信息的大小 - export_off 32768 // 导出表(export table)的位置 - export_size 48 // 导出表(export table)的大小 - ``` - -2. 加载当前程序链接的所有动态库到指定的内存中。`LC_LOAD_DYLIB` - - ```shell - Load command 12 - cmd LC_LOAD_DYLIB // 指明了 load command 的类型是 LC_LOAD_DYLIB,意味着接下来的信息是关于加载一个动态库的 - cmdsize 56 // 这个命令的总大小是56字节 - name /usr/lib/libSystem.B.dylib (offset 24) // 指定了要加载的动态库的路径和文件名。这里是 libSystem.B.dylib,位于 /usr/lib/ 目录下。(offset 24) 表示文件名在命令数据中的偏移量 - time stamp 2 Thu Jan 1 08:00:02 1970 // 这是动态库的时间戳,用于版本检查。不过,这个时间戳通常是0或一个固定值,因为系统库的加载不依赖于时间戳 - current version 1319.0.0 // 这是动态库的当前版本号,用于确保应用程序加载的是兼容的版本 - compatibility version 1.0.0 // 这是动态库的兼容版本号,表明应用程序至少需要这个版本的库才能正常运行 - ``` - - 通过下面指令便可以获取到 Load Command 为 LC_LOAD_DYLIB 的记录,也就是可以看到所需要的全部动态库 - - ```shell - objdump --macho --private-headers /Users/unix_kernel/Library/Developer/Xcode/DerivedData/LLDBWithDSYM-bhspsnzkxcmhjjcekslnvrepukag/Build/Products/Debug-iphoneos/LLDBWithDSYM.app/LLDBWithDSYM | grep -A 7 'LC_LOAD_DYLIB' - ``` - -3. 搜索所有动态库,绑定需要在调用程序之前用的符号(非懒加载符号)。`LC_DYSYMTAB` - - 使用下面指令便可以查看 LC_DYSYMTAB 相关信息 - - ```shell - objdump --macho --private-headers /Users/unix_kernel/Library/Developer/Xcode/DerivedData/LLDBWithDSYM-bhspsnzkxcmhjjcekslnvrepukag/Build/Products/Debug-iphoneos/LLDBWithDSYM.app/LLDBWithDSYM | grep -A 30 'LC_DYSYMTAB' - ``` - - `LC_DYSYMTAB` 对于动态链接非常重要,因为: - - - 定义了符号的分类(本地/外部定义/未定义) - - - 定位了间接符号表(用于方法调用和动态绑定) - - 如何定位间接符号表? - - ```shell - indirectsymoff 69744 # 间接符号表在文件中的偏移地址 - nindirectsyms 25 # 间接符号表包含25个条目 - ``` - - - 间接符号表起始位置 = **文件起始位置 + 69744 字节** - - 每个条目大小 = **4 字节** (32 位无符号整数) - - 总表大小 = 25 条目 × 4 字节/条目 = **100 字节** - - 结束位置 = 69744 + 100 = **69844 字节** - - 结构类似于: - - ```shell - 文件布局: - 0x0000 ┌───────────────────┐ - │ Mach-O 头部 │ - ├───────────────────┤ - │ 加载命令区域 │ - │ (包含LC_DYSYMTAB) │ - ├───────────────────┤ - │ __TEXT段 │ - ├───────────────────┤ - │ __DATA段 │ - ├───────────────────┤ - 0x69744├───────────────────┤ ← 间接符号表开始位置 - │ 条目0: 4字节 │ - │ 条目1: 4字节 │ - │ ... │ - │ 条目24: 4字节 │ - 0x69844├───────────────────┤ ← 间接符号表结束位置 - │ 其他数据 │ - └───────────────────┘ - ``` - - - 提供了重定位信息(地址修正数据) - - ```shell - extreloff 0 # 外部重定位表偏移=0 → 不存在 - nextrel 0 # 外部重定位条目数=0 - locreloff 0 # 本地重定位表偏移=0 → 不存在 - nlocrel 0 # 本地重定位条目数=0 - ``` - - - 包含模块表(动态链接遗留信息) - - ```shell - ilocalsym 0 # 本地符号起始索引=0 - nlocalsym 173 # 本地符号数量=173 - iextdefsym 173 # 外部定义符号起始索引=173 - nextdefsym 8 # 外部定义符号数量=8 - iundefsym 181 # 未定义符号起始索引=181 - nundefsym 22 # 未定义符号数量=22 - ``` - -4. 在 indirect symbol table 中,将需要绑定的导入符号真实地址替换。`LC_DYSYMTAB` - - 工作流程: - - 动态库、静态库在使用类似 NSLog 等系统动态库符号的时候,由于系统动态库是是共享缓存技术,所以编译阶段没办法确定这些系统符号的地址。源码调用 NSLog,编译器会生成 `call _NSLog`,标记为未定义符号(在 Mach-O 中体现为 `n_type` 的 `N_UNDF` 标志)。 - - 这时候间接符号表就登场了,存储了在符号表中的索引,符号的真实地址存储在 `_DATA` 段的 **_la_symbol_ptr**,一开始 **_la_symbol_ptr** 中的地址,指向 **dyld_stub_binder** - - - - 链接阶段同样无法确认系统符号地址。 - - - 对于静态库来说,当和 App 链接的时候,由于启用 dead code strip,没有被 App 使用的符号全被 Strip 掉,使用的符号和 App 合并了,静态库的间接符号表一样合并到了 App 的间接符号表中。 - - 对于动态库来说,动态库无法合并和裁剪,所以导出符号表和全部的符号都被保留了,但也不能和 App 合并。 - - - - 所以总的来说,编译链接后,App 启动的时候,第一次调用了 NSLog,汇编层面会发生类似调用 `call _NSLog` 去实现,然后 dyld 通过 dyld_stub_binder 去扫描加载的动态库的导出符号表信息,查询真正地址后发生 NSLog 调用,并把真实地址写入懒加载符号表(_la_symbol_prt)中。第二次及以后调用的时候,便可以从懒加载符号表中直接获取到真实地址了。 - - 这也是 Fishhook 可以正常 work 的基础。 - -5. 向程序提供 Runtime 运行时使用 dyld 的接口(存在于 libdyld .dylib 中,由 `LC_LOAD_DYLIB` 提供) - -6. 配置 Runtime,执行所有动态库/image 中使用的全局构造函数 - -7. dyld 调用程序入口函数,开始执行程序。`LC_MAIN` - - ``` - Load command 11 - cmd LC_MAIN // 里的 cmd 指明了加载命令的类型是 LC_MAIN,这个命令用于指定程序的主入口点 - cmdsize 24 // 这个命令的大小是24字节,这是命令头加上任何尾部数据的总和 - entryoff 16288 // 指定了程序入口点的偏移量,它是相对于文件的开始位置的。在这个例子中,入口点位于文件开头的第 16288 字节处。入口点通常是 main 函数的地址 - stacksize 0 // 这个字段指定了为程序的主线程初始栈分配的大小。在这个例子中,栈大小被设置为0,这可能意味着栈的大小将使用默认值,或者在其他地方指定(例如,通过链接器的其他设置或命令行选项) - ``` - - - - - -### 5. main 函数 - -实验一: - -3. - -编写2个函数,main 函数和 test 函数,除了方法名不同,在汇编侧是一样的。 - -实验二: - - - -可以看到创建的 `test.c` 文件中,只有一个 `test` 方法。没有 `main` 方法,然后用 gcc 发现链接报错。 - -然后利用 gcc 指令 ` gcc -nostartfiles -e_test test.c` 发现可以编译通过,运行也没问题。 - -当然除了 gcc,很多嵌入式平台,可以在代码中指定 c 程序的起点。 - -比如 STM32,专门有个汇编文件,用于系统的初始化。 - -总结一下: - -- main 函数和其他普通函数并无区别 -- main 函数是很多程序的默认起点,但绝不是非它不可,任何函数都可以被设置成程序起点。 - - - -iOS 侧,dyld 默认以 main 函数作为函数起点。 - - - -### 6. dyld 加载过程 - - - - - -- 调⽤ `fork `函数,创建⼀个进程 -- 调⽤ `execve` 或其衍⽣函数,在该进程上加载,执⾏ `Mach-O`⽂件 -- 将 `Mach-O` ⽂件加载到内存 -- 开始分析 `Mach-O` 中的 `mach_header`,以确认它是有效的 `Mach-O` ⽂件 -- 验证通过,根据 `mach_header `解析 `load commands`。根据解析结果,将程序各个部分加载到指定的地址空间,同时设置保护标记 -- 从 `LC_LOAD_DYLINKEN`中加载 `dyld` -- `dyld`开始⼯作 -- 调用 `__dyld_start()` 函数,通知 `dyld` 开始工作 -- 调用 `dyldbootstrap::start` 函数,使 `dyld` ⾃身进⼊可运⾏状态 -- 调用 `dyld::_main` 函数,`dyld `的入口函数 -- 检查共享缓存中的缓存,如果找到直接返回(红线逻辑),否则继续后面的流程 -- 共享缓存中没有找到,则继续下面流程 -- 加载所有手动插入的动态库 -- 链接程序需要的动态库 -- 链接插入的库 -- 应用插入函数 -- 绑定符号 -- 调用 `instantiateMainExecutable` ,为主可执行文件创建镜像 -- 调用当前程序与动态库的初始化构造函数(`__attribute__((constructor))`) -- 通过 `LC_MAIN` 查找设置程序⼊⼝函数,将胶⽔地址设置成⼊⼝函数地址,否则胶⽔地址为0 -- 提供胶水地址,返回到 `dyld::_main` 函数中继续执行 -- 通过 `dyld::_main`→`dyldbootstrap::start`→`__dyld_start()`,dyld 配置完成,把控制权交给可执⾏⽂件的⼊⼝函数`main()`,继续后面的流程 - - - -QA:有没有这样一个疑问:dyld 的工作流程有一部分是:加载所有手动插入的动态库、链接程序需要的动态库、链接插入的库、应用插入的函数。为什么 dyld 的工作流程为什么和链接器的有些重合?链接动态库为什么是 dyld 在做的事情 - -链接器(Linker)和动态链接器(Dynamic Linker)在程序构建和运行过程中的不同角色,它们的工作看似有重合,实则处于不同阶段且分工明确 - -```mermaid -graph LR -A[源代码] --> B[编译阶段] -B --> C[目标文件 .o] -C --> D[静态链接阶段] -D --> E[可执行文件] -E --> F[运行时] -F --> G[dyld] - -subgraph 静态链接器 ld -D[符号解析
重定位
静态库合并
生成加载命令] -end - -subgraph 动态链接器 dyld -G[加载动态库
地址空间分配
符号绑定
运行初始化] -end - -``` - -1. **时间点不同**: - - - **链接器**:在**编译时**工作(构建阶段) - - **dyld**:在**运行时**工作(程序启动后) - -2. **地址确定性**: - - - 动态库加载地址在编译时**无法确定**(受 ASLR 影响) - - 系统库位置由共享缓存决定(每次启动可能不同) - -3. **工作内容本质差异**: - - | 功能 | 链接器 (ld) | dyld | - | :----------------- | :------------------- | :----------------- | - | **静态库处理** | ✅ 合并到可执行文件 | ❌ 不处理 | - | **动态库依赖记录** | ✅ 写入 LC_LOAD_DYLIB | ❌ 只读取 | - | **地址分配** | ❌ 仅预留空间 | ✅ 实际分配内存地址 | - | **符号绑定** | ❌ 只设置占位符 | ✅ 实际解析地址 | - | **共享缓存** | ❌ 无法访问 | ✅ 直接映射使用 | - -dyld 完整流程 - -```mermaid -sequenceDiagram - participant K as 内核 - participant D as dyld - participant M as 主程序 - participant S as 共享缓存 - participant L as 动态库 - - K->>D: 1. 传递控制权 - D->>M: 2. 解析 Mach-O 头 - D->>D: 3. 递归加载依赖库 - loop 每个 LC_LOAD_DYLIB - D->>S: 4a. 检查共享缓存 - S-->>D: 返回缓存地址 - D->>L: 4b. 加载磁盘动态库 - L-->>D: 返回加载地址 - end - D->>D: 5. 重定位符号 - D->>M: 6. 绑定非懒加载符号 - D->>M: 7. 初始化程序 - D->>M: 8. 调用 main() -``` - -由于 ASLR 的存在 - -1. **静态链接器**: - - 专注**可执行文件结构构建** - - 处理**确定性的链接操作** - - 生成**可重定位的 Mach-O 文件** -2. **动态链接器**: - - 处理**环境相关**的地址分配 - - 管理**运行时动态行为** - - 实现**资源共享和安全特性** - -### 7. dyld 应用 - -窥探系统库底层实现的时候可能需要从动态库共享缓存中提取出某个 Framework,比如 UIKit。这时候要么用第三方工具,要么用 dyld 的能力。 - -查看 `dsc_extractor.cpp` 代码可以看到是具备抽取能力的。修改源码,将 if 宏定义去掉。如下 - -```c -#include -#include -#include - - -typedef int (*extractor_proc)(const char* shared_cache_file_path, const char* extraction_root_path, - void (^progress)(unsigned current, unsigned total)); - -int main(int argc, const char* argv[]) -{ - if ( argc != 3 ) { - fprintf(stderr, "usage: dsc_extractor \n"); - return 1; - } - - //void* handle = dlopen("/Volumes/my/src/dyld/build/Debug/dsc_extractor.bundle", RTLD_LAZY); - void* handle = dlopen("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/lib/dsc_extractor.bundle", RTLD_LAZY); - if ( handle == NULL ) { - fprintf(stderr, "dsc_extractor.bundle could not be loaded\n"); - return 1; - } - - extractor_proc proc = (extractor_proc)dlsym(handle, "dyld_shared_cache_extract_dylibs_progress"); - if ( proc == NULL ) { - fprintf(stderr, "dsc_extractor.bundle did not have dyld_shared_cache_extract_dylibs_progress symbol\n"); - return 1; - } - - int result = (*proc)(argv[1], argv[2], ^(unsigned c, unsigned total) { printf("%d/%d\n", c, total); } ); - fprintf(stderr, "dyld_shared_cache_extract_dylibs_progress() => %d\n", result); - return 0; -} -``` - -然后用 clang++ 编译,命令为 `clang++ dsc_extractor.cpp` - -将编译后的产物复制到动态库共享缓存目录下去。然后执行命令`./dsc_extractor dyld_shared_cache_armv7s armv7s`,代表将动态库提取到 armv7s 目录下。 - - - -dyld 本身就是一种 MachO 文件,MachO 文件类型为7,是一种包含多种架构的结构,如下图: - - - -可以加载以下类型的 Mach-O 文件:MH_EXECUTE、MH_DYLIB、MH_BUNDLE - - - -#### 1. 插入动态库 - -工作原理:使用 `__attribute__((constructor))` 是 GCC 和 Clang 编译器支持的一个属性,用于标记一个函数为构造函数,该函数会在程序加载动态库时自动执行。 - -动态库的加载过程通常是在程序启动时进行的,因此 `__attribute__((constructor))` 属性标记的函数会在 `main` 函数执行之前被调用。 - - - -第一步:创建 `Inject` 动态库,新建 `Inject.m` 文件 - -```c++ -#import - -__attribute__((constructor)) -static void customConstructor(int argc, const char **argv) { - NSLog(@"Hello,I am an injected dynamic library!"); -} -``` - -第二步:创建 iOS App 测试工程,接入 Inject 动态库。将 `Inject.framework` 里的动态库拖到 App 工程根目录。 - -第三步:Edit scheme -> Run -> Environment Variables。Name 为 `DYLD_INSERT_LIBRARIES`,value 为 InjectFunction 动态库路径,`${SRCROOT}/Inject`。 - - - -第四步:运行输出如下 - - - -具体 [Demo]() - -不只是这一种方式可以插入动态库,图上是 GUI 的方式,给 Xcode Enviornment Variables 中加入 `DYLD_INSERT_LIBRARIES` 的方式实现。还可通过 **dlopen("/path/to/your_lib.dylib", RTLD_NOW)** 实现。 - - - -#### 2. 函数替换 - -工作原理: - -**在 iOS 中,`__DATA,__interpose` 是一个特殊的 Mach-O 段,用于实现函数插桩 (Function Interposing)。它允许你在不修改原始库代码的情况下,拦截并替换库中的某个函数** - -```shell -Mach-O 文件结构 -├── Header -├── Load Commands -└── Segments (每个段包含多个节) - ├── __TEXT (代码段) - │ ├── __text - │ └── __cstring - └── __DATA (数据段) <-- __interpose 所在位置 - ├── __data - ├── __bss - └── __interpose <-- 拦截功能的核心 -``` - -`__interpose` 通过在 `__DATA` 段中声明特殊结构,通知 dyld(动态链接器)进行函数重定向 - -```c -// 拦截结构体定义 -struct interpose_t { - void* replacement; // 替换函数地址 - void* original; // 原始函数地址 -}; -``` - -**`__attribute__((used)) static struct { ... } ... __attribute__ ((section("__DATA, __interpose"))) = { ... };`** 定义一个结构体,它包含两个指向函数的指针:`replacement` 和 `replacee`。结构体使用 `__attribute__((used))` 属性标记,以避免编译器将其优化掉。同时,它被放置在 `__DATA, __interpose` 段,这个段是专门为函数插桩而设计的。 - - - -参考 [dyld::dyld-interposing.h](https://opensource.apple.com/source/dyld/dyld-210.2.3/include/mach-o/dyld-interposing.h) - -第一步:创建 `InjectFunction` 动态库,新建 `InjectFunction.m` 文件 - -```c++ -#import -#define INTERPOSE(_replacement, _replacee) \ - __attribute__((used)) static struct { \ - const void* replacement; \ - const void* replacee; \ - } _interpose_##_replacee __attribute__ ((section("__DATA, __interpose"))) = { \ - (const void*) (unsigned long) &_replacement, \ - (const void*) (unsigned long) &_replacee \ - }; - -void my_NSLog(NSString *format, ...) { - NSLog(@"Injected Function ---> %@", format); -} -INTERPOSE(my_NSLog, NSLog); -``` - -第二步:创建 iOS App 测试工程,接入 InjectFunction 动态库。将 `InjectFunction.framework` 里的动态库托到 App 工程根目录。 - -第三步:Edit scheme -> Run -> Environment Variables。Name 为 `DYLD_INSERT_LIBRARIES`,value 为 InjectFunction 动态库路径,当有多个动态库的时候,中间用 `:` 把路径隔开。`${SRCROOT}/Inject:${SRCROOT}/InjectFunction`。 - -第四步:运行输出。发现 NSLog 确实被 hook 做了替换。 - - - - - -应用场景:不能上架,但可以去做探索、验证源码等场景。 - - - -### 8. 调试 dyld - -第一种是替换系统的 dyld,风险大,需要感知源码。需要准备带调试信息的 `dyld/libdyld.dylib/libclosured.dylib` ,与系统做替换,风险较大。 - -第二种方式是**通过 lldb 调试 dyld**。dyld 提供了一些环境变量,用于控制 dyld 在运行过程中输出感兴趣的信息。 - -lldb 保留了一个库列表,避免在按名称设置断点时出现问题,而 dyld 与 libdyld.dyllib 就在该列表上。 - -有2种方式可以强制在 dyld 上设置断点: - -- **br set -n dyldbootstrap::start -s dyld** - - `br set`:`breakpoint set` 的简写,表示设置断点 - - `-n dyldbootstrap::start`:`-n` 指定函数名称,`dyldbootstrap::start` 是 macOS/iOS 系统动态链接器 **dyld** 的入口函数。当程序启动时,系统会先执行此函数完成动态库加载和程序初始化 - - `-s dyld`:`-s` 限定共享库范围,`dyld` 是 macOS/iOS 的核心动态链接器(路径通常为 `/usr/lib/dyld`),所有用户进程的启动都依赖它 -- **set target.breakoints-use-platform-avoid-list 0**:禁用 LLDB 的系统断点保护机制 - - 默认值为1,LLDB 会遵守操作系统提供的**断点黑名单**(如 macOS 的 `dyld`、系统框架关键函数)。禁止在这些敏感位置设置断点,防止系统崩溃。 - - **设置为 `0` (禁用),强制允许在任何地址设置断点**,包括: - - 操作系统内核函数 - - 动态链接器 (`dyld`) 内部 - - 系统框架关键路径(如 `libobjc` 的运行时函数) - -推荐使用 dyld 提供的环境变量来控制 dyld 在运行过程中输出感兴趣的信息。格式为:**DYLD_PRINT_APIS=1 ${MachOPath}** - -- `DYLD_PRINT_APIS`: 打印 dyld 内几乎所有发生的调用。格式为:**DYLD_PRINT_APIS=1 ${MachOPath}** - - ```shell - DYLD_PRINT_APIS=1 Users/unix_kernel/Library/Developer/Xcode/DerivedData/LLDBWithDSYM-bhspsnzkxcmhjjcekslnvrepukag/Build/Products/Debug-iphoneos/LLDBWithDSYM.app/LLDBWithDSYM - ``` - -- `DYLD_PRINT_LIBRARIES` :打印在应用程序启动期间正在加载的所有动态库 - -- `DYLD_PRINT_WARNINGS`:打印 dyld 运行过程中的辅助信息 - -- `DYLD_PATH`:显示 dyld 搜索动态库的目录顺序 - -- `DYLD_PRINT_ENV`:显示 dyld 初始化的环境变量 - -- `DLYD_PRINT_SEGMENTS`:打印当前程序的 segment 信息 - -- `DYLD_PRINT_STATISTICS`:打印 pre-main 耗时 - -- `DYLD_PRINT_INITIALIZERS`:会在执行每个镜像(image)的初始化器(initializer)时打印出一行信息。这些初始化器包括 C++ 的静态构造函数以及使用 `__attribute__((constructor))` 标记的函数。这个环境变量对于调试和分析程序启动时的初始化顺序和行为非常有用。 - -怎么用? - - - -另一种方式是利用 Xcode -> Edit Scheme,增加或修改 dyld 环境变量 - - - - - - - -## 十二、Mach-O - -Mach Object 的缩写,是 iOS/MacOS 上用于存储程序、库的标准格式。 - -Mach-O 格式⽤来替代 BSD 系统的 `a.out` 格式。Mach-O ⽂件格式保存了在编译过程和链接过程中产⽣的机器代码和数据,从⽽为静态链接和动态链接的代码提供了单⼀⽂件格式。 - - - -在 XNU 源码中可以查看 Mach-O 的定义。`loader.h` - -属于 Mach-O 格式的文件类型有: - -```c -#define MH_OBJECT 0x1 /* relocatable object file */ -#define MH_EXECUTE 0x2 /* demand paged executable file */ -#define MH_FVMLIB 0x3 /* fixed VM shared library file */ -#define MH_CORE 0x4 /* core file */ -#define MH_PRELOAD 0x5 /* preloaded executable file */ -#define MH_DYLIB 0x6 /* dynamically bound shared library */ -#define MH_DYLINKER 0x7 /* dynamic link editor */ -#define MH_BUNDLE 0x8 /* dynamically bound bundle file */ -#define MH_DYLIB_STUB 0x9 /* shared library stub for static */ - /* linking only, no section contents */ -#define MH_DSYM 0xa /* companion file with only debug */ - /* sections */ -#define MH_KEXT_BUNDLE 0xb /* x86_64 kexts */ -``` - - - -### 1. 常见的 Mach-O 文件类型 - -- MH_OBJECT - - 目标文件(.o) - - 静态库文件(.a),静态库其实就是 N 个 `.o` 合并在一起 - -- MH_EXECUTE:可执行文件 - -- MH_DYLIB:动态库文件 - - dylib - - .framework -- MH_DYLINKER:动态链接器 (/usr/lib/dyld) -- MH_DSYM:存储着二进制文件符号。`.dSYM/Contents/Resource/DWARF/xx` 常用于还原堆栈 - -Xcode 中也可以查看 Mach-O 文件类型 - - - - - -源代码(比如c文件),编译变为目标文件(比如.o文件),再经过链接变为可执行文件。 - -Tips:`file` 命令可以查看文件类型。 - - - -`find . -name "*.c"` 比如在当前路径查找 .c 文件 - - - -### 2. Universal Binary - -通用二进制文件,可以同时适用于多种架构的二进制文件,包含多种不同架构的独立的二进制文件。 - -- 因为要存储多种架构的代码,所以通用二进制文件通常比单一平台的二进制程序更大 - -- 由于两种架构有共同的一些资源,所以并不会达到单一版本的2倍多。 - -- 执行的过程中,只调用一部分代码,运行起来不需要额外的内存。 - -因为通用二进制文件比原来的大,所以被成为“胖二进制文件”(Fat Binary),使用 Hopper 打开会显示 “FAT archive” - -信息查看: - -- 查看某可执行文件(Test)支持的架构指令集 :`lipo -info Test` - -- 将某个指令集拆出来比如 arm64:`lipo Test -thin arm64 -o Test_arm64` - -- 也可以将多个指令集合并:`lipo -create Test_arm64 Test_armv7 -output Test_universal` - -Xcode 中可以修改指令集。由 Build Settings 的2个配置决定: `Architectures->Architectures` `User-Defined->VALID_ARCHS`,取2者的交集。 -其中 `Architectures->Architectures` 的值 `$(ARCHS_STANDARD)` 是 Xcode 内置的环境变量,不同版本的 Xcode 值不一样,是通用的一些架构值。比如 Xcode9下,`$(ARCHS_STANDARD)` 可能为 arm64、armv7。Xcode 4下,`$(ARCHS_STANDARD)` 可能为 armv7 - - - -### 3. Mach-O 结构 - - - -一个 Mach-O 文件包含3块 - -- Header:文件类型、目标架构类型信息 - -- Load commands:描述文件在虚拟内存中的逻辑结构、布局 - -- Raw segment data:在 Load Commands 中定义的 segment 的原始数据 - -可以用 [MachOView:](https://github.com/fangshufeng/MachOView) 和系统自带的 atool 查看 Mach-O 信息 - - - -比如我用 otool 查看我编写的一个 SwiftUIDemo 所依赖的共享缓存库 - - - -用 MachOView 查看 DDD Mach-O 文件 - - - - - -可以看到在 Mach-O 文件上,`__PAGEZERO` 的 `VM Size` 有值,但是 File Size 为0,也就是说 `__PAGEZERO` 在 Mach-O 中不占据内存,但是程序运行起来之后,会占据虚拟内存。所以代码段在 Mach-O 中 File Offset 为0(如果前面的 `__PAGEZERO` 的 File size 有值,这里的 File Offset 就不为0)。 - -**在没有 ASLR 的时候:__TEXT 代码段地址 = __PAGEZEROR 地址** - - - -### 4. Mach-O 文件如何查找地址 - -第一步,编写一个名为 `main.m` 的代码 - -```objective-c -void test1 () {} -void test2() {} -int globalValue; -int main () { - globalValue = 21; - globalValue = 20; - test1(); - test2(); - return 0; -} -``` - -第二步,将其转换为可执行文件,指令为`clang main.m -o main` - -第三步,分析查看可执行文件 `objdump --macho -d main` - -第四步,将其转为目标文件,指令为 `clang -c main.m -o main.o` - -第五步,查看目标文件指令信息 `objdump --macho -d main.o` - - - -分析下指令信息: - -- 可以看到源码中从上到下声明的函数,在目标文件和可执行文件中顺序是一致的 - - - 可执行文件中,顺序也是从上到下,依次执行的。左侧是真实指令地址 - - 目标文件中,顺序也是从上大下,但没有真实地址,是相对地址,是偏移量。 - -- 可以看到像函数调用的逻辑,是 ` e8 00 00 00 00 callq _test1`,其中 `e8` 是固定指令,代表 `callq`。后面的 `00 00 00 00 ` 是相对地址,test1 真正的地址是 `下面 4e` + `上面 00 00 00 00` 的结果。这是一种**近地址相对位移技术** - -- test1、test2 2个函数都存在,地址不一样,为什么都是 `00 00 00 00 ` ?那系统如何确定函数真实地址?需要找个地方将这些符号存起来,然后再找个时机去把真实的地址写进去。需要**重定位符号表**,告诉链接器在链接阶段需要重定位 - - - - - 使用 `objdump --macho --reloc main.o` 指令查看 main.o 的重定位符号表。 - - 符号表中 `test1` 的地址就是 `0000004a` 对应的数据。`49` 后面就是 `4a` - - 符号表中 `test2` 的地址就是 `0000004f` 对应的数据。`4e` 后面就是 `4f` - - 符号表中 `globalValue` 的地址就是 `0000003f` 对应的数据,`3c 3d 3e 3f`,经过 `48 8b 05` 到 `3f` - -#### 1. 如何找到函数的真实地址 - -以 test1 为例。 - -```shell -100003f93: c7 00 14 00 00 00 movl $20, (%rax) -100003f99: e8 b2 ff ff ff callq _test1 -100003f9e: e8 bd ff ff ff callq _test2 -100003fa3: 31 c0 xorl %eax, %eax -``` - -第一步:根据 B2 计算得到原码,然后计算出原码。 - -iOS 是小端序,从右向左看,`b2 ff ff ff` 可以看到后4位是 ff,所以是一个经过计算后的补码信息。所以需要计算得到原码信息。 - -补码如何到原码?所有的1取反,然后加1。第一步计算结果为 `0x4E` - -第二步:根据近地址相对位移技术,`下一条指令地址+ b2 ff ff ff` 就是函数 test1 的真实地址。但是 FF 是负的,所以 `0x100003f9e - 0x4E = 0x100003F50 `,也就是 Text 段中 test1 的真实地址。 - -完整的 - - - -#### 2. 如何找到全局变量地址 - -第一步,先用指令查看全局变量的地址值。 `objdump --macho -s main`,可以看到地址值为 `0x100008000` - - - - - -第二步,手动计算。根据近地址相对位移技术原理,`下一条指令 + 0x407a` = `0x100003f86 +0x407a = 0x100008000` - -去掉最开始的 `48 8d 05` ,iOS 小端序,所以是 `0x407a` - -```shell -100003f70: 55 pushq %rbp -100003f71: 48 89 e5 movq %rsp, %rbp -100003f74: 48 83 ec 10 subq $16, %rsp -100003f78: c7 45 fc 00 00 00 00 movl $0, -4(%rbp) - -100003f7f: 48 8d 05 7a 40 00 00 leaq _globalValue(%rip), %rax -100003f86: c7 00 15 00 00 00 movl $21, (%rax) - -100003f8c: 48 8d 05 6d 40 00 00 leaq _globalValue(%rip), %rax -100003f93: c7 00 14 00 00 00 movl $20, (%rax) -``` - -可见 `0x100008000` 和数据段的地址是一样的。 - -至此,可以了解到 Mach-O 文件是按照不同 Section 去加载数据,访问数据的。但不同 Section 是语义上更方便理解的,程序执行最本质的是:不管处于什么 section,只看偏移量。 - - - -## 十三、.dSYM 文件 - -### 1. 定义 - -`.dSYM` 文件就是保存 DWARF 格式的调试信息的文件。 - -`.DSYM` (debugging symbol)文件是保存十六进制函数地址映射信息的中转文件,调试信息(symbols)都包含在该文件中。Xcode 工程每次编译运行都会生成新的 `.DSYM` 文件。默认情况下 debug 模式时不生成 `.DSYM` ,可以在 Build Settings -> Build Options -> Debug Information Format 后将值 `DWARF` 修改为 `DWARF with DSYM File`,这样再次编译运行就可以生成 `.DSYM` 文件。 - -DWARF 是被众多编译器和调试器使用的,用于支持源码级别调试的调试文件格式。 - - - -### 2. 探索 - -#### 1. 如何生成 `.dSYM` 文件 - -第一步,新建 `main.m` 文件 - -第二步:Xcode 每次编译运行都会生成新的 `.DSYM` 文件。使用 clang -g 参数也可以生成 DSYM 文件。指令为: **`clang -g -c main.m -o main.o`** - -第三步:查看 Mach-O 中,包含 `__DWARF` 的段,使用指令查看:**`objdump --macho --private-headers main.o`** - - - -可以看到:**编译阶段,编译器会把调试信息放到一个单独的段中,该段名为 `__DWARF`** - -我们知道:**链接阶段,Strip 会把调试信息从 .o 文件中剥离出来,单独放在符号表中** - -继续向下窥探验证 - -第四步:编译成可执行文件,指令为 `clang -g main.m -o main` - -第五步:查看可执行文件中是否包含 `__DWARF` 段。`objdump --macho --private-headers main` - - - -第六步:查看可执行文件的符号表。指令为:`nm -pa main`。红色区域代表调试符号。 - - - -可以看到:链接完成后,所有的调试符号、使用的符号,都放在符号表里了。 - -看到了调试符号和 DWARF 信息,那如何生成 .dSYM 文件?因为 DWARF 是一种调试格式,所以知道调试符号,只要按照 DWARF 格式组织,写入一个文件就可以了。 - -- 指令 **`clang -g1 main.m -o main`**,**参数 `-g1` 用于生成 `.dSYM` 文件**。 -- 指令 **`dwarfdump main.dSYM` 用于查看 `.dSYM` 文件** - - - - - - - -- 读取 `debug map` -- 从 `.o` 文件中加载 DWARF -- 重新定位所有地址 -- 最后将全部的 DWARF 打包成 `.dSYM` bundle - - - -结论: - -编译器会在编译阶段,把调试信息放在单独的 `__DWARF` 段中。当去链接的时候,会把 `__DWARF` 段删掉,同时把所有的调试信息放在符号表中。 - -打包上线的时候会把调试符号等裁剪掉,但是线上统计到的堆栈我们仍然要能够知道对应的源代码,这时候就需要把符号写到另外一个单独的文件里,这个文件就是DSYM。 - - - -#### 2. Xcode Build Settings 中的 `DWARF` 和 `DWARF with dSYM File` 有啥区别? - -##### 1. DWARF - -```mermaid -graph LR -A[源代码] --> B[编译] -B --> C[生成Mach-O文件] -C --> D[包含DWARF调试信息] -D --> E[可执行文件体积膨胀] -``` - -调试信息位置:直接嵌入在 `.o` 目标文件和最终可执行文件中 - -文件结构: - -```shell -App (Mach-O) -├── __TEXT (代码段) -├── __DATA (数据段) -└── __DWARF (调试段) // 包含所有调试信息 -``` - -调试过程:LLDB 直接从可执行文件中读取调试信息 - -优点:调试速度快(无需加载额外文件) - -缺点:增加30%~50%的 App 体积,源码信息泄漏 - -##### 2. DWARF with dSYM File - -```mermaid -graph LR -A[源代码] --> B[编译] -B --> C[生成Mach-O文件] -C --> D[dsymutil工具] -D --> E[剥离调试信息] -E --> F[精简的可执行文件] -D --> G[生成.dSYM文件] -``` - -文件结构: - -```shell -App (精简Mach-O) // 无调试信息 -App.dSYM // 独立调试信息包 - └── Contents - └── Resources - └── DWARF - └── App // 实际DWARF数据 -``` - -调试过程: - -- 调试器从 .dSYM 加载符号信息 -- 崩溃服务使用 .dSYM 符号化堆栈 - -| **特性** | **DWARF** | **DWARF with dSYM File** | -| :--------------- | :----------------- | :----------------------- | -| **调试信息位置** | 嵌入在可执行文件中 | 分离到独立的.dSYM文件 | -| **文件大小** | 可执行文件体积大 | 可执行文件体积小 | -| **调试速度** | 调试启动快 | 调试启动稍慢 | -| **符号化能力** | 需要完整可执行文件 | 使用.dSYM文件即可 | -| **适用场景** | 开发调试阶段 | 发布/测试阶段 | -| **安全隐私** | 源码信息易暴露 | 保护源码信息 | -| **Bitcode支持** | 不支持 | 支持 | -| **默认配置** | Debug模式默认 | Release模式默认 | - - - -#### 3. 深入理解程序技术器 - -在崩溃日志中 `4 ExploreDSYM 0x103b84f17 -[ViewController visitArray] + 55 (ViewController.m:25)` 的 `+55` 表示**程序计数器(PC)距离函数入口点的字节偏移** - -##### 1. 程序执行流程 - -```mermaid -graph LR - A[函数入口] --> B[指令1] - B --> C[指令2] - C --> D[...] - D --> E[崩溃点] - E --> F[后续指令] - - style A fill:#4CAF50,stroke:#388E3C - style E fill:#F44336,stroke:#D32F2F -``` -- 偏移量:0x37(十进制 55) -- 函数入口地址:0x103b84f17 - 0x37 = 0x103B84EE0(符号 -[ViewController visitArray] 的起始地址) -- 奔溃点地址:0x103b84f17 - -##### 2. 为什么需要这个偏移量? -###### 1. 精确代码定位 -- 函数内部位置:程序员写的一个函数,最后转换为汇编,内部可能存在好几十条指令。偏移量精确定位到具体指令 -- 多行代码映射:一个函数可能对应的多行源代码,偏移量确定具体行号 - -###### 2. 优化代码分析 -- 内联函数识别:当函数被内联时,偏移量帮助定位到函数原始地址 -- 尾部调用优化:编译器优化后,返回地址可能不在函数末尾 - - -#### 4. 剖析 .dSYM 符号化 - -第一步:新建 iOS 项目,Buidl Setting 切换为 `DWARF with dSYM File ` 。设置模拟 crash(数组越界) - -第二步:Mac 自带 `console` App 查看崩溃报告,因为有 `.dSYM` 文件,所以可以看到方法信息 - - - -思考:假设线上 crash 了,如何根据 crash 堆栈中没有符号化的地址,找到符号的真实地址? - -```shell -------------------------------------- -Translated Report (Full Report Below) -------------------------------------- - -Incident Identifier: 2AA5A2DD-78A3-4FCC-807D-4523F88DBD0E -CrashReporter Key: B8015533-4AEE-8714-C435-0ABAB8898AE5 -Hardware Model: MacBookPro11,4 -Process: ExploreDSYM [23538] -Path: /Users/USER/Library/Developer/CoreSimulator/Devices/C099153B-7736-4241-A734-2514070F8E90/data/Containers/Bundle/Application/E85716C6-245A-4B16-BA20-1A030036DE46/ExploreDSYM.app/ExploreDSYM -Identifier: com.unix.kernel.ExploreDSYM -Version: 1.0 (1) -Code Type: X86-64 (Native) -Role: Foreground -Parent Process: launchd_sim [18357] -Coalition: com.apple.CoreSimulator.SimDevice.C099153B-7736-4241-A734-2514070F8E90 [8102] -Responsible Process: SimulatorTrampoline [624] - -Date/Time: 2025-06-19 17:15:50.0523 +0800 -Launch Time: 2025-06-19 17:15:47.7752 +0800 -OS Version: macOS 12.7.6 (21H1320) -Release Type: User -Report Version: 104 - -Exception Type: EXC_CRASH (SIGABRT) -Exception Codes: 0x0000000000000000, 0x0000000000000000 -Exception Note: EXC_CORPSE_NOTIFY -Triggered by Thread: 0 - -Last Exception Backtrace: -0 CoreFoundation 0x7ff80042889b __exceptionPreprocess + 226 -1 libobjc.A.dylib 0x7ff80004dba3 objc_exception_throw + 48 -2 CoreFoundation 0x7ff8004b0019 -[__NSCFString characterAtIndex:].cold.1 + 0 -3 CoreFoundation 0x7ff80035282d +[NSConstantArray automaticallyNotifiesObserversForKey:] + 0 -4 ExploreDSYM 0x103b84f17 -[ViewController visitArray] + 55 (ViewController.m:25) -5 ExploreDSYM 0x103b84e64 __29-[ViewController viewDidLoad]_block_invoke + 36 (ViewController.m:20) -6 libdispatch.dylib 0x7ff80013ca3a _dispatch_client_callout + 8 -7 libdispatch.dylib 0x7ff80013fe87 _dispatch_continuation_pop + 715 -8 libdispatch.dylib 0x7ff80015534a _dispatch_source_invoke + 2046 -9 libdispatch.dylib 0x7ff80014c1e9 _dispatch_main_queue_drain + 1015 -10 libdispatch.dylib 0x7ff80014bde4 _dispatch_main_queue_callback_4CF + 31 -11 CoreFoundation 0x7ff800387b1f __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9 -12 CoreFoundation 0x7ff800382436 __CFRunLoopRun + 2482 -13 CoreFoundation 0x7ff8003816a7 CFRunLoopRunSpecific + 560 -14 GraphicsServices 0x7ff809cb128a GSEventRunModal + 139 -15 UIKitCore 0x10c80aad3 -[UIApplication _run] + 994 -16 UIKitCore 0x10c80f9ef UIApplicationMain + 123 -17 ExploreDSYM 0x103b851be main + 110 (main.m:17) -18 dyld_sim 0x103dc12bf start_sim + 10 -19 dyld 0x1055ce52e start + 462 - -Thread 0 Crashed:: Dispatch queue: com.apple.main-thread -0 libsystem_kernel.dylib 0x7ff83611cfce __pthread_kill + 10 -1 libsystem_pthread.dylib 0x7ff8361741ff pthread_kill + 263 -2 libsystem_c.dylib 0x7ff800132fe0 abort + 130 -3 libc++abi.dylib 0x7ff800258742 abort_message + 241 -4 libc++abi.dylib 0x7ff80024995d demangling_terminate_handler() + 266 -5 libobjc.A.dylib 0x7ff800032082 _objc_terminate() + 96 -6 libc++abi.dylib 0x7ff800257b65 std::__terminate(void (*)()) + 8 -7 libc++abi.dylib 0x7ff800257b16 std::terminate() + 54 -8 libdispatch.dylib 0x7ff80013ca4e _dispatch_client_callout + 28 -9 libdispatch.dylib 0x7ff80013fe87 _dispatch_continuation_pop + 715 -10 libdispatch.dylib 0x7ff80015534a _dispatch_source_invoke + 2046 -11 libdispatch.dylib 0x7ff80014c1e9 _dispatch_main_queue_drain + 1015 -12 libdispatch.dylib 0x7ff80014bde4 _dispatch_main_queue_callback_4CF + 31 -13 CoreFoundation 0x7ff800387b1f __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9 -14 CoreFoundation 0x7ff800382436 __CFRunLoopRun + 2482 -15 CoreFoundation 0x7ff8003816a7 CFRunLoopRunSpecific + 560 -16 GraphicsServices 0x7ff809cb128a GSEventRunModal + 139 -17 UIKitCore 0x10c80aad3 -[UIApplication _run] + 994 -18 UIKitCore 0x10c80f9ef UIApplicationMain + 123 -19 ExploreDSYM 0x103b851be main + 110 (main.m:17) -20 dyld_sim 0x103dc12bf start_sim + 10 -21 dyld 0x1055ce52e start + 462 - -Thread 1: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - -Thread 2: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - -Thread 3: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - -Thread 4:: com.apple.uikit.eventfetch-thread -0 libsystem_kernel.dylib 0x7ff83611693a mach_msg_trap + 10 -1 libsystem_kernel.dylib 0x7ff836116ca8 mach_msg + 56 -2 CoreFoundation 0x7ff80038788e __CFRunLoopServiceMachPort + 145 -3 CoreFoundation 0x7ff800381fdf __CFRunLoopRun + 1371 -4 CoreFoundation 0x7ff8003816a7 CFRunLoopRunSpecific + 560 -5 Foundation 0x7ff800c568b4 -[NSRunLoop(NSRunLoop) runMode:beforeDate:] + 213 -6 Foundation 0x7ff800c56b2d -[NSRunLoop(NSRunLoop) runUntilDate:] + 72 -7 UIKitCore 0x10c8e0286 -[UIEventFetcher threadMain] + 535 -8 Foundation 0x7ff800c8011b __NSThread__start__ + 1009 -9 libsystem_pthread.dylib 0x7ff8361744e1 _pthread_start + 125 -10 libsystem_pthread.dylib 0x7ff83616ff6b thread_start + 15 - -Thread 5: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - -Thread 6: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - -Thread 7: -0 libsystem_pthread.dylib 0x7ff83616ff48 start_wqthread + 0 - - -Thread 0 crashed with X86 Thread State (64-bit): - rax: 0x0000000000000000 rbx: 0x0000000105649600 rcx: 0x00007ff7bc379b98 rdx: 0x0000000000000000 - rdi: 0x0000000000000103 rsi: 0x0000000000000006 rbp: 0x00007ff7bc379bc0 rsp: 0x00007ff7bc379b98 - r8: 0x00007ff7bc379a60 r9: 0x00007ff7bc379cc0 r10: 0x0000000000000000 r11: 0x0000000000000246 - r12: 0x0000000000000103 r13: 0x0000003000000008 r14: 0x0000000000000006 r15: 0x0000000000000016 - rip: 0x00007ff83611cfce rfl: 0x0000000000000246 cr2: 0x0000000000000000 - -Logical CPU: 0 -Error Code: 0x02000148 -Trap Number: 133 - - -Binary Images: - 0x7ff836115000 - 0x7ff83614cfff libsystem_kernel.dylib (*) <2fe67e94-4a5e-3506-9e02-502f7270f7ef> /usr/lib/system/libsystem_kernel.dylib - 0x7ff83616e000 - 0x7ff836179ff7 libsystem_pthread.dylib (*) <5a5f7316-85b7-315e-baf3-76211ee65604> /usr/lib/system/libsystem_pthread.dylib - 0x7ff8000b5000 - 0x7ff800139ff7 libsystem_c.dylib (*) <8a60f5c1-ea1f-352b-b778-967be44e3677> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/system/libsystem_c.dylib - 0x7ff800248000 - 0x7ff80025dffb libc++abi.dylib (*) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libc++abi.dylib - 0x7ff80002c000 - 0x7ff80005ffe9 libobjc.A.dylib (*) <2a7a213a-fdb2-311c-81d7-efdfd9ddf25a> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libobjc.A.dylib - 0x7ff80013a000 - 0x7ff800185ff3 libdispatch.dylib (*) <59be51c1-e9f3-3a60-8108-cd70ae082897> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/system/libdispatch.dylib - 0x7ff800303000 - 0x7ff80068bffc com.apple.CoreFoundation (6.9) <2be0f79f-8b25-3614-9e7e-dbac565f72dd> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation - 0x7ff809cae000 - 0x7ff809cb5ff2 com.apple.GraphicsServices (1.0) <16365e42-1d5c-363d-84d1-3bb290a43253> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices - 0x10b9c9000 - 0x10d494fff com.apple.UIKitCore (1.0) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore - 0x103b83000 - 0x103b86fff com.unix.kernel.ExploreDSYM (1.0) /Users/USER/Library/Developer/CoreSimulator/Devices/C099153B-7736-4241-A734-2514070F8E90/data/Containers/Bundle/Application/E85716C6-245A-4B16-BA20-1A030036DE46/ExploreDSYM.app/ExploreDSYM - 0x103dbf000 - 0x103e1efff dyld_sim (*) <6fb74554-3370-3677-93d4-7f7a01ea6a80> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim - 0x1055c9000 - 0x105634fff dyld (*) /usr/lib/dyld - 0x7ff8006fe000 - 0x7ff80102eff4 com.apple.Foundation (6.9) <86cd050d-44fc-3045-a1f3-8ad5047b329e> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/Foundation.framework/Foundation - -EOF - ------------ -Full Report ------------ - -{"app_name":"ExploreDSYM","timestamp":"2025-06-19 17:15:55.00 +0800","app_version":"1.0","slice_uuid":"be3ba671-c69f-3a68-9d76-310c4e150256","build_version":"1","platform":7,"bundleID":"com.unix.kernel.ExploreDSYM","share_with_app_devs":0,"is_first_party":0,"bug_type":"309","os_version":"macOS 12.7.6 (21H1320)","incident_id":"2AA5A2DD-78A3-4FCC-807D-4523F88DBD0E","name":"ExploreDSYM"} -{ - "uptime" : 110000, - "procLaunch" : "2025-06-19 17:15:47.7752 +0800", - "procRole" : "Foreground", - "version" : 2, - "userID" : 501, - "deployVersion" : 210, - "modelCode" : "MacBookPro11,4", - "procStartAbsTime" : 110816195256181, - "coalitionID" : 8102, - "osVersion" : { - "train" : "macOS 12.7.6", - "build" : "21H1320", - "releaseType" : "User" - }, - "captureTime" : "2025-06-19 17:15:50.0523 +0800", - "incident" : "2AA5A2DD-78A3-4FCC-807D-4523F88DBD0E", - "bug_type" : "309", - "pid" : 23538, - "procExitAbsTime" : 110818470471618, - "cpuType" : "X86-64", - "procName" : "ExploreDSYM", - "procPath" : "\/Users\/USER\/Library\/Developer\/CoreSimulator\/Devices\/C099153B-7736-4241-A734-2514070F8E90\/data\/Containers\/Bundle\/Application\/E85716C6-245A-4B16-BA20-1A030036DE46\/ExploreDSYM.app\/ExploreDSYM", - "bundleInfo" : {"CFBundleShortVersionString":"1.0","CFBundleVersion":"1","CFBundleIdentifier":"com.unix.kernel.ExploreDSYM"}, - "storeInfo" : {"deviceIdentifierForVendor":"9BE90473-1F72-514D-A6F8-2A4F5F1C42D7","thirdParty":true}, - "parentProc" : "launchd_sim", - "parentPid" : 18357, - "coalitionName" : "com.apple.CoreSimulator.SimDevice.C099153B-7736-4241-A734-2514070F8E90", - "crashReporterKey" : "B8015533-4AEE-8714-C435-0ABAB8898AE5", - "responsiblePid" : 624, - "responsibleProc" : "SimulatorTrampoline", - "wakeTime" : 27225, - "sleepWakeUUID" : "231A480C-858C-466F-AA42-E97E3B5B94AB", - "sip" : "enabled", - "isCorpse" : 1, - "exception" : {"codes":"0x0000000000000000, 0x0000000000000000","rawCodes":[0,0],"type":"EXC_CRASH","signal":"SIGABRT"}, - "asiBacktraces" : ["0 CoreFoundation 0x00007ff8004288ab __exceptionPreprocess + 242\n1 libobjc.A.dylib 0x00007ff80004dba3 objc_exception_throw + 48\n2 CoreFoundation 0x00007ff8004b0019 -[__NSCFString characterAtIndex:].cold.1 + 0\n3 CoreFoundation 0x00007ff80035282d +[NSConstantArray automaticallyNotifiesObserversForKey:] + 0\n4 ExploreDSYM 0x0000000103b84f17 -[ViewController visitArray] + 55\n5 ExploreDSYM 0x0000000103b84e64 __29-[ViewController viewDidLoad]_block_invoke + 36\n6 libdispatch.dylib 0x00007ff80013ca3a _dispatch_client_callout + 8\n7 libdispatch.dylib 0x00007ff80013fe87 _dispatch_continuation_pop + 715\n8 libdispatch.dylib 0x00007ff80015534a _dispatch_source_invoke + 2046\n9 libdispatch.dylib 0x00007ff80014c1e9 _dispatch_main_queue_drain + 1015\n10 libdispatch.dylib 0x00007ff80014bde4 _dispatch_main_queue_callback_4CF + 31\n11 CoreFoundation 0x00007ff800387b1f __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9\n12 CoreFoundation 0x00007ff800382436 __CFRunLoopRun + 2482\n13 CoreFoundation 0x00007ff8003816a7 CFRunLoopRunSpecific + 560\n14 GraphicsServices 0x00007ff809cb128a GSEventRunModal + 139\n15 UIKitCore 0x000000010c80aad3 -[UIApplication _run] + 994\n16 UIKitCore 0x000000010c80f9ef UIApplicationMain + 123\n17 ExploreDSYM 0x0000000103b851be main + 110\n18 dyld 0x0000000103dc12bf start_sim + 10\n19 ??? 0x00000001055ce52e 0x0 + 4384941358"], - "extMods" : {"caller":{"thread_create":0,"thread_set_state":0,"task_for_pid":0},"system":{"thread_create":0,"thread_set_state":627,"task_for_pid":9},"targeted":{"thread_create":0,"thread_set_state":0,"task_for_pid":0},"warnings":0}, - "lastExceptionBacktrace" : [{"imageOffset":1202331,"symbol":"__exceptionPreprocess","symbolLocation":226,"imageIndex":6},{"imageOffset":138147,"symbol":"objc_exception_throw","symbolLocation":48,"imageIndex":4},{"imageOffset":1757209,"symbol":"-[__NSCFString characterAtIndex:].cold.1","symbolLocation":0,"imageIndex":6},{"imageOffset":325677,"symbol":"+[NSConstantArray automaticallyNotifiesObserversForKey:]","symbolLocation":0,"imageIndex":6},{"imageOffset":7959,"sourceLine":25,"sourceFile":"ViewController.m","symbol":"-[ViewController visitArray]","imageIndex":9,"symbolLocation":55},{"imageOffset":7780,"sourceLine":20,"sourceFile":"ViewController.m","symbol":"__29-[ViewController viewDidLoad]_block_invoke","imageIndex":9,"symbolLocation":36},{"imageOffset":10810,"symbol":"_dispatch_client_callout","symbolLocation":8,"imageIndex":5},{"imageOffset":24199,"symbol":"_dispatch_continuation_pop","symbolLocation":715,"imageIndex":5},{"imageOffset":111434,"symbol":"_dispatch_source_invoke","symbolLocation":2046,"imageIndex":5},{"imageOffset":74217,"symbol":"_dispatch_main_queue_drain","symbolLocation":1015,"imageIndex":5},{"imageOffset":73188,"symbol":"_dispatch_main_queue_callback_4CF","symbolLocation":31,"imageIndex":5},{"imageOffset":543519,"symbol":"__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__","symbolLocation":9,"imageIndex":6},{"imageOffset":521270,"symbol":"__CFRunLoopRun","symbolLocation":2482,"imageIndex":6},{"imageOffset":517799,"symbol":"CFRunLoopRunSpecific","symbolLocation":560,"imageIndex":6},{"imageOffset":12938,"symbol":"GSEventRunModal","symbolLocation":139,"imageIndex":7},{"imageOffset":14949075,"symbol":"-[UIApplication _run]","symbolLocation":994,"imageIndex":8},{"imageOffset":14969327,"symbol":"UIApplicationMain","symbolLocation":123,"imageIndex":8},{"imageOffset":8638,"sourceLine":17,"sourceFile":"main.m","symbol":"main","imageIndex":9,"symbolLocation":110},{"imageOffset":8895,"symbol":"start_sim","symbolLocation":10,"imageIndex":10},{"imageOffset":21806,"symbol":"start","symbolLocation":462,"imageIndex":11}], - "faultingThread" : 0, - "threads" : [{"triggered":true,"id":1036283,"threadState":{"r13":{"value":206158430216},"rax":{"value":0},"rflags":{"value":582},"cpu":{"value":0},"r14":{"value":6},"rsi":{"value":6},"r8":{"value":140701991410272},"cr2":{"value":0},"rdx":{"value":0},"r10":{"value":0},"r9":{"value":140701991410880},"r15":{"value":22},"rbx":{"value":4385445376,"symbolLocation":0,"symbol":"_main_thread"},"trap":{"value":133},"err":{"value":33554760},"r11":{"value":582},"rip":{"value":140704035753934,"matchesCrashFrame":1},"rbp":{"value":140701991410624},"rsp":{"value":140701991410584},"r12":{"value":259},"rcx":{"value":140701991410584},"flavor":"x86_THREAD_STATE","rdi":{"value":259}},"queue":"com.apple.main-thread","frames":[{"imageOffset":32718,"symbol":"__pthread_kill","symbolLocation":10,"imageIndex":0},{"imageOffset":25087,"symbol":"pthread_kill","symbolLocation":263,"imageIndex":1},{"imageOffset":516064,"symbol":"abort","symbolLocation":130,"imageIndex":2},{"imageOffset":67394,"symbol":"abort_message","symbolLocation":241,"imageIndex":3},{"imageOffset":6493,"symbol":"demangling_terminate_handler()","symbolLocation":266,"imageIndex":3},{"imageOffset":24706,"symbol":"_objc_terminate()","symbolLocation":96,"imageIndex":4},{"imageOffset":64357,"symbol":"std::__terminate(void (*)())","symbolLocation":8,"imageIndex":3},{"imageOffset":64278,"symbol":"std::terminate()","symbolLocation":54,"imageIndex":3},{"imageOffset":10830,"symbol":"_dispatch_client_callout","symbolLocation":28,"imageIndex":5},{"imageOffset":24199,"symbol":"_dispatch_continuation_pop","symbolLocation":715,"imageIndex":5},{"imageOffset":111434,"symbol":"_dispatch_source_invoke","symbolLocation":2046,"imageIndex":5},{"imageOffset":74217,"symbol":"_dispatch_main_queue_drain","symbolLocation":1015,"imageIndex":5},{"imageOffset":73188,"symbol":"_dispatch_main_queue_callback_4CF","symbolLocation":31,"imageIndex":5},{"imageOffset":543519,"symbol":"__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__","symbolLocation":9,"imageIndex":6},{"imageOffset":521270,"symbol":"__CFRunLoopRun","symbolLocation":2482,"imageIndex":6},{"imageOffset":517799,"symbol":"CFRunLoopRunSpecific","symbolLocation":560,"imageIndex":6},{"imageOffset":12938,"symbol":"GSEventRunModal","symbolLocation":139,"imageIndex":7},{"imageOffset":14949075,"symbol":"-[UIApplication _run]","symbolLocation":994,"imageIndex":8},{"imageOffset":14969327,"symbol":"UIApplicationMain","symbolLocation":123,"imageIndex":8},{"imageOffset":8638,"sourceLine":17,"sourceFile":"main.m","symbol":"main","imageIndex":9,"symbolLocation":110},{"imageOffset":8895,"symbol":"start_sim","symbolLocation":10,"imageIndex":10},{"imageOffset":21806,"symbol":"start","symbolLocation":462,"imageIndex":11}]},{"id":1036302,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]},{"id":1036303,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]},{"id":1036304,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]},{"id":1036305,"name":"com.apple.uikit.eventfetch-thread","frames":[{"imageOffset":6458,"symbol":"mach_msg_trap","symbolLocation":10,"imageIndex":0},{"imageOffset":7336,"symbol":"mach_msg","symbolLocation":56,"imageIndex":0},{"imageOffset":542862,"symbol":"__CFRunLoopServiceMachPort","symbolLocation":145,"imageIndex":6},{"imageOffset":520159,"symbol":"__CFRunLoopRun","symbolLocation":1371,"imageIndex":6},{"imageOffset":517799,"symbol":"CFRunLoopRunSpecific","symbolLocation":560,"imageIndex":6},{"imageOffset":5605556,"symbol":"-[NSRunLoop(NSRunLoop) runMode:beforeDate:]","symbolLocation":213,"imageIndex":12},{"imageOffset":5606189,"symbol":"-[NSRunLoop(NSRunLoop) runUntilDate:]","symbolLocation":72,"imageIndex":12},{"imageOffset":15823494,"symbol":"-[UIEventFetcher threadMain]","symbolLocation":535,"imageIndex":8},{"imageOffset":5775643,"symbol":"__NSThread__start__","symbolLocation":1009,"imageIndex":12},{"imageOffset":25825,"symbol":"_pthread_start","symbolLocation":125,"imageIndex":1},{"imageOffset":8043,"symbol":"thread_start","symbolLocation":15,"imageIndex":1}]},{"id":1036306,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]},{"id":1036307,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]},{"id":1036308,"frames":[{"imageOffset":8008,"symbol":"start_wqthread","symbolLocation":0,"imageIndex":1}]}], - "usedImages" : [ - { - "source" : "P", - "arch" : "x86_64", - "base" : 140704035721216, - "size" : 229376, - "uuid" : "2fe67e94-4a5e-3506-9e02-502f7270f7ef", - "path" : "\/usr\/lib\/system\/libsystem_kernel.dylib", - "name" : "libsystem_kernel.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140704036085760, - "size" : 49144, - "uuid" : "5a5f7316-85b7-315e-baf3-76211ee65604", - "path" : "\/usr\/lib\/system\/libsystem_pthread.dylib", - "name" : "libsystem_pthread.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703129358336, - "size" : 544760, - "uuid" : "8a60f5c1-ea1f-352b-b778-967be44e3677", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/usr\/lib\/system\/libsystem_c.dylib", - "name" : "libsystem_c.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703131009024, - "size" : 90108, - "uuid" : "ae8cbd53-0926-3251-b648-6f32d9330a50", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/usr\/lib\/libc++abi.dylib", - "name" : "libc++abi.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703128797184, - "size" : 212970, - "uuid" : "2a7a213a-fdb2-311c-81d7-efdfd9ddf25a", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/usr\/lib\/libobjc.A.dylib", - "name" : "libobjc.A.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703129903104, - "size" : 311284, - "uuid" : "59be51c1-e9f3-3a60-8108-cd70ae082897", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/usr\/lib\/system\/libdispatch.dylib", - "name" : "libdispatch.dylib" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703131774976, - "CFBundleShortVersionString" : "6.9", - "CFBundleIdentifier" : "com.apple.CoreFoundation", - "size" : 3706877, - "uuid" : "2be0f79f-8b25-3614-9e7e-dbac565f72dd", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/System\/Library\/Frameworks\/CoreFoundation.framework\/CoreFoundation", - "name" : "CoreFoundation", - "CFBundleVersion" : "1953.300" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703292907520, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.GraphicsServices", - "size" : 32755, - "uuid" : "16365e42-1d5c-363d-84d1-3bb290a43253", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/System\/Library\/PrivateFrameworks\/GraphicsServices.framework\/GraphicsServices", - "name" : "GraphicsServices", - "CFBundleVersion" : "1.0" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 4489777152, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.apple.UIKitCore", - "size" : 28098560, - "uuid" : "adb282b1-2fb2-38e0-8492-47f9443eb1ef", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/System\/Library\/PrivateFrameworks\/UIKitCore.framework\/UIKitCore", - "name" : "UIKitCore", - "CFBundleVersion" : "6209" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 4357369856, - "CFBundleShortVersionString" : "1.0", - "CFBundleIdentifier" : "com.unix.kernel.ExploreDSYM", - "size" : 16384, - "uuid" : "be3ba671-c69f-3a68-9d76-310c4e150256", - "path" : "\/Users\/USER\/Library\/Developer\/CoreSimulator\/Devices\/C099153B-7736-4241-A734-2514070F8E90\/data\/Containers\/Bundle\/Application\/E85716C6-245A-4B16-BA20-1A030036DE46\/ExploreDSYM.app\/ExploreDSYM", - "name" : "ExploreDSYM", - "CFBundleVersion" : "1" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 4359712768, - "size" : 393216, - "uuid" : "6fb74554-3370-3677-93d4-7f7a01ea6a80", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/usr\/lib\/dyld_sim", - "name" : "dyld_sim" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 4384919552, - "size" : 442368, - "uuid" : "eea022bb-a6ab-3cd1-8ac1-54ce8cfd3333", - "path" : "\/usr\/lib\/dyld", - "name" : "dyld" - }, - { - "source" : "P", - "arch" : "x86_64", - "base" : 140703135948800, - "CFBundleShortVersionString" : "6.9", - "CFBundleIdentifier" : "com.apple.Foundation", - "size" : 9637877, - "uuid" : "86cd050d-44fc-3045-a1f3-8ad5047b329e", - "path" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Library\/Developer\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime\/Contents\/Resources\/RuntimeRoot\/System\/Library\/Frameworks\/Foundation.framework\/Foundation", - "name" : "Foundation", - "CFBundleVersion" : "1953.300" - } -], - "sharedCache" : { - "base" : 140703128616960, - "size" : 3002335232, - "uuid" : "229cb26a-91c2-3a54-ac19-858f5ac3393c" -}, - "vmSummary" : "ReadOnly portion of Libraries: Total=647.6M resident=0K(0%) swapped_out_or_unallocated=647.6M(100%)\nWritable regions: Total=612.5M written=0K(0%) resident=0K(0%) swapped_out=0K(0%) unallocated=612.5M(100%)\n\n VIRTUAL REGION \nREGION TYPE SIZE COUNT (non-coalesced) \n=========== ======= ======= \nActivity Tracing 256K 1 \nColorSync 32K 4 \nFoundation 16K 1 \nKernel Alloc Once 8K 1 \nMALLOC 216.4M 40 \nMALLOC guard page 32K 7 \nMALLOC_NANO (reserved) 384.0M 1 reserved VM address space (unallocated)\nSTACK GUARD 56.0M 8 \nStack 11.6M 8 \nVM_ALLOCATE 28K 3 \n__DATA 4836K 257 \n__DATA_CONST 23.0M 270 \n__DATA_DIRTY 26K 12 \n__FONT_DATA 4K 1 \n__LINKEDIT 350.8M 18 \n__OBJC_RO 28.4M 1 \n__OBJC_RW 882K 1 \n__TEXT 296.8M 278 \ndyld private memory 1280K 2 \nmapped file 37.1M 7 \nshared memory 16K 1 \n=========== ======= ======= \nTOTAL 1.4G 922 \nTOTAL, minus reserved VM space 1.0G 922 \n", - "legacyInfo" : { - "threadTriggered" : { - "queue" : "com.apple.main-thread" - } -}, - "trialInfo" : { - "rollouts" : [ - { - "rolloutId" : "6112e14f37f5d11121dcd519", - "factorPackIds" : { - "SIRI_TEXT_TO_SPEECH" : "634710168e8be655c1316aaa" - }, - "deploymentId" : 240000231 - }, - { - "rolloutId" : "6112dda2fc54bc3389840642", - "factorPackIds" : { - "SIRI_DICTATION_ASSETS" : "620aec83b02b354d3afd2f50" - }, - "deploymentId" : 240000145 - } - ], - "experiments" : [ - - ] -} -} -``` - -分析: - -问题1: `4 ExploreDSYM 0x103b84f17 -[ViewController visitArray] + 55 (ViewController.m:25)` Crash 日志中的符号地址是偏移后的还是偏移前的? - -- iOS 存在 ASLR 技术,所以当发生 crash 的时候,获取到的符号是已经经过 dyld 启动加载,被偏移之后的地址 - -问题2:.dSYM 文件保存的是虚拟地址还是偏移后的地址? - -处于安全原因,Apple 设计了 ASLR 机制。是 App 启动,经由 dyld 后对加载的动态库做了地址随机偏移,而 .dSYM 文件是在编译、链接后生成的,所以肯定保存的是虚拟地址。 - - - -问题3:怎么计算原始地址? - -- 所以为了还原原始地址,也就是**符号偏移量**, 需要经过计算:**RuntimeAddress = ImageBaseAddress + SymbolOffset + PCOffset** => **SymbolOffset = RuntimeAddress - ImageBaseAddress - PCOffset** - - RuntimeAddress:**崩溃点运行时地址**,也就是 Crash 日志中符号的地址 - - ImageBaseAddress:**镜像加载基址**,也就是 Crash 日志中,符号归属库的价值开始地址 - - PCOffset:崩溃点距离符号起始的指令偏移 - -如上 Crash 数据: - -- crash 发生在:**`4 ExploreDSYM 0x103b84f17 -[ViewController visitArray] + 55 (ViewController.m:25)`**,其中 **+55** 偏移量表示程序计数器(PC)距离函数入口点的字节偏移量,所以 **RuntimeAddress = 0x103b84f17,PCOffset = 0x37(十进制55,十六进制37)** - -- Crash 发生的的符号所在动态库为:ExploreDSYM。Crash 日志下面的 **Binary Images** 列出来所依赖动态库信息: - - ``` shell - ExploreDSYM 0x103b83000 - 0x103b86fff com.unix.kernel.ExploreDSYM (1.0) /Users/USER/Library/Developer/CoreSimulator/Devices/C099153B-7736-4241-A734-2514070F8E90/data/Containers/Bundle/Application/E85716C6-245A-4B16-BA20-1A030036DE46/ExploreDSYM.app/ExploreDSYM - ``` - - 所以 **ImageBaseAddress = 0x103b83000** - -- 根据上述公式:**SymbolOffset = RuntimeAddress - ImageBaseAddress - PCOffset = 0x103b84f17 - 0x103b83000 - 0x37 = 0x100001EE0** - -- 3种验证方式: - - - 在终端切目录到 App 内用:**nm -a ExploreDSYM | grep 1ee0** 验证,发现找到该符号的信息 - - - - - 在终端切目录到 App 内用: `dwarfdump --lookup 地址 --arch 架构 {AppName}.app.dSYM` 来找到相关信息,里面有符号名、文件名、代码行数,具体为:**dwarfdump --lookup 0x100001EE0 ExploreDSYM.app.dSYM** 验证,发现找到该符号的信息 - - - - - 在终端切目录到 App 内用:**objdump -d --start-address=0x100001ee0 --stop-address=0x100001f17 ExploreDSYM.app/ExploreDSYM** 验证,发现找到该符号的信息 - - 指令格式为: - - ```shell - objdump -d --start-address={0x100001ee0} \ - --stop-address={0x100001ee0+0x37} \ - ExploreDSYM - ----> - objdump -d --start-address=0x100001ee0 \ - --stop-address=0x100001F17 \ - ExploreDSYM - ``` - - - - -- 符号表存储了当前文件的符号信息,静态链接器(ld) 和动态链接器(dyld) 在链接的过程中都会读取符号表,另外调试器也会用符号表来把符号映射到源文件。 -- 打包上线的时候会把调试符号等裁剪掉,但是线上统计到的堆栈我们仍然要能够知道对应的源代码,这时候就需要把符号写到另外一个单独的文件里,这个文件就是 DSYM。 -- 符号化,什么是符号化?依靠符号表根据地址找对应的符号名、代码文件名的这个过程就叫符号化 -- 但是符号表中记录的信息是未经 ASLR 的,也就是根据偏移地址来记录的。所以 crash 发生拿到的地址,需要计算出原始地址(原始地址也是相对 Mach-O 的地址)才可以根据符号表拿到原始符号、文件信息 - - - -#### 5. 计算原始地址 - -核心公式:**RuntimeAddress = ImageBaseAddress + SymbolOffset + PCOffset** - -```objective-c -#import -#import - -// 获取 ASLR 后的地址,也就是 RuntimeAddress -uintptr_t get_slide_address(void) { - uintptr_t vmAddress_slide = 0; - // 遍历所有加载过的 image,包括 ipa 中的可执行文件(也就是主 App 的可执行文件) + 依赖的动态库 - for (uint32_t i = 0; i < _dyld_image_count(); i++) { - // 处理当前 image - // 当前 image 的名称 - const char *imageName = (char *)_dyld_get_image_name(i); - - const struct mach_header *header = _dyld_get_image_header(i); - if (header->filetype == MH_EXECUTE) { - // 获取 image 当前的偏移地址。偏移是基于该符号所在 image 的。vmAddress_slide 也就是 ImageBaseAddress - vmAddress_slide = _dyld_get_image_vmaddr_slide(i); - - NSString *str = [NSString stringWithUTF8String:imageName]; - // 当前 image 名如果包含 ExploreDSYM,则说明是要找的 image 地址 - if ([str containsString:@"ExploreDSYM"]) { - NSLog(@"image name %s at address 0x%llx and ASLR slide 0x%lx.\n", imageName, (mach_vm_address_t)header, vmAddress_slide); - break; - } - } - } - return vmAddress_slide; -} - -// 计算虚拟内存地址 SymbolOffset = RuntimeAddress - ImageBaseAddress -- (void)getRuntimeAddress { - // 获取 sel 的 ASLR 后的地址,因为启动后经过 dyld 做了偏移 - IMP imp = class_getMethodImplementation(self.class, @selector(visitArray)); - unsigned long imppos = (unsigned long)imp; - unsigned long slide = get_slide_address(); - // SymbolOffset = RuntimeAddress - ImageBaseAddress - unsigned long addr = imppos - slide; - NSLog(@"%lu", addr); -} -``` - -拿到真实地址,然后利用 **dwarfdump --lookup 0x0000000100001cb0 ./ExploreDSYM.app.dSYM** 找到对应的信息,可以看到 - -- 符号名 -- 符号所在代码文件 -- 符号所在代码文件的多少行 - - - -可以确认:由于 iOS ASLR 的存在,符号真正的地址,是基于 image 的开始地址进行偏移得到的。 - -**RuntimeAddress = ImageBaseAddress + SymbolOffset + PCOffset**,类似一个 `y = 1x + b` 的运算 - -注意:PCOffset 理论上一直存在,但**表示上可能缺失**: - -- 当 PCOffset = 0 时通常省略 -- 调试信息不足时不显示 -- 系统库/优化代码中常见省略 - - - -## 十四、编译链接综合实战题目 - -### 1. 动态库还是静态库,对于 App 包体积影响更大? - -核心结论:**在绝大多数情况下,链接静态库对 iOS App 包体积影响更小(即产生的 App 更小)** - -**原因分析:** - -1. **静态库的链接机制与优化:** - - 静态库 (`libxxx.a`) 本质上是一个 `.o` 文件的归档。 - - 当 App 链接静态库时,**链接器 (`ld`)** 并不会把整个静态库文件都塞进最终的可执行文件。 - - 相反,链接器会执行一个关键步骤:**只提取** App 代码实际**引用到的那些 `.o` 文件**中的代码和数据。 - - 更进一步,在链接器内部(或通过 Xcode 的 `Dead Code Stripping` 选项)会进行 **`Dead Code Stripping` (死代码剥离)**。这意味着即使链接器提取了一个 `.o` 文件,它也会分析这个 `.o` 文件内部的符号(函数、全局变量等),**只保留那些最终被 App 或其他库代码实际使用到的符号**。没有被任何地方引用的符号会被安全地移除。 - - **这就是静态库节省体积的关键:链接器在目标文件级别和符号级别进行了精细的裁剪,只包含 App 真正需要的部分。** -2. **动态库的打包机制:** - - 动态库 (`libxxx.dylib` 或 `.framework`) 是一个独立的、预链接好的可执行代码单元。 - - 当 App 链接并使用一个动态库时,App 本身只包含对这个库的**引用信息**(比如需要调用的函数名、库的安装路径等)。这些信息存储在 `LC_LOAD_DYLIB` 加载命令和符号表中。 - - **然而,动态库文件本身 (`xxx.dylib` 或 `xxx.framework/xxx`) 必须作为一个完整的文件被包含在 App Bundle 中。** - - 即使 App 只使用了动态库中的一小部分功能(比如只用了其中一个函数),**整个动态库文件也必须被打包进去**。链接器没有机会像处理静态库那样只提取动态库中被用到的部分。 - - **这是动态库增大包体积的关键:无论实际使用多少,整个库文件都必须完整地包含在 App 里。** -3. **符号表与 Stripping:** - - 正如你提到的,无论是静态链接还是动态链接,最终 App 可执行文件 (`Mach-O`) 都会包含一个符号表。 - - 在 App 发布时(Archive 或 Release build),Xcode 会执行 **`Strip Linked Product`** (通常设置为 `YES`)。这个步骤会移除对运行时**非必需**的符号信息。 - - **剥离后:** - - **静态链接的代码:** 剥离后只剩下极其有限的调试信息(如果保留的话)和动态链接器必需的符号(如 `NSClassFromString` 需要的类名)。大部分原始符号名称都被移除,只保留地址信息。 - - **动态库引用:** App 本身只需要保留对动态库中**外部符号的引用信息**(如函数名)。这些引用信息在剥离过程中**通常会被保留**,因为它们是动态链接器在运行时正确绑定符号所必需的(除非是隐藏符号)。但更重要的是,**动态库文件本身内部也包含了自己的符号表**。这个符号表在动态库被剥离时会被精简,但**整个动态库文件的体积不会因为 App 使用了其中多少而改变**。 - - **关键点:** 符号表的剥离 (`strip`) 发生在链接之后,它确实能减小可执行文件的大小,但符号表本身在整个二进制中占比相对较小。**影响包体积的最大因素还是代码段 (`__TEXT`) 和数据段 (`__DATA`) 的大小。** 静态库通过 `Dead Code Stripping` 在链接阶段就移除了未使用的代码/数据,这是它节省体积的主要手段。动态库缺少这种精细的按需提取机制。 -4. **系统动态库的例外:** - - **系统提供的动态库** (如 `UIKit`, `Foundation`, `libSystem.dylib`) **不需要**打包到你的 App Bundle 中。它们已经存在于 iOS 设备上。 - - 链接系统库时,App 只包含引用信息,不会增加额外体积(除了符号表引用,但剥离后也很小)。 - - **只有你 App 自己打包的第三方动态库或自己开发的动态库才会增加 App 体积。** - -#### 1. **间接符号表(Indirect Symbol Table)的本质** - -- **它存储的是索引,不是地址!** - - 它位于 Mach-O 文件的 `__LINKEDIT` 段中。 - - 它是一个 **`uint32_t` 数组**。 - - 每个条目存储的是一个 **符号在动态符号表(`Dynamic Symbol Table`, `LC_DYSYMTAB` Load Command 指向)中的索引**。 -- **作用:** 它是连接 **需要动态绑定符号的位置(如 `__stubs`, `__got`, `__la_symbol_ptr`)** 和 **符号定义(在某个动态库中)** 的桥梁。 -- **在磁盘上的 Mach-O 文件中:** 间接符号表只包含索引信息,**不包含任何目标地址**。目标地址是未知的,需要在运行时解析。 - -#### 2. **绑定时机:启动时绑定 vs 延迟绑定** - -`dyld` 解析符号地址的时机由符号的 **绑定信息(Binding Info)** 决定。这些信息存储在 `__LINKEDIT` 段中,由 `LC_DYLD_INFO` 或 `LC_DYLD_INFO_ONLY` Load Command 指向。 - -- **a) 启动时绑定 (Non-Lazy / Load-Time Binding):** - - **绑定对象:** - - **全局/静态变量 (`__DATA, __got` 或 `__DATA_CONST, __got`):** 这些数据符号**必须在程序启动、任何代码访问它们之前**就被解析并填入正确的地址。因为数据访问通常没有像函数调用那样的“桩”机制来延迟处理。 - - **某些关键函数 (较少见):** 如果编译器或链接器明确指定需要立即绑定(例如使用 `-bind_at_load` 链接器标志,但在 iOS/macOS 上通常不鼓励)。 - - **绑定时机:** `dyld` 在 App 启动过程的 **`bind phase`** 完成这些绑定。 - - **过程:** - 1. `dyld` 加载所有依赖的动态库。 - 2. 在 `bind phase`,`dyld` 遍历 **`bind opcodes`**(一种紧凑的指令序列,描述了哪些位置的指针需要绑定到哪个库的哪个符号)。 - 3. 根据 `bind opcode` 找到: - - 需要绑定的位置(例如 `__got` 中的某个条目地址)。 - - 目标符号名(通过间接符号表索引找到动态符号表项,得到符号名)。 - - 目标库。 - 4. `dyld` 在目标库的导出符号表(通常是 `Trie` 树)中查找该符号,得到其实际内存地址(库基地址 `slide` + 符号偏移)。 - 5. **将这个实际地址写入**需要绑定的位置(如 `__got` 中的条目)。 - - **结果:** 当 App 的 `main` 函数执行时,这些非延迟绑定的符号地址**已经确定**。访问这些数据或调用这些函数(如果绑定了函数)不会有额外延迟。 -- **b) 延迟绑定 (Lazy Binding / Run-Time Binding):** - - **绑定对象:** **函数符号**(绝大多数情况下)。这是默认且优化的行为。 - - **绑定时机:** **第一次调用该函数时。** - - **机制 (基于 `__stubs` 和 `__la_symbol_ptr`):** - 1. **`__stubs` Section (`__TEXT` 段):** - - 包含一系列小的桩代码(`stub`)。每个桩代码对应一个需要延迟绑定的函数。 - - 初始状态下,每个 `stub` 的指令是跳转到 `dyld_stub_binder` 函数(由 `dyld` 提供),并传递一个关键参数:该桩对应的 **延迟绑定信息偏移量(Lazy Binding Info Offset)**。 - 2. **`__la_symbol_ptr` Section (`__DATA` 段):** - - 是一个指针数组(`Pointer`)。 - - **初始状态:** 每个条目存储的是**对应 `__stubs` 中那个桩代码的地址**。这确保了第一次调用函数时,CPU 会跳转到 `__stubs` 中的桩代码。 - 3. **第一次调用发生:** - - App 代码调用 `NSLog` (编译后通常是 `callq _NSLog`)。 - - `_NSLog` 符号在编译时被解析为指向 `__stubs` 中 `NSLog` 桩代码的地址(或者在某些架构/优化下直接指向 `__la_symbol_ptr` 条目)。 - - CPU 执行跳转到 `__stubs` 中的 `NSLog` 桩代码。 - - 桩代码执行: - - 跳转到 `dyld_stub_binder`。 - - 传递自身对应的延迟绑定信息偏移量。 - 4. **`dyld_stub_binder` 工作:** - - 根据传入的偏移量,找到延迟绑定信息(`lazy bind opcodes`)。 - - 解析 `opcode`:确定要绑定的符号名(通过间接符号表索引)和目标库。 - - 在目标库的导出符号表(`Trie`)中查找该符号,得到 `NSLog` 的实际内存地址。 - - **关键步骤:** `dyld_stub_binder` **将 `__la_symbol_ptr` 中对应 `NSLog` 的条目内容,从指向桩代码的地址,改写为 `NSLog` 函数的实际地址!** - - 然后,`dyld_stub_binder` **直接跳转到 `NSLog` 函数的实际地址执行。** - 5. **后续调用:** - - 由于 `__la_symbol_ptr` 中的 `NSLog` 条目已被更新为真实地址。 - - 下一次调用 `NSLog` 时,CPU 会**直接跳转到 `NSLog` 的真实地址**,不再经过 `__stubs` 桩代码和 `dyld_stub_binder`。**绑定只发生一次!** - - **优点:** - - **加速启动:** App 启动时,`dyld` 只需要处理非延迟绑定(主要是数据)。成千上万的函数绑定被推迟到实际使用时,显著减少了启动时间。 - - **节省内存和 I/O:** 如果某些函数在整个 App 生命周期中从未被调用(例如,特定功能分支下的函数),那么它们就**永远不会被绑定**,避免了不必要的查找和 I/O(读取绑定信息、查找符号)。 - - **`LC_DYLD_INFO` 中的 `lazy_bind_off/size`:** 专门存储描述哪些函数符号需要延迟绑定以及如何绑定的 `opcode` 指令。 - -#### 3. GOT 的核心作用和工作原理 - -##### 1. 解决数据访问的位置无关性问题 - -- 动态库(或 PIE 可执行文件)会被加载到内存的任意地址(由 ASLR 随机化)。编译器在编译时**无法预知**全局变量(如 `extern int g_globalVar;`)最终会位于哪个内存地址。 -- 如果代码直接使用绝对地址访问 `g_globalVar`(如 `mov eax, [0x12345678]`),在加载地址随机化后,这个地址肯定是错误的。 -- **GOT 提供了间接层:** 代码不直接访问全局变量的绝对地址,而是访问一个 **固定位置** 的指针(即 GOT 条目),这个指针在运行时会被 `dyld` 填充为变量的**真实绝对地址**。 - -##### 2. GOT 的结构与位置 - -- **位置:** 在 Mach-O 文件中,GOT 通常位于 `__DATA,__got` 或 `__DATA_CONST,__got` section 中。现代系统倾向于使用 `__DATA_CONST` 以提高安全性(只读)。 -- **结构:** 它是一个 **指针数组(`uintptr_t` 数组)**。每个需要动态解析的**全局或静态数据符号**(变量)在 GOT 中独占一个条目(slot)。 -- **初始状态:** 在磁盘上的 Mach-O 文件中,GOT 条目的值通常为 **0 或其他占位值**(非有效地址)。真正的地址需要在运行时由 `dyld` 解析后填充。 - -##### 3. 动态库与 App 的全局变量会写入同一个 GOT 吗? - -**不会。每个 Mach-O 模块(App 或动态库)拥有独立的 GOT:** - -- **主程序 (App)** - 在 `__DATA_CONST,__got` 中存储: - - 它引用的外部全局变量地址(如动态库中的 `global_in_dylib`) - - 自身需动态绑定的全局变量(较少见) -- **动态库 (如 lib.dylib)** - 在自身的 `__DATA,__got` 中存储: - - 它引用的其他动态库的全局变量地址 - - 若自身全局变量被外部引用(如 `global_in_dylib`),**该变量地址不写入 GOT,而是存储在动态库的数据段**(`__DATA,__data`),供其他模块通过其 GOT 间接访问。 - -##### 4. 总结 - -1. **提供间接层:** 在 `__DATA/__got` 中为每个需要动态解析的**全局数据变量**预留一个指针槽位。 -2. **支撑位置无关代码 (PIC):** 使代码通过 **RIP 相对寻址 + GOT 间接访问** 的方式,能够在加载地址随机化 (ASLR) 后正确访问外部全局数据。 -3. **存储解析结果:** 在运行时,由 `dyld` 在启动阶段 (**非延迟绑定**) 查找并计算出每个全局数据变量的**真实内存绝对地址**。 -4. **填充真实地址:** `dyld` 将计算得到的全局变量的**真实绝对地址写入**其对应的 GOT 条目。 -5. **实现高效数据访问:** 绑定完成后,代码通过访问 GOT 条目获得变量地址,再进行数据读写。整个过程保证了动态链接环境下全局数据访问的正确性和效率。 - -#### 4. Non-Lazy Binding(启动时绑定) 和 Lazy Binding(延迟绑定) - -```mermaid -sequenceDiagram - participant K as 内核 - participant D as dyld - participant A as App - participant L as libSystem.dylib - participant M as 内存映射 - - rect rgb(240, 248, 255) - note over K,D: 启动阶段 - 非延迟绑定(数据符号) - A->>K: execve() 启动 - K->>D: 加载 dyld 到内存 - D->>D: 解析 App 的 Mach-O 头 - D->>D: 读取 LC_LOAD_DYLIB(依赖库列表) - D->>K: mmap() 请求加载 libSystem.dylib - K->>M: 分配随机地址空间 (ASLR) - M-->>K: 返回基址 0x7fff80000000 - K-->>D: 映射成功,基址=0x7fff80000000 - D->>L: 验证代码签名 - L-->>D: 签名有效 ✅ - D->>D: 解析绑定信息 (LC_DYLD_INFO) - D->>D: 查找非延迟绑定符号(如 errno) - D->>L: 查询导出表 Trie 树 - L-->>D: 返回 errno 偏移 0x5A000 - D->>D: 计算真实地址 = 0x7fff805A000 - D->>A: 更新 __got 条目:
__got[errno] = 0x7fff805A000 - end - - rect rgb(255, 250, 240) - note over A,D: 运行时阶段 - 延迟绑定(函数符号) - A->>A: 首次调用 NSLog - A->>A: 执行 __stubs[NSLog] 桩代码 - A->>D: 调用 _dyld_stub_binder(索引42) - D->>D: 解析 lazy bind opcodes - D->>D: 通过 Indirect Symbol Table
找到符号名 "_NSLog" - D->>L: 查询导出表 Trie 树 - L-->>D: 返回 NSLog 偏移 0x19B20 - D->>D: 计算真实地址 = 0x7fff8019B20 - D->>A: 修改 __la_symbol_ptr:
_NSLog_ptr = 0x7fff8019B20 - D->>L: 跳转到 NSLog 实现 - L-->>A: 执行 NSLog 函数 - end - - rect rgb(240, 255, 240) - note over A: 后续调用 - A->>A: 再次调用 NSLog - A->>A: 直接通过 __la_symbol_ptr
跳转 0x7fff8019B20 - A->>L: 执行 NSLog 实现 - end -``` - -##### 1. 启动阶段 - 非延迟绑定(蓝色区域) - -1. **内核加载**:内核加载 dyld 并处理 mmap 请求 -2. **ASLR 处理**:为 libSystem 分配随机基址(0x7fff80000000) -3. **代码签名验证**:dyld 验证动态库签名 -4. **数据符号绑定**: - - dyld 解析非延迟绑定信息(如 `errno` 变量) - - 查询动态库的导出 Trie 树获取符号偏移 - - 计算真实地址(基址 + 偏移) - - **更新 App 的 GOT(__got 段)** - - *完成后数据变量立即可用* - -##### 2. 首次调用 - 延迟绑定(橙色区域) - -1. **桩代码触发**:App 调用 NSLog(实际执行桩代码) -2. **绑定器调用**:桩代码调用 `_dyld_stub_binder` 并传递符号索引 -3. **符号解析**: - - dyld 通过 Indirect Symbol Table 获取符号名 - - 查询动态库导出 Trie 获取函数偏移(0x19B20) - - 计算真实地址(0x7fff8019B20) -4. **指针更新**: - - **修改 __la_symbol_ptr 条目** - - 跳转到真实函数地址 - - *绑定仅发生一次* - -##### 3. 后续调用 - 直接访问(绿色区域) - -- 直接通过已更新的 `__la_symbol_ptr` 跳转 -- 无 dyld 参与,全速执行 - - - -#### 5. 总结对比 - -| 特性 | 静态库 (`libxxx.a`) | 动态库 (`libxxx.dylib` / `.framework`) | -| :---------------------------- | :---------------------------------------------------- | :----------------------------------------------- | -| **包含机制** | **按需提取**:只链接 App 实际用到的 `.o` 文件中的符号 | **全量打包**:整个库文件必须包含在 App Bundle 中 | -| **核心优化** | **Dead Code Stripping**:移除库中未使用的代码/数据 | **无**:即使只用一个函数,整个库文件也得打包 | -| **对 App 可执行文件大小影响** | 较小 (仅包含实际使用的代码+剥离后符号表) | 很小 (仅包含引用信息+剥离后符号表) | -| **对 App 整体包体积影响** | **小** | **大** (库文件本身显著增加体积) | -| **符号表作用** | 链接时定位;剥离后大部分移除 | App 中:运行时绑定;库文件中:自身需要 | -| **系统库处理** | 通常不直接链接系统静态库 | **不增加体积** (库已存在于系统) | -| **主要体积来源** | App 可执行文件中链接进来的**有效代码/数据** | App Bundle 中**完整的动态库文件** | - -**因此,对于 iOS App 包体积优化:** - -1. **优先使用静态库:** 对于第三方库或自己的模块库,**静态链接是减小最终包体积的首选方式**,因为它允许链接器移除未使用的代码。 -2. **谨慎使用自定义动态库:** 只有在有**强烈需求**时(如模块热更新 - 需注意 App Store 审核风险、需要在 App Extension 和主 App 间共享大量代码且内存占用优化优先于包体积时)才考虑打包自己的动态库,并意识到它会显著增加包大小。 -3. **充分利用系统动态库:** 链接系统动态库不会增加你的包体积,可以放心使用。 -4. **启用编译选项:** 确保 Xcode 的 `Dead Code Stripping` 设置为 `YES` (默认通常是),并且 `Strip Linked Product` 在 Release 模式下设置为 `YES` (默认也是)。对于动态库本身,也要配置其 Stripping 选项。 - -所以,链接静态库对于体积影响更小。关键在于 **`Dead Code Stripping` 机制允许链接器只包含 App 实际需要的代码**,而动态库则必须完整包含整个文件。符号表的影响相对较小,剥离 (`strip`) 操作对两者都有效,但无法弥补动态库全量包含带来的根本性体积增加。 - -这也解释了 Apple 的官方建议:**iOS 开发优先使用静态库**。 - -### 2. 运行报错:Symbol not found 类问题 - -#### 1. 符号定位错误 - -报错日志为: - -```shell -dyld[5466]: Symbol not found: _NSUserActivityTypeBrowsingWeb - Referenced from: <61E75C66-9BA7-3070-B783-EADF1665E135> /private/var/containers/Bundle/Application/51B572E2-1311-459F-89F1-11B96C2B48C4/xxxx.app/xxxx.debug.dylib - Expected in: <2C72BAF6-60AA-38C5-BC25-04F76D3EAAC2> /System/Library/Frameworks/CoreServices.framework/CoreServices - Symbol not found: _NSUserActivityTypeBrowsingWeb - Referenced from: <61E75C66-9BA7-3070-B783-EADF1665E135> /private/var/containers/Bundle/Application/51B572E2-1311-459F-89F1-11B96C2B48C4/xxxx.app/xxxx.debug.dylib - Expected in: <2C72BAF6-60AA-38C5-BC25-04F76D3EAAC2> /System/Library/Frameworks/CoreServices.framework/CoreServices - dyld config: DYLD_LIBRARY_PATH=/usr/lib/system/introspection DYLD_INSERT_LIBRARIES=/usr/lib/libLogRedirect.dylib:/usr/lib/libBacktraceRecording.dylib:/usr/lib/libRPAC.dylib:/usr/lib/libViewDebuggerSupport.dylib:/System/Library/PrivateFrameworks/GPUToolsCapture.framework/GPUToolsCapture -``` - -根本在于: - -```shell -Symbol not found: _NSUserActivityTypeBrowsingWeb -Expected in: CoreServices.framework -``` - -实际上,`NSUserActivityTypeBrowsingWeb` **属于 Foundation.framework**(iOS 10+ 引入),而不是 CoreServices.framework - -#### 2. 链接问题 - -```mermaid -graph LR - A[应用启动] --> B[加载 CoreServices] - B --> C[需要 NSUserActivityTypeBrowsingWeb] - C --> D{检查已加载框架} - D -->|Foundation 未加载| E[符号未找到] - D -->|Foundation 已加载| F[成功] -``` - -根本问题:**链接顺序错误导致符号解析失败** - -- CoreServices.framework 内部依赖 Foundation.framework 的符号 -- 但你的链接顺序是:**先链接 CoreServices,后链接 Foundation** -- 当 CoreServices 尝试使用 `NSUserActivityTypeBrowsingWeb` 时,Foundation 尚未加载 - - - -#### 3. 系统库为什么出现这种链接错误? - -系统库怎么可能会出错??虽然代码都是系统库,但最终都在 App 的链接配置的地方声明链接顺序的,链接顺序影响符号的发现能力。现在的工程都依赖 Cocoapods 构建。Cocoapods 会对依赖的库名称进行字典排序 - -```shell -# CocoaPods 处理逻辑 -# 原始顺序 -frameworks = ['CoreServices', 'Foundation', 'UIKit'] -# 排序后的顺序 -sorted_frameworks = frameworks.sort # => ['CoreServices', 'Foundation', 'UIKit'] -``` - -字典序排序使 "CoreServices" 排在 "Foundation" 前。生成的 `OTHER_LDFLAGS` 变为: - -```shell --framework CoreServices --framework Foundation -``` - -[Cocospods::Xcodeproj/lib/xcodeproj /config.rb]( https://github.com/CocoaPods/Xcodeproj/blob/master/lib/xcodeproj/config.rb#L122-L160 ) 源码也可证明该问题: - -```ruby -def to_hash(prefix = nil) - list = [] - list += other_linker_flags[:simple].to_a.sort - modifiers = { - :frameworks => '-framework ', - :weak_frameworks => '-weak_framework ', - :libraries => '-l', - :arg_files => '@', - :force_load => '-force_load', - } - [:libraries, :frameworks, :weak_frameworks, :arg_files, :force_load].each do |key| - modifier = modifiers[key] - sorted = other_linker_flags[key].to_a.sort - if key == :force_load - list += sorted.map { |l| %(#{modifier} #{l}) } - else - list += sorted.map { |l| %(#{modifier}"#{l}") } - end - end - - result = attributes.dup - result['OTHER_LDFLAGS'] = list.join(' ') unless list.empty? - result.reject! { |_, v| INHERITED.any? { |i| i == v.to_s.strip } } - - result = @includes.map do |incl| - path = File.expand_path(incl, @filepath.dirname) - if File.readable? path - Xcodeproj::Config.new(path).to_hash - else - {} - end - end.inject(&:merge).merge(result) unless @filepath.nil? || @includes.empty? - - if prefix - Hash[result.map { |k, v| [prefix + k, v] }] - else - result - end - end -``` - - - -**链接器的工作方式:** - -- **`ld` 按从左到右顺序加载框架** -- **当 CoreServices 需要 Foundation 的符号时,右侧的 Foundation 尚未加载** - -#### 4. 解决方案:调整链接顺序 - -在 Xcode 工程的 **Build Settings > Other Linker Flags** 中: - -1. 在 **最前面** 添加:`-framework Foundation` -2. 保留 `$(inherited)` 继承 Pods 的设置 - -```shell -OTHER_LDFLAGS = ( - -framework Foundation, - $(inherited) # Pods 生成的标志 -) -``` - -#### 5. 既然存在问题,Cocoapods 为什么这么设计 - -##### 1. 排序功能的设计目的 - -1. **确保生成确定性(Determinism)** - CocoaPods 的核心目标之一是保证 **跨环境一致性**。通过字典序排序链接标志,无论依赖库的声明顺序如何,最终生成的 `.xcconfig` 文件内容始终保持一致。这避免了: - - 不同机器执行 `pod install` 后出现无关的 Git 差异; - - 多人协作时因文件顺序随机变动导致的冲突10。 -2. **简化内部处理逻辑** - 排序后更容易实现: - - **去重**:合并重复的链接标志(如多次引用的同一框架); - - **依赖分析**:在生成 Pods 项目时,清晰映射库与框架的关联关系610。 -3. **兼容非顺序敏感场景** - 多数情况下,链接顺序不影响结果(例如独立的功能库)。排序在提升可维护性的同时,对大部分项目无负面影响 - -##### 2. 排序引发 Bug 的本质原因 - -当排序与**链接器的顺序敏感性冲突**时,问题显现: - -1. **链接器的工作机制** - 链接器(`ld`)按从左到右顺序解析符号: - - - 若库 A 依赖库 B,则 B 必须出现在 A 的左侧; - - **字典序排序可能破坏隐式依赖关系**(如 `CoreServices` 依赖 `Foundation`,但 `CoreServices < Foundation` 按字母序排在前面)7。 - -2. **典型案例** - 用户遇到的 `NSUserActivityTypeBrowsingWeb` 符号丢失: - - bash - - ``` - # 排序后错误顺序 - -framework CoreServices # 先链接,需 Foundation 中的符号 - -framework Foundation # 后链接,符号未被引用 - ``` - - 此时链接器在 `CoreServices` 中遇到未定义符号,但右侧无 `Foundation`,因此报错 - -##### 3. 排序的取舍:为何不取消? - -| **优点** | **代价** | -| :----------------- | :----------------------- | -| ✅ 保证多环境一致性 | ⚠️ 破坏隐式链接顺序 | -| ✅ 简化依赖管理逻辑 | ⚠️ 需手动调整关键框架顺序 | -| ✅ 减少冗余标志 | | - -CocoaPods 选择排序,本质是**权衡后的工程决策**: - -- 多数项目不涉及深层符号依赖,排序收益 > 成本; -- 顺序敏感性问题可通过显式声明(如 `$(inherited)` 或手动调整)解决 - -CocoaPods 的排序不是“缺陷”,而是**为确定性牺牲局部灵活性**的典型设计。它解决了协作与维护的核心痛点,代价是将链接顺序的责任转移给开发者。在工程实践中,这种妥协是合理的——毕竟,可预测的构建环境比偶发的链接错误更可控。 - -正如软件开发中的许多决策:**没有完美解,只有最适,ROI 也相对较高** diff --git a/Chapter1 - iOS/1.92.md b/Chapter1 - iOS/1.92.md deleted file mode 100644 index 3b576ef..0000000 --- a/Chapter1 - iOS/1.92.md +++ /dev/null @@ -1 +0,0 @@ -# flutter 无痕埋点 diff --git a/Chapter1 - iOS/1.93.md b/Chapter1 - iOS/1.93.md deleted file mode 100644 index 8c4f013..0000000 --- a/Chapter1 - iOS/1.93.md +++ /dev/null @@ -1 +0,0 @@ -# flutter 新功能引导 diff --git a/Chapter1 - iOS/1.94.md b/Chapter1 - iOS/1.94.md deleted file mode 100644 index 51d79cd..0000000 --- a/Chapter1 - iOS/1.94.md +++ /dev/null @@ -1,22 +0,0 @@ -# APM - Wake Up - -> - -网传:如果在老设备上,使用最新的 iOS 系统,苹果会自动降频(CPU 频率),从而让你的 iPhone 看上去很卡,让你主动去购买新的设备。 - -其实,苹果在 iOS 13 的时候,在内核中加入了一个新的性能衡量指标`wakeup`。CPU 频率和设备电池有关系。看看 ARM 架构中对于 CPU 功耗问题的描述: - -> Many ARM systems are mobile devices and powered by batteries. In such systems, optimization of power use, and total energy use, is a key design constraint. Programmers often spend significant amounts of time trying to save battery life in such systems. - -由于ARM被大量使用于低功耗设备,而这些设备往往会由电池来作为驱动,所以 ARM 在硬件层面就对功耗这个问题进行了优化设计。 - -功耗可以分为2种类型,即静态功耗与动态功耗。 -静态功耗指的是只要 CPU 通上电,由于芯片无法保证绝对绝缘,所以会存在“漏电”的情况,而且越大的芯片这种问题越严重,这也是芯片厂家为什么拼命的研究更小尺寸芯片的原因。这部分功耗由于是硬件本身决定的,所以我们无法去控制,而这种类型功耗占比不大。 - -动态功耗指的是 CPU 运行期间,接通时钟后,执行指令所带来的额外开销,而这个开销会和时钟周期频率相关,频率越高,耗电量越大。这也就说明了苹果为什么会控制 CPU 使用率,而相关研究(Facebook 也做过)也表明,CPU 在20以下和20以上的能耗几乎是成倍的增加。 - -且苹果在具体哪个系统推出了 Battery Health 模块,其中有个 Maximum Capacity 的指标。用于判断电池的性能。苹果在这个开放出来之前,肯定已经在收集电池的健康状况。 - -iOS 11.3 及更高版本优化了性能管理功能,会定期评估所需的性能管理程度,以避免意外关机。如果电池健康能够满足系统所观察到的对峰值电源的要求,则系统会调低性能管理的程度。如果再次出现意外关机,则系统会调高性能管理的程度。这种评估是持续进行的,使得性能管理更能适应实际情况。 - -综上:当老设备运行新的 iOS 操作系统,系统会判断电池健康状态,如果电池不够健康,那么系统为了防止电池持续损坏(当 CPU 以较高频率工作,则会损坏电池设备,降低寿命),会自动降低 CPU 频率。其中这个有个判断标准,这个标准是不断变化的。 \ No newline at end of file diff --git a/Chapter1 - iOS/1.95.md b/Chapter1 - iOS/1.95.md deleted file mode 100644 index e86b6fb..0000000 --- a/Chapter1 - iOS/1.95.md +++ /dev/null @@ -1,388 +0,0 @@ -# 从 Flutter 和前端角度出发,聊聊单线程模型下如何保证 UI 流畅性 - -> 文章主题是“单线程模型下如何保证 UI 的流畅性”。该话题针对的是 Flutter 性能原理展开的,但是 dart 语言就是 js 的延伸,很多概念和机制都是一样的。具体不细聊。此外 js 也是单线程模型,在界面展示和 IO 等方面和 dart 类似。所以结合对比讲一下,帮助梳理和类比,更加容易掌握本文的主题,和知识的横向拓展。 -> -> 先从前端角度出发,分析下 event loop 和事件队列模型。再从 Flutter 层出发聊聊 dart 侧的事件队列和同步异步任务之间的关系。 - -## 一、单线程模型的设计 - -### 1. 最基础的单线程处理简单任务 - -假设有几个任务: - -- 任务1: "姓名:" + "杭城小刘" -- 任务2: "年龄:" + "1995" + "02" + "20" -- 任务3: "大小:" + (2021 - 1995 + 1) -- 任务4: 打印任务1、2、3 的结果 - -在单线程中执行,代码可能如下: - -```c++ -//c -void mainThread () { - string name = "姓名:" + "杭城小刘"; - string birthday = "年龄:" + "1995" + "02" + "20" - int age = 2021 - 1995 + 1; - printf("个人信息为:%s, %s, 大小:%d", name.c_str(), birthday.c_str(), age); -} -``` - -线程开始执行任务,按照需求,单线程依次执行每个任务,执行完毕后线程马上退出。 - -![基础单线程模型](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-18-SingleThread1.png) - -### 2. 线程运行过程中来了新的任务怎么处理? - -问题1 介绍的线程模型太简单太理想了,不可能从一开始就 n 个任务就确定了,大多数情况下,会接收到新的 m 个任务。那么 section1 中的设计就无法满足该需求。 - -**要在线程运行的过程中,能够接受并执行新的任务,就需要有一个事件循环机制。**最基础的事件循环可以想到用一个循环来实现。 - -```c++ -// c++ -int getInput() { - int input = 0; - cout<< "请输入一个数"; - cin>>input; - return input; -} - -void mainThread () { - while(true) { - int input1 = getInput(); - int input2 = getInput(); - int sum = input1 + input2; - print("两数之和为:%d", sum); - } -} -``` - -相较于第一版线程设计,这一版做了以下改进: - -- 引入了**循环机制**,线程不会做完事情马上退出。 -- 引入了**事件**。线程一开始会等待用户输入,等待的时候线程处于暂停状态,当用户输入完毕,线程得到输入的信息,此时线程被激活。执行相加的操作,最终输出结果。不断的等待输入,并计算输出。 - -![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-18-SingleThread2.png) - -### 3. 处理来自其他线程的任务 - -真实环境中的线程模块远远没有这么简单。比如浏览器环境下,线程可能正在绘制,可能会接收到1个来自用户鼠标点击的事件,1个来自网络加载 css 资源完成的事件等等。第二版线程模型虽然引入了事件循环机制,可以接受新的事件任务,但是发现没?这些任务之来自线程内部,该设计是无法接受来自其他线程的任务的。 - -![第三版线程模型](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-18-SingleThread3.png) - -从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些事件任务,当接受到的资源加载完成后的消息,则渲染线程会开始 DOM 解析;当接收到来自鼠标点击的消息,渲染主线程则会执行绑定好的鼠标点击事件脚本(js)来处理事件。 - -需要一个合理的数据结构,来存放并获取其他线程发送的消息? - -**消息队列**这个词大家都听过,在 GUI 系统中,事件队列是一个通用解决方案。 - -![事件队列](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-18-SingleThread4.png) - -**消息队列(事件队列)是一种合理的数据结构。要执行的任务添加到队列的尾部,需要执行的任务,从队列的头部取出。** - -有了消息队列之后,线程模型得到了升级。如下: - -![单线程模型第四版](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-18-SingleThread5.png) - -可以看出改造分为3个步骤: - -- 构建一个消息队列 -- IO 线程产生的新任务会被添加到消息队列的尾部 -- 渲染主线程会循环的从消息队列的头部读取任务,执行任务 - -伪代码。构造队列接口部分 - -```c++ -class TaskQueue { - public: - Task fetchTask (); // 从队列头部取出1个任务 - void addTask (Task task); // 将任务插入到队列尾部 -} -``` - -改造主线程 - -```c++ -TaskQueue taskQueue; -void processTask (); -void mainThread () { - while (true) { - Task task = taskQueue.fetchTask(); - processTask(task); - } -} -``` - -IO 线程 - -```c++ -void handleIOTask () { - Task clickTask; - taskQueue.addTask(clickTask); -} -``` - -Tips: 事件队列是存在多线程访问的情况,所以需要加锁。 - -### 4. 处理来自其他线程的任务 - -![单线程模型+跨进程任务](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-19-SingleThread6.png) - -浏览器环境中, 渲染进程经常接收到来自其他进程的任务,IO 线程专门用来接收来自其他进程传递来的消息。IPC 专门处理跨进程间的通信。 - -### 5. 消息队列中的任务类型 - -消息队列中有很多消息类型。内部消息:如鼠标滚动、点击、移动、宏任务、微任务、文件读写、定时器等等。 - -消息队列中还存在大量的与页面相关的事件。如 JS 执行、DOM 解析、样式计算、布局计算、CSS 动画等等。 - -上述事件都是在渲染主线程中执行的,因此编码时需注意,尽量减小这些事件所占用的时长。 - -### 6. 如何安全退出 - -Chrome 设计上,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,每次执行完1个任务时,判断该标志。如果设置了,则中断任务,退出线程 - -### 7. 单线程的缺点 - -事件队列的特点是先进先出,后进后出。那后进的任务也许会被前面的任务因为执行时间过长而阻塞,等待前面的任务执行完毕才可以执行后面的任务。这样存在2个问题。 - -- 如何处理高优先级的任务 - - 假如要监控 DOM 节点的变化情况(插入、删除、修改 innerHTML),然后触发对应的逻辑。最基础的做法就是设计一套监听接口,当 DOM 变化时,渲染引擎同步调用这些接口。不过这样子存在很大的问题,就是 DOM 变化会很频繁。如果每次 DOM 变化都触发对应的 JS 接口,则该任务执行会很长,导致**执行效率**的降低 - - 如果将这些 DOM 变化做为异步消息,假如消息队列中。可能会存在因为前面的任务在执行导致当前的 DOM 消息不会被执行的问题,也就是影响了监控的**实时性**。 - - 如何权衡效率和实时性?**微任务** 就是解决该类问题的。 - - 通常,我们把消息队列中的任务成为**宏任务**,每个宏任务中都包含一个**微任务队列**,在执行宏任务的过程中,假如 DOM 有变化,则该变化会被添加到该宏任务的微任务队列中去,这样子效率问题得以解决。 - - 当宏任务中的主要功能执行完毕欧,渲染引擎会执行微任务队列中的微任务。因此实时性问题得以解决 - -- 如何解决单个任务执行时间过长的问题 - - ![卡顿](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-19-SingleThread7.png) - - 可以看出,假如 JS 计算超时导致动画 paint 超时,会造成卡顿。浏览器为避免该问题,采用 callback 回调的设计来规避,也就是让 JS 任务延后执行。 - -## 二、 flutter 里的单线程模型 - -### 1. event loop 机制 - -Dart 是单线程的,也就是代码会有序执行。此外 Dart 作为 Flutter 这一 GUI 框架的开发语言,必然支持异步。 - -一个 Flutter 应用包含一个或多个 **isolate**,默认方法的执行都是在 **main isolate** 中;**一个 isolate 包含1个 Event loop 和1个 Task queue。其中,Task queue 包含1个 Event queue 事件队列和1个 MicroTask queue 微任务队列**。如下: - -![Flutter Event Loop](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/FlutterSingleThread1.png) - -为什么需要异步?因为大多数场景下 应用都并不是一直在做运算。比如一边等待用户的输入,输入后再去参与运算。这就是一个 IO 的场景。所以单线程可以再等待的时候做其他事情,而当真正需要处理运算的时候,再去处理。因此虽是单线程,但是给我们的感受是同事在做很多事情(空闲的时候去做其他事情) - -某个任务涉及 IO 或者异步,则主线程会先去做其他需要运算的事情,这个动作是靠 event loop 驱动的。和 JS 一样,dart 中存储事件任务的角色是事件队列 event queue。 - -Event queue 负责存储需要执行的任务事件,比如 DB 的读取。 - -Dart 中存在2个队列,一个微任务队列(Microtask Queue)、一个事件队列(Event Queue)。 - -Event loop 不断的轮询,先判断微任务队列是否为空,从队列头部取出需要执行的任务。如果微任务队列为空,则判断事件队列是否为空,不为空则从头部取出事件(比如键盘、IO、网络事件等),然后在主线程执行其回调函数,如下: - -![Flutter 单线程模型](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2021-06-20-FlutterSingleThread2.png) - -### 2. 异步任务 - -微任务,即在一个很短的时间内就会完成的异步任务。微任务在事件循环中优先级最高,只要微任务队列不为空,事件循环就不断执行微任务,后续的事件队列中的任务持续等待。微任务队列可由 `scheduleMicroTask` 创建。 - -通常情况,微任务的使用场景比较少。Flutter 内部也在诸如手势识别、文本输入、滚动视图、保存页面效果等需要高优执行任务的场景用到了微任务。 - -所以,一般需求下,异步任务我们使用优先级较低的 Event Queue。比如 IO、绘制、定时器等,都是通过事件队列驱动主线程来执行的。 - -Dart 为 Event Queue 的任务提供了一层封装,叫做 Future。把一个函数体放入 Future 中,就完成了同步任务到异步任务的包装(类似于 iOS 中通过 GCD 将一个任务以同步、异步提交给某个队列)。Future 具备链式调用的能力,可以在异步执行完毕后执行其他任务(函数)。 - -看一段具体代码: - -```dart -void main() { - print('normal task 1'); - Future(() => print('Task1 Future 1')); - print('normal task 2'); - Future(() => print('Task1 Future 2')) - .then((value) => print("subTask 1")) - .then((value) => print("subTask 2")); -} -// -lbp@MBP  ~/Desktop  dart index.dart -normal task 1 -normal task 2 -Task1 Future 1 -Task1 Future 2 -subTask 1 -subTask 2 -``` - -main 方法内,先添加了1个普通同步任务,然后以 Future 的形式添加了1个异步任务,Dart 会将异步任务加入到事件队列中,然后理解返回。后续代码继续以同步任务的方式执行。然后再添加了1个普通同步任务。然后再以 Future 的方式添加了1个异步任务,异步任务被加入到事件队列中。此时,事件队列中存在2个异步任务,Dart 在事件队列头部取出1个任务以同步的方式执行,全部执行(先进先出)完毕后再执行后续的 then。 - -Future 与 then 公用1个事件循环。如果存在多个 then,则按照顺序执行。 - -例2: - -```dart -void main() { - Future(() => print('Task1 Future 1')); - Future(() => print('Task1 Future 2')); - - Future(() => print('Task1 Future 3')) - .then((_) => print('subTask 1 in Future 3')); - - Future(() => null).then((_) => print('subTask 1 in empty Future')); -} -lbp@MBP  ~/Desktop  dart index.dart -Task1 Future 1 -Task1 Future 2 -Task1 Future 3 -subTask 1 in Future 3 -subTask 1 in empty Future -``` - -main 方法内,Task 1 添加到 Future 1中,被 Dart 添加到 Event Queue 中。Task 1 添加到 Future 2中,被 Dart 添加到 Event Queue 中。Task 1 添加到 Future 3中,被 Dart 添加到 Event Queue 中,subTask 1 和 Task 1 共用 Event Queue。Future 4中任务为空,所以 then 里的代码会被加入到 Microtask Queue,以便下一轮事件循环中被执行。 - -综合例子 - -```dart -void main() { - Future(() => print('Task1 Future 1')); - Future fx = Future(() => null); - Future(() => print("Task1 Future 3")).then((value) { - print("subTask 1 Future 3"); - scheduleMicrotask(() => print("Microtask 1")); - }).then((value) => print("subTask 3 Future 3")); - - Future(() => print("Task1 Future 4")) - .then((value) => Future(() => print("sub subTask 1 Future 4"))) - .then((value) => print("sub subTask 2 Future 4")); - - Future(() => print("Task1 Future 5")); - - fx.then((value) => print("Task1 Future 2")); - - scheduleMicrotask(() => print("Microtask 2")); - - print("normal Task"); -} -lbp@MBP  ~/Desktop  dart index.dart -normal Task -Microtask 2 -Task1 Future 1 -Task1 Future 2 -Task1 Future 3 -subTask 1 Future 3 -subTask 3 Future 3 -Microtask 1 -Task1 Future 4 -Task1 Future 5 -sub subTask 1 Future 4 -sub subTask 2 Future 4 -``` - -解释: - -- Event Loop 优先执行 main 方法同步任务,再执行微任务,最后执行 Event Queue 的异步任务。所以 normal Task 先执行 -- 同理微任务 Microtask 2 执行 -- 其次,Event Queue FIFO,Task1 Future 1 被执行 -- fx Future 内部为空,所以 then 里的内容被加到微任务队列中去,微任务优先级最高,所以 Task1 Future 2 被执行 -- 其次,Task1 Future 3 被执行。由于存在2个 then,先执行第一个 then 中的 subTask 1 Future 3,然后遇到微任务,所以 Microtask 1 被添加到微任务队列中去,等待下一次 Event Loop 到来时触发。接着执行第二个 then 中的 subTask 3 Future 3。随着下一次 Event Loop 到来,Microtask 1 被执行 -- 其次,Task1 Future 4 被执行。随后的第一个 then 中的任务又是被 Future 包装成一个异步任务,被添加到 Event Queue 中,第二个 then 中的内容也被添加到 Event Queue 中。 -- 接着,执行 Task1 Future 5。本次事件循环结束 -- 等下一轮事件循环到来,打印队列中的 sub subTask 1 Future 4、sub subTask 1 Future 5. - -### 3. 异步函数 - -异步函数的结果在将来某个时刻才返回,所以需要返回一个 Future 对象,供调用者使用。调用者根据需求,判断是在 Future 对象上注册一个 then 等 Future 执行体结束后再进行异步处理,还是同步等到 Future 执行结束。Future 对象如果需要同步等待,则需要在调用处添加 **await**,且 Future 所在的函数需要使用 **async** 关键字。 - -await 并不是同步等待,而是异步等待。Event Loop 会将调用体所在的函数也当作异步函数,将等待语句的上下文整体添加到 Event Queue 中,一旦返回,Event Loop 会在 Event Queue 中取出上下文代码,等待的代码继续执行。 - -await 阻塞的是当前上下文的后续代码执行,并不能阻塞其调用栈上层的后续代码执行 - -```dart -void main() { - Future(() => print('Task1 Future 1')) - .then((_) async => await Future(() => print("subTask 1 Future 2"))) - .then((_) => print("subTask 2 Future 2")); - Future(() => print('Task1 Future 2')); -} -lbp@MBP  ~/Desktop  dart index.dart -Task1 Future 1 -Task1 Future 2 -subTask 1 Future 2 -subTask 2 Future 2 -``` - -解析: - -- Future 中的 Task1 Future 1 被添加到 Event Queue 中。其次遇到第一个 then,then 里面是 Future 包装的异步任务,所以 `Future(() => print("subTask 1 Future 2"))` 被添加到 Event Queue 中,所在的 await 函数也被添加到了 Event Queue 中。第二个 then 也被添加到 Event Queue 中 -- 第二个 Future 中的 'Task1 Future 2 不会被 await 阻塞,因为 await 是异步等待(添加到 Event Queue)。所以执行 'Task1 Future 2。随后执行 "subTask 1 Future 2,接着取出 await 执行 subTask 2 Future 2 - -### 4. Isolate - -Dart 为了利用多核 CPU,将 CPU 层面的密集型计算进行了隔离设计,提供了多线程机制,即 Isolate。每个 Isolate 资源隔离,都有自己的 Event Loop 和 Event Queue、Microtask Queue。Isolate 之间的资源共享通过消息机制通信(和进程一样) - -使用很简单,创建时需要传递一个参数。 - -```dart -void coding(language) { - print("hello " + language); -} -void main() { - Isolate.spawn(coding, "Dart"); -} -lbp@MBP  ~/Desktop  dart index.dart -hello Dart -``` - -大多数情况下,不仅仅需要并发执行。可能还需要某个 Isolate 运算结束后将结果告诉主 Isolate。可以通过 Isolate 的管道(SendPort)实现消息通信。可以在主 Isolate 中将管道作为参数传递给子 Isolate,当子 Isolate 运算结束后将结果利用这个管道传递给主 Isolate - -```dart -void coding(SendPort port) { - const sum = 1 + 2; - // 给调用方发送结果 - port.send(sum); -} - -void main() { - testIsolate(); -} - -testIsolate() async { - ReceivePort receivePort = ReceivePort(); // 创建管道 - Isolate isolate = await Isolate.spawn(coding, receivePort.sendPort); // 创建 Isolate,并传递发送管道作为参数 - // 监听消息 - receivePort.listen((message) { - print("data: $message"); - receivePort.close(); - isolate?.kill(priority: Isolate.immediate); - isolate = null; - }); -} -lbp@MBP  ~/Desktop  dart index.dart -data: 3 -``` - -此外 Flutter 中提供了执行并发计算任务的快捷方式-**compute 函数**。其内部对 Isolate 的创建和双向通信进行了封装。 - -实际上,业务开发中使用 compute 的场景很少,比如 JSON 的编解码可以用 compute。 - -计算阶乘: - -```dart -int testCompute() async { - return await compute(syncCalcuateFactorial, 100); -} - -int syncCalcuateFactorial(upperBounds) => upperBounds < 2 - ? upperBounds - : upperBounds * syncCalcuateFactorial(upperBounds - 1); -``` - -总结: - -- Dart 是单线程的,但通过事件循环可以实现异步 -- Future 是异步任务的封装,借助于 await 与 async,我们可以通过事件循环实现非阻塞的同步等待 -- Isolate 是 Dart 中的多线程,可以实现并发,有自己的事件循环与 Queue,独占资源。Isolate 之间可以通过消息机制进行单向通信,这些传递的消息通过对方的事件循环驱动对方进行异步处理。 -- flutter 提供了 CPU 密集运算的 compute 方法,内部封装了 Isolate 和 Isolate 之间的通信 -- 事件队列、事件循环的概念在 GUI 系统中非常重要,几乎在前端、Flutter、iOS、Android 甚至是 NodeJS 中都存在。 diff --git a/Chapter1 - iOS/1.97.md b/Chapter1 - iOS/1.97.md deleted file mode 100644 index 1dcef83..0000000 --- a/Chapter1 - iOS/1.97.md +++ /dev/null @@ -1,4 +0,0 @@ -# __attribute__ 的骚操作 - -https://mp.weixin.qq.com/s/FTC-IYVCqzGU-00nj5bVfw -https://www.jianshu.com/p/965f6f903114 diff --git a/Chapter1 - iOS/1.98.md b/Chapter1 - iOS/1.98.md deleted file mode 100644 index 289b8b1..0000000 --- a/Chapter1 - iOS/1.98.md +++ /dev/null @@ -1,1132 +0,0 @@ -# 前端、BFF、后端一些常见的设计模式 - -> 写在开头的部分,本文的契机是最近我们组同事在客户端实现了一套 Redux,对某个业务域的功能进行重构设计,iOS、Android 都遵循这套规则,即 Redux。为什么需要客户端去实现一套 Redux?商品模块业务逻辑非常负责,商品基础信息非常多,比如多规格、多单位、价格、库存等信息,还有对应的门店、网店模型,还有各种行业能力开关控制,早期的实现有 Rx 的角色,导致代码逻辑较为复杂,数据流动比较乱。架构设计、代码维护各方面来看都不是很优雅,加上最近有大的业务调整,中台的同学用 Redux + 单向数据流的方式重构了业务。 -> -> 另一个下线经常请求网关数据,iOS、Android 各自去声明 DTO Model,然后解析数据,生成客户端需要的数据,这样“重复”的行为经常发生,所以索性用 TS + 脚本,统一做掉了网关数据模型自动生成 iOS、Android 模型的能力。但是在讨论框架设计的时候回类比 React Redux、Flutter Fish Redux、Vuex 等,还会聊到单向数据流、双向数据流,但是有些同学的理解就是错误的。所以本文第一部分「纠错题」部分就是讲清楚前端几个关键概念。 -> -> 后面的部分按照逻辑顺序讲一下:微前端 -> BFF/网关 -> 微服务。 - - - -## 一、纠错题 - -### 1. Vue 是双向数据流吗? - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/VueReact1.jpg) - -如果回答“是”,那么你应该没有搞清楚“**双向数据流**”与“**双向绑定**”这2个概念。其实,准确来说两者不是一个维度的东西,单向数据流也可以实现双向绑定。 - -其实,你要是仔细看过 Vue 的官方文档,那么官方就已经说明 Vue 其实是 [One-Way Data Flow](https://vuejs.org/v2/guide/components-props.html#One-Way-Data-Flow)。下面这段话来自官方 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayDataFlow.png) - -首先明确下,我们说的数据流就是组件之间的数据流动。Vue 中所有的 props 都使得其父子 props 之间形成一个单向下行绑定:父级 props 的更新回向下流动到子组件中,但反过来不行,这样防止从子组件意外更改其父组件的状态,从而导致你的应用数据流难以理解。 - -此外,每次父级组件发生变更时,子组件中所有的 props 都将会被刷新为最新的值,这意味着你不应该在子组件内去修改 prop,假如你这么做了,浏览器会在控制台中输出警告。Vue 和 React 修改 props 报错如下: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayDataFlowError.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-OneWayDataFlowError.png) - - - -### 2. React 是 MVVM 吗? - -不直接回答这个问题,我们先来聊几个概念,这几个概念弄清楚了,问题的答案也就呼之欲出了。 - - - -#### 2.1 单向绑定、双向绑定的区别? - -讲 MVVM 一定要讲 binder 这个角色。也就是单向绑定和双向绑定。 - -Vue 支持单向绑定和双向绑定。单向绑定其实就是 Model 到 View 的关联,比如 v-bind。双向绑定就是 Model 的更新会同步到 View,View 上数据的变化会自动同步到 Model,比如 v-model. - - v-model 其实是语法糖,因为 Vue 是 tamplate,所以在经过 webpack ast 解析之后,tamplate 会变为 render,v-model 变为 v-bind 和 v-on - -可以用单向绑定很简单地可以实现双向绑定的效果,就是 one-way binding + auto event binding。如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayBind.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-TwoWayBind.png) - -其实看了源码就会发现(Vue3 Proxy),单向绑定和双向绑定的区别在于,双向绑定把数据变更的操作部分,由框架内部实现了,调用者无须感知。 - - - -#### 2.2 Redux - -使用 React Redux 一个典型的流程图如下 - -![React-Redux](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-06-26-Redux-Structures.png) - -假如我们把 action 和 dispatcher 的实现隐藏在框架内部,这个图可以简化为 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-Redux1.png) - - - -假如进一步,我们将互相手动通知的机制再隐藏起来,可以简化为 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-Redux2.png) - -你仔细看,是不是就是 MVVM?那问题的答案很明显了,React 不是 MVVM。 - - - -### 3. Vue 是 MVVM 吗 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-MVVM.png) - -官方说了“虽然没有完全遵循 MVVM 的思想,但是受到了 MVVM 的启发。所以 Debug 的时候经常看到 VM” Model 的变动会触发 View 的更新,这一点体现在 V-bind 上 而 View 的变更即使同步到 Model 上,体现在 V-model 上 上述都遵循 MVVM,但是 Vue 提供了 Ref 属性,允许使用 ref 拿到 Dom,从而直接操作 Dom 的一些样式或者属性,这一点打破了 MVVM 的规范 - - - -### 4. 单向绑定和双向绑定使用场景 - -我们再来思考一个问题,为什么 Vue 要移除 .sync, Angular 要增加 < 来实现单向绑定? - -当应用变得越来越大,双向数据流会带来很多的不可预期的结果,这让 debug、调试、测试都变得复杂。 - -所以 Vue、Angular 都意识到这一点,所以移除了 .sync,支持单向绑定。 - -正面聊聊问题: - -单向绑定,带来的单向数据流,带来的好处是所有状态变化都可以被记录、跟踪,状态变化必须通过手动调用通知,源头可追溯,没有“暗箱操作”。同时组件数据只有唯一的入口和出口,使得程序更直观更容易理解,有利于应用的可维护性。缺点是实现同样的需求,代码量会上升,数据的流转过程变长。同时由于对应用状态独立管理的严格要求(单一的全局store),在处理局部状态较多的场景时(如用户输入交互较多的“富表单型”应用)会显得特别繁琐。 - -双向绑定优点是在表单交互较多的场景下,会简化大量业务无关的代码。缺点就是由于都是“暗箱操作”,无法追踪局部状态的变化,潜在的行为太多也增加了出错时 debug 的难度。同时由于组件数据变化来源入口变得可能不止一个,程序员水平参差不齐,写出的代码很容易将数据流转方向弄得紊乱,整个应用的质量就不可控。 - -总结:单向绑定跟双向绑定在功能上基本上是互补的,所以我们可以在合适的场景下使用合适的手段。比如在 UI 控件中(通常是类表单操作),可以使用双向的方式绑定数据;而其他场景则使用单向绑定的方式 - - - - - -## 二、 微前端 - -上面提到的 Vue、React、单向数据流、双向数据流都是针对单个应用去组织和设计工程的,但是当应用很大的时候就会存在一些问题。引出今天的一个主题,“微前端” - -客户端的同学都知道,我们很早就在使用组件化、模块化来拆分代码和逻辑。那么你可以说出到底什么是组件、什么是模块? - -组件就是把重复的代码提取出来合并成为一个个组件,组件强调的是重用,位于系统最底层,其他功能都依赖于组件,独立性强 - -模块就是分属同一功能/业务的代码进行隔离成独立的模块,可以独立运行,以页面、功能等维度划分为不同模块,位于业务架构层。 - -这些概念在前端也是如此,比如写过 Vue、React 的都知道,对一个页面需要进行组件的拆分,一个大页面由多个 UI 子组件组成。模块也一样,前端利用自执行函数(IIFE)来实现模块化,成熟的 CommonJS、AMD、CMD、UMD 规范等等。比如 iOS 侧利用模块化来实现了商品、库存、开单等业务域的拆分,也实现了路由库、网络库等基础能力模块。而前端更多的是利用模块化来实现命名空间和代码的可重用性 - -其实,看的出来,前端、客户端实行的模块化、组件化的目标都是分治思想,更多是站在代码层面进行拆分、组织管理。但是随着前端工程的越来越重,传统的前端架构已经很难遵循“敏捷”的思想了。 - - - -### 1. 什么是微前端 - -关心技术的人听到微前端,立马会想起微服务。微服务是面向服务架构的一种变体,把应用程序设计为一些列松耦合的细粒度服务,并通过轻量级的通信协议组织起来。越老越重的前端工程面临同样的问题,自然而然就将微服务的思想借鉴到了前端领域。 - -言归正传,微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。**微前端不是单纯的前端框架或者工具,而是一套架构体系**。 - - - - - -### 2. 特点 - -#### 2.1 技术无关 - -抛两个场景,大家思考一下: - -- 你新入职一家公司,老板扔给你一个 5 年陈的项目,需要你在这个项目上持续迭代加功能。 -- 你们起了一个新项目,老板看惯了前端的风起云涌技术更迭,只给了架构上的一个要求:"如何确保这套技术方案在 3~5 年内还葆有生命力,不会在 3、5 年后变成又一个遗产项目?" - -第一个场景我们初步一想,可以啊,我只需要把新功能用 React/Vue 开发,反正他们都只是 UI library,给我一个Dom 节点我想怎么渲染怎么渲染。但是你有没有考虑过这只是浮在表层的视图实现,沉在底下的工程设施呢?我要怎么把这个用 React 写的组件打出一个包,并且集成到原来的用 ES5 写的代码里面?或者我怎么让 Webpack 跟 之前的 Grunt 能和谐共存一起友好的产出一个符合预期的 Bundle? - -第二个场景,你如何确保技术栈在 3~5 年都葆有生命力?别说跨框架了,就算都是 React,15 跟 16 都是不兼容的,hooks 还不能用于 Class component 呢我说什么了?还有打包方案,现在还好都默认 Webpack,那 Webpack 版本升级呢,每次都跟进吗?别忘了还有 Babel、less、typescript 诸如此类呢?别说 3 年,有几个人敢保证自己的项目一年后把所有依赖包升级到最新还能跑起来? - -为什么举这两个场景呢,因为我们去统计一下业界关于”微前端“发过声的公司,会发现 adopt 微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求(有也是内部的中后台系统),为什么会这样?很简单,因为很少有 ToC 软件活得过 3 年以上的。而对于 ToB 应用而言,3~5 年太常见了好吗!去看看阿里云最早的那些产品的控制台,去看看那些电信软件、银行软件,哪个不是 10 年+ 的寿命?企业软件的升级有多痛这个我就不多说了。所以大部分企业应用都会有一个核心的诉求,就是**如何确保我的遗产代码能平滑的迁移,以及如何确保我在若干年后还能用上时下热门的技术栈?** - -如何给遗产项目续命,才是我们对微前端最开始的诉求。很多开发可能感受不深,毕竟那些一年挣不了几百万,没什么价值的项目要么是自己死掉了,要么是扔给外包团队维护了,但是要知道,对很多做 ToB 领域的中小企业而言,这样的系统可能是他们安身立命之本,不是能说扔就扔的,他们承担不了那么高的试错成本。 - -甚至新的业务,每个技术团队应该可以根据自己团队成员的技术储备、根据业务特点选择合适的技术栈,而不需要特别关心其他团队的情况,也就是 A 团队可以用 React,B 团队可以用 Vue,C 团队甚至可以用 Angular 去实现。 - - - -#### 2.2 简单、松耦合的代码库 - -比起一整块的前端工程来说,微前端架构下的代码库更小更容易开发、维护。此外,更重要的是避免模块间不合理的隐式耦合造成的复杂度上升。通过界定清晰的应用边界来降低意外耦合的可能性,增加子应用间逻辑耦合的成本,促使开发者明确数据和事件在应用程序中的流向 - - - -#### 2.3 增量升级 - -理想的代码自然是模块清晰、依赖明确、易于扩展、便于维护的……然而,实践中出于各式各样的原因: - -- 历史项目,祖传代码 -- 交付压力,当时求快 -- 就近就熟,当时求稳…… - -总存在一些不那么理想的代码: - -- 技术栈落后,甚至强行混用多种技术栈 -- 耦合混乱,不敢动,牵一发何止动全身 -- 重构不彻底,重构-烂尾,换个姿势重构-又烂尾…… - -而要对这些代码进行彻底重构的话,**最大的问题是很难有充裕的资源去大刀阔斧地一步到位**,在逐步重构的同时,既要确保中间版本能够平滑过渡,同时还要持续交付新特性: - -所以,为了实施渐进式重构,我们需要一种增量升级的能力,先让新旧代码和谐共存,再逐步转化旧代码,直到整个重构完成 - -这种增量升级的能力意味着我们能够*对产品功能进行低风险的局部替换*,包括升级依赖项、更替架构、UI 改版等。另一方面,也带来了技术选型上的灵活性,有助于新技术、新交互模式的实验性试错 - - - -#### 2.4 独立部署 - -独立部署的能力在微前端体系中至关重要,能够缩小变更范围,进而降低相关风险 - -因此,每个微前端都应具备有自己的持续交付流水线(包括构建、测试并部署到生产环境),并且要能独立部署,不必过多考虑其它代码库和交付流水线的当前状态: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontEnd1.png) - - - -假如 A、B、C 三个 Biz 存在三个系统,三者可以独立部署,最后由主系统去集成发布应用。这样子更加独立灵活。想想以前的单体应用,比如一个复杂的业务应用,很可能在 B 构建的时候没有通过 lint 导致整个应用失败掉,这样既浪费了构建 A 的时间,又让整个应用的构建变为串行,低效。 - - - -#### 2.5 团队自治 - -除了代码和发布周期上的解耦之外,微前端还有助于形成独立的团队,由不同的团队各自负责一块独立的产品功能,最好根据 Biz 去划分,由于代码都是独立的,所以基于 Biz 的前端业务团队,可以设计思考,提供更加合理的接口和能力。甚至可以抽象更多的基础能力,按照业务思考提供更加完备的能力,也就是团队更加自治。 - - - -### 3 如何实现 - -微前端主要采用的是组合式应用路由方案,该方案的核心思想是“主从”(玩过 Jenkins 的同学是不是很耳熟),即包括一个基座“MainApp “ 应用和若干个微应用(MircroApp),基座大多采用的是一个前端 SPA 项目,主要负责应用注册、路由映射、消息下发等,而微应用是独立的前端项目,这些项目不限于采用的具体技术(React、Vue、Angular、Jquery)等等,每个微应用注册到基座中,由基座进行管理,即使没有基座,这些微应用都可以单独访问,如下: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontend.png) - - - -一个微应用框架需要解决的问题是: - -- 路由切换的分发问题 - - 作为微前端的基座应用,是整个应用的入口,负责承载当前微应用的展示和对其他路由微应用的转发,对于当前微应用的展示,一般是由以下几步构成: - - 1. 作为一个SPA的基座应用,本身是一套纯前端项目,要想展示微应用的页面除了采用iframe之外,要能先拉取到微应用的页面内容, 这就需要**远程拉取机制**。 - 2. 远程拉取机制通常会采用fetch API来首先获取到微应用的HTML内容,然后通过解析将微应用的JavaScript和CSS进行抽离,采用eval方法来运行JavaScript,并将CSS和HTML内容append到基座应用中留给微应用的展示区域,当微应用切换走时,同步卸载这些内容,这就构成的当前应用的展示流程。 - 3. 当然这个流程里会涉及到CSS样式的污染以及JavaScript对全局对象的污染,这个涉及到隔离问题会在后面讨论,而目前针对远程拉取机制这套流程,已有现成的库来实现,可以参考 [import-html-entry](https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fimport-html-entry) 和 [system.js](https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fsystem.js)。 - - 对于路由分发而言,以采用vue-router开发的基座SPA应用来举例,主要是下面这个流程: - - 1. 当浏览器的路径变化后,vue-router会监听hashchange或者popstate事件,从而获取到路由切换的时机。 - 2. 最先接收到这个变化的是基座的router,通过查询注册信息可以获取到转发到那个微应用,经过一些逻辑处理后,采用修改hash方法或者pushState方法来路由信息推送给微应用的路由,微应用可以是手动监听hashchange或者popstate事件接收,或者采用React-router,vue-router接管路由,后面的逻辑就由微应用自己控制。 - -- 主应用、微应用的隔离问题 - - 应用隔离问题主要分为主应用和微应用,微应用和微应用之间的JavaScript执行环境隔离,CSS样式隔离,我们先来说下CSS的隔离。 - - **CSS隔离**:当主应用和微应用同屏渲染时,就可能会有一些样式会相互污染,如果要彻底隔离CSS污染,可以采用CSS Module 或者命名空间的方式,给每个微应用模块以特定前缀,即可保证不会互相干扰,可以采用webpack的postcss插件,在打包时添加特定的前缀。 - - 而对于微应用与微应用之间的CSS隔离就非常简单,在每次应用加载时,将该应用所有的link和style 内容进行标记。在应用卸载后,同步卸载页面上对应的link和style即可。 - - **JavaScript隔离**:每当微应用的JavaScript被加载并运行时,它的核心实际上是对全局对象Window的修改以及一些全局事件的改变,例如jQuery这个js运行后,会在Window上挂载一个`window.$`对象,对于其他库React,Vue也不例外。为此,需要在加载和卸载每个微应用的同时,尽可能消除这种冲突和影响,最普遍的做法是采用沙箱机制(SandBox)。 - - 沙箱机制的核心是让局部的JavaScript运行时,对外部对象的访问和修改处在可控的范围内,即无论内部怎么运行,都不会影响外部的对象。通常在Node.js端可以采用vm模块,而对于浏览器,则需要结合with关键字和window.Proxy对象来实现浏览器端的沙箱。 - -- 通信问题 - - 应用间通信有很多种方式,当然,要让多个分离的微应用之间要做到通信,本质上仍离不开中间媒介或者说全局对象。所以对于消息订阅(pub/sub)模式的通信机制是非常适用的,在基座应用中会定义事件中心Event,每个微应用分别来注册事件,当被触发事件时再有事件中心统一分发,这就构成了基本的通信机制,流程如下图: - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontendCommunicate.png) - - -目前开源了很多微前端框架,比如 [qiankun](https://github.com/umijs/qiankun) ,感兴趣的可以去看看源码 - - - -### 4. 是否采用微前端 - -为什么要做微前端?或者说微前端解决了什么问题?也就回答了是否需要采用微前端。微前端的鼻祖 Single-spa 最初要解决的问题是,在老项目中使用新的技术栈。现阶段蚂蚁金服微前端框架 Qiankun 所声明的微前端要解决的另一个主要问题是,巨石工程所面临的维护困难喝协作开发困难的问题。如果工程面临这两方面的问题,我觉得微前端就可以试试了。你们觉得呢? - - - - - -## 三、微服务平台下基于 GraphQL 构建 BFF 的思考 - -### 1. 大前端架构演进 - -我们来讲一个故事 - -#### 1. V1 - -假设早期2011年一家电商公司完成了单体应用的拆分,后端服务已经 SOA 化,此时应用的形态仅有 Web 端,V1架构图如下所示: - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure1.png) - -#### 2. V2 - -时间来到了2012年初,国内刮起了一阵无线应用的风,各个大厂都开始了自己的无线应用开发。为了应对该需求,架构调整为 V2,如下所示 - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure2.png) - -这个架构存在一些问题: - -- 无线 App 和内部的微服务强耦合,任何一侧的变化都可能对另一侧造成影响 - -- 无线 App 需要知道内部服务细节 - -- 无线 App 端需要做大量的聚合裁剪和适配逻辑 - - 聚合:某一个页面可能同时需要调用好几个后端 API 进行组合,比如商品详情页所需的数据,不能一次调用完成 - - 裁剪:后端提供的基础服务比较通用,返回的 Payload 比较大,字段太多,App 需要根据设备和业务需求,进行字段裁剪用户的客户端是Android还是iOS,是大屏还是小屏,是什么版本。再比如,业务属于哪个行业,产品形态是什么,功能投放在什么场景,面向的用户群体是谁等等。这些因素都会带来面向端的功能逻辑的差异性。后端在微服务和领域驱动设计(不在本文重点讲解范畴)的背景下,各个服务提供了当前 Domain 的基础功能,比如商品域,提供了商品新增、查询、删除功能,列表功能、详情页功能。 - - 但是在商品详情页的情况下,数据需要调用商品域、库存域、订单域的能力。客户端需要做大量的网络接口处理和数据处理。 - - 适配:一些常见的适配常见就是格式转换,比如有些后端服务比较老,提供 SOAP/XML 数据,不支持 JSON,这时候就需要做一些适配代码 - -- 随着使用类型的增多(iOS Phone/Pad、Android Phone/Pad、Hybrid、小程序),聚合裁剪和适配的逻辑的开发会造成设备端的大量重复劳动 - -- 前端比如 iOS、Android、小程序、H5 各个 biz 都需要业务数据。最早的设计是后端接口直出。这样的设计会导致出现一个问题,因为 biz1 因为迭代发版,造成后端改接口的实现。 Biz2 的另一个需求又会让服务端改设计实现,造成接口是面向业务开发的。不禁会问,基础服务到底是面向业务接口开发的吗?这和领域驱动的设计相背离 - -在这样的背景下诞生了 BFF(Backend For Frontend) 层。各个领域提供基础的数据模型,各个 biz 存在自己的 BFF 层,按需取查询(比如商品基础数据200个,但是小程序列表页只需要5个),在比如商品详情页可能需要商品数据、订单数据、评价数据、推荐数据、库存数据等等,最早的设计该详情页可能需要请求6个接口,这对于客户端、网页来说,体验很差,有个新的设计,详情页的 BFF 去动态组装调用接口,一个 BFF 接口就组装好了数据,直接返回给业务方,体验很好 - - - -#### 3. V2.1 - -V2 架构问题太多,没有开发实施。为解决上述问题,在外部设备和内部微服务之间引入一个新的角色 Mobile BFF。BFF 也就是 Backend for Frontend 的简称,可以认为是一种适配服务,将后端的微服务进行适配(主要包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好和统一的 API,方便无线设备接入访问后端服务。V2.1 服务架构如下 - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure3.png) - -这个架构的优势是: - -- 无线 App 和内部服务不耦合,通过引入 BFF 这层简介,使得两边可以独立变化: - - 后端如果发生变化,通过 BFF 屏蔽,前端设备可以做到不受影响 - - 前端如果发生变化,通过 BFF 屏蔽,后端微服务可以暂不变化 - - 当无线端有新的需求的时候,通过 BFF 屏蔽,可以减少前后端的沟通协作开销,很多需求由前端团队在 BFF 上就可以自己搞定 -- 无线 App 只需要知道 Mobile BFF 的地址,并且服务接口是统一的,不需要知道内部复杂的微服务地址和细节 -- 聚合裁剪和适配逻辑在 Mobile BFF 上实现,无线 App 端可以简化瘦身 - -#### 4. V3 - -V2.1 架构比较成功,实施后较长一段时间支持了公司早期无线业务的发展,随着业务量暴增,无线研发团队不断增加,V2.1 架构的问题也被暴露了出来: - -- Mobile BFF 中不仅有各个业务线的聚合/裁剪/适配和业务逻辑,还引入了很多横跨切面的逻辑,比如安全认证,日志监控,限流熔断等,随着时间的推移,代码变得越来越复杂,技术债越来越多,开发效率不断下降,缺陷数量不断增加 -- Mobile BFF 集群是个失败单点,严重代码缺陷将导致流量洪峰可能引起集群宕机,所有无线应用都不可用 - -为了解决上述伪命题,决定在外部设备和 BFF 之间架构一个新的层,即 Api Gateway,V3 架构如下: - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure4.png) - -新的架构 V3 有如下调整: - -- BFF 按团队或者业务进行解耦拆分,拆分为若干个 BFF 微服务,每个业务线可以并行开发和交付各自负责的 BFF 微服务 -- 官关(一般由独立框架团队负责运维)专注横跨切面功能,包括: - - 路由,将来自无线设备的请求路由到后端的某个微服务 BFF 集群 - - 认证,对涉及敏感数据的 Api 进行集中认证鉴权 - - 监控,对 Api 调用进行性能监控 - - 限流熔断,当出现流量洪峰,或者后端BFF/微服务出现延迟或故障,网关能够主动进行限流熔断,保护后端服务,并保持前端用户体验可以接受。 - - 安全防爬,收集访问日志,通过后台分析出恶意行为,并阻断恶意请求。 -- 网关在无线设备和BFF之间又引入了一层间接,让两边可以独立变化,特别是当后台BFF在升级或迁移时,可以做到用户端应用不受影响 - -在新的 V3 架构中,网关承担了重要的角色,它是解耦和后续升级迁移的利器,在网关的配合下,单块 BFF 实现了解耦拆分,各业务团队可以独立开发和交付各自的微服务,研发效率大大提升,另外,把横跨切面逻辑从 BFF 剥离到网关后,BFF 的开发人员可以更加专注于业务逻辑交付,实现了架构上的关注分离。 - - - -#### 5 V4 - -业务在不断发展,技术架构也需要不断的迭代和升级,近年来技术团队又新来了新的业务和技术需求: - -- 开放内部的业务能力,建设开发平台。借助第三方社区开发者能力进一步拓宽业务形态 -- 废弃传统服务端 Web 应用模式,引入前后端分离架构,前端采用 H5 单页技术给用户提供更好的用户体验 - -![V4](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure5.png) - -V4 的思路和 V3 差不多,只是拓展了新的接入渠道: - -- 引入面向第三方开放 Api 的 BFF 层和配套的网关层,支持第三方开发者在开放平台上开发应用 -- 引入面向 H5 应用的 BFF 和配套网关,支持前后分离和 H5 单页应用模式 - -V4 是一个比较完整的现代微服务架构,从外到内依次是:端用户体验层 -> 网关层 -> BFF 层 -> 微服务层。整个架构层次清晰、职责分明,是一种灵活的能够支持业务不断创新的演进式架构 - - - - - -总结: - -1. 在微服务架构中,BFF(Backend for Frontend)也称聚合层或者适配层,它主要承接一个适配角色:将内部复杂的微服务,适配成对各种不同用户体验(无线/Web/H5/第三方等)友好和统一的API。聚合裁剪适配是BFF的主要职责。 -2. 在微服务架构中,网关专注解决跨横切面逻辑,包括路由、安全、监控和限流熔断等。网关一方面是拆分解耦的利器,同时让开发人员可以专注业务逻辑的实现,达成架构上的关注分离。 -3. 端用户体验层->网关层->BFF层->微服务层,是现代微服务架构的典型分层方式,这个架构能够灵活应对业务需求的变化,是一种支持创新的演化式架构。 -4. 技术和业务都在不断变化,架构师要不断调整架构应对这些的变化,BFF和网关都是架构演化的产物。 - - - - - - - -### 2. BFF & GraphQL - -大前端模式下经常会面临下面2个问题 - -- 频繁变化的 API 是需要向前兼容的 -- BFF 中返回的字段不全是客户端需要的 - -至此 2015 年 GraphQL 被 Facebook 正式开源。它并不是一门语言,而是一种 API 查询风格。本文着重讲解了大前端模式下 BFF 层的设计和演进,需要配合 GraphQL 落地。下面简单介绍下 GraphQL,具体的可以查看 Node 或者某个语言对应的解决方案。 - -#### 1. 使用 GraphQL - -服务端描述数据 - 客户端按需请求 - 服务端返回数据 - -服务端描述数据 - -```GraphQL -type Project { - name: String - tagline: String - contributors: [User] -} -``` - -客户端按需请求 - -``` -{ - Project(name: 'GraphQL') { - tagline - } -} -``` - -服务端返回数据 - -``` -{ - "project": { - tagline: "A query language for APIS" - } -} -``` - - - -### 2. 特点 - -#### 1.定义数据模型,按需获取 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature1.png) - -就像写 SQL 一样,描述好需要查询的数据即可 - -#### 2. 数据分层 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature2.png) - -数据格式清晰,语义化强 - -#### 3. 强类型,类型校验 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature3.png) - -拥有 GraphQL 自己的类型描述,类型检查 - -#### 4. 协议⽽非存储 - -看上去和 MongoDB 比较像,但是一个是数据持久化能力,一个是接口查询描述。 - -#### 5. ⽆须版本化 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature4.png) - -写过客户端代码的同学会看到 Apple 给 api 废弃的时候都会标明原因,推荐用什么 api 代替等等,这点 GraphQL 也支持,另外废弃的时候也描述了如何解决,如何使用。 - - - -### 3. 什么时候需要 BFF - -- 后端被诸多客户端使用,并且每种客户端对于同一类 Api 存在定制化诉求 -- 后端微服务较多,并且每个微服务都只关注于自己的领域 -- 需要针对前端使用的 Api 做一些个性话的优化 - -什么时候不推荐使用 BFF - -- 后端服务仅被一种客户端使用 -- 后端服务比较简单,提供为公共服务的机会不多(DDD、微服务) - - - -### 4. 总结 - -在微服务架构中,BFF(Backend for Frontend)也称re聚合层或者适配层,它主要承接一个适配角色:将内部复杂的微服务,适配成对各种不同用户体验(无线/Web/H5/第三方等)友好和统一的API。聚合裁剪适配是BFF的主要职责。 - -在微服务架构中,网关专注解决跨横切面逻辑,包括路由、安全、监控和限流熔断等。网关一方面是拆分解耦的利器,同时让开发人员可以专注业务逻辑的实现,达成架构上的关注分离。 - -端用户体验层->网关层->BFF层->微服务层,是现代微服务架构的典型分层方式,这个架构能够灵活应对业务需求的变化,是一种支持创新的演化式架构。 - -技术和业务都在不断变化,架构师要不断调整架构应对这些的变化,BFF和网关都是架构演化的产物。 - - - -## 四、后端架构演进 - - - -### 1. 一些常见名词解释 - -云原生( Cloud Native )是一种构建和运行应用程序的方法,是一套技术体系和方法论。 Cloud Native 是一个组合词,Cloud+Native。 Cloud 是适应范围为云平台,Native 表示应用程序从设计之初即考虑到云的环境,原生为云而设计,在云上以最佳姿势运行,充分利用和发挥云平台的弹性+分布式优势。 - -Iaas:基础设施服务,Infrastructure as a service,如果把软件开发比作厨师做菜,那么IaaS就是他人提供了厨房,炉子,锅等基础东西, 你自己使用这些东西,自己根据不同需要做出不同的菜。由服务商提供服务器,一般为云主机,客户自行搭建环境部署软件。例如阿里云、腾讯云等就是典型的IaaS服务商。 - -Paas:平台服务,Platform as a service,还是做菜比喻,比如我做一个黄焖鸡米饭,除了提供基础东西外,那么PaaS还给你提供了现成剁好的鸡肉,土豆,辣椒, 你只要把这些东西放在一起,加些调料,用个小锅子在炉子上焖个20分钟就好了。自己只需关心软件本身,至于软件运行的环境由服务商提供。我们常说的云引擎、云容器等就是PaaS。 - -Faas:函数服务,Function as a Service,同样是做黄焖鸡米饭,这次我只提供酱油,色拉油,盐,醋,味精这些调味料,其他我不提供,你自己根据不同口味是多放点盐, 还是多放点醋,你自己决定。 - -Saas:软件服务,Software as a service,同样还是做黄焖鸡米饭,这次是直接现成搞好的一个一个小锅的鸡,什么调料都好了,已经是个成品了,你只要贴个牌,直接卖出 去就行了,做多是在炉子上焖个20分钟。这就是SaaS(Software as a Service,软件即服务)的概念,直接购买第三方服务商已经开发好的软件来使用,从而免去了自己去组建一个团队来开发的麻烦。 - -Baas:你了解到,自己要改的东西,只需要前端改了就可以了,后端部分完全不需要改。这时候你动脑筋,可以招了前端工程师,前端页面自己做,后端部分还是用服务商的。 - -这就是BaaS(Backend as a Service,后端即服务),自己只需要开发前端部分,剩下的所有都交给了服务商。经常说的“后端云”就是BaaS的意思,例如像LeanCloud、Bomb等就是典型的BaaS服务商。 - -MicroService vs Severless:MicroService是微服务,是一种专注于单一责任与功能的小型服务,Serverless相当于更加细粒度和碎片化的单一责任与功能小型服务,他们都是一种特定的小型服务, 从这个层次来说,Serverless=MicroService。 - -MicroService vs Service Mesh:在没有ServiceMesh之前微服务的通信,数据交换同步也存在,也有比较好的解决方案,如Spring Clould,OSS,Double这些,但他们有个最大的特点就是需要你写入代码中,而且需要深度的写 很多逻辑操作代码,这就是侵入式。而ServiceMesh最大的特点是非侵入式,不需要你写特定代码,只是在云服务的层面即可享受微服务之间的通信,数据交换同步等操作, 这里的代表如,docker+K8s,istio,linkerd等。 - - - -### 2. 架构演进 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerArchProgress.png) - -后端整个的演进可以如上图所示,经历了不断迭代,具体的解释这里不做展开。跟 SOA 相提并论的还有一个 ESB(企业服务总线),简单来说ESB就是一根管道,用来连接各个服务节点。为了集成不同系统,不同协议的服务,ESB 可以简单理解为:它做了消息的转化解释和路由工作,让不同的服务互联互通;使用ESB解耦服务间的依赖 - - SOA 和微服务架构的差别 - -- 微服务去中心化,去掉 ESB 企业总线。微服务不再强调传统 SOA 架构里面比较重的 ESB 企业服务总线,同时 SOA 的思想进入到单个业务系统内部实现真正的组件化 -- Docker 容器技术的出现,为微服务提供了更便利的条件,比如更小的部署单元,每个服务可以通过类似 Node 或者 Spring Boot 等技术跑在自己的进程中。 -- SOA 注重的是系统集成方面,而微服务关注的是完全分离 - -​ - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroServer.png) - -REST API:每个业务逻辑都被分解为一个微服务,微服务之间通过REST API通信。 - -API Gateway:负责服务路由、负载均衡、缓存、访问控制和鉴权等任务,向终端用户或客户端开发API接口. - -# 前端、BFF、后端一些常见的设计模式 - -> 写在开头的部分,本文的契机是最近我们组同事在客户端实现了一套 Redux,对某个业务域的功能进行重构设计,iOS、Android 都遵循这套规则,即 Redux。为什么需要客户端去实现一套 Redux?商品模块业务逻辑非常负责,商品基础信息非常多,比如多规格、多单位、价格、库存等信息,还有对应的门店、网店模型,还有各种行业能力开关控制,早期的实现有 Rx 的角色,导致代码逻辑较为复杂,数据流动比较乱。架构设计、代码维护各方面来看都不是很优雅,加上最近有大的业务调整,中台的同学用 Redux + 单向数据流的方式重构了业务。 -> -> 另一个下线经常请求网关数据,iOS、Android 各自去声明 DTO Model,然后解析数据,生成客户端需要的数据,这样“重复”的行为经常发生,所以索性用 TS + 脚本,统一做掉了网关数据模型自动生成 iOS、Android 模型的能力。但是在讨论框架设计的时候回类比 React Redux、Flutter Fish Redux、Vuex 等,还会聊到单向数据流、双向数据流,但是有些同学的理解就是错误的。所以本文第一部分「纠错题」部分就是讲清楚前端几个关键概念。 -> -> 后面的部分按照逻辑顺序讲一下:微前端 -> BFF/网关 -> 微服务。 - - - -## 一、纠错题 - -### 1. Vue 是双向数据流吗? - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/VueReact1.jpg) - -如果回答“是”,那么你应该没有搞清楚“**双向数据流**”与“**双向绑定**”这2个概念。其实,准确来说两者不是一个维度的东西,单向数据流也可以实现双向绑定。 - -其实,你要是仔细看过 Vue 的官方文档,那么官方就已经说明 Vue 其实是 [One-Way Data Flow](https://vuejs.org/v2/guide/components-props.html#One-Way-Data-Flow)。下面这段话来自官方 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayDataFlow.png) - -首先明确下,我们说的数据流就是组件之间的数据流动。Vue 中所有的 props 都使得其父子 props 之间形成一个单向下行绑定:父级 props 的更新回向下流动到子组件中,但反过来不行,这样防止从子组件意外更改其父组件的状态,从而导致你的应用数据流难以理解。 - -此外,每次父级组件发生变更时,子组件中所有的 props 都将会被刷新为最新的值,这意味着你不应该在子组件内去修改 prop,假如你这么做了,浏览器会在控制台中输出警告。Vue 和 React 修改 props 报错如下: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayDataFlowError.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-OneWayDataFlowError.png) - - - -### 2. React 是 MVVM 吗? - -不直接回答这个问题,我们先来聊几个概念,这几个概念弄清楚了,问题的答案也就呼之欲出了。 - - - -#### 2.1 单向绑定、双向绑定的区别? - -讲 MVVM 一定要讲 binder 这个角色。也就是单向绑定和双向绑定。 - -Vue 支持单向绑定和双向绑定。单向绑定其实就是 Model 到 View 的关联,比如 v-bind。双向绑定就是 Model 的更新会同步到 View,View 上数据的变化会自动同步到 Model,比如 v-model. - - v-model 其实是语法糖,因为 Vue 是 tamplate,所以在经过 webpack ast 解析之后,tamplate 会变为 render,v-model 变为 v-bind 和 v-on - -可以用单向绑定很简单地可以实现双向绑定的效果,就是 one-way binding + auto event binding。如下 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-OneWayBind.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-TwoWayBind.png) - -其实看了源码就会发现(Vue3 Proxy),单向绑定和双向绑定的区别在于,双向绑定把数据变更的操作部分,由框架内部实现了,调用者无须感知。 - - - -#### 2.2 Redux - -使用 React Redux 一个典型的流程图如下 - -![React-Redux](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/2019-06-26-Redux-Structures.png) - -假如我们把 action 和 dispatcher 的实现隐藏在框架内部,这个图可以简化为 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-Redux1.png) - - - -假如进一步,我们将互相手动通知的机制再隐藏起来,可以简化为 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/React-Redux2.png) - -你仔细看,是不是就是 MVVM?那问题的答案很明显了,React 不是 MVVM。 - - - -### 3. Vue 是 MVVM 吗 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Vue-MVVM.png) - -官方说了“虽然没有完全遵循 MVVM 的思想,但是受到了 MVVM 的启发。所以 Debug 的时候经常看到 VM” Model 的变动会触发 View 的更新,这一点体现在 V-bind 上 而 View 的变更即使同步到 Model 上,体现在 V-model 上 上述都遵循 MVVM,但是 Vue 提供了 Ref 属性,允许使用 ref 拿到 Dom,从而直接操作 Dom 的一些样式或者属性,这一点打破了 MVVM 的规范 - - - -### 4. 单向绑定和双向绑定使用场景 - -我们再来思考一个问题,为什么 Vue 要移除 .sync, Angular 要增加 < 来实现单向绑定? - -当应用变得越来越大,双向数据流会带来很多的不可预期的结果,这让 debug、调试、测试都变得复杂。 - -所以 Vue、Angular 都意识到这一点,所以移除了 .sync,支持单向绑定。 - -正面聊聊问题: - -单向绑定,带来的单向数据流,带来的好处是所有状态变化都可以被记录、跟踪,状态变化必须通过手动调用通知,源头可追溯,没有“暗箱操作”。同时组件数据只有唯一的入口和出口,使得程序更直观更容易理解,有利于应用的可维护性。缺点是实现同样的需求,代码量会上升,数据的流转过程变长。同时由于对应用状态独立管理的严格要求(单一的全局store),在处理局部状态较多的场景时(如用户输入交互较多的“富表单型”应用)会显得特别繁琐。 - -双向绑定优点是在表单交互较多的场景下,会简化大量业务无关的代码。缺点就是由于都是“暗箱操作”,无法追踪局部状态的变化,潜在的行为太多也增加了出错时 debug 的难度。同时由于组件数据变化来源入口变得可能不止一个,程序员水平参差不齐,写出的代码很容易将数据流转方向弄得紊乱,整个应用的质量就不可控。 - -总结:单向绑定跟双向绑定在功能上基本上是互补的,所以我们可以在合适的场景下使用合适的手段。比如在 UI 控件中(通常是类表单操作),可以使用双向的方式绑定数据;而其他场景则使用单向绑定的方式 - - - - - -## 二、 微前端 - -上面提到的 Vue、React、单向数据流、双向数据流都是针对单个应用去组织和设计工程的,但是当应用很大的时候就会存在一些问题。引出今天的一个主题,“微前端” - -客户端的同学都知道,我们很早就在使用组件化、模块化来拆分代码和逻辑。那么你可以说出到底什么是组件、什么是模块? - -组件就是把重复的代码提取出来合并成为一个个组件,组件强调的是重用,位于系统最底层,其他功能都依赖于组件,独立性强 - -模块就是分属同一功能/业务的代码进行隔离成独立的模块,可以独立运行,以页面、功能等维度划分为不同模块,位于业务架构层。 - -这些概念在前端也是如此,比如写过 Vue、React 的都知道,对一个页面需要进行组件的拆分,一个大页面由多个 UI 子组件组成。模块也一样,前端利用自执行函数(IIFE)来实现模块化,成熟的 CommonJS、AMD、CMD、UMD 规范等等。比如 iOS 侧利用模块化来实现了商品、库存、开单等业务域的拆分,也实现了路由库、网络库等基础能力模块。而前端更多的是利用模块化来实现命名空间和代码的可重用性 - -其实,看的出来,前端、客户端实行的模块化、组件化的目标都是分治思想,更多是站在代码层面进行拆分、组织管理。但是随着前端工程的越来越重,传统的前端架构已经很难遵循“敏捷”的思想了。 - - - -### 1. 什么是微前端 - -关心技术的人听到微前端,立马会想起微服务。微服务是面向服务架构的一种变体,把应用程序设计为一些列松耦合的细粒度服务,并通过轻量级的通信协议组织起来。越老越重的前端工程面临同样的问题,自然而然就将微服务的思想借鉴到了前端领域。 - -言归正传,微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。**微前端不是单纯的前端框架或者工具,而是一套架构体系**。 - - - - - -### 2. 特点 - -#### 2.1 技术无关 - -抛两个场景,大家思考一下: - -- 你新入职一家公司,老板扔给你一个 5 年陈的项目,需要你在这个项目上持续迭代加功能。 -- 你们起了一个新项目,老板看惯了前端的风起云涌技术更迭,只给了架构上的一个要求:"如何确保这套技术方案在 3~5 年内还葆有生命力,不会在 3、5 年后变成又一个遗产项目?" - -第一个场景我们初步一想,可以啊,我只需要把新功能用 React/Vue 开发,反正他们都只是 UI library,给我一个Dom 节点我想怎么渲染怎么渲染。但是你有没有考虑过这只是浮在表层的视图实现,沉在底下的工程设施呢?我要怎么把这个用 React 写的组件打出一个包,并且集成到原来的用 ES5 写的代码里面?或者我怎么让 Webpack 跟 之前的 Grunt 能和谐共存一起友好的产出一个符合预期的 Bundle? - -第二个场景,你如何确保技术栈在 3~5 年都葆有生命力?别说跨框架了,就算都是 React,15 跟 16 都是不兼容的,hooks 还不能用于 Class component 呢我说什么了?还有打包方案,现在还好都默认 Webpack,那 Webpack 版本升级呢,每次都跟进吗?别忘了还有 Babel、less、typescript 诸如此类呢?别说 3 年,有几个人敢保证自己的项目一年后把所有依赖包升级到最新还能跑起来? - -为什么举这两个场景呢,因为我们去统计一下业界关于”微前端“发过声的公司,会发现 adopt 微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求(有也是内部的中后台系统),为什么会这样?很简单,因为很少有 ToC 软件活得过 3 年以上的。而对于 ToB 应用而言,3~5 年太常见了好吗!去看看阿里云最早的那些产品的控制台,去看看那些电信软件、银行软件,哪个不是 10 年+ 的寿命?企业软件的升级有多痛这个我就不多说了。所以大部分企业应用都会有一个核心的诉求,就是**如何确保我的遗产代码能平滑的迁移,以及如何确保我在若干年后还能用上时下热门的技术栈?** - -如何给遗产项目续命,才是我们对微前端最开始的诉求。很多开发可能感受不深,毕竟那些一年挣不了几百万,没什么价值的项目要么是自己死掉了,要么是扔给外包团队维护了,但是要知道,对很多做 ToB 领域的中小企业而言,这样的系统可能是他们安身立命之本,不是能说扔就扔的,他们承担不了那么高的试错成本。 - -甚至新的业务,每个技术团队应该可以根据自己团队成员的技术储备、根据业务特点选择合适的技术栈,而不需要特别关心其他团队的情况,也就是 A 团队可以用 React,B 团队可以用 Vue,C 团队甚至可以用 Angular 去实现。 - - - -#### 2.2 简单、松耦合的代码库 - -比起一整块的前端工程来说,微前端架构下的代码库更小更容易开发、维护。此外,更重要的是避免模块间不合理的隐式耦合造成的复杂度上升。通过界定清晰的应用边界来降低意外耦合的可能性,增加子应用间逻辑耦合的成本,促使开发者明确数据和事件在应用程序中的流向 - - - -#### 2.3 增量升级 - -理想的代码自然是模块清晰、依赖明确、易于扩展、便于维护的……然而,实践中出于各式各样的原因: - -- 历史项目,祖传代码 -- 交付压力,当时求快 -- 就近就熟,当时求稳…… - -总存在一些不那么理想的代码: - -- 技术栈落后,甚至强行混用多种技术栈 -- 耦合混乱,不敢动,牵一发何止动全身 -- 重构不彻底,重构-烂尾,换个姿势重构-又烂尾…… - -而要对这些代码进行彻底重构的话,**最大的问题是很难有充裕的资源去大刀阔斧地一步到位**,在逐步重构的同时,既要确保中间版本能够平滑过渡,同时还要持续交付新特性: - -所以,为了实施渐进式重构,我们需要一种增量升级的能力,先让新旧代码和谐共存,再逐步转化旧代码,直到整个重构完成 - -这种增量升级的能力意味着我们能够*对产品功能进行低风险的局部替换*,包括升级依赖项、更替架构、UI 改版等。另一方面,也带来了技术选型上的灵活性,有助于新技术、新交互模式的实验性试错 - - - -#### 2.4 独立部署 - -独立部署的能力在微前端体系中至关重要,能够缩小变更范围,进而降低相关风险 - -因此,每个微前端都应具备有自己的持续交付流水线(包括构建、测试并部署到生产环境),并且要能独立部署,不必过多考虑其它代码库和交付流水线的当前状态: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontEnd1.png) - - - -假如 A、B、C 三个 Biz 存在三个系统,三者可以独立部署,最后由主系统去集成发布应用。这样子更加独立灵活。想想以前的单体应用,比如一个复杂的业务应用,很可能在 B 构建的时候没有通过 lint 导致整个应用失败掉,这样既浪费了构建 A 的时间,又让整个应用的构建变为串行,低效。 - - - -#### 2.5 团队自治 - -除了代码和发布周期上的解耦之外,微前端还有助于形成独立的团队,由不同的团队各自负责一块独立的产品功能,最好根据 Biz 去划分,由于代码都是独立的,所以基于 Biz 的前端业务团队,可以设计思考,提供更加合理的接口和能力。甚至可以抽象更多的基础能力,按照业务思考提供更加完备的能力,也就是团队更加自治。 - - - -### 3 如何实现 - -微前端主要采用的是组合式应用路由方案,该方案的核心思想是“主从”(玩过 Jenkins 的同学是不是很耳熟),即包括一个基座“MainApp “ 应用和若干个微应用(MircroApp),基座大多采用的是一个前端 SPA 项目,主要负责应用注册、路由映射、消息下发等,而微应用是独立的前端项目,这些项目不限于采用的具体技术(React、Vue、Angular、Jquery)等等,每个微应用注册到基座中,由基座进行管理,即使没有基座,这些微应用都可以单独访问,如下: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontend.png) - - - -一个微应用框架需要解决的问题是: - -- 路由切换的分发问题 - - 作为微前端的基座应用,是整个应用的入口,负责承载当前微应用的展示和对其他路由微应用的转发,对于当前微应用的展示,一般是由以下几步构成: - - 1. 作为一个SPA的基座应用,本身是一套纯前端项目,要想展示微应用的页面除了采用iframe之外,要能先拉取到微应用的页面内容, 这就需要**远程拉取机制**。 - 2. 远程拉取机制通常会采用fetch API来首先获取到微应用的HTML内容,然后通过解析将微应用的JavaScript和CSS进行抽离,采用eval方法来运行JavaScript,并将CSS和HTML内容append到基座应用中留给微应用的展示区域,当微应用切换走时,同步卸载这些内容,这就构成的当前应用的展示流程。 - 3. 当然这个流程里会涉及到CSS样式的污染以及JavaScript对全局对象的污染,这个涉及到隔离问题会在后面讨论,而目前针对远程拉取机制这套流程,已有现成的库来实现,可以参考 [import-html-entry](https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fimport-html-entry) 和 [system.js](https://link.juejin.cn/?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fsystem.js)。 - - 对于路由分发而言,以采用vue-router开发的基座SPA应用来举例,主要是下面这个流程: - - 1. 当浏览器的路径变化后,vue-router会监听hashchange或者popstate事件,从而获取到路由切换的时机。 - 2. 最先接收到这个变化的是基座的router,通过查询注册信息可以获取到转发到那个微应用,经过一些逻辑处理后,采用修改hash方法或者pushState方法来路由信息推送给微应用的路由,微应用可以是手动监听hashchange或者popstate事件接收,或者采用React-router,vue-router接管路由,后面的逻辑就由微应用自己控制。 - -- 主应用、微应用的隔离问题 - - 应用隔离问题主要分为主应用和微应用,微应用和微应用之间的JavaScript执行环境隔离,CSS样式隔离,我们先来说下CSS的隔离。 - - **CSS隔离**:当主应用和微应用同屏渲染时,就可能会有一些样式会相互污染,如果要彻底隔离CSS污染,可以采用CSS Module 或者命名空间的方式,给每个微应用模块以特定前缀,即可保证不会互相干扰,可以采用webpack的postcss插件,在打包时添加特定的前缀。 - - 而对于微应用与微应用之间的CSS隔离就非常简单,在每次应用加载时,将该应用所有的link和style 内容进行标记。在应用卸载后,同步卸载页面上对应的link和style即可。 - - **JavaScript隔离**:每当微应用的JavaScript被加载并运行时,它的核心实际上是对全局对象Window的修改以及一些全局事件的改变,例如jQuery这个js运行后,会在Window上挂载一个`window.$`对象,对于其他库React,Vue也不例外。为此,需要在加载和卸载每个微应用的同时,尽可能消除这种冲突和影响,最普遍的做法是采用沙箱机制(SandBox)。 - - 沙箱机制的核心是让局部的JavaScript运行时,对外部对象的访问和修改处在可控的范围内,即无论内部怎么运行,都不会影响外部的对象。通常在Node.js端可以采用vm模块,而对于浏览器,则需要结合with关键字和window.Proxy对象来实现浏览器端的沙箱。 - -- 通信问题 - - 应用间通信有很多种方式,当然,要让多个分离的微应用之间要做到通信,本质上仍离不开中间媒介或者说全局对象。所以对于消息订阅(pub/sub)模式的通信机制是非常适用的,在基座应用中会定义事件中心Event,每个微应用分别来注册事件,当被触发事件时再有事件中心统一分发,这就构成了基本的通信机制,流程如下图: - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroFrontendCommunicate.png) - -目前开源了很多微前端框架,比如 [qiankun](https://github.com/umijs/qiankun) ,感兴趣的可以去看看源码 - - - -### 4. 是否采用微前端 - -为什么要做微前端?或者说微前端解决了什么问题?也就回答了是否需要采用微前端。微前端的鼻祖 Single-spa 最初要解决的问题是,在老项目中使用新的技术栈。现阶段蚂蚁金服微前端框架 Qiankun 所声明的微前端要解决的另一个主要问题是,巨石工程所面临的维护困难喝协作开发困难的问题。如果工程面临这两方面的问题,我觉得微前端就可以试试了。你们觉得呢? - - - - - -## 三、微服务平台下基于 GraphQL 构建 BFF 的思考 - -### 1. 大前端架构演进 - -我们来讲一个故事 - -#### 1. V1 - -假设早期2011年一家电商公司完成了单体应用的拆分,后端服务已经 SOA 化,此时应用的形态仅有 Web 端,V1架构图如下所示: - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure1.png) - -#### 2. V2 - -时间来到了2012年初,国内刮起了一阵无线应用的风,各个大厂都开始了自己的无线应用开发。为了应对该需求,架构调整为 V2,如下所示 - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure2.png) - -这个架构存在一些问题: - -- 无线 App 和内部的微服务强耦合,任何一侧的变化都可能对另一侧造成影响 - -- 无线 App 需要知道内部服务细节 - -- 无线 App 端需要做大量的聚合裁剪和适配逻辑 - - 聚合:某一个页面可能同时需要调用好几个后端 API 进行组合,比如商品详情页所需的数据,不能一次调用完成 - - 裁剪:后端提供的基础服务比较通用,返回的 Payload 比较大,字段太多,App 需要根据设备和业务需求,进行字段裁剪用户的客户端是Android还是iOS,是大屏还是小屏,是什么版本。再比如,业务属于哪个行业,产品形态是什么,功能投放在什么场景,面向的用户群体是谁等等。这些因素都会带来面向端的功能逻辑的差异性。后端在微服务和领域驱动设计(不在本文重点讲解范畴)的背景下,各个服务提供了当前 Domain 的基础功能,比如商品域,提供了商品新增、查询、删除功能,列表功能、详情页功能。 - - 但是在商品详情页的情况下,数据需要调用商品域、库存域、订单域的能力。客户端需要做大量的网络接口处理和数据处理。 - - 适配:一些常见的适配常见就是格式转换,比如有些后端服务比较老,提供 SOAP/XML 数据,不支持 JSON,这时候就需要做一些适配代码 - -- 随着使用类型的增多(iOS Phone/Pad、Android Phone/Pad、Hybrid、小程序),聚合裁剪和适配的逻辑的开发会造成设备端的大量重复劳动 - -- 前端比如 iOS、Android、小程序、H5 各个 biz 都需要业务数据。最早的设计是后端接口直出。这样的设计会导致出现一个问题,因为 biz1 因为迭代发版,造成后端改接口的实现。 Biz2 的另一个需求又会让服务端改设计实现,造成接口是面向业务开发的。不禁会问,基础服务到底是面向业务接口开发的吗?这和领域驱动的设计相背离 - -在这样的背景下诞生了 BFF(Backend For Frontend) 层。各个领域提供基础的数据模型,各个 biz 存在自己的 BFF 层,按需取查询(比如商品基础数据200个,但是小程序列表页只需要5个),在比如商品详情页可能需要商品数据、订单数据、评价数据、推荐数据、库存数据等等,最早的设计该详情页可能需要请求6个接口,这对于客户端、网页来说,体验很差,有个新的设计,详情页的 BFF 去动态组装调用接口,一个 BFF 接口就组装好了数据,直接返回给业务方,体验很好 - - - -#### 3. V2.1 - -V2 架构问题太多,没有开发实施。为解决上述问题,在外部设备和内部微服务之间引入一个新的角色 Mobile BFF。BFF 也就是 Backend for Frontend 的简称,可以认为是一种适配服务,将后端的微服务进行适配(主要包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好和统一的 API,方便无线设备接入访问后端服务。V2.1 服务架构如下 - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure3.png) - -这个架构的优势是: - -- 无线 App 和内部服务不耦合,通过引入 BFF 这层简介,使得两边可以独立变化: - - 后端如果发生变化,通过 BFF 屏蔽,前端设备可以做到不受影响 - - 前端如果发生变化,通过 BFF 屏蔽,后端微服务可以暂不变化 - - 当无线端有新的需求的时候,通过 BFF 屏蔽,可以减少前后端的沟通协作开销,很多需求由前端团队在 BFF 上就可以自己搞定 -- 无线 App 只需要知道 Mobile BFF 的地址,并且服务接口是统一的,不需要知道内部复杂的微服务地址和细节 -- 聚合裁剪和适配逻辑在 Mobile BFF 上实现,无线 App 端可以简化瘦身 - -#### 4. V3 - -V2.1 架构比较成功,实施后较长一段时间支持了公司早期无线业务的发展,随着业务量暴增,无线研发团队不断增加,V2.1 架构的问题也被暴露了出来: - -- Mobile BFF 中不仅有各个业务线的聚合/裁剪/适配和业务逻辑,还引入了很多横跨切面的逻辑,比如安全认证,日志监控,限流熔断等,随着时间的推移,代码变得越来越复杂,技术债越来越多,开发效率不断下降,缺陷数量不断增加 -- Mobile BFF 集群是个失败单点,严重代码缺陷将导致流量洪峰可能引起集群宕机,所有无线应用都不可用 - -为了解决上述伪命题,决定在外部设备和 BFF 之间架构一个新的层,即 Api Gateway,V3 架构如下: - -![V1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure4.png) - -新的架构 V3 有如下调整: - -- BFF 按团队或者业务进行解耦拆分,拆分为若干个 BFF 微服务,每个业务线可以并行开发和交付各自负责的 BFF 微服务 -- 官关(一般由独立框架团队负责运维)专注横跨切面功能,包括: - - 路由,将来自无线设备的请求路由到后端的某个微服务 BFF 集群 - - 认证,对涉及敏感数据的 Api 进行集中认证鉴权 - - 监控,对 Api 调用进行性能监控 - - 限流熔断,当出现流量洪峰,或者后端BFF/微服务出现延迟或故障,网关能够主动进行限流熔断,保护后端服务,并保持前端用户体验可以接受。 - - 安全防爬,收集访问日志,通过后台分析出恶意行为,并阻断恶意请求。 -- 网关在无线设备和BFF之间又引入了一层间接,让两边可以独立变化,特别是当后台BFF在升级或迁移时,可以做到用户端应用不受影响 - -在新的 V3 架构中,网关承担了重要的角色,它是解耦和后续升级迁移的利器,在网关的配合下,单块 BFF 实现了解耦拆分,各业务团队可以独立开发和交付各自的微服务,研发效率大大提升,另外,把横跨切面逻辑从 BFF 剥离到网关后,BFF 的开发人员可以更加专注于业务逻辑交付,实现了架构上的关注分离。 - - - -#### 5 V4 - -业务在不断发展,技术架构也需要不断的迭代和升级,近年来技术团队又新来了新的业务和技术需求: - -- 开放内部的业务能力,建设开发平台。借助第三方社区开发者能力进一步拓宽业务形态 -- 废弃传统服务端 Web 应用模式,引入前后端分离架构,前端采用 H5 单页技术给用户提供更好的用户体验 - -![V4](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WebInstructure5.png) - -V4 的思路和 V3 差不多,只是拓展了新的接入渠道: - -- 引入面向第三方开放 Api 的 BFF 层和配套的网关层,支持第三方开发者在开放平台上开发应用 -- 引入面向 H5 应用的 BFF 和配套网关,支持前后分离和 H5 单页应用模式 - -V4 是一个比较完整的现代微服务架构,从外到内依次是:端用户体验层 -> 网关层 -> BFF 层 -> 微服务层。整个架构层次清晰、职责分明,是一种灵活的能够支持业务不断创新的演进式架构 - - - - - -总结: - -1. 在微服务架构中,BFF(Backend for Frontend)也称聚合层或者适配层,它主要承接一个适配角色:将内部复杂的微服务,适配成对各种不同用户体验(无线/Web/H5/第三方等)友好和统一的API。聚合裁剪适配是BFF的主要职责。 -2. 在微服务架构中,网关专注解决跨横切面逻辑,包括路由、安全、监控和限流熔断等。网关一方面是拆分解耦的利器,同时让开发人员可以专注业务逻辑的实现,达成架构上的关注分离。 -3. 端用户体验层->网关层->BFF层->微服务层,是现代微服务架构的典型分层方式,这个架构能够灵活应对业务需求的变化,是一种支持创新的演化式架构。 -4. 技术和业务都在不断变化,架构师要不断调整架构应对这些的变化,BFF和网关都是架构演化的产物。 - - - - - - - -### 2. BFF & GraphQL - -大前端模式下经常会面临下面2个问题 - -- 频繁变化的 API 是需要向前兼容的 -- BFF 中返回的字段不全是客户端需要的 - -至此 2015 年 GraphQL 被 Facebook 正式开源。它并不是一门语言,而是一种 API 查询风格 - -#### 1. 使用 GraphQL - -服务端描述数据 - 客户端按需请求 - 服务端返回数据 - -服务端描述数据 - -```GraphQL -type Project { - name: String - tagline: String - contributors: [User] -} -``` - -客户端按需请求 - -``` -{ - Project(name: 'GraphQL') { - tagline - } -} -``` - -服务端返回数据 - -``` -{ - "project": { - tagline: "A query language for APIS" - } -} -``` - - - -### 2. 特点 - -#### 1.定义数据模型,按需获取 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature1.png) - -就像写 SQL 一样,描述好需要查询的数据即可 - -#### 2. 数据分层 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature2.png) - -数据格式清晰,语义化强 - -#### 3. 强类型,类型校验 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature3.png) - -拥有 GraphQL 自己的类型描述,类型检查 - -#### 4. 协议⽽非存储 - -看上去和 MongoDB 比较像,但是一个是数据持久化能力,一个是接口查询描述。 - -#### 5. ⽆须版本化 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/GraphQLFeature4.png) - -写过客户端代码的同学会看到 Apple 给 api 废弃的时候都会标明原因,推荐用什么 api 代替等等,这点 GraphQL 也支持,另外废弃的时候也描述了如何解决,如何使用。 - - - -### 3. 什么时候需要 BFF - -- 后端被诸多客户端使用,并且每种客户端对于同一类 Api 存在定制化诉求 -- 后端微服务较多,并且每个微服务都只关注于自己的领域 -- 需要针对前端使用的 Api 做一些个性话的优化 - -什么时候不推荐使用 BFF - -- 后端服务仅被一种客户端使用 -- 后端服务比较简单,提供为公共服务的机会不多(DDD、微服务) - - - -### 4. 总结 - -在微服务架构中,BFF(Backend for Frontend)也称re聚合层或者适配层,它主要承接一个适配角色:将内部复杂的微服务,适配成对各种不同用户体验(无线/Web/H5/第三方等)友好和统一的API。聚合裁剪适配是BFF的主要职责。 - -在微服务架构中,网关专注解决跨横切面逻辑,包括路由、安全、监控和限流熔断等。网关一方面是拆分解耦的利器,同时让开发人员可以专注业务逻辑的实现,达成架构上的关注分离。 - -端用户体验层->网关层->BFF层->微服务层,是现代微服务架构的典型分层方式,这个架构能够灵活应对业务需求的变化,是一种支持创新的演化式架构。 - -技术和业务都在不断变化,架构师要不断调整架构应对这些的变化,BFF和网关都是架构演化的产物。 - - - -## 四、后端架构演进 - -### 1. 一些常见名词解释 - -云原生( Cloud Native )是一种构建和运行应用程序的方法,是一套技术体系和方法论。 Cloud Native 是一个组合词,Cloud+Native。 Cloud 是适应范围为云平台,Native 表示应用程序从设计之初即考虑到云的环境,原生为云而设计,在云上以最佳姿势运行,充分利用和发挥云平台的弹性+分布式优势。 - -Iaas:基础设施服务,Infrastructure as a service,如果把软件开发比作厨师做菜,那么IaaS就是他人提供了厨房,炉子,锅等基础东西, 你自己使用这些东西,自己根据不同需要做出不同的菜。由服务商提供服务器,一般为云主机,客户自行搭建环境部署软件。例如阿里云、腾讯云等就是典型的IaaS服务商。 - -Paas:平台服务,Platform as a service,还是做菜比喻,比如我做一个黄焖鸡米饭,除了提供基础东西外,那么PaaS还给你提供了现成剁好的鸡肉,土豆,辣椒, 你只要把这些东西放在一起,加些调料,用个小锅子在炉子上焖个20分钟就好了。自己只需关心软件本身,至于软件运行的环境由服务商提供。我们常说的云引擎、云容器等就是PaaS。 - -Faas:函数服务,Function as a Service,同样是做黄焖鸡米饭,这次我只提供酱油,色拉油,盐,醋,味精这些调味料,其他我不提供,你自己根据不同口味是多放点盐, 还是多放点醋,你自己决定。 - -Saas:软件服务,Software as a service,同样还是做黄焖鸡米饭,这次是直接现成搞好的一个一个小锅的鸡,什么调料都好了,已经是个成品了,你只要贴个牌,直接卖出 去就行了,做多是在炉子上焖个20分钟。这就是SaaS(Software as a Service,软件即服务)的概念,直接购买第三方服务商已经开发好的软件来使用,从而免去了自己去组建一个团队来开发的麻烦。 - -Baas:你了解到,自己要改的东西,只需要前端改了就可以了,后端部分完全不需要改。这时候你动脑筋,可以招了前端工程师,前端页面自己做,后端部分还是用服务商的。 - -这就是BaaS(Backend as a Service,后端即服务),自己只需要开发前端部分,剩下的所有都交给了服务商。经常说的“后端云”就是BaaS的意思,例如像LeanCloud、Bomb等就是典型的BaaS服务商。 - -MicroService vs Severless:MicroService是微服务,是一种专注于单一责任与功能的小型服务,Serverless相当于更加细粒度和碎片化的单一责任与功能小型服务,他们都是一种特定的小型服务, 从这个层次来说,Serverless=MicroService。 - -MicroService vs Service Mesh:在没有ServiceMesh之前微服务的通信,数据交换同步也存在,也有比较好的解决方案,如Spring Clould,OSS,Double这些,但他们有个最大的特点就是需要你写入代码中,而且需要深度的写 很多逻辑操作代码,这就是侵入式。而ServiceMesh最大的特点是非侵入式,不需要你写特定代码,只是在云服务的层面即可享受微服务之间的通信,数据交换同步等操作, 这里的代表如,docker+K8s,istio,linkerd 等。 - - - -### 2. 架构演进 - -#### 1. 演进图 - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerArchProgress.png) - -后端整个的演进可以如上图所示,经历了不断迭代,目前到了微服务、Service Mesh 的时代。具体的解释这里不做展开,感兴趣的可以去自行谷歌。跟 SOA 相提并论的还有一个 ESB(企业服务总线),简单来说 ESB 就是一根管道,用来连接各个服务节点,为了集成不同系统,不同协议的服务。 - -ESB 可以简单理解为:它做了消息的转化解释和路由工作,让不同的服务互联互通,使用ESB解耦服务间的依赖。 - -#### 2. SOA 和微服务架构的差别 - -微服务去中心化,去掉 ESB 企业总线。微服务不再强调传统 SOA 架构里面比较重的 ESB 企业服务总线,同时 SOA 的思想进入到单个业务系统内部实现真正的组件化 - -Docker 容器技术的出现,为微服务提供了更便利的条件,比如更小的部署单元,每个服务可以通过类似 Node 或者 Spring Boot 等技术跑在自己的进程中。 - -SOA 注重的是系统集成方面,而微服务关注的是完全分离 - -#### 3. 早期微服务架构 - - - -​ - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MicroServer.png) - -REST API:每个业务逻辑都被分解为一个微服务,微服务之间通过REST API通信。 - -API Gateway:负责服务路由、负载均衡、缓存、访问控制和鉴权等任务,向终端用户或客户端开发API接口 - -每个业务逻辑都被分解为一个微服务,微服务之间通过 REST API 通信。一些微服务也会向终端用户或客户端开发API接口。但通常情况下,这些客户端并不能直接访问后台微服务,而是通过 API Gateway 来传递请求。API Gateway 一般负责服务路由、负载均衡、缓存、访问控制和鉴权等任务。 - - - -#### 4. 断路器 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerCircultBreaker.png) - - - -断路器背后的基本思想非常简单。您将受保护的函数调用包装在断路器对象中,该对象监视故障。一旦故障达到某个阈值,断路器就会跳 - -闸,并且所有对断路器的进一步调用都会返回错误,而根本不会进行受保护的调用。通常,如果断路器跳闸,您还需要某种监视器警报。 - -通常,断路器和服务发现等基础实现独立接入。 - - - -#### 6. 早期微服务架构的问题及解决方案 - -- 框架/SDK太多,后续升级维护困难 -- 服务治理逻辑嵌入业务应用,占有业务服务资源 -- 服务治理策略难以统一 -- 额外的服务治理组件(中间件)的维护成本 -- 多语言:随着Node、Java以及其他后端服务的兴起,可能需要开发多套基础组件来配合主应用接入,SDK维护成本高 - -随机引入了 **Sidecar** 模式: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerSideCare.png) - -将这些基础服务和应用程序绑定在一起,但使用独立的进程或容器部署,这能为跨语言的平台服务提供同构接口。SideCare 很多人会很懵逼,这个词怎么理解,下面的配图,旁边那一小坨就比较形象了。 - - - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerSideCarePattern.png) - -Sidecar模式的优势 - -- 在运行时环境和编程语言方面独立于它的主应用程序,不需要为每种语言开发一个 Sidecar -- Sidecar可以访问与主应用程序相同的资源 -- 因为它靠近主应用程序(部署在一起),所以在它们之间通信时没有明显的延迟 -- 即使对于不提供可扩展性机制的应用程序,也可以、将sidecar作为自己的进程附加到主应用程序所在的主机或子容器中进行扩展 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServerSideCareApply.png) - - - -#### 7. Service Mesh 的形成 - -每个服务都将有一个配套的代理 sidecar,鉴于服务仅通过 sidecar 代理相互通信,我们最终会得到类似于下图的部署: - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServicesMesh.png) - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ServicesMeshArch.png) - -服务网格是用于处理服务到服务通信的专用基础设施层。它负责通过构成现代云原生应用程序的复杂服务拓扑来可靠地交付请求。在实践中,服务网格通常被实现为一系列轻量级网络代理,这些代理与应用程序代码一起部署,应用程序不需要知道。 - -**网格 = 容器网格 + 服务** - -典型的 Service Mesh 架构 - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/MainServicesMeshArch.png) - -控制层: - -- 不直接解析数据包 -- 与控制平面中的代理通信,下发策略和配置 -- 负责网络行为的可视化 -- 通常提供 API 或者命令行工具可用于配置版本化管理,便于持续集成和部署 - -数据层: - -- 通常是按照无状态目标设计的,但实际上为了提高流量转发性能,需要缓存一些数据,因此无状态也是有争议的 -- 直接处理入站和出站数据包,转发、路由、健康检查、负载均衡、认证、鉴权、产生监控数据等 -- 对应用来说透明,即可以做到无感知部署 - -Istio 是一个典型主流的 Service Mesh 框架。有关 Istio 的详细介绍可以看这里的[电子书](https://jimmysong.io/istio-handbook/concepts/istio-architecture.html)。 - - - - - -## 五、写在最后 - -为什么客户端工程师需要看这些,看上去和日常工作没啥关系,但是知识都是积累才有用的,你先储备才有机会发挥出来,知识是会复利的,某个领域的知识和解决方案可以为你在解决其他问题的时候提供思路和眼界。另外大前端不可能只和客户端、前端打交道,后端的解决方案、技术趋势也要了解,有助于你站在更宏观的角度解决某个问题,清楚设计。 diff --git a/Chapter1 - iOS/1.99.md b/Chapter1 - iOS/1.99.md deleted file mode 100644 index 45ddb3c..0000000 --- a/Chapter1 - iOS/1.99.md +++ /dev/null @@ -1,193 +0,0 @@ -# 客户端质量把控 - -> 笔者结合中台经验,本文重点谈谈 App 的质量稳定性该如何做。业务作为 App 的核心服务之一,业务异常监控当然也很重要,这不是本文重点。 - -## 质量问题的现状 - -对于质量问题,直接以小故事的形式展开。下面是移动中台年度针对质量复盘的一些思考 - -1. 技术方案阶体现测试用例 -对于业务项目来说,会存在测试资源、冒烟用例、精准测试、QA 新业务的业务回归、核心业务的 UI 自动化、高铁阶段的 QA 人工回归等。这里简单讲讲这些词语,对于新的业务项目,一定会有测试资源,简单说就是 QA,新项目在经过 PRD、MRD、需求讨论会、Kick-off 之后,技术方案评审后,会经过测试用例评审,产出的结果就是用例指南,到时候 QA 会在用例平台指配给对应的开发。 -敏捷开发思想下,业务需求跟车,而不是针对业务项目开车,每周一创建本周高铁,需求买票跟着上车。上车之前针对你的开发分支,会走精准测试,产出精准测试报告,分析测试报告,如果覆盖率比较低,需要分析是兜底代码太多,还是 QA 没有执行完全,针对后者你可以结合用例,是否有遗漏,然后去 push QA 再去回归。 -针对不变的业务,沉淀出的自动化用例,会走 UI 自动化测试。期间线下性能监控会发现一些性能问题。每周值班 QA 会无差别回归业务。 - -但是啊,这些大多是针对业务,如果是基础 SDK 的能力和性能,大多是无法定位到问题的,所以针对技术 SDK 可能没有测试资源,需要中台开发者在 SDK 阶段,去思考基础 SDK 本身的核心用例,用例需要思考功能用例和性能用例,还需要思考一些开关情况、版本升级等问题。 -所以第一个话题,主要是针对基础 SDK 来说的,不过业务项目,在技术方案阶段思考的不是测试用例,而是天网报警(业务异常埋点上报)、业埋点(核心数据)等 - -2. 官方组件引入 BetterMR -经过约定:业务代码经过测试之后,才可以从个人分支合并到 dev 分支(注意 dev 分支不是市场分支,release 分支是市场分支)。提交的 MR 必须至少 +2 后才可以合并。其中1个人是同技术栈的老司机,另一个人是同项目的业务开发,做到对齐。 - -代码质量直接关系到产品质量,Code Review 是保证代码质量一个最显著可行的措施之一,而 BetterMR 是我们探索最佳 Code Review 的方式之一。 - -约定与建议 -【约定】后续所有项目与日常均默认走 betterMR 流程,如果相对简单,可以申请不走 betterMR 流程; -【约定】MR 分级,默认为普通 MR,在 24H 内完成 review;提交者可选择为紧急 MR,在 2H 内要完成 review; -【约定】在后续规划中,架构师在工作分配上预留一定时间到 CR 上; -【约定】被 @ 的 reviewer 当自己手头忙碌无法 review 的情况下,可以选择在评论中 @ 一位 backup 替自己 review; -【建议】紧急MR发出后,请提 MR 的同学主动口头或企业微信联系和催促 reviewer 快速响应; -【建议】reviewer 手头忙碌时,可以先 +1 merge,后续再 review 建议; - -reviewer 数量与选择 -约定与建议 -【约定】每个 MR @ 到两位同学,其中包含该业务域的 owner,以及另一位适合的同学(熟悉业务或者熟悉代码); -【约定】MR 不要 @ 超过两位同学; - - -小MR流程上是否可以更快一些 -约定与建议 -【约定】质量是核心问题,因此暂时所有走 betterMR 的项目和日常都坚持走 +2 的逻辑;直至我们的质量数据有显著好转,代表我们的质量意识有明显提升,再考虑轻量化; -【建议】提MR的同学和 reviewer 可以通过更有效的描述、注释、沟通来加速 review 流程,如 UI 部分更快速 review,逻辑部分重点review 等; - -MR的代码量与有效拆分 -约定与建议 -【约定】在技术评审与 kick-off 阶段对工作量进行MR任务的逻辑拆分,业务域 owner 在这两个阶段进行把关,拆分的任务尽量粒度细化; -【约定】在一个 MR 中尽量将相关逻辑完整提交,有利于代码的整体 review; -【约定】在技术评审阶段,业务域 owner 对技术方案与拆分做内审,提前熟悉改动面和设计细节,避免在 MR 提交的代码之外存在逻辑遗漏; -【建议】在保证子任务MR逻辑完整的前提下,尽量约束每个 MR 的代码量,保证 review 效果; - -3. 业务 SDK 接入精准测试,产出报告必须分析 -业务项目我在第一部分说明了会针对业务做哪些测试动作,在中台角度出发,思考业务中台(比如商品、消息)如何保证质量,也可以参考业务项目,接入精准测试,针对每一份测试报告,做进一步的分析,如果覆盖率比较低,需要分析是兜底代码太多,还是 QA 没有执行完全,针对后者你可以结合用例,是否有遗漏,然后去 push QA 再去回归。 - -技术中台负责的业务中台项目,也就是业务 SDK 也需要严格管控,否则就是业务异常,从而产生线上问题或者线上资损。 - -4. 业务项目一定接入天网报警,基础 SDK 关键流程接入天网报警 -App 质量与稳定性划分为:性能与质量稳定性、业务稳定性。业务不稳定了就很容易产生线上问题或者资损。针对业务异常,我们对线上问题归因做了一些梳理,一般可以分为: -方法或接口的参数数据类型不对、参数值不在合法区间、边界 case 没有覆盖、其他(历史遗留 bug、三方 SDK 升级导致、2端沟通不足需求没对齐); -假如我们将第一二类问题解决好,线上问题将会显著改善。这正好就是天网报警的设计初衷,天网报警用于业务异常监控并报警。天网报警监控并不像 APM 一样是 SDK 去主动监控的,而是需要开发者自己在当前负责的模块、当前开发的项目、当前开发的日常迭代中去梳理关键业务流程和业务场景,对于一些可能存在的异常 case,去埋点上报。 - -所以制定规范:业务项目一定要接入天网报警,基础 SDK 比如 IM、商品,Socket 链接有问题,那么就是线上问题,肯定是业务异常。所以这样的关键环节一定要梳理并上报 - -5. 新 SDK UT 覆盖率90%以上,老 SDK 基于 BDD 通过 -基于资源有限的情况下,历史遗留的 SDK 可能无法去梳理并编写单测,那老的 SDK 可以去给予行为去编写 BDD 测试用例,这里不展开描述什么是 BDD 和怎么实践。针对新的 SDK 在技术方案阶段就需要思考好测试用例并体现出来,开发阶段 UT 覆盖率须大于90%。 - -6. SDK 一定要 Lint 通过 -这里的 lint 并不是针对语法、锁进等的 OCLint,而是 pod lint。因为发生过一些情况,就是 MR 提交后,去打包系统打包阶段, 因为 pod SDK 的问题导致的打包失败,所以 pod 的 lint 一定要通过,将问题提前解决掉。 - -7. SDK Warning 清理 -SDK 内部的 warning 尽量清理掉,比如 UIWebView 或者某个使用的 API 苹果标记为待废弃,假如你不按时修改掉,万一上线后用户使用的某个功能异常,那就 GG 了 - -8. SDK 核心用例梳理,确保接入 App 集成测试 -老的 SDK 梳理核心用例,便于 BDD 测试。SDK 的所有功能需要接入至少2个业务线 App 去验证功能和性能是否符合预期 - -9. SDK Demo 必须体现开发能力,多端 Demo 对齐 -SDK 的功能设计、类的 API 多端对齐,能力一致。且在 Demo 上可以体现出核心功能 - -10. 脏乱差治理并优化 -年底统计线上问题原因,经常会发现不管是业务线还是中台,都有一些遗留或者接手的线上问题,所以不管何种原因,都需要 Owner 意识,脏乱差梳理去修复问题 - -11. 确保测试用例冒烟通过 -QA 指派的测试用例一定要冒烟通过,冒烟打回很严重的,这是对质量的不认真,也是对 QA 工作的不尊重 - -12. 关键功能有限老司机操刀开发,避免形成卡点(进度)或者影响质量,太忙的情况下至少老带新 -核心业务功能,新人很难评估到所有影响面和边缘 case,所以优先老司机操刀开发,或者新人梳理评估出方案,老司机 review 把关。避免因为不熟悉造成进度落后或者线上质量问题 - -13. 基础 SDK 交叉测试 -业务项目有 QA 资源,基础 SDK 不一定有测试资源,需要开发者本身去思考测试用例,包括功能和性能方面,最后可以交叉测试,Android、iOS 互测,确保质量。 - - - -## SDK 质量 CheckList - -- ChangeLog、Podspec、Readme 完善 - -- BetterMR +3 机制深入贯彻(一名角色为项目的另一端同学,另一名角色为本技术栈更资深的老司机) - -- 冒烟通过率100%(假如技术项目、日常优化可以交叉测试) - -- 精准测试,以及精准测试报告分析。代码行覆盖率至少80% - -- 高铁包回归阶段:UI 自动化点击页面,发现性能(APM)与稳定性问题(Crash)、业务异常天网报警监控(之前都是忽略未上线前高铁阶段的质量问题的,提前感知问题提前修复,减少线上问题) - -- 业务 SDK 正式发布阶段,业务线接入升级时,工程师需充当 QA 角色,评估业务影响面,数据需要全面评估(新老版本兼容性、灰度策略),产出 SDK 性能测试报告和影响面报告 - -技术 SDK 质量 CheckList - -- ChangeLog、Podspec、Readme 完善,SDK 发布必须 Lint 通过 - -- BetterMR +3 机制深入贯彻(一名角色为项目的另一端同学,另一名角色为本技术栈更资深的老司机) - -- 新开的 SDK 必须写单测,覆盖率90%以上 - -- 对于存量 SDK 可以通过 BDD 补充测试用例,不如 APM 卡顿测试,可以在10s内 Mock 3次卡顿。假设普通卡顿临界值为0.2s,严重卡顿为1s,ANR为5s,手动Mock3次卡顿,分别为0.3s、2s、6s,基于 BDD 我们可以对监控结果进行判断,比如断言抓到3次卡顿,其中2次严重卡顿、1次 ANR - -- 开发阶段必须关心性能:内存、电量、卡顿、网络。用 Instrucments 测试 - -- 冒烟通过率100%(假如技术项目、日常优化可以交叉测试) - -- 高铁包回归阶段:UI 自动化点击页面,发现性能(APM)与稳定性问题(Crash)、业务异常天网报警监控(之前都是忽略未上线前高铁阶段的质量问题的,提前感知问题提前修复,减少线上问题) - -- 业务 SDK 正式发布阶段,业务线接入升级时,工程师需充当 QA 角色,评估业务影响面,数据需要全面评估(新老版本兼容性、灰度策略),产出 SDK 性能测试报告和影响面报告 - - - -## SDK 测试方法 - -客户端SDK是为第三方开发者提供的软件开发工具包,包括 SDK 接口、开发文档和 Demo 示例等。SDK 和应用之间的关系?以 IM 为例,App 调用 IM SDK 接口。进行客服消息功能模块的接入,也包括消息 PUSH 功能的使用方。包括 Weex、JS 等资源的更新、商品数据更新 PUSH 后端上的感知能力、智能经营消息等。 - - -### 客户端 SDK 测试的对象 -客户端 SDK 测试,就是对提供给开发者的工具包里面的内容进行测试 - -因此测试的主要内容有: -1. SDK 接口和文档 -- SDK 接口是测试的主要对象,也是核心的内容 - -2. SDK 日志 -对开发者来说,SDK 接口里面的具体实现是透明的,当上层调用时遇到问题,只能依赖 SDK 打印的日志来定位分析。所以 SDK 日志是否完备,是否有助于解决问题,对应用开发者和 SDK 提供方来说都很重要 - -3. Demo 或行业解决方案 -Demo 测试可以看成是基于行为的测试。Demo 是SDK提供方用来示例如何调用接口实现具体的功能,也可以作为开发者直观感受SDK接入效果。行业解决方案类似 Demo,但是,比 Demo 更加像一个产品,具有比较完整和典型的行业应用场景。可以让行业开发者比较明确知道,接入这个 SDK 做出来的产品效果如何。 - -4. 其他周边 -比如UIkit等,可能只是在SDK开发中的附带输出,但对有的开发者来说能极大降低接入成本 - -### 客户端SDK接口测试类型 -客户端SDK根据需求和开发平台不同,可能需要选择不同的测试类型对SDK接口进行测试 - -常见的测试类型有: -1. 功能测试 -保证 SDK 接口功能正确性和完备性。客户端 SDK 接口测试跟服务端接口测试类似,包括场景覆盖和接口参数覆盖 -主要测试各种参数组合下的返回值,考虑数据是否缓存与存储,是否有回调,对于请求成功或失败都能按预期进行处理 - -2. 性能测试 -保证 SDK 接口满足特定的性能需求,比如资源占用、移动设备耗电量等。比如 APM 在卡顿定制抓取堆栈的时候会对设备有内存和 CPU 的影响,如果全量抓取一次堆栈会更加耗时,如果异步抓取主线程堆栈。实现不好,很有可能在发生卡顿的时候,由于 APM 实现不好,导致本来的卡顿变成了 OOM。所以测试时就需要考虑这个场景的性能 - -3. 兼容性测试 -保证 SDK 兼容特定的设备平台,并与其他软件兼容。兼容设备平台的工作量通常是比较大的,先根据产品需求和市场现状对需要适配的设备平台做分析,再根据需要覆盖的机型、系统版本、分辨率等进行优先覆盖排序 -移动端 SDK 兼容性测试需要考虑下对模拟器的支持,因为很多开发者可能就是先在模拟器上开发。客户端 SDK 覆盖多平台设备的,还要考虑多端消息数据包的互通 - -4. 稳定性测试 -考察业务场景在一定压力下,持续运行一段时间,接口功能和设备资源占用有无异常。比如早期做 APM 时候,参考腾讯 Matrix 的代码,居然线上发生了 OOM,最后二分法排查代码改动,居然定位到了 CPU 利用率获取代码中,有 c 对象没有 free,所以代码的稳定性也是需要测试去考虑和关注的。 - - -5. 网络相关测试 -保证在不同网络类型,不同网络环境下,SDK 接口都能较好的处理。在涉及到多媒体资源或音视频通信,弱网下测试的需求较多,并且弱网下的处理通常需要反复优化和对比,不仅是新老版本效果对比,还包括竞品的效果对比测试 - -6. 安全性测试 -对隐私数据保护,访问权限的控制,用户服务鉴权等,SDK 接口的安全性问题也是比较突出。安全性很多是在架构设计和开发设计中就考虑进去,但是最好还是有专门的安全性测试 - -上述诸多测试类型中,功能测试先行。在进行客户端SDK测试前,需要全面的了解测试对象的细节: -- 了解业务流程,结合API接口文档和开发指南,理顺接口的使用场景和调用关系; -- 了解 SDK 协议,理解协议中字段的意义以及服务器端的处理逻辑; -- 了解各接口或协议返回码,分析对应的场景; -- 了解开发实现细节,可以绘制成图,便于测试分析和分层验证。 - -对客户端 SDK 进行测试,可以采用的分层测试方式由上至下依次有:基于 Demo 和解决方案->基于接口调用->基于代码。 - -1、基于 Demo 和解决方案的测试 -大多客户端SDK在提测时,都会有对应的Demo或者解决方案提交给测试,因此可以覆盖到该Demo或解决方案对应的接口或业务场景。而且测试人员可以比较直观的看到界面表现,上手快,所以在客户端SDK测试中比较常用,也是比较有效的。 - -但这种测试方式的缺点也很多,Demo对接口和业务场景覆盖比较有限,对接口的输入输出参数不能全覆盖,发现问题时定位复杂度增加。精心设计的Demo以及多解决方案的形式或许可以最大程度满足测试需要,但是需要较大的Demo开发测试投入,也使得问题暴露的时间大大滞后。基于Demo和解决方案的测试,可以是手工的也可以是UI层自动化测试。 - -2、基于接口调用的自动化测试 -基于接口调用的测试,包括对单个接口的测试,也包括业务场景的覆盖。这种测试方式直接有效,需要一定开发基础 - -比如 SDK Repo(功能实现代码 + XCTest Case 代码),通过脚本同步 SDK 代码到测试 Repo,主要包括:SDK 功能实现代码 + XCTest Case 代码 + QA 的 XCTest 代码(Special TestCase + Normal TestCase) -其中,开发的测试 Case 走基础单元测试。QA 的 Special Test Case,专项测试的测试 Case(最小回归集等),日常测试的测试 Case - -基于接口调用的自动化测试,需要有产品的思路、开发的知识和测试的思维,做起来有难度。但是因为SDK接口通常比较稳定,所以一旦实现并投入使用,测试效率和质量的收益都很大,值得拥有。 - -3、基于代码的单元测试 -单元测试是为开发代码质量保驾护航的一个重要环节,在测试左移推进的道路上,大家越来越意识到单元测试的重要价值。特别是在一些核心业务上,值得开发同学投入精力去做。 - -其他测试类型的展开,跟应用层测试类似,就不再重复了。 diff --git a/Chapter1 - iOS/chapter1.md b/Chapter1 - iOS/chapter1.md index c9968c0..b5be711 100644 --- a/Chapter1 - iOS/chapter1.md +++ b/Chapter1 - iOS/chapter1.md @@ -1,137 +1,136 @@ +# Part One -# 第一部分 +Part One mainly introduces problems encountered or interesting knowledge in iOS development -第一部分主要介绍 iOS 开发中遇到的问题或者有趣的知识 + * [1. App size optimization — image resources](1.1.md) + * [2. Understand initializers thoroughly](1.2.md) + * [3. The mystery of view controller loading](1.3.md) + * [4. How to elegantly debug mobile web pages?](1.4.md) + * [5. Event responder chain](1.5.md) + * [6. Dual-list linkage in food delivery apps](1.6.md) + * [7. Underlying principles of how objects are stored in memory](1.7.md) + * [8. Implement WeChat public account effect: long-press image to save to album](1.8.md) + * [9. Do you really know hitTest and pointInside methods?](1.9.md) + * [10. Hybrid exploration (Part 1)](1.10.md) + * [11. Events in iOS](1.11.md) + * [12. NSFileManager ultimate guide](1.12.md) + * [13. Clever uses of UINavigationController](1.13.md) + * [14. Deep dive into URL Schemes (Part 1)](1.14.md) + * [15. The evolution of URL Schemes](1.15.md) + * [16. Using CocoaPods](1.16.md) + * [16. Mixing Objective-C and Swift](1.16.md) + * [17. Changing the frame of UI controls with non-adjustable heights](1.17.md) + * [18. Using YYModel](1.18.md) + * [19. Implementing a wave animation](1.19.md) + * [20. Exploration of underlying principles](1.20.md) + * [21. Zen and the Art of Objective-C Programming](1.21.md) + * [22. Modify UITextField placeholder style](1.22.md) + * [23. Dismiss keyboard when UIScrollView is dragged](1.23.md) + * [24. Read Apple source code: NSRange](1.24.md) + * [25. CAReplicatorLayer (replicator layer)](1.25.md) + * [26. CAShapeLayer](1.26.md) + * [27. Weibo-like animation](1.27.md) + * [28. Globally match and highlight in UILabel](1.28.md) + * [29. JavascriptCore](1.29.md) + * [30. Xcode tips](1.30.md) + * [31. Terminal efficiency](1.31.md) + * [32. Ultimate screenshots](1.32.md) + * [33. Push notifications](1.33.md) + * [34. App ratings](1.34.md) + * [35. Some layout tips](1.35.md) + * [36. iOS numeric calculation precision loss issues](1.36.md) + * [37. Some things seen but not tried](1.37.md) + * [38. RunLoop exploration](1.38.md) + * [39. Multithreading exploration](1.39.md) + * [40. Memory issues study](1.40.md) + * [41. iOS app launch performance optimization resources summary](1.41.md) + * [42. App security](1.42.md) + * [43. Clever debugging tricks](1.43.md) + * [44. Awesome Hybrid - 1](1.44.md) + * [45. ](1.45.md) + * [46. KVC & KVO](1.46.md) + * [47. Currency formatting](1.47.md) + * [48. Category, Extension, load, initialize](1.48.md) + * [49. MVC, MVP, MVVM](1.49.md) + * [50. "Static libraries" and "dynamic libraries"](1.50.md) + * [51. CocoaPods related tips](1.51.md) + * [52. Developer productivity boosters](1.52.md) + * [53. iOS data persistence](1.53.md) + * [54. Set author information in Xcode](1.54.md) + * [55. The most powerful and detailed stealth analytics scheme ever](1.55.md) + * [56. Security in the big front-end era](1.56.md) + * [57. Thoughts on Auto Layout](1.57.md) + * [58. Summary of migrating between Swift versions](1.58.md) + * [59. Scattered iOS knowledge](1.59.md) + * [60. App slimming techniques](1.60.md) + * [61. App launch time optimization and binary reordering](1.61.md) + * [62. Implementing code review with OCLint](1.62.md) + * [63. Apple's official open-source resources](1.63.md) + * [64. Understanding componentization, modularization, plugins, sub-apps, frameworks, libraries](1.64.md) + * [65. Multi-platform fusion solutions](1.65.md) + * [66. Mobile network layer optimization](1.66.md) + * [67. iOS project compilation speed optimization](1.67.md) + * [68. Protect your app's security](1.68.md) + * [69. React-Native summary](1.69.md) + * [70. Different dynamic capabilities](1.70.md) + * [71. Flutter first experience — installation](1.71.md) + * [72. Architecture design insights](1.72.md) + * [73. Learning Ruby](1.73.md) + * [74. Build an APM monitoring system](1.74.md) + * [75. Write good tests to improve app quality](1.75.md) + * [76. iOS crash analysis](1.76.md) + * [77. Accelerating iOS packaging system construction](1.77.md) + * [78. App store submission pre-checks](1.78.md) + * [79. In-depth understanding of various locks](1.79.md) + * [80. Build a general, configurable data reporting SDK](1.80.md) + * [81. __asm__ symbol renaming tricks](1.81.md) + * [82. Runtime](1.82.md) + * [83. NSURLProtocol application scenarios](1.83.md) + * [84. WKWebView tips collection](1.84.md) + * [85. Unified jump techniques](1.85.md) + * [86. GCD source code exploration](1.86.md) + * [87. Objective-C internals exploration](1.87.md) + * [88. fishhook principles](1.88.md) + * [89. Block internals](1.89.md) + * [90. YYImage framework principles — explore efficient image loading](1.90.md) + * [91. DYLD and Mach-O](1.91.md) + * [92. Flutter stealth analytics](1.92.md) + * [93. Flutter new feature guide](1.93.md) + * [94. APM–Wake Up](1.94.md) + * [95. From Flutter and front-end perspectives, discuss how to ensure UI smoothness under a single-threaded model](1.95.md) + * [96. An idea to improve app computation performance](1.96.md) + * [97. Fancy uses of __attribute__](1.97.md) + * [98. Common design patterns for front-end, BFF, back-end](1.98.md) + * [99. Client-side quality control](1.99.md) + * [100. iOS client-side network errors](1.100.md) + * [101. Offscreen rendering](1.101.md) + * [102. LLVM](1.102.md) + * [103. Design patterns and their scenarios](1.103.md) + * [104. NSNotification internals](1.104.md) + * [105. iOS UI rendering pipeline](1.105.md) + * [106. NSUserDefaults internals exploration](1.106.md) + * [107. IM technology](1.107.md) + * [108. Best practices for precise testing](1.108.md) + * [109. Assembly learning](1.109.md) + * [110. Use design patterns to design a client-side validator](1.110.md) + * [111. HarmonyOS tips for iOS developers](1.111.md) + * [112. Swift enum memory layout](1.112.md) + * [113. Swift struct and class memory layout](1.113.md) + * [114. Swift optimizations](1.114.md) + * [115. AI empowerment on the endpoint](1.115.md) + * [116. Deep dive into Swift classes](1.116.md) + * [117. Swift protocol exploration](1.117.md] + * [118. Swift error handling](1.118.md) + * [119. Analyze Swift String](1.119.md) + * [120. Swift access control](1.120.md) + * [121. Swift memory management](1.121.md) + * [122. Swift literal fundamentals](1.122.md) + * [123. Swift pattern matching](1.123.md) + * [128. SwiftUI research](1.128.md) - * [1、工程大小优化之图片资源](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.1.md) - * [2、看透构造方法](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.2.md) - * [3、控制器加载的玄机](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.3.md) - * [4、如何优雅地调试手机网页?](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.4.md) - * [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、教你实现微信公众号效果:长按图片保存到相册](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) - * [12、NSFileManager终极杀手](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.12.md) - * [13、UINavigationController的妙用](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.13.md) - * [14、URL-Schemes深度剖析(上)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.14.md) - * [15、URL Schemes 的发展](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.15.md) - * [16、CocoaPods的使用](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.16.md) - * [16、OC与Swift混编](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.16.md) - * [17、对于不可调节高度的UI控件进行改变frame](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.17.md) - * [18、YYModel 的使用](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.18.md) - * [19、实现波浪动画](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.19.md) - * [20、底层原理探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.20.md) - * [21、禅与 Objective-C 编程艺术](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.21.md) - * [22、修改 UITextField placeholder 样式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.22.md) - * [23、UIScrollView拖拽时回收键盘](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.23.md) - * [24、读 Apple 源码看看 NSRange](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.24.md) - * [25、复制层(CAReplicatorLayer)](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.25.md) - * [26、CAShapeLayer](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.26.md) - * [27、仿微博动画](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.27.md) - * [28、UILabel 全局匹配并高亮](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.28.md) - * [29、JavascriptCore](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.29.md) - * [30、Xcode 小技巧](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.30.md) - * [31、终端效率](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.31.md) - * [32、终极截屏](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.32.md) - * [33、推送](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.33.md) - * [34、App 评分](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.34.md) - * [35、一些布局小知识](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.35.md) - * [36、iOS数值计算精度丢失问题](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.36.md) - * [37、一些看到但未尝试的知识](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.37.md) - * [38、RunLoop探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.38.md) - * [39、多线程探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.39.md) - * [40、内存问题研究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.40.md) - * [41、iOS 应用启动性能优化资料汇总](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.41.md) - * [42、App security](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.42.md) - * [43、奇技淫巧调试篇](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.43.md) - * [44、Awesome Hybrid - 1](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.44.md) - * [45、](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.45.md) - * [46、KVC && KVO](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.46.md) - * [47、金额格式化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.47.md) - * [48、类别(Category)、拓展(Extension)、load、initialize](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、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) - * [55、史上最强、最详细无痕埋点方案](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.55.md) - * [56、大前端时代的安全性](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.56.md) - * [57、自动布局的思考](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.57.md) - * [58、Swift每个版本迁移的总结](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.58.md) - * [59、iOS零散知识](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.59.md) - * [60、App瘦身之道](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.60.md) - * [61、App 启动时间优化与二进制重排](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.61.md) - * [62、OCLint实现Code Review](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.62.md) - * [63、苹果官方开源资料](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.63.md) - * [64、组件化、模块化、插件、子应用、框架、库理解](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.64.md) - * [65、多端融合方案](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.65.md) - * [66、移动端网络层优化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.66.md) - * [67、iOS工程编译速度优化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.67.md) - * [68、守护你的App安全](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.68.md) - * [69、React-Native总结](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.69.md) - * [70、不一样的动态化能力](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.70.md) - * [71、Flutter初体验-安装](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.71.md) - * [72、架构设计心得](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.72.md) - * [73、Ruby学习](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.73.md) - * [74、带你打造一套 APM 监控系统](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.74.md) - * [75、写好测试,提升应用质量](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.75.md) - * [76、iOS Crash分析](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.76.md) - * [77、iOS 打包系统构建加速](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.77.md) - * [78、App 上架包预检](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.78.md) - * [79、深入理解各种锁](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.79.md) - * [80、打造一个通用、可配置的数据上报 SDK](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) - * [85、统跳技术](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.85.md) - * [86、GCD 源码探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.86.md) - * [87、Objective-C 底层探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.87.md) - * [88、fishhook 原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.88.md) - * [89、block 底层原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.89.md) - * [90、YYImage 框架原理,探索图片高效加载原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.90.md) - * [91、DYLD 及 Mach-O](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.91.md) - * [92、flutter 无痕埋点](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.92.md) - * [93、flutter 新功能引导](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.93.md) - * [94、APM-Wake Up](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.94.md) - * [95、从 flutter 和前端角度出发,聊聊单线程模型下如何保证 UI 流畅性](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.95.md) - * [96、一个提高 App 运算性能的想法](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.96.md) - * [97、__attribute__ 的骚操作](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.97.md) - * [98、前端、BFF、后端一些常见的设计模式](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.98.md) - * [99、客户端质量把控](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.99.md) - * [100、iOS 端底层网络错误](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.100.md) - * [101、离屏渲染](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.101.md) - * [102、LLVM](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.102.md) - * [103、设计模式及其场景](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.103.md) - * [104、NSNotification底层原理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.104.md) - * [105、iOS 界面渲染流程](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.105.md) - * [106、NSUserDefault 底层原理探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.106.md) - * [107、IM技术](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.107.md) - * [108、精准测试最佳实践](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.108.md) - * [109、汇编学习](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.109.md) - * [110、妙用设计模式来设计一个客户端校验器](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.110.md) - * [111、写给 iOSer 的鸿蒙开发 tips](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.111.md) - * [112、Swift 枚举值内存布局](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.112.md) - * [113、Swift 结构体和类的内存布局](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.113.md) - * [114、Swift 优化](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.114.md) - * [115、AI 对端上的赋能](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.115.md) - * [116、Swift 类底层剖析](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.116.md) - * [117、Swift 协议探究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.117.md) - * [118、Swift 错误处理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.118.md) - * [119、剖析 Swift String](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.119.md) - * [120、Swift 访问控制](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.120.md) - * [121、Swift 内存管理](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.121.md) - * [122、Swift 字面量本质](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.122.md) - * [123、Swift 模式匹配](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.123.md) - * [128、SwiftUI 研究](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.128.md) - - * [139. 图形渲染技巧](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.139.md) - * [140. Aspects](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.140.md) - * [143. AI 对端上的赋能](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.143.md) - * [146. Weex 底层原理与 APM](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.146.md) - * [147. Rust 在移动端可以做什么](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.147.md) - * [148. 移动端的“安全气垫”](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.148.md) \ No newline at end of file + * [139. Graphics rendering techniques](1.139.md) + * [140. Aspects](1.140.md) + * [143. AI empowerment on the endpoint](1.143.md) + * [146. Weex internals and APM](1.146.md) + * [147. What Rust can do on mobile](1.147.md) + * [148. "Safety cushion" for mobile](1.148.md) \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/.DS_Store b/Chapter2 - Web FrontEnd/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/Chapter2 - Web FrontEnd/.DS_Store and /dev/null differ diff --git a/Chapter2 - Web FrontEnd/2.1.md b/Chapter2 - Web FrontEnd/2.1.md deleted file mode 100644 index 33bdc29..0000000 --- a/Chapter2 - Web FrontEnd/2.1.md +++ /dev/null @@ -1,196 +0,0 @@ -# last-child 与 last-of-type - -> 同学们遇到过给同一组元素的最后一个元素设置css失效的情况吗?我遇到过,当时使用:last-child居然不起作用,看到名字不科学啊,明明是“最后一个元素”,那为什么设置CSS失效呢?今天来一探究竟吧 - -* 先看一组`:last-child`正常工作的代码 - -``` - - - - - :last-child、:last-of-type - - - - - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • - -
- - -``` -![效果1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180507-091957@2x.png) - -* 再先看一组`:last-child`不正常工作的代码 - -``` - - - - - :last-child、:last-of-type - - - - - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • -

    我是来骚扰的

    -
- - -``` - - -![效果2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180507-092046@2x.png) - -问题抛出来了,那么来研究下:last-child和:last-of-type究竟是何方神圣。 - -1. :last-child:**The last-child CSS pseudo-class represents the last element among a group of sibling elements.(:last-child这个css伪类代表的一组兄弟元素当中最后一个元素)但经过代码发现,它说的一组元素应该是指其父元素的所有子元素且类型为:last-child前面指定的类型的一组元素。** - -2. :last-of-type:**The last-of-type CSS pseudo-class represents the last element of its type among a group of sibling elements.(**:last-of-type这个css伪类代表其类型的一组兄弟元素中的最后一个元素**)所以它指的是和**:last-of-type前面的元素类型一致的一组元素的最后一个元素 - -同理::nth-last-child和:nth-last-of-type的区别在于父元素的子元素中且与:nth-last-child前面的元素类型一致的最后一个元素 - -做个验证 - -* :nth-last-child可以正常工作的代码 - -``` - - - - - :last-child、:last-of-type - - - - - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • - -
- - - -``` - - -![效果3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180507-092145@2x.png) - -* :nth-last-child不能正常工作的代码 - -``` - - - - - :last-child、:last-of-type - - - - - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • -

    我是来骚扰的

    -
- - -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180507-092232@2x.png) - -* 接下来:nth-last-of-type闪亮登场 - -``` - - - - - :last-child、:last-of-type - - - - - -
    -
  • 1
  • -
  • 2
  • -
  • 3
  • -

    我是来骚扰的

    -
- - -``` - -![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180507-092358@2x.png) \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/2.10.md b/Chapter2 - Web FrontEnd/2.10.md deleted file mode 100644 index 7211706..0000000 --- a/Chapter2 - Web FrontEnd/2.10.md +++ /dev/null @@ -1,61 +0,0 @@ - -# 调试工具安装 - ---- - -## 安装方式一 - -Vue-devtools 可以从 Chrome 商店直接下载安装,前提需要翻墙。 - -## 安装方式二 - -* 第一步:找到 Vue-devtools 的 github 地址,并将其 clone 到本地。 - -``` -git clone https://github.com/vuejs/vue-devtools.git -``` - -* 第二步:安装项目所依赖的 npm 包 - -``` -npm install -``` - -遇到的问题: - -![遇到的问题](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Chrome-Vue-tools1.png) - -改用命令 - -``` -npm install chromedriver --chromedriver_cdnurl=http://cdn.npm.taobao.org/dist/chromedriver -``` - -![改用命令](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Chrome-Vue-tools3.png) - -继续 npm install - -* 第三步:编译项目文件 - -``` -npm run build -``` - -![编译项目文件](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Chrome-Vue-tools4.png) - -* 第四步:添加至 Chrome 浏览器的拓展 - -``` -浏览器地址栏输入:chrome://extensions/ - -点击“加载已解压的拓展程序”选择本地 clone 下来的文件夹中的 shells -> chrome 文件夹(vue-devtools-master/shells/chrome ) -``` - -![Chrome 添加拓展](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Chrome-Vue-tools5.png) - -* 第五步:重启浏览器 - -* 第六步:在浏览器中的调试 Vue 代码 -![Chrome 调试 Vue](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Chrome-Vue-tools6.png) - - diff --git a/Chapter2 - Web FrontEnd/2.11.md b/Chapter2 - Web FrontEnd/2.11.md deleted file mode 100644 index fdcd4c4..0000000 --- a/Chapter2 - Web FrontEnd/2.11.md +++ /dev/null @@ -1,10 +0,0 @@ -# H5页面保存页面为图片 - -方案:有个github的js库可以将html转换为canvas,然后canvas可以转换为图片,然后图片可以下载,所以基本路线就是: - -* html2canvas.js :将htmldom转为canvas ([https://github.com/niklasvh/html2canvas](https://github.com/niklasvh/html2canvas "html2canvas")) -* canvasAPI: toDataUrl\(\)将canvas转为base64格式 -* 图片下载 - -具体描述:https://juejin.im/post/5a17c5e26fb9a04527254689 - diff --git a/Chapter2 - Web FrontEnd/2.12.md b/Chapter2 - Web FrontEnd/2.12.md deleted file mode 100644 index 7983fdb..0000000 --- a/Chapter2 - Web FrontEnd/2.12.md +++ /dev/null @@ -1,180 +0,0 @@ -# Promise - -## 一、基础使用 - -举个例子: - -``` -//方式一 -function test(resolve, reject) { - var timeout = Math.random() * 2; - console.log("开始尝试promise"); - setTimeout(function() { - if (timeout < 1) { - resolve(timeout); - } else { - reject(timeout); - } - }, 1000); -} - -function resolve(time) { - console.log("小于1的数字" + time); -} - -function reject(time) { - console.log("不小于1的数字" + time); -} -var p1 = new Promise(test); -var p2 = p1.then(function(result) { - console.log("成功" + result); -}); -var p3 = p1.catch(function(reason) { - console.log("失败" + reason); -}); - -// 方式二 -new Promise(test).then(function(result) { - console.log("成功" + result); -}).catch(function(reason) { - console.log("失败" + reason); -}); - - -//方式三 -
- -
- - -var logging = document.getElementById("test-promise-log"); -while (logging.children.length > 1) { - logging.removeChild(logging.children[logging.children.length - 1]); -} - -function log(s) { - var p = document.createElement("p"); - p.innerHTML = s; - logging.appendChild(p); -} - - -new Promise(function(resolve, reject) { - log("start new Promise..."); - var timeout = Math.random() * 2; - log('set timeout to: ' + timeout + 'seconds.'); - setTimeout(function() { - if (timeout < 1) { - log('call resolve()...'); - resolve('200 OK'); - } else { - log('call reject()...'); - reject('timeout in ' + timeout + 'sconds.'); - } - }, timeout * 1000); - -}).then(function(result) { - log('Done: ' + result); -}).catch(function(reason) { - log("Failed: " + reason); -}); - - ------------- - -start new Promise... - -set timeout to: 1.6579586637257697seconds. - -call reject()... - -Failed: timeout in 1.6579586637257697sconds. -``` - -可见 Promise 的最大好处就是在异步执行的流程中,将执行代码和处理结果的代码清晰地分离了。 - -## 二、串行执行 - -比如有若干个异步任务,需要先做任务1,如果任务1成功后执行任务2...,任何一个环节中的任务失败则不再继续执行错误处理函数。 - -要完成这个需求,传统写法需要一层一层的嵌套代码有了 Promise ,就可以 - -``` -task1.then(task2).then(task3).catch(erroeHandler); -``` - -举个例子 - -``` -function add(num) { - return new Promise(function(resolve, reject) { - log(num + " + " + num + "..."); - setTimeout(resolve, 500, num + num); - }); -} - -function mul(num) { - return new Promise(function(resolve, reject) { - log(num + " x " + num + "..."); - setTimeout(resolve, 500, num * num); - }); -} - -new Promise(function(resolve, reject) { - resolve(100); -}).then(add).then(mul).then(add).then(mul).then(function(result) { - log("the result is " + result); -}); - - ---------------------- -100 + 100... - -200 x 200... - -40000 + 40000... - -80000 x 80000... - -the result is 6400000000 -``` - -## 三、并行执行 - -``` - var p1 = new Promise(function(resolve, reject) { - setTimeout(resolve, 500, "p1 success"); - }); - - var p2 = new Promise(function(resolve, reject) { - setTimeout(resolve, 500, "p2 success"); - }); - Promise.all([p1, p2]).then(function(results) { - console.log(results) - }); -``` - -多个 任务需要同时进行也就是并行执行,那么就可以使用 Promise.all\(\) 实现 - - - -## 四、容错处理,只需要拿到先返回的结果。 - -``` -var p1 = new Promise(function(resolve, reject) { - setTimeout(resolve, 500, "p1 success"); -}); - -var p2 = new Promise(function(resolve, reject) { - setTimeout(resolve, 500, "p2 success"); -}); - - -//使用第一个返回的结果 -Promise.race([p1, p2]).then(function(result) { - console.log("结果是: " + result); -}); -``` - - - diff --git a/Chapter2 - Web FrontEnd/2.13.md b/Chapter2 - Web FrontEnd/2.13.md deleted file mode 100644 index 73b3616..0000000 --- a/Chapter2 - Web FrontEnd/2.13.md +++ /dev/null @@ -1,223 +0,0 @@ -# webpack-dev-server 的配置和使用 - - - -> webpack-dev-server是一个小型的`Node.js Express`服务器,它使用`webpack-dev-middleware`来服务于webpack的包,除此自外,它还有一个通过[Sock.js](http://sockjs.org/)来连接到服务器的微型运行时. -> -> 说人话就是可以极大的提高开发效率。这么好的东西那就用起来 - - - - - -1、看看 webpack.config.js - -``` -module.exports = { - target:"web", //编译平台 web - entry:path.join(__dirname,"src/index.js"), //应用程序的主入口 - output:{ - filename:"bundle.js", //输出文件名 - path: path.join(__dirname, "dist") - }, - module:{ - rules:[ - { - test:/\.vue$/, - loader:"vue-loader" - }, - { - test:/\.css$/, - use:[ - "style-loader", - "css-loader" - ] - }, - { - test:/\.(gif|jpg|jpeg|png|svg)$/, - use:[ - { - loader:"url-loader", - options:{ - limit:1024, - name:'[name]-lbp.[ext]' //甚至可以加一些名字处理规则 '[name]-lbp.[ext]' - } - } - ] - } - ] - }, - /* - 在 plugins 中写 webpack.DefinePlugin 是为了在 webpack 打包的时候选择源代码的版本。当在 dev 的时候会打包开发所需要的所有东西,比如警告信息 - */ - plugins:[ - new webpack.DefinePlugin({ - 'process.env':{ - NODE_ENV: isDev ? '"development"' : '"production"' - } - }), - new HTMLPlugin() - ] -} -``` - - - -配置文件提供1个入口和一个出口,webpack 根据这个文件来执行 js 的打包和编译。webpack-dev-server 其中的部分功能就克服了上面2个问题。 webpack-dev-server 主要启动了1个使用 express 的 HTTP 服务器(资源文件)。由于这个 HTTP 服务器和 client 使用了 websocket 通讯协议,原始文件作出了改动,webpack-dev-server 会实时编译,但是最后编译的文件并没有输出到目标文件夹 - -## 一些截图 - - - -![](/assets/todo-20180226-1.png) - - - -![](/assets/todo-20180226-2.png) - - - -![](/assets/todo-20180226-3.png) - - - - - -![](/assets/todo-20180226-4.png) - - - -![](/assets/todo-20180226-5.png) - - - -完整的 webpack.config.js - -``` -const path = require("path"); -const HTMLPlugin = require("html-webpack-plugin") -const webpack = require("webpack") - - -//process.env 是读取系统环境。比如在启动服务的时候会通过 npm run build/dev 运行,其真实入口是在 webpack.config.js ,然后我们在 webpack.config.js设置为 production 或 development。那么就可以通过 process.env.NODE_ENV 获取 -const isDev = process.env.NODE_ENV === 'developement' - - - -//webpack 会将文件打包为 bundle.js -//需要为 .vue 文件声明一个类型, 因为 webpack 只识别 .js 且支持的语法为 ES5 -//增加一个 module 模块,一个键为 rules ,值可以是多个数组。检测文件以 .vue 结尾的话就以 vue-loader 解析 -//vue-loader 为 webpack 解析 .vue - - - -/* -查看 dist 文件夹下的 bundle.js 文件 -最顶部都是 webpack 处理包的代码 -其次是 vue 代码 -webpack 做的事情就是将不同类型的静态资源打包成 JS,然后在 HTML 中引入 JS 就可以减小 HTTP 请求。 -以 css 结尾的文件使用 css-loader。 -css-loader:从css文件中将内容读了出来。 -style-loader:判断将css代码插入到html还是写到一个新的文件中 - -在写图片的读取规则的时候,用到了use,use数组里面是一个个对象,loader 可以配置一些选项 -url-loader 可以将图片转换为 base64 字符串直接写到 JS 内容里面而不用生成一个图片,这对于一些小的图片是比较有利的,这样子可以减小 http 请求。 -url-loader 封装了 file-loader:读取了文件内容并做一些简单操作,再把图片换个名称存在一个地方 - limit选项:如果图片小于1024就可以转义成 base64 - name选项: 可以处理图片的名字 - -配置完之后则需要安装相应的模块,npm install - - -配置环境变量: -1、MAC 平台:NODE_ENV=production -2、Window 平台 SET NODE_ENV=production -3、为了配置开发环境和正式环境,需要安装: cross-env包。需要修改package.json 文件中的 scripts-> build 和 dev - - -*/ -const config = { - target:"web", //编译平台 web - entry:path.join(__dirname,"src/index.js"), //应用程序的主入口 - output:{ - filename:"bundle.js", //输出文件名 - path: path.join(__dirname, "dist") - }, - module:{ - rules:[ - { - test:/\.vue$/, - loader:"vue-loader" - }, - { - test:/\.css$/, - use:[ - "style-loader", - "css-loader" - ] - }, - { - test:/\.(gif|jpg|jpeg|png|svg)$/, - use:[ - { - loader:"url-loader", - options:{ - limit:1024, - name:'[name]-lbp.[ext]' //甚至可以加一些名字处理规则 '[name]-lbp.[ext]' - } - } - ] - } - ] - }, - /* - 在 plugins 中写 webpack.DefinePlugin 是为了在 webpack 打包的时候选择源代码的版本。当在 dev 的时候会打包开发所需要的所有东西,比如警告信息 - */ - plugins:[ - new webpack.DefinePlugin({ - 'process.env':{ - NODE_ENV: isDev ? '"development"' : '"production"' - } - }), - new HTMLPlugin() - ] -} - - -//为了判断是开发环境还是生成环境,判断了 isDEV -/* -webpack 2.0 以后增加了 devSever。用来处理开发环境的配置 -0.0.0.0:可以通过 localhost 和本地电脑 ip 访问项目 -overlay:在 webpack 编译的时候如果有错误显示在网页上。errors:true -*/ -if (isDev) { - config.devtool = "#cheap-module-eval-source-map" - config.devServer = { - port:8000, - host:'0.0.0.0', - overlay:{ - errors:true - }, - hot:true - } - config.plugins.push( - new webpack.HotModuleReplacementPlugin(), - new webpack.NoEmitOnErrorsPlugin() - ) -} - -module.exports = config -``` - - - - - - - - - - - - - diff --git a/Chapter2 - Web FrontEnd/2.14.md b/Chapter2 - Web FrontEnd/2.14.md deleted file mode 100644 index 676c8ee..0000000 --- a/Chapter2 - Web FrontEnd/2.14.md +++ /dev/null @@ -1,18 +0,0 @@ -# Web 与 H5 交互的坑 - - - -1、遇到一个问题 ,一个功能在 iOS 手机上正常工作,但是在 Android 上不正常,依照经验来看无非就是2个原因:(1)、URL参数少传递了;(2)、JS 在移动端的 webview 上报错了,所以我让远程对接的人员将 url 打印出来,发现没错。继续让他打印查看下 js 错误日志,发现 “Cannot read property "getItem" of null”。 代码出错行数在 259。看了下具体代码就是读取 localstorage - -![报错信息](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Andoid_Webview_Localstroage_erroe.jpg) - -想了想,在我们的项目中 Android 原生的代码在使用 webview 的时候额外设置了代码具体如下 - - - -``` -mWebView.getSettings().setDomStorageEnabled(true); -``` - - - diff --git a/Chapter2 - Web FrontEnd/2.15.md b/Chapter2 - Web FrontEnd/2.15.md deleted file mode 100644 index 1dcfd87..0000000 --- a/Chapter2 - Web FrontEnd/2.15.md +++ /dev/null @@ -1,25 +0,0 @@ -# 前端持久化 - -1. 早期的前端存储主要是 cookie,后来经过前端工程师不屑的追求以及开发,有了后来的 localStorage 和 sessionStorage 技术。 -2. 再到后来技术演进有了 indexDB 技术,也就是一个事务型的 key-value 形式的数据库 -3. 说说 cookie 有了,为什么还要有 localStorage,因为 cookie 虽然可以存储一些数据,但是大小非常有限,也就是 4k。并且 cookie 的本质工作是用来追踪浏览器用户的信息,因为 HTTP 是无状态协议,即服务器不知道用户上一次做了些什么,这严重阻碍了 Web 应用程序的时下。也就是通过 HTTP 连接无法知道用户干了些什么,比如说当用户在浏览一个商城的时候无法记录他购买了什么商品,介于此我们可以使用 cookie 技术用来绕开 HTTP 无状态的的特点,服务器可以设置或读取 cookie 中包含的信息,借此维护用户和服务器会话中的状态。 - -cookie 的另一个日常应用场景就是当用户勾选了某个网站的“下次自动登录”,那么在下次访问这个网站的时候,用户发现没有输入账号和密码就已经是登录状态。这是因为前一次登录的时候服务器发送了包含登录凭证(用户名和密,以及包含登录时长 的 token 信息)的 cookie 到用户的硬盘上,第二次登录时发现登录时长还有效,那么则自动登录 -4. 对于存储数据这个功能来说,cookie 的缺陷 - * cookie 会被附加到每个 HTTP 请求的头部去,所以如果用来存储数据,那么每次请求就加大了请求数据量 - * cookie 在 HTTP 请求中是明文传递的,所以存在安全性问题(除非 HTTPS) - * cookie 只有4k的存储空间 - -5.cookie 的另一种不好的方面表现在:当用户去访问某个网站的时候(通过搜索引擎查到的),这个网站包含一种叫做网页臭虫的图片,通常是1像素大小(以便于隐藏),它们的作用是将所有访问过此页面的计算机写入 cookie,而后,电子商务网站读取这些 cookie 信息,并寻找写入这些 cookie 的网站,随机发送包含针对这个网站的相关产品广告的垃圾邮件给这些用户 - - - - - - - - - - - - diff --git a/Chapter2 - Web FrontEnd/2.16.md b/Chapter2 - Web FrontEnd/2.16.md deleted file mode 100644 index 9e2969f..0000000 --- a/Chapter2 - Web FrontEnd/2.16.md +++ /dev/null @@ -1,13 +0,0 @@ -# VS-Code 的配置 - -1、Mac 下 按住 “command+shift+p”打开命令面板,输入Preferences: Open User Settings,在右边的区域输入 - -``` -{ - "window.zoomLevel": -1, - "editor.fontSize": 17, - "files.autoSave": "onFocusChange", - "terminal.integrated.fontSize": 15, - "editor.tabSize": 2 -} -``` \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/2.17.md b/Chapter2 - Web FrontEnd/2.17.md deleted file mode 100644 index 33f7e8e..0000000 --- a/Chapter2 - Web FrontEnd/2.17.md +++ /dev/null @@ -1,576 +0,0 @@ -# Vue - -> Vue学习过程中遇到的一些小tips - -1、组件化 - -Vue 的组件可以分为全局组件和局部组件 - -全局组件:声明好后可以在全局使用 -局部组件:只可以在当前模块使用 - -``` -//全局组件 -Vue.componetns('todo-item',{ - template:'
  • { {content} }
  • ', - prop:['content'] -}); -//局部组件 -var HobbyItem = { - template:"
  • hobby
  • " -} - -new Vue({ - el:"#test", - components:{ - 'todo-item':HobbyItem - } -}); -``` - -2、模块、实例 - -每一个模块都是 Vue 的一个实例,也就是说可以向每一个模版中像写 Vue 的实例一样进行开发。 - -3、父组件与子组件通信用 props 传递 - -4、子组件向外触发事件 this.$emit(事件名,参数列表) - -5、template 模版下最外层只可有一个根元素 -``` - - -``` - -6、单独在 .vue 文件中,data 是作为函数存在的。 - -``` - -data: function(){ - -} - -data () { - -} - -``` - -7、在 vue-cli 脚手架中,组件的声明方式 - -``` -export default{ - components : { - 'todo-item':Todo-Item - } -} - -components:['Todo-item'] - -``` - -8、style 中可以声明样式作用域 -style 同名的样式不会对其他组件有影响 - -9、在用 Vue 框架在开发的时候一般都会用 **Vue-cli** 脚手架,但是这样经过 webpack 打包后源代码会被打包成 dist 目录下的 bundle.js,此时的代码是被压缩处理过的,且经过 webpack 的处理各个代码模块的逻辑,代码可读性不是很好且不可调试,此时有些人在开发阶段就会需要调试?此时怎么办呢 - -慌不要慌,小哥哥带你 hold 住全场。 -* 脚手架已经帮你处理好了这块需求了,看下图 - -![配置图例](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180602-210826@2x.png) - -* 有些大佬不要脚手架,喜欢自己初始化项目,用 npm 挨个安装所需要的依赖。然后自己配置 webpack 的 options。需要调试的话,需要做下面的配置 - - ``` - config.devtool = '#cheap-module-eval-source-map' - - ``` - -这样你就可以在浏览器当中像写普通的 JS 一样进行调试代码了。比如 - -![调试界面](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180602-211328@2x.png) - - -10、Vue 中 **** 底层的做法是 ** render()**方法, 对 template 中的元素依次遍历创造节点 - -11、使用 stylus 写 css 的时候需要告诉 webpack如何处理代码,所以 -``` - -``` - -12、在使用 v-for 或 jsx 的map方法时候需要在生成的节点设置一个唯一的**key**作为标识,这样下次渲染的时候就可以根据 key 值判断,是否需要刷新 dom ,提高了效率 - -13、props 的另一种写法 -``` -props:[ - todos:{ - type:Array, - required:true - } -] -``` - -14、css 单独打包,缓存下来,提高程序的体验。需要使用**extrat-text-webpack-plugin** - -15、业务代码与框架代码如果打包在一起,后期维护很麻烦(因为框架代码基本不需要很快更新,但是业务代码经常更新),所以应该拆分出来,单独打包。将框架代码方便缓存。 - -16、webpack 的2个概念: - hash: 各个文件生成的**hash**值相同 - chunkhash:不同文件(模块)生成的**hash**值不同 - - -- Vue 使用官方组件(比如 vue-resource) - - 先引入 - - 再 use(必须在 main.js 中) - -``` -import VueResource from 'vue-resource' -Vue.use(VueResource); -``` - -- 第三方组件 - - - 哪里用,哪里引入(比如 axios) - -- npm install 组件名 --save - - 将安装的组件名称的依赖写入 package.json - -- 网络请求组件 - - - vue-resource - - axios - - fetch-jsonp - -- 父组件给子组件传值 - - - 父组件在调用子组件的时候传递 - - - 子组件 声明 porps - - - 同时可以在子组件接收的时候验证传值的正确性 - - ``` - props:['name','age'] - - props:{ - 'name':String, - 'age':Number - } - ``` - - - -- 父组件给子组件传递方法 - - ``` - //父 - - - - - //子 - export default{ - props:["run"], - methods:{ - test(){ - run(); - } - } - } - ``` - -- 子组件可以通过上面传递值给父组件 - -- 父组件可以将自身传递给子组件。所以可以在子组件里面访问父组件的属性和执行父组件的方法 - -- 父组件主动获取子组件的数据和方法 - - - 在调用子组件的时候给子组件定义一个 ref - - ``` - - ``` - - - 在父组件里面通过**this.$refs.header.属性** 和 **this.$refs.header.方法** - - - 子组件里面获取父组件的属性和方法 **this.$parent.属性** 和**this.$parent.方法** - -- 非父子组件间的传值 - - - 先定义1个中间的组件 - - 在需要暴露数据的一方。import 中间组件,调用 $emit(事件名称,数据) - - 在需要接受数据的一方。import 中间组件,调用 $on(事件名称,function(data){ //... }) - - ``` - //中间组件 - import Vue from 'Vue'; - var VueEvent = new Vue(); - export default = Vue; - - //Home.vue(传递数据给 News.vue) - import VueEvent from '../model/VueEvent.js'; - - VueEvent.$emit("postData",{"name":"杭城小刘"}); - - //News.vue(接收数据) - import VueEvent from '../model/VueEvent.js'; - - new Vue({ - mounted(){ - VueEvent.$on("postData",function(data){ - console.log("从Home组件接收到的数据:"+data); - }); - } - }); - ``` - -- 路由的使用规则 - - - 创建、引用组件(main.js) - - ``` - import Home form './Components/Home.vue' - import News form './Components/News.vue' - ``` - - - 导入 vue-router 并 use(main.js) - - ``` - import VueRouter from 'vue-router' - Vue.use(VueRouter); - ``` - - - 配置路由(main.js) - - ``` - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - ]; - ``` - - - 实例化 router(main.js) - - ``` - const router = new VueRouter({ - routes - //等于 routes:routes,只有当key和value一致的时候可以简写 - }); - ``` - - - 挂载 router(main.js) - - ``` - var vue = new Vue({ - el:"#app", - router, - data(){ - return { - msg:"root components" - } - } - }); - ``` - - - 在模版里面放上路由的出口。将 写到根组件上 (App.vue) - - ``` - //App.vue - - ``` - -- 要实现类似导航效果,可以使用 **** - - ``` - 首页 - 新闻 - ``` - -- 默认首页 - - ``` - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - ``` - -- Vue 动态路由和 Get 传值 - - ``` -
      -
    • { {item} }
    • -
    - - //新增加的 router-link 需要在路由配置里面添加配置项。 - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"/content",component:"Content"}, - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - ``` - - - 上面的写法是静态路由,也就是不能传递参数 - - - 那么什么是动态路由,也就是可以传递参数 - - ``` - //新增加的 router-link 需要在路由配置里面添加配置项。 - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"/content:newid",component:"Content"}, //动态路由 - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - ``` - - - 子组件页面获取动态路由传递过来的值 - - ``` - this.$route.params - ``` - - - 子组件拿到父组件动态传递过来的值用动态路由 - - ``` -
      -
    • { {item} }
    • -
    - - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"/content:newid",component:"Content"}, //动态路由 - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - - this.$route.params - ``` - - - Get 传值 - - ``` - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"/content",component:"Content"}, //动态路由 - {path:"/product",component:"Product"}, // - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - -
      -
    • { {item} }
    • -
    - - //拿到值 - this.$route.query - ``` - -- 编程式导航(JS 跳转控) - - ``` - //直接跳转到某个组件 - this.$route.push({path:'News' }); - //跳转到某个组件并且传递值 - this.$route.push({path:'/content/495'}); - ``` - - - 命名路由跳转 - - ``` - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - {path:"/content",component:"Content",name:'news'}, //动态路由 - {path:"/product",component:"Product"}, // - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - //跳转 - this.$router.push({name:'news'}); - ``` - - - -- vue-router 默认的是 hash 模式, 也就是 127.0.0.1/# 这种。所以我们在初始化 vue-router 的时候可以修改它的模式 - - ``` - const VueRouter = new VueRouter({ - mode: 'history',//hash 模式改为 history 模式 - routs - }) - ``` - -- 嵌套路由(比如顶部的菜单栏不变,点击左边的菜单栏实现页面内容的切换) - - ``` - //注册路由嵌套 - const routes = [ - {path:"/home",component:"Home"}, - {path:"/news",component:"News"}, - { - path:"/user", - components:"User", - children:[ - {path:'useradd',component:'UserAdd'}, - {path:'userlist',component:'UserList'}, - ] - }, - {path:"/content",component:"Content",name:'news'}, //动态路由 - {path:"/product",component:"Product"}, // - {path:"*",redirect:"News"} //默认跳转到首页 - ]; - - //将放到动态加载的子组件的地方 - //User.vue - - - ``` - - - -- Vuex:主要解决不同组件之间的数据共享、数据持久化(不适合与小项目,主要用于大项目) - - - 安装 vuex - - - src 目录下新建一个 vuex 的文件夹 - - - vuex 文件夹下面新建一个 store.js 文件 - - - 引入 vuex - - ``` - import Vue from 'vue' - import Vuex from 'vuex' - Vue.use(Vuex); - ``` - - - 定义数据 - - ``` - // state 在 vuex 中用于存储数据 - var state = { - count:1 - } - ``` - - - 定义方法 - - ``` - var muations = { - incCount(){ - ++state.count; - } - } - ``` - - - 暴露 vuex - - ``` - const store = new Vuex.Store({ - state, - mutations - }); - - export default store; - ``` - -- 使用 vuex(解决不同组件之间数据共享问题;数据持久化) - - - 引入 vuex - - ``` - import store from '../vuex/store.js' - ``` - - - 注册 vuex - - ``` - export default{ - data(){ - return { - msg: 'Hello' - } - }, - store, - methods:{ - - } - } - ``` - - - 使用 - - ``` - //访问属性 - this.$store.state.count - //触发方法+不带参数 - this.$store.commit('incCount'); - - //触发方法+不带参数 - this.$store.commit('方法名',参数); - - var mutation = { - addList(state,data{ - state.list = data; - } - } - ``` - - - getters 类似于计算属性,改变 state 里面的 count 的值会触发 setters 里面的方法,从而在里面可以获取新的值(做一些逻辑操作) - - ``` - var getters = { - computedCount: (state) => { - return state.count*2 - } - } - ``` - - - action 类似于 mutation ,在外面使用的时候用 this.$state.dispath('方法名') - - -- 某些页面可能我们只需要切换数据源和样式模版。所以数据源有可能是变化的,页面的模版也是变化的。 - 这样子我们可能会用到 `v-if、v-else-if...v-else` 来切换页面的模版。但是在模版里面去 `v-for` 循环展示数据源。 - 早期遇到错误,大概意思是说我们循环动态生成的元素,有 key 重复了,最后查找资料得到解决方案。在判断模版的时候需要使用 `` - ``` - - ``` \ No newline at end of file diff --git a/Chapter2 - Web FrontEnd/2.18.md b/Chapter2 - Web FrontEnd/2.18.md deleted file mode 100644 index 9ef75fb..0000000 --- a/Chapter2 - Web FrontEnd/2.18.md +++ /dev/null @@ -1,610 +0,0 @@ -# 反爬技术研究 - -> 对于内容型的公司,数据的安全性很重要。对于内容公司来说,数据的重要性不言而喻。比如你一个做在线教育的平台,题目的数据很重要吧,但是被别人通过爬虫技术全部爬走了?如果核心竞争力都被拿走了,那就是凉凉。再比说有个独立开发者想抄袭你的产品,通过抓包和爬虫手段将你核心的数据拿走,然后短期内做个网站和 App,短期内成为你的劲敌。 - - - -## 一、爬虫手段 - -目前爬虫技术都是从渲染好的 html 页面直接找到感兴趣的节点,然后获取对应的文本. -有些网站安全性做的好,比如列表页可能好获取,但是详情页就需要从列表页点击对应的 item,将 itemId 通过 form 表单提交,服务端生成对应的参数,然后重定向到详情页(重定向过来的地址后才带有详情页的参数 detailID),这个步骤就可以拦截掉一部分的爬虫开发者 - - - -## 二、制定出**Web 端反爬技术方案** - -从这2个角度(网页所见非所得、查接口请求没用)出发,制定了下面的反爬方案。 - - -1. 使用HTTPS 协议 -2. 单位时间内限制掉请求次数过多,则封锁该账号 -3. 前端技术限制 (接下来是核心技术) - -举例:比如需要正确显示的数据为“19950220” - -#### 2.1 原始数据加密 - -1. 先按照自己需求利用相应的规则(数字乱序映射,比如正常的0对应还是0,但是乱序就是 0 <-> 1,1 <-> 9,3 <-> 8,...)制作自定义字体(ttf) -2. 根据上面的乱序映射规律,求得到需要返回的数据 19950220 -> 17730220 -3. 对于第一步得到的字符串,依次遍历每个字符,将每个字符根据按照线性变换(y=kx+b)。线性方程的系数和常数项是根据当前的日期计算得到的。比如当前的日期为“2018-07-24”,那么线性变换的 k 为 7,b 为 24。 -4. 然后将变换后的每个字符串用“3.1415926”拼接返回给接口调用者。(为什么是3.1415926,因为对数字伪造反爬,所以拼接的文本肯定是数字的话不太会引起研究者的注意,但是数字长度太短会误伤正常的数据,所以用所熟悉的 Π) - -``` -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 页面 - -1. 先将拿到的字符串按照“3.1415926”拆分为数组 -2. 对数组的每1个数据,按照“线性变换”(y=kx+b,k和b同样按照当前的日期求解得到),逆向求解到原本的值。 -3. 将步骤2的的到的数据依次拼接,再根据 ttf 文件 Render 页面上。 - - -#### 2.3 后端需要根据上一步设计的协议将数据进行加密处理 - -下面以 **Node.js** 为例讲解后端需要做的事情 - -1. 首先后端设置接口路由 - -2. 获取路由后面的参数 - -3. 根据业务需要根据 SQL 语句生成对应的数据。如果是数字部分,则需要按照上面约定的方法加以转换 - -4. 将生成数据转换成 JSON 返回给调用者 - - ```js - // json - var JoinOparatorSymbol = "3.1415926"; - function encode(rawData, ruleType) { - if (!isNotEmptyStr(rawData)) { - return ""; - } - var date = new Date(); - var year = date.getFullYear(); - var month = date.getMonth() + 1; - var day = date.getDate(); - - var encodeData = ""; - for (var index = 0; index < rawData.length; index++) { - var datacomponent = rawData[index]; - if (!isNaN(datacomponent)) { - if (ruleType < 3) { - var currentNumber = rawDataMap(String(datacomponent), ruleType); - encodeData += (currentNumber * month + day) + JoinOparatorSymbol; - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - else { - encodeData += rawDataMap(String(datacomponent), ruleType) + JoinOparatorSymbol; - } - } - else if (ruleType == 4) { - encodeData += rawDataMap(String(datacomponent), ruleType); - } - - } - if (encodeData.length >= JoinOparatorSymbol.length) { - var lastTwoString = encodeData.substring(encodeData.length - JoinOparatorSymbol.length, encodeData.length); - if (lastTwoString == JoinOparatorSymbol) { - encodeData = encodeData.substring(0, encodeData.length - JoinOparatorSymbol.length); - } - } - ``` - - ```javascript - //字体映射处理 - function rawDataMap(rawData, ruleType) { - - if (!isNotEmptyStr(rawData) || !isNotEmptyStr(ruleType)) { - return; - } - var mapData; - var rawNumber = parseInt(rawData); - var ruleTypeNumber = parseInt(ruleType); - if (!isNaN(rawData)) { - lastNumberCategory = ruleTypeNumber; - //字体文件1下的数据加密规则 - if (ruleTypeNumber == 1) { - if (rawNumber == 1) { - mapData = 1; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 4; - } - else if (rawNumber == 4) { - mapData = 5; - } - else if (rawNumber == 5) { - mapData = 3; - } - else if (rawNumber == 6) { - mapData = 8; - } - else if (rawNumber == 7) { - mapData = 6; - } - else if (rawNumber == 8) { - mapData = 9; - } - else if (rawNumber == 9) { - mapData = 7; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - //字体文件2下的数据加密规则 - else if (ruleTypeNumber == 0) { - - if (rawNumber == 1) { - mapData = 4; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 3; - } - else if (rawNumber == 4) { - mapData = 1; - } - else if (rawNumber == 5) { - mapData = 8; - } - else if (rawNumber == 6) { - mapData = 5; - } - else if (rawNumber == 7) { - mapData = 6; - } - else if (rawNumber == 8) { - mapData = 7; - } - else if (rawNumber == 9) { - mapData = 9; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - //字体文件3下的数据加密规则 - else if (ruleTypeNumber == 2) { - - if (rawNumber == 1) { - mapData = 6; - } - else if (rawNumber == 2) { - mapData = 2; - } - else if (rawNumber == 3) { - mapData = 1; - } - else if (rawNumber == 4) { - mapData = 3; - } - else if (rawNumber == 5) { - mapData = 4; - } - else if (rawNumber == 6) { - mapData = 8; - } - else if (rawNumber == 7) { - mapData = 3; - } - else if (rawNumber == 8) { - mapData = 7; - } - else if (rawNumber == 9) { - mapData = 9; - } - else if (rawNumber == 0) { - mapData = 0; - } - } - else if (ruleTypeNumber == 3) { - - if (rawNumber == 1) { - mapData = ""; - } - else if (rawNumber == 2) { - mapData = ""; - } - else if (rawNumber == 3) { - mapData = ""; - } - else if (rawNumber == 4) { - mapData = ""; - } - else if (rawNumber == 5) { - mapData = ""; - } - else if (rawNumber == 6) { - mapData = ""; - } - else if (rawNumber == 7) { - mapData = ""; - } - else if (rawNumber == 8) { - mapData = ""; - } - else if (rawNumber == 9) { - mapData = ""; - } - else if (rawNumber == 0) { - mapData = ""; - } - } - else{ - mapData = rawNumber; - } - } else if (ruleTypeNumber == 4) { - var sources = ["年", "万", "业", "人", "信", "元", "千", "司", "州", "资", "造", "钱"]; - //判断字符串为汉字 - if (/^[\u4e00-\u9fa5]*$/.test(rawData)) { - - if (sources.indexOf(rawData) > -1) { - var currentChineseHexcod = rawData.charCodeAt(0).toString(16); - var lastCompoent; - var mapComponetnt; - var numbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; - var characters = ["a", "b", "c", "d", "e", "f", "g", "h", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]; - - if (currentChineseHexcod.length == 4) { - lastCompoent = currentChineseHexcod.substr(3, 1); - var locationInComponents = 0; - if (/[0-9]/.test(lastCompoent)) { - locationInComponents = numbers.indexOf(lastCompoent); - mapComponetnt = numbers[(locationInComponents + 1) % 10]; - } - else if (/[a-z]/.test(lastCompoent)) { - locationInComponents = characters.indexOf(lastCompoent); - mapComponetnt = characters[(locationInComponents + 1) % 26]; - } - mapData = "&#x" + currentChineseHexcod.substr(0, 3) + mapComponetnt + ";"; - } - } else { - mapData = rawData; - } - - } - else if (/[0-9]/.test(rawData)) { - mapData = rawDataMap(rawData, 2); - } - else { - mapData = rawData; - } - - } - return mapData; - } - ``` - - ```javascript - //api - module.exports = { - "GET /api/products": async (ctx, next) => { - ctx.response.type = "application/json"; - ctx.response.body = { - products: products - }; - }, - - "GET /api/solution1": async (ctx, next) => { - - try { - var data = fs.readFileSync(pathname, "utf-8"); - ruleJson = JSON.parse(data); - rule = ruleJson.data.rule; - } catch (error) { - console.log("fail: " + error); - } - - var data = { - code: 200, - message: "success", - data: { - name: "@杭城小刘", - year: LBPEncode("1995", rule), - month: LBPEncode("02", rule), - day: LBPEncode("20", rule), - analysis : rule - } - } - - ctx.set("Access-Control-Allow-Origin", "*"); - ctx.response.type = "application/json"; - ctx.response.body = data; - }, - - - "GET /api/solution2": async (ctx, next) => { - try { - var data = fs.readFileSync(pathname, "utf-8"); - ruleJson = JSON.parse(data); - rule = ruleJson.data.rule; - } catch (error) { - console.log("fail: " + error); - } - - var data = { - code: 200, - message: "success", - data: { - name: LBPEncode("建造师",rule), - birthday: LBPEncode("1995年02月20日",rule), - company: LBPEncode("中天公司",rule), - address: LBPEncode("浙江省杭州市拱墅区石祥路",rule), - bidprice: LBPEncode("2万元",rule), - negative: LBPEncode("2018年办事效率太高、负面基本没有",rule), - title: LBPEncode("建造师",rule), - honor: LBPEncode("最佳奖",rule), - analysis : rule - } - } - ctx.set("Access-Control-Allow-Origin", "*"); - ctx.response.type = "application/json"; - ctx.response.body = data; - }, - - "POST /api/products": async (ctx, next) => { - var p = { - name: ctx.request.body.name, - price: ctx.request.body.price - }; - products.push(p); - ctx.response.type = "application/json"; - ctx.response.body = p; - } - }; - ``` - - ```javascript - //路由 - const fs = require("fs"); - - function addMapping(router, mapping){ - for(var url in mapping){ - if (url.startsWith("GET")) { - var path = url.substring(4); - router.get(path,mapping[url]); - console.log(`Register URL mapping: GET: ${path}`); - }else if (url.startsWith('POST ')) { - var path = url.substring(5); - router.post(path, mapping[url]); - console.log(`Register URL mapping: POST ${path}`); - } else if (url.startsWith('PUT ')) { - var path = url.substring(4); - router.put(path, mapping[url]); - console.log(`Register URL mapping: PUT ${path}`); - } else if (url.startsWith('DELETE ')) { - var path = url.substring(7); - router.del(path, mapping[url]); - console.log(`Register URL mapping: DELETE ${path}`); - } else { - console.log(`Invalid URL: ${url}`); - } - - } - } - - - function addControllers(router, dir){ - fs.readdirSync(__dirname + "/" + dir).filter( (f) => { - return f.endsWith(".js"); - }).forEach( (f) => { - console.log(`Process controllers:${f}...`); - let mapping = require(__dirname + "/" + dir + "/" + f); - addMapping(router,mapping); - }); - } - - module.exports = function(dir){ - let controllers = dir || "controller"; - let router = require("koa-router")(); - - addControllers(router,controllers); - return router.routes(); - }; - - - ``` - - - -#### 2.4 前端根据服务端返回的数据逆向解密 - - ```javascript - $("#year").html(getRawData(data.year,log)); - - // util.js - var JoinOparatorSymbol = "3.1415926"; - function isNotEmptyStr($str) { - if (String($str) == "" || $str == undefined || $str == null || $str == "null") { - return false; - } - return true; - } - - function getRawData($json,analisys) { - $json = $json.toString(); - if (!isNotEmptyStr($json)) { - return; - } - - var date= new Date(); - var year = date.getFullYear(); - var month = date.getMonth() + 1; - var day = date.getDate(); - var datacomponents = $json.split(JoinOparatorSymbol); - var orginalMessage = ""; - for(var index = 0;index < datacomponents.length;index++){ - var datacomponent = datacomponents[index]; - if (!isNaN(datacomponent) && analisys < 3){ - var currentNumber = parseInt(datacomponent); - orginalMessage += (currentNumber - day)/month; - } - else if(analisys == 3){ - orginalMessage += datacomponent; - } - else{ - //其他情况待续,本 Demo 根据本人在研究反爬方面的技术并实践后持续更新 - } - } - return orginalMessage; - } - - ``` - - 比如后端返回的是323.14743.14743.1446,根据我们约定的算法,可以的到结果为1773 - - - -#### 2.5 根据 ttf 文件 Render 页面 - - ![自定义字体文件](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180724-184215.png) - 上面计算的到的1773,然后根据ttf文件,页面看到的就是1995 - -#### 2.6 加密混淆 - - 为了防止爬虫人员查看 JS 研究问题,所以对 JS 的文件进行了加密处理。如果你的技术栈是 Vue 、React 等,webpack 为你提供了 JS 加密的插件,也很方便处理 - - [JS混淆工具](http://www.javascriptobfuscator.com/Javascript-Obfuscator.aspx) - -- 个人觉得这种方式还不是很安全。于是想到了各种方案的组合拳。比如 - - - - -##  三、反爬升级版 - -个人觉得如果一个前端经验丰富的爬虫开发者来说,上面的方案可能还是会存在被破解的可能,所以在之前的基础上做了升级版本 - -1. 组合拳1: 字体文件不要固定,虽然请求的链接是同一个,但是根据当前的时间戳的最后一个数字取模,比如 Demo 中对4取模,有4种值 0、1、2、3。这4种值对应不同的字体文件,所以当爬虫绞尽脑汁爬到1种情况下的字体时,没想到再次请求,字体文件的规则变掉了 😂 -2. 组合拳2: 前面的规则是字体问题乱序,但是只是数字匹配打乱掉。比如 **1** -> **4**, **5** -> **8**。接下来的套路就是每个数字对应一个 **unicode 码** ,然后制作自己需要的字体,可以是 .ttf、.woff 等等。 - -![网页检察元素得到的效果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180726-161418.png) -![接口返回数据](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180726-161429.png) - -这几种组合拳打下来。对于一般的爬虫就放弃了。 - - - -## 四、反爬手段再升级 - -上面说的方法主要是针对**数字**做的反爬手段,如果要对汉字进行反爬怎么办?接下来提供几种方案 - -1. **方案1:** 对于你站点频率最高的词云,做一个汉字映射,也就是自定义字体文件,步骤跟数字一样。先将常用的汉字生成对应的 ttf 文件;根据下面提供的链接,将 ttf 文件转换为 svg 文件,然后在下面的“字体映射”链接点进去的网站上面选择前面生成的 svg 文件,将svg文件里面的每个汉字做个映射,也就是将汉字专为 unicode 码(注意这里的 unicode 码不要去在线直接生成,因为直接生成的东西也就是有规律的。我给的做法是先用网站生成,然后将得到的结果做个简单的变化,比如将“e342”转换为 “e231”);然后接口返回的数据按照我们的这个字体文件的规则反过去映射出来。 - -2. **方案2:** 将网站的重要字体,将 html 部分生成图片,这样子爬虫要识别到需要的内容成本就很高了,需要用到 OCR。效率也很低。所以可以拦截钓一部分的爬虫 - -3. **方案3:** 看到携程的技术分享“反爬的最高境界就是 Canvas 的指纹,原理是不同的机器不同的硬件对于 Canvas 画出的图总是存在像素级别的误差,因此我们判断当对于访问来说大量的 canvas 的指纹一致的话,则认为是爬虫,则可以封掉它”。 - - 本人将方案1实现到 Demo 中了。 - - - -#### 关键步骤 - -1. 先根据你们的产品找到常用的关键词,生成**词云** -2. 根据词云,将每个字生成对应的 unicode 码 -3. 将词云包括的汉字做成一个字体库 -4. 将字体库 .ttf 做成 svg 格式,然后上传到 [icomoon](https://icomoon.io/app/#/select/font) 制作自定义的字体,但是有规则,比如 **“年”** 对应的 **unicode 码**是 **“\u5e74”** ,但是我们需要做一个 **恺撒加密** ,比如我们设置 **偏移量** 为1,那么经过**恺撒加密** **“年”**对应的 **unicode** 码是**“\u5e75”** 。利用这种规则制作我们需要的字体库 -5. 在每次调用接口的时候服务端做的事情是:服务端封装某个方法,将数据经过方法判断是不是在词云中,如果是词云中的字符,利用规则(找到汉字对应的 unicode 码,再根据凯撒加密,设置对应的偏移量,Demo 中为1,将每个汉字加密处理)加密处理后返回数据 -6. 客户端做的事情: - - 先引入我们前面制作好的汉字字体库 - - 调用接口拿到数据,显示到对应的 Dom 节点上 - - 如果是汉字文本,我们将对应节点的 css 类设置成汉字类,该类对应的 font-family 是我们上面引入的汉字字体库 - -```css -//style.css -@font-face { - font-family: "NumberFont"; - src: url('http://127.0.0.1:8080/Util/analysis'); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -@font-face { - font-family: "CharacterFont"; - src: url('http://127.0.0.1:8080/Util/map'); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -h2 { - font-family: "NumberFont"; -} - -h3,a{ - font-family: "CharacterFont"; -} -``` - - -![接口效果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095006%402x.png) -![审查元素效果](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095124%402x.png) - - - -传送门:[字体制作的步骤](https://blog.csdn.net/fdipzone/article/details/68166388)、[ttf转svg](https://everythingfonts.com/ttf-to-svg)、[字体映射规则](https://icomoon.io/app/#/select/font) - -实现效果: - - 1. 页面上看到的数据跟审查元素看到的结果不一致 - 2. 去查看接口数据跟审核元素和界面看到的三者不一致 - 3. 页面每次刷新之前得出的结果更不一致 - 4. 对于数字和汉字的处理手段都不一致 - - 这几种组合拳打下来。对于一般的爬虫就放弃了。 - - - - ![数字反爬-网页显示效果、审查元素、接口结果情况1](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180810-151046@2x.png) - ![数字反爬-网页显示效果、审查元素、接口结果情况2](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/WX20180810-151203@2x.png) - ![数字反爬-网页显示效果、审查元素、接口结果情况3](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-151239@2x.png) - ![数字反爬-网页显示效果、审查元素、接口结果情况4](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-151308@2x.png) - ![汉字反爬-网页显示效果、审查元素、接口结果情况1](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095006%402x.png) - ![汉字反爬-网页显示效果、审查元素、接口结果情况2](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180810-095124%402x.png) - -
    - - -前面的 ttf 转 svg 网站当 ttf 文件太大会限制转换,让你购买,贴出个新链接。[ttf转svg](https://convertio.co/zh/font-converter/) - - - -## [Demo 地址](https://github.com/FantasticLBP/Anti-WebSpider) - - ![效果演示](https://raw.githubusercontent.com/FantasticLBP/Anti-WebSpider/master/Anti-WebSpider.gif) - - - - 运行步骤 - -```powershell -//客户端。先查看本机 ip 在 Demo/Spider-develop/Solution/Solution1.js 和 Demo/Spider-develop/Solution/Solution2.js 里面将接口地址修改为本机 ip - -$ cd Demo -$ ls -REST Spider-release file-Server.js -Spider-develop Util rule.json -$ node file-Server.js -Server is runnig at http://127.0.0.1:8080/ - -//服务端 先安装依赖 -$ cd REST/ -$ npm install -$ node app.js -``` - - -## 提问 -1. 反爬技术方案本身会不会对盲人等特殊群体的视障模式有影响? -https://medium.com/frochu/回歸初心-一探web-accessibility-baaa4d22f4a7 -alt diff --git a/Chapter2 - Web FrontEnd/2.19.md b/Chapter2 - Web FrontEnd/2.19.md deleted file mode 100644 index 4b24f28..0000000 --- a/Chapter2 - Web FrontEnd/2.19.md +++ /dev/null @@ -1,1079 +0,0 @@ -# webpack 从入门到精通 - -## 小实验 - -我们一步步打包一个小项目看看 webpack 是如何工作的。 - -1. 先写一个 hello.js - - ```javascript - function hello(messgae){ - alert(messgae); - } - ``` - -2. 然后对其打包,发现终端报错。解决后知道在 webpack 2.0 的时候,我们打包一个 js 文件可能是这样的,比如将 hello.js 打包为 hello.bundle.js 。 - - ```powershell - webpack hello.js hello.bundle.js - ``` - - 但是在现在 webpack 4.5.0 的时候就需要指定 mode 和输出路径 - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js - ``` - - mode 有指定的3种值, development、production、none。区别在于 development 打包出来的东西是没压缩的、可读的,production 打包出来的是压缩的、不可读的。 - -3. 然后编写一个 world.js 和 一个 style.css ,然后进行打包 - - ```javascript - require('./world.js'); - require('./style.css'); - - function hello(messgae){ - alert(messgae); - } - hello("hello webpack"); - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180802-095124@2x.png) - - 通过报错信息知道, webpack 对于 css 文件并不是默认支持的,需要指定相应的 loader 对其打包。 - -4. 所以我们继续安装 css-loader、style-loader。然后指定 css 文件的 loader 为 css-loader - - ```javascript - require('./world.js'); - require('css-loader!./style.css'); - - function hello(messgae){ - alert(messgae); - } - - hello("hello webpack"); - ``` - -5. 接下来设置页面的背景颜色,发现网页并没有生效。这是因为 webpack 并不知道我们的样式如何作用到 html 中,所以我们需要指定 style-loader - - ```css - //style.css - body,html{ - margin: 0; - padding: 0; - } - - body{ - font-size: 17px; - background: burlywood; - } - ``` - - ```javascript - //hello.js - require('./world.js'); - require('style-loader!css-loader!./style.css'); - - function hello(messgae){ - alert(messgae); - } - - hello("hello webpack"); - ``` - -6. 查看网页效果。发现函数确实执行了,背景颜色也生效了,我们写的 css 代码新建了一个 **style标签** 被直接写入到 html 中了。 - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180802-100727@2x.png) - -7. 说说2个 loader 的作用。 - - * css-loader 就是 webpack 可以处理 css 文件。 - * style-loader 的作用就是将 css-loader 处理完的文件新建一个 style 标签插入到 html 中 - -8. 很多人会想 **require\('style-loader!css-loader!./style.css'\);** 我每次写一个 css 文件,那么都需要在前面加入 **style-loader、css-loader** 吗?显然不是,webpack 还为我们提供了简单写法 - - ```javascript - require('./world.js'); - // require('style-loader!css-loader!./style.css'); - require('./style.css'); - - function hello(messgae){ - alert(messgae); - } - - hello("hello webpack"); - ``` - - webpack-cli 写法为 - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js --module-bind 'css=style-loader!css-loader' - ``` - -9. 之前的做法还存在一个弊端,就是每次修改了代码,我们都需要在终端重新运行打包命令,十分繁琐。这里强大的 webpack 为我们提供了一个 option,可以监听代码改变然后自动打包。如下 - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js --module-bind 'css=style-loader!css-loader' --watch - ``` - - 这样,我们在源代码每次一修改,webpack 会自动打包 - -10. 如果你想看到打包过程,那么可以使用 **pregress** 参数。这样在打包的时候可以看到左下角有构件的进度 - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js --module-bind 'css=style-loader!css-loader' --progress - ``` - -11. 如果像看到打包的模块,可以使用 **--display-modules** - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js --module-bind 'css=style-loader!css-loader' --progress --display-modules - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180802-103343@2x.png) - -12. 如果想知道打包某个模块的原因,可以使用 **--display-reasons** - - ```powershell - webpack --mode=development hello.js --output-file hello.bundle.js --module-bind 'css=style-loader!css-loader' --progress --display-modules --display-reasons - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180802-103717@2x.png) - -## 用 webpack.config.js 完成上述步骤 - -1. 初始化项目,编辑 webpack.config.js - - ```powershell - var path = require('path'); - - module.exports = { - entry: './src/script/main.js', - output:{ - path: path.resolve(__dirname,'./dist/js'), - filename: 'bundle.js' - } - } - ``` - -2. 有了 webpack.config.js 文件,就不需要和上面的方式一样,指定对应的 configuration option。在终端运行 **webpack --mode=development ** - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-110653@2x.png) - -3. 注意:如果我们将 webpack.config.js 改名为 webpack.dev.config.js ,然后在命令行打包,会发现没效果。 - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-110938@2x.png) - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-111011@2x.png) - - 要将 webpack.dev.config.js 同样生效,我们需要在命令行使用下面命令。 - - ```powershell - webpack --mode=development --config webpack.dev.config.js - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-111143@2x.png) - -4. 如果想像上个实验一样,看到打包时候的一些信息,怎么办呢? - - 可以配合 npm 的 package.json 文件中的 scripts 标签,在下面添加 key 为 webpack 的项,然后将命令写到后边。然后在命令行运行 **npm run webpack** - - ``` - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "webpack": "webpack --config webpack.config.js --mode=development --progress --display-modules --display-reasons --colors" - }, - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-113022@2x.png) - -5. 对于 webpack 的 entrt 主要有3种写法,每种写法都有不同区别。 - - * 如果 webpack 只有单一入口,那么就可以是字符串。 - - ```json - entry: './src/script/main.js', - ``` - - * 如果 webpack 有多个入口,那么就可以是数组。 - - ```json - entry: ['./src/script/main.js','./src/script/a.js'], - ``` - - * 如果 webpack 有多个入口,那么可以用对象。 - - ```json - entry: { - main: './src/script/main.js', - a : './src/script/a.js' - }, - ``` - -6. 如果指定了多个入口,那么执行打包会报错,因为 webpack 文档说如果多个 entry,且只有一个 output 的 filename,那么打包的结果会覆盖。所以我们需要设置如下 - - > When combining with the [`output.library`](https://webpack.js.org/configuration/output#output-library) option: If an array is passed only the last item is exported. - - ```json - var path = require('path'); - - module.exports = { - entry: { - main: './src/script/main.js', - a : './src/script/a.js' - }, - output:{ - path: path.resolve(__dirname,'./dist/js'), - filename: '[name]-[hash].js' - } - } - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-120033@2x.png) - - 将文件修改为 **filename: '\[name\]-\[chunkhash\].js'** - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/QQ20180802-120113@2x.png) - - 会发现 hash 和 chunkhash 的输出的文件名并不一样 - - 说明: chunkhash 是根据文件的内容生成的唯一标示(类似于md5生成的唯一标示、文件版本号)。如果一个资源在打包前后文本没有变过的话,二次打包的生成的 chunkhash 是一致的。 - -## 生成项目中 html 页面文件 - -对于生成的的 js,我们 html 如何使用呢?难道每次一打包,html 中的 script 需要修改 src 吗?不是的,webpack 提供了 html-webpack-plugin - -1. 安装 - - ```powershell - npm install html-webpack-plugin --save-dev - ``` - -2. 然后运行命令,将现有的 js 打包引入到 html 文件中 - - ```json - var htmlWebpackPlugin = require('html-webpack-plugin'); - //... - plugins: [ - new htmlWebpackPlugin() - ] - ``` - - 然后生成的文件是 webpack 帮我们生成的 html 文件。当然我们可以新建一个自己的 html 作为模版。 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html' - }) - ] - ``` - - ```html - //模版 - - - webpack demo - - -

    我是 webpack 生成 html 的模版

    - - - - //打包生成的 - - - webpack demo - - -

    我是 webpack 生成 html 的模版

    - - - - ``` - - 说明:上面选中的 template 写了 index.html 就会找到合适的文件是因为 webpack 有个上下文参数 context,会根据上下文找到对应的 html(这里就是根目录) - -3. 上述的缺点是生成的 html 也会放在 dist/js 目录下。 - - ![](https://github.com/FantasticLBP/knowledge-kit/raw/master/WX20180802-151750@2x.png) - - 需要做到的效果就是 html 放在根目录, js 放在 dist 目录下的 js 目录下。需要对 webpack.config.js 的 output 属性做修改 - -4. ```js - output:{ - path: path.resolve(__dirname,'./dist'), - filename: 'js/[name]-[chunkhash].js' - }, - ``` -5. plugins 的参数很多可以自定义 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'index-[hash].html', - inject: 'head' - }) - ] - ``` - - * template: 指定生成 html 的模版 - * filename:指定生成 html 的命名规则 - * inject :指定生成 js 的 script 插入的位置。head、body - -6. 如果想通过 plugins 传值到 生成的 html,怎么办? - - * htmlWebpackPlugin.options 对象就可以拿到传递过来的值 - * <%= htmlWebpackPlugin.options.title %> 模版语法来拿值 - - ```json - //webpack.config.js - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'index.html', - inject: 'head', - title: 'Webpack is awesome', - date : new Date() - }) - ] - ``` - - ```html - // 模版 html - - - <%= htmlWebpackPlugin.options.title %> - - -

    我是 webpack 生成 html 的模版

    -

    时间:<%= htmlWebpackPlugin.options.date %>

    - - - - //生成的html - - - Webpack is awesome - - -

    我是 webpack 生成 html 的模版

    -

    时间:Thu Aug 02 2018 15:40:50 GMT+0800 (CST)

    - - - ``` - -7. 我们很好奇 html-webpack-plugin 可以传递什么参数?或者这个对象包含什么信息。做个测试就知道了 - - ```html - //模版 html - <% for (var key in htmlWebpackPlugin){ %> - <%= key %> - <% } %> - - //生成的 html - files - options - ``` - - 看到最外层的节点就2个:files、options。那么我们分别对这2个节点遍历输出。因为遍历出的 value (**htmlWebpackPlugin.files\[key\] **)可能是对象、数组。所以用 **JSON.Stringfy\(htmlWebpackPlugin.files\[key\]\)** 打印 - - ```html - - - <%= htmlWebpackPlugin.options.title %> - - -

    我是 webpack 生成 html 的模版

    -

    时间:<%= htmlWebpackPlugin.options.date %>

    - <% for (var key in htmlWebpackPlugin.files){ %> - <%= key %> <%= JSON.stringify(htmlWebpackPlugin.files[key]) %> - <% } %> -
    - <% for (var key in htmlWebpackPlugin.options){ %> - <%= key %> <%= JSON.stringify(htmlWebpackPlugin.options[key]) %> - <% } %> - - - - //生成的 html - - - Webpack is awesome - - -

    我是 webpack 生成 html 的模版

    -

    时间:Thu Aug 02 2018 15:51:27 GMT+0800 (CST)

    - - publicPath "" - - chunks {"main":{"size":28,"entry":"js/main-82c7521f0a4a776cc00b.js","hash":"82c7521f0a4a776cc00b","css":[]},"a":{"size":18,"entry":"js/a-273641522fd044fc27c7.js","hash":"273641522fd044fc27c7","css":[]} } - - js ["js/main-82c7521f0a4a776cc00b.js","js/a-273641522fd044fc27c7.js"] - - css [] - - manifest - -
    - - template "/Users/liubinpeng/Desktop/webpackdemo/Demo2/node_modules/html-webpack-plugin/lib/loader.js!/Users/liubinpeng/Desktop/webpackdemo/Demo2/index.html" - - templateParameters - - filename "index.html" - - hash false - - inject "head" - - compile true - - favicon false - - minify false - - cache true - - showErrors true - - chunks "all" - - excludeChunks [] - - chunksSortMode "auto" - - meta {} - - title "Webpack is awesome" - - xhtml false - - date "2018-08-02T07:51:27.110Z" - - - - ``` - -8. 有时候我们想把部分 js 放到 head ,部分 js 放到 body 中。单独通过 webpack.config.js 是没办法实现这个目的,结合上面的成果,我们可以拿到 **htmlWebpackPlugin.files.chunks** 属性,比如将 a.js 放到 head 标签,main.js 放到 body 标签。 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'index.html', - inject: false, - title: 'Webpack is awesome', - date : new Date() - }) - ] - ``` - - ```html - //模版 html - - - <%= htmlWebpackPlugin.options.title %> - - - -

    我是 webpack 生成 html 的模版

    -

    时间:<%= htmlWebpackPlugin.options.date %>

    - - - - ``` - - ```html - //生成 html - - - Webpack is awesome - - - -

    我是 webpack 生成 html 的模版

    -

    时间:Thu Aug 02 2018 15:58:56 GMT+0800 (CST)

    - - - - ``` - - 需要注意的是当自定义 js 文件的位置的时候,需要将 webpack.config.js 中 plugins 下的 inject 设置为 false - -9. 接下来看到的这种需求绝对很有料。前面我们看到的都是相对路径,但是我们的产品需要上线,所以我们的 js 文件资源路径需要改变。如下: - - ```json - //webpack.config.js - output:{ - path: path.resolve(__dirname,'./dist'), - filename: 'js/[name]-[chunkhash].js', - publicPath: 'http://test.lbp.com' - }, - ``` - - ```html - //生成的 html - - - Webpack is awesome - - - -

    我是 webpack 生成 html 的模版

    -

    时间:Thu Aug 02 2018 16:25:12 GMT+0800 (CST)

    - - - - ``` - -10. 利用 webpack 我们还可以打包好的 html 做一些优化,比如删除注释、去掉空格. - - 修改 webpack.config.js 中 plugins 节点下的 htmlWebpackPlugin 的 minify 属性 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'index.html', - inject: false, - title: 'Webpack is awesome', - date : new Date(), - minify:{ - removeComments: true, - collapseWhitespace: true - } - }) - ], - ``` - - 我们对 模版 html 写一些注释,运行 npm run webpack 后看到生成的页面中注释、空格都被去掉了。 - -11. 如果想打包生成多个 html 怎么办?可能使用 plugins 下的 new htmlWebpackPlugin\(\) 多来几组配置项 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'a.html', - title: 'this is a.html', - chunks: ['main','a'], - inject: 'body' - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'b.html', - title: 'this is b.html', - chunks: ['main','b'], - inject: 'body' - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'c.html', - title: 'this is c.html', - chunks: ['main','c'], - inject: 'body' - }) - ] - ``` - - 注意:这里我们可以指定每个生成的 filename 以及 title。实现上述需求关键点在于 **chunks** 这个属性。用一个数组的形式来指定需要引用的 chunk。 - -12. 上面只是实现了 a、b、c 3个页面,如果多了的话按照上面的写法要烦死人的。 webpack 为我们提供了 **excludeChunks** 这个属性,它指定了不需要包含的chunk。上面写法的另一种写法 - - ```json - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'a.html', - title: 'this is a.html', - // chunks: ['main','a'], - inject: 'body', - excludeChunks: ['b','c'] - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'b.html', - title: 'this is b.html', - //chunks: ['main','b'], - inject: 'body', - excludeChunks: ['a','c'] - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'c.html', - title: 'this is c.html', - // chunks: ['main','c'], - inject: 'body', - excludeChunks: ['a','b'] - }) - ], - ``` - -13. 有种需求是:当我们需要减小首页 HTTP 请求(提高首页的渲染速度),也就是将一些首页必须用到的 JS 文件用内联的方式写在首页 html 的 script 标签里面,不重要的 js 文件通过 script 的 src 引入。要怎么做呢?webpack 在设计之初没想到这种需求,很多人在 github 提了很多 issue ,官方认识到这种需求,所以在后期更新的 demo 中看到了解决方案。 - - * htmlWebpackPlugin.files.chunks 对象拿到的是我们在 webpack.config.js 设置过 publicPath 生成的完整路径 - - * 通过截取字符串子串的方式拿到文件地址。 **htmlWebpackPlugin.files.chunks.main.entry.substr\(htmlWebpackPlugin.files.publicPath.length\) ** - - * [官方的解决方案](https://github.com/jantimon/html-webpack-plugin/blob/master/examples/inline/template.jade) - - ```jade - compilation.assets[jsFile.substr(htmlWebpackPlugin.files.publicPath.length)].source() - ``` - - 在我们的项目中加以改造,为生成的每个页面的 header 里面加入 main.js。在 body 部分加入除了 main.js 之外的其他 js。 - - ```html - //模版html - - - <%= htmlWebpackPlugin.options.title %> - - - - -

    我是 webpack 生成 html 的模版

    - <% for(var key in htmlWebpackPlugin.files.chunks){ %> - <% if( key !== 'main'){ %> - - <% } %> - <% } %> - - - ``` - - ```js - //webpack.config.js - var htmlWebpackPlugin = require('html-webpack-plugin'); - var path = require('path'); - - module.exports = { - // entry: './src/script/main.js', - // entry: ['./src/script/main.js','./src/script/a.js'], - entry: { - main: './src/script/main.js', - a : './src/script/a.js', - b : './src/script/b.js', - c : './src/script/c.js' - }, - output:{ - path: path.resolve(__dirname,'./dist'), - filename: 'js/[name]-[hash].js', - publicPath: 'http://test.lbp.com' - }, - plugins: [ - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'a.html', - title: 'this is a.html', - inject: false, - excludeChunks: ['b','c'] - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'b.html', - title: 'this is b.html', - inject: false, - excludeChunks: ['a','c'] - }), - new htmlWebpackPlugin({ - template: 'index.html', - filename: 'c.html', - title: 'this is c.html', - inject: false, - excludeChunks: ['a','b'] - }) - ], - } - ``` - - 为了验证生效,我将 main.js 加入了 alert - - ```js - //main.js - function helloworld(msg){ - alert(msg); - } - - helloworld("hello webpack"); - ``` - - 看看打包后生成的 a.html - - ```html - - - this is a.html - - - - -

    我是 webpack 生成 html 的模版

    - - - - ``` - - 在浏览器调试后发现页面是可以正常弹出“hello webpack” - -## loader - -新建项目,一步步认识 loader - -1. Js loader - - 我们写的项目中会用 es6,但是并不是所有的浏览器都支持 es6(虽然各个浏览器厂商每年在不断新增对 es6 的支持),所以我们需要使用 babel 将 es6 转换为浏览器都支持的 es2015 。所以使用 babel-loader 的时候需要指定 babel 转换的模式。loader 官方给出了2种方式 - - * 可以直接像 url 的 get 形式一样,将参数传递在后面。 - - ```js - //方式1 - require("babel-loader?presets=latest"); - //方式2 - { - test: /\.png$/, - loader: 'url-loader?presets=latest' - } - ``` - - * 写在 query 参数里面 - - ```js - { - test: /\.js$/, - loader: 'babel', - query: { - presets: ['latest'] - } - } - ``` - - * 其实还有一种方式:在 package.json 文件里面添加一个 key 为 babel。 - - ```json - "babel": { - presets: ['latest'] - } - ``` - - 注意:webpack 现在已经是4.5.0了。以前的版本的写法是 - - ```js - module: { - loaders: [ - { - test: /\.js$/, - loader: 'babel-loader', - query: { - presets: ['latest'] - } - } - ] - }, - ``` - - 现在的写法为 - - ```js - module: { - rules: [ - { - test: /\.js$/, - loader: 'babel-loader', - options: { - presets: ['latest'] - } - } - ] - }, - ``` - - 注意:我们工程如果安装的依赖非常多,node\_modules 文件非常多,babel 转换会很慢,这时候需要指定2个参数可以显著提高速度 - - ```js - module: { - rules: [ - { - test: /\.js$/, - loader: 'babel-loader', - options: { - presets: ['latest'] - }, - exclude: path.resolve(__dirname,'./node_modules/'), - include: path.resolve(__dirname,'./src') - } - ] - }, - ``` - -2. css loader - - 打包 css 经常会用到 css-loader、style-loader。我们经常写 flex 的时候很多浏览器兼容性不一致,所以我们需要加前缀,这时候需要使用 postcss-loader、autoprefixer - - 官网给出2种写法 - - * loader - - ```js - { - test: /\.css$/, - loader: 'style-loader!css-loader!postcss-loader' - } - ``` - - * loaders - - ```js - { - test: /\.css$/, - loaders: ['style-loader','css-loader','postcss-loader'] - } - ``` - - 如果项目中不只是使用了 css 的话,比如还使用了 less 和 sass 的话,我们需要将 css 加额外的设置 - - ```js - { - test: /\.css$/, - use:[ - 'style-loader', - {loader: 'css-loader', options: {importLoaders: 1} }, - { - loader: 'postcss-loader', - options:{ - plugins:function(){ - return [ - require('postcss-import')(), - require('autoprefixer')({browsers:['last 5 versions']}) - ] - } - } - } - ] - }, - { - test: /\.less$/, - use:[ - 'style-loader', - {loader: 'css-loader', options: {importLoaders: 1} }, - { - loader: 'postcss-loader', - options:{ - plugins:function(){ - return [ - require('postcss-import')(), - require('autoprefixer')({browsers:['last 5 versions']}) - ] - } - } - }, - 'less-loader' - ] - }, - ``` - -3. 处理模版 - - 在 webpack 经常打包处理的时候会遇到模版。有普通的 html 模版,也会有 ejs 模式下的 tpl 模版 - - - html 模版 - - ```html - - - <%= htmlWebpackPlugin.options.title %> - - - -
    - -
    - - - ``` - - ```js - //layer.js - import './layer.less' - import tpl from './layer.html' - - function layer(){ - return { - name: 'layer', - tpl: tpl - } - } - - export default layer; - - //app.js - import Layer from './components/layer/layer.js'; - import './css/common.css'; - - - const App = function(){ - var dom = document.getElementById("app"); - var layer = new Layer(); - dom.innerHTML = layer.tpl; - } - - new App() - ``` - - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180803-152208@2x.png) - - - - ejs 模版 - - ```js - //layer.tpl -
    -
    this is <%= name %> layr
    - <% for(var i=0;i < arr.length; i++){ %> - <%= arr[i] %> - <% } %> - -
    - - //layer.js - import './layer.less' - import tpl from './layer.tpl' - - function layer(){ - return { - name: 'layer', - tpl: tpl - } - } - - export default layer; - - - //app.js - import Layer from './components/layer/layer.js'; - import './css/common.css'; - - - const App = function(){ - var dom = document.getElementById("app"); - var layer = new Layer(); - dom.innerHTML = layer.tpl({ - name: 'john', - arr: ['swift','Objective-C','JS','python'] - }); - } - - new App() - ``` - ![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/WX20180803-152612@2x.png) - - - diff --git a/Chapter2 - Web FrontEnd/2.2.md b/Chapter2 - Web FrontEnd/2.2.md deleted file mode 100644 index bd11743..0000000 --- a/Chapter2 - Web FrontEnd/2.2.md +++ /dev/null @@ -1,94 +0,0 @@ -# 正则表达式 - -* \d :匹配一个数字 -* \w : 匹配任意一个字母或数字 -* . : 可以匹配任意字符串 -* \* : 可以匹配任意个字符(包括0个) -* +: 至少一个字符 -* ? : 表示0个或1个字符 - -* {n} :表示n个字符 - -* {n-m} : 表示n-m个字符 - -* \[ \] :表示范围 - -* \[0-9a-zA-Z\\_\] : 可以匹配一个数字、字母或者下划线 - -* \[0-9a-zA-Z\\_\]+: 可以匹配至少由一个数字、字母或者下划线组成的字符串 - -* \[0-9a-zA-Z\_$\]\[0-9a-zA-Z\_\_$\]\* : 可以匹配由数字、字母或者下划线,后接任意个由一个数字、字母或者下划线、$组成的字符串 - -# RegExp - -JS有2种方式创建一个正则表达式。 - -第一种方式是直接通过/正则表达式/写出来。 - -第二种 是通过new RegExp\('正则表达式'\)创建一个RegExp对象。 - -#### 注意 - -因为第二种的写法问题,所以每个`\` 需要转义,也就是 `\\` - -``` - - - -``` - -# 分组 - -除了简单地判断是否匹配外,正则表达式还可以用来提取分组 ,用 `()` - -``` - - - -``` - -* 如果正则表达式中定义了组,就可以在RegExp对象上用exec\(\)方法提取出子串来。 - -* exec\(\)方法在匹配成功后会返回一个Array,第一个元素为正则表达式匹配到的整个字符串,后面的元素则表示匹配成功的子串。 -* exec\(\)方法在匹配失败后会返回null - - - -# 贪婪匹配 - - - -由于 正则表达式默认使用贪婪匹配模式,因此会造成一些问题。比如 - -``` -var res = /^(\d+)(0*)$/; -res.exec('102300'); //['102300','102300',''] -``` - -由于\d+采用贪婪匹配模式,所以会匹配到后面的0,所以加上\d+?代表使用非贪婪匹配模式 - -``` -var res = /^(\d+?)(0*)$/; -res.exec('102300'); -``` - - - - - - - diff --git a/Chapter2 - Web FrontEnd/2.20.md b/Chapter2 - Web FrontEnd/2.20.md deleted file mode 100644 index 2dcee32..0000000 --- a/Chapter2 - Web FrontEnd/2.20.md +++ /dev/null @@ -1,14 +0,0 @@ -# 大前端 - -记录大前端领域较好的文章 - -1. [“观点|蚂蚁金服玉伯:我们是如何从前端技术进化到体验科技的?”](https://juejin.im/post/5b6904f1f265da0f48613ab0#comment) -2. [Pattern: Backends For Frontends](https://samnewman.io/patterns/architectural/bff/) -3. [了解 BFF 架构](https://segmentfault.com/a/1190000009558309) - - 什么情况需要 BFF? - - 比如你的后台写好了接口,App 的第一个版本上线后发现现有的数据结构不能满足第二个版本的界面需求了。这时候如果没做版本控制,直接将接口做了修改,那么对于 App 没有升级的用户来说是灾难,接口很可能会挂掉。 - - 如果之前是 iOS 和 Android App,后来加了小程序和 H5 页面,可能需求不一样,这样直接在接口上做修改的话,之前的接口会有非常多的判断代码,逻辑太乱了。一个好的设计是 **“单一原则”**。 后台做基于领域模型的 RPC 接口,前端则根据一个中间服务拿数据,常见的有 Node、PHP、Python提供这种服务, BFF 就是这样一种概念。 - - - diff --git a/Chapter2 - Web FrontEnd/2.21.md b/Chapter2 - Web FrontEnd/2.21.md deleted file mode 100644 index c18972f..0000000 --- a/Chapter2 - Web FrontEnd/2.21.md +++ /dev/null @@ -1,111 +0,0 @@ -# Canvas - -## 支持性 - -由于浏览器对 Canvas 的支持标准不一致,所以通常 <canvas> 内部添加一些说明行的 HTML 代码,如果浏览器支持 Canvas,它将忽略 <canvas> 内部的 HTML,如果浏览器不支持 Canvas,它将显示 <canvas> 内部的HTML。 - -## 一、基础使用 - -使用 Canvas 前,用 canvas.getContext 来判断浏览器是否支持 Canvas - -``` -var canvas = document.getElementById("test-canvas"); -if (canvas.getContext) { - console.log("你的浏览器支持canvas"); -} else { - console.log("你的浏览器不支持canvas"); -} -``` - -getContext\('2d'\) 方法拿到一个 CanvasRenderingContext2D 对象,所有的绘图操作都需要通过这个对象完成。 - -``` -var ctx = canvas.getContext("2d"); -``` - -如果需要绘制 3D图形,我们可以通过 - -``` -var gl = canvas.getContext("webgl"); -``` - -### 1、绘制形状 - -``` -var ctx = canvas.getContext("2d"); -ctx.fillStyle = "rgb(200,0,0)"; -ctx.fillRect(10,10,50,50); - -ctx.fillStyle = "rgba(0,0,200,0.5)"; -ctx.fillRect(30,30,50,50); -``` - -![](/assets/canvas - 绘制图形1.png) - -* #### 绘制矩形 - -不同于 SVG,Canvas 只提供了一种原生的图形绘制能力:矩形。 所有的其他图形的绘制都至少需要生成一条路径。 - -1. 绘制一个填充矩形 fillRect\(x,y,width,height\) -2. 绘制一个矩形的边框 strokeRect\(x,y,width,height\) -3. 清除矩形的指定区域 clearRect\(x,y,width,height\) - -``` -ctx.fillRect(20,20,160,160); -ctx.clearRect(30,30,140,140); -ctx.strokeRect(80,80,40,40); -``` - -![](/assets/canvas - 绘制图形2.png) - -#### 2、绘制路径 - -图形的基本元素是路径,路径是通过不同颜色和宽度的线段或曲线相连形成的不同形状点的集合。一个路径,甚至一个子路径,都是闭合的。使用路径绘制图形需要一些额外的步骤。 - -* 首先你需要创建路径的起始点 -* 然后使用画图命令去画出路径 -* 之后把路径封闭 -* 一旦路径生成,你就可以通过秒变或者填充路径区域来渲染图形了。 - -beginPath\(\):新建一条路径,生成之后,图形绘制命令被只想到路径上生成路径。 - -closePath\(\):闭合路径之后图形绘制命令又重新指向到上下文中。 - -stroke\(\):通过线条来绘制图形轮廓。给图形描边。 - -fill\(\): 通过填充路径的内容区域生成实心的图形。 - -``` -var ctx = canvas.getContext("2d"); -ctx.beginPath(); -ctx.moveTo(75,50); -ctx.lineTo(100,75); -ctx.lineTo(100,25); -ctx.fillStyle = "red"; -ctx.fill(); -``` - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chapter2 - Web FrontEnd/2.22.md b/Chapter2 - Web FrontEnd/2.22.md deleted file mode 100644 index 223d655..0000000 --- a/Chapter2 - Web FrontEnd/2.22.md +++ /dev/null @@ -1,100 +0,0 @@ -# 动画控制的另一种技术 - - - -> 在 HTML5 的时代里我们可以通过 css3 的 animation 和 kerframes 配合使用动画;也可以使用 css 的 transform 控制动画;在 JS 里面我们通常用 setTimeout 和 setInterval 来控制动画时间。setTimeout 和 setInterval 对于控制动画时间不是很准确,因为它是靠电脑的刷新频率。并且当浏览器切换到其他页面或者最小化的时候动画还在执行并不会停止,显然是在做一些无用功。接下来要介绍一个新的特性,并且主流浏览器都对他进行了支持。 - - - -### requestAnimationFrame - -来看看 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame) 对它的介绍 - -> window.requestAnimationFrame() 方法告诉浏览器您希望执行动画并在浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。 -> -> 注意:若您想在下次重绘时产生另一个动画画面,您的回调例程必须调用 requestAnimationFrame() -> -> 当你需要更新屏幕画面时就可以调用此方法。在浏览器下次重绘前执行回调函数。回调的次数通常是每秒60次,但大多数浏览器通常匹配 W3C 所建议的刷新频率。在大多数浏览器里,当运行在后台标签页或者隐藏的