开篇叨叨

这一系列博客写到这里基本上是快完成第一阶段的内容了,在规划这一系列博客之前对该篇博客的定位是:综述现有的动态化热更新方案,然后介绍下JavaScriptCore,但是后面思考了下,因为这部分内容更新换代非常快,并且对这部分的选型往往会依照现有的项目实际情况,以及团队的技术栈和个人喜好,所以后续想弄成一个可更新不断迭代的方案对比列表,这里重点介绍JavaScriptCore以及动态化的核心内容。

我理解的动态化包括:应用内容的动态化,比如一些活动页面等经常更换内容的页面,还有一部分是包括热更新在内的通过发布补丁来修复线上bug。当然如果从宽泛角度来讲应用内可配置的内容也属于动态化。在技术选型的时候我们会将跨平台和动态化综合考虑,因为一个项目中不可能跨平台选用Flutter,动态化选用Weex。其实现有的比较流行的跨平台方案主要有ReactNative, Weex, 最基本的WebView + JsBridge,以及目前比较流行的Flutter. 如果动态化内容只是简单的活动展示页的化其实WebView + JsBridge就可以满足需求了。没必要引入三方框架,引入框架会带来整个包的体积增大。但是如果你的动态化页面包含许多交互,以及动态效果,往往这些动态效果需要比较高的性能。这时候就需要通过引入RN,Weex或者Flutter了,但是Flutter的动态化目前比较捉襟见肘,所以个人比较偏向RN 或者 Weex,两者比较还是比较偏向RN,但是Weex比较吸引我的是使用Vue来写前端页面。因为个人只会Vue 不会 React。RN 在后续的专题中会另起一个系列。目前打算是在个人对前端技术进行总结的时候来写这部分博客,哈哈,如果按照老规矩那又得一两年之后。但是说不准,可能会提前。

最后说个大家比较容易犯的误区就是:大家会认为动态化这部分只要配备一个Web开发就可以了,但是实际上项目中往往会遇到各种平台适配问题,所以这部分一般会以一个Web开发为主,然后iOS端和Android端各派出一个人手来进行接入以及支援。

好了叨叨结束就要开始我们的正题了

这篇博客主要从下面三个方面进行介绍:

1. WKWebView
2. JavaScriptCore
3. Hybird && JSBridge
  1. WKWebView

WKWebView是在iOS8的时候推出的,在这之前UIWebview是iOS平台的唯一选择。由于UIWebview存在系统级的内存泄露、极高内存峰值、较差的稳定性、以及Javascript的运行性能及通信限制等问题,反观WKWebView号称拥有60fps滚动刷新率,丰富的手势、高效的Web和Native通信机制,默认进度条采用了和safari相同的Nitro引擎极大提升了Javascript的运行速度,WKWebView独立的进程管理,也降低了内存占用及Crash对主App的影响。目前很多项目已经逐渐弃用UIWebview转向使用WKWebView,但是如果你的项目需要支持iOS7那么就必须要UIWebview和WKWebview混合使用,下面是二者的简单对比:

1.1 UIWebview

在看WKWebView之前我们先看下UIWebview。UIWebview接口比较简单,它主要有如下几个部分构成:

页面加载

页面加载主要有三种方式一种是NSURLRequest,一种是http String格式,还有一种是NSData格式。

- (void)loadRequest:(NSURLRequest *)request;
- (void)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;
- (void)loadData:(NSData *)data MIMEType:(NSString *)MIMEType textEncodingName:(NSString *)textEncodingName baseURL:(NSURL *)baseURL;

比较重要的属性

// UIWebView代理
@property (nullable, nonatomic, assign) id <UIWebViewDelegate> delegate;

//只读的scrollView 以及对应的request
@property (nonatomic, readonly, strong) UIScrollView *scrollView API_AVAILABLE(ios(5.0));
@property (nullable, nonatomic, readonly, strong) NSURLRequest *request;

//一些状态属性
@property (nonatomic, readonly, getter=canGoBack) BOOL canGoBack;
@property (nonatomic, readonly, getter=canGoForward) BOOL canGoForward;
@property (nonatomic, readonly, getter=isLoading) BOOL loading;

//其他控制UIWebView行为的属性

UIWebViewDelegate

我们可以看到iOS 13 上这些代理已经被标记为不支持了,所以一般情况下除非你的应用需要支持iOS 7,否则不再建议使用UIWebView了。

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webViewDidStartLoad:(UIWebView *)webView API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webViewDidFinishLoad:(UIWebView *)webView API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error API_DEPRECATED("No longer supported.", ios(2.0, 12.0));

将javascript嵌入页面中

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;

下面举几个简单的例子:

  • 获取当前页面的url
    - (void)webViewDidFinishLoad:(UIWebView *)webView {  
    NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];
    }
  • 获取页面title
    - (void)webViewDidFinishLoad:(UIWebView *)webView {  
    NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
    }
  • 修改界面元素的值。
    NSString *js_result = [webView stringByEvaluatingJavaScriptFromString:@"document.getElementsByName('q')[0].value='Hello World';"];
  • 表单提交:
    NSString *js_result2 = [webView stringByEvaluatingJavaScriptFromString:@"document.forms[0].submit(); "];

调用JavaScript方法

self.context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
[self.context evaluateScript:callScript];

暴露方法给JavaScript调用

构建给JavaScript调用的对象JSObject

IDLJSObject *object = [[IDLJSObject alloc] initWithViewController:self];
self.context = [self.viewHolder.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
self.context[@"appWebViewFinish"] = object;
@interface IDLJSObject:NSObject

@end

@interface IDLJSObject()<IDLOnlineJSExport>
@property (nonatomic , weak) UIViewController *viewController;
@end

@implementation IDLJSObject

#pragma mark - Life Cycle
- (instancetype)initWithViewController:(UIViewController *)viewController {
if (self = [super init]) {
_viewController = viewController;
}
return self;
}

#pragma mark - Protocal IDLOnlineJSExport
- (void)finishWebView {
nm_dispatch_to_main_queue(^{
[self.viewController.navigationController popViewControllerAnimated:YES];
});
}
@end

暴露出去的方法:

@protocol IDLOnlineJSExport <JSExport>
- (void)finishWebView;
@end

1.2 WKWebView

UIWebView 在很长一段时间里面由于占用内存大,JavaScript导致的内存泄漏深受诟病,而在iOS 8之后,Apple在它的WebKit库中新增加了WKWebView,WKWebView采用跨进程方案,Nitro JS 解析器,高达 60fps 的刷新率,并且高度支持H5特性,深受开发者的欢迎。在流程上WKWebView多暴露了一些细节给开发者,使得开发者在整个流程的把控上更加灵活了。

重要的属性

WKWebView的配置
@property (nonatomic, readonly, copy) WKWebViewConfiguration *configuration;

WKWebView重定位Delegate
@property (nullable, nonatomic, weak) id <WKNavigationDelegate> navigationDelegate;

WKWebView交互Delegate
@property (nullable, nonatomic, weak) id <WKUIDelegate> UIDelegate;

//与WKWebView相关联的ScrollView
@property (nonatomic, readonly, strong) UIScrollView *scrollView;

@property (nullable, nonatomic, readonly, copy) NSString *title;
@property (nullable, nonatomic, readonly, copy) NSURL *URL;
@property (nonatomic, readonly, getter=isLoading) BOOL loading;
@property (nonatomic, readonly) double estimatedProgress;
@property (nonatomic, readonly) BOOL hasOnlySecureContent;
@property (nonatomic, readonly) BOOL canGoBack;
@property (nonatomic, readonly) BOOL canGoForward;
@property (nonatomic) BOOL allowsBackForwardNavigationGestures;
@property (nullable, nonatomic, copy) NSString *customUserAgent API_AVAILABLE(macos(10.11), ios(9.0));

加载方法


- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;

- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL API_AVAILABLE(macos(10.11), ios(9.0));

- (nullable WKNavigation *)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;

- (nullable WKNavigation *)loadData:(NSData *)data MIMEType:(NSString *)MIMEType characterEncodingName:(NSString *)characterEncodingName baseURL:(NSURL *)baseURL API_AVAILABLE(macos(10.11), ios(9.0));

重要的方法

- (nullable WKNavigation *)goBack;
- (nullable WKNavigation *)goForward;
- (nullable WKNavigation *)reload;
- (nullable WKNavigation *)reloadFromOrigin;
- (void)stopLoading;

执行JavaScript方法

- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;

WKNavigationDelegate

从上图上看关于UIWebView与WKWebView的代理主要区别在于UIWebView只在加载的时候问询一次,而WKWebView在URL对应的内容加载结束之后还会进行一次问询,下面会对二者的代理进行一一对比。

是否加载的问询

#pragma mark - UIWebViewDelegate
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {return YES;}

#pragma mark - WKNavigationDelegate
//加载之前问询
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { }
//加载之后问询
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { }

同意载入之后,组件就开始下载指定 URL 的内容,在下载之前会调用一次 开始下载 回调,通知开发者 Web 已经开始下载。

#pragma mark - UIWebViewDelegate
- (void)webViewDidStartLoad:(UIWebView *)webView {}

#pragma mark - WKNavigationDelegate
//Invoked when a main frame navigation starts.
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation { }
//Invoked when content starts arriving for the main frame.
- (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation { }

页面下载完毕之后,UIWebView 会直接载入视图并调用载入成功回调,而WKWebView 会发询问,确定下载的内容被允许之后再载入视图。

#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {}

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation { }

如果载入失败则会调用对应的代理进行处理。


#pragma mark - UIWebViewDelegate
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {}

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error { }

- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error {}

除了上面的差异外WKWebView还多处了几处回调:

重定向回调:

WKWebView 在收到服务器重定向消息并且跳转询问允许之后,会回调重定向方法,这点是 UIWebView 没有的。

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(null_unspecified WKNavigation *)navigation;

因为 WKWebView 是跨进程的方案,当 WKWebView 进程退出时,会对主进程做一次方法回调。

- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macosx(10.11), ios(9.0)) {}

HTTPS 证书自定义处理

- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler

WKUIDelegate

WKUIDelegate 包含了一系列UI相关的代理,包括Alert、Confirm、Prompt 等视图,以及WebView的创建以及关闭等。这个是之前UIWebView所不具备的,需要通过JS桥来实现。还有在 iOS 10之后,WKUIDelegate还新增了链接预览的支持,相关方法也在该协议中。

调用JavaScript方法

调用JavaScript方法是通过对应WebView提供的接口实现,但是WKWebView与UIWebView二者在实现上还是有区别的:WKWebView的接口是异步的,而UIWebView是同步的

#pragma mark - WKWebView
[wkWebView evaluateJavaScript:@"document.title"
completionHandler:^(id _Nullable ret, NSError * _Nullable error) {
NSString *title = ret;
}];

暴露方法给JavaScript调用

创建WKUserContentController,并添加到WKWebViewConfiguration,在初始化的时候传入WKWebView

WKUserContentController *userContent = [[WKUserContentController alloc] init];
[userContent addScriptMessageHandler:id<WKScriptMessageHandler> name:@"MyNative"];

WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
config.userContentController = userContent;

WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];

实现WKScriptMessageHandler,在这里可以接收WKScriptMessage类型的数据。WKScriptMessage类型的数据的body可以存放 NSNumber, NSString, NSDate, NSArray,NSDictionary, 以及 NSNull类型的数据

#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {

}

完成如下工作后Js中就可以通过如下形式进行调用native方法了:

function callNative() {
window.webkit.messageHandlers.MyNative.postMessage('body');
}

WKWebView的关键节点

对于Web开发,业务逻辑一般是基于Web容器的关键节点展开的,我们需要很清楚得了解何时注入对应的Js脚本,何时对Web容器进行设置,何时启动加载,都有哪些回调,何时回调,下面两张图很清楚得解释了这部分内容:


浏览器组成

整个浏览器的大致结构如下所示:

每个浏览器都有自己的内核,Safari的内核就是WebKit,上图的介于浏览器UI Layer 和 Native Layer的就是浏览器的内核WebKit.WebKit Embedding API负责WebKit与浏览器UI Layer进行交互的部分。Platform API 是为了让WebKit更加方便得移植到各个操作系统而提供的一些与平台无关,但是开放给平台的接口,这个和Android 的 HAL层一个道理,一般像FreeRTOS类似的可移植的跨平台操作系统都会存在一个抽象层来隔离各个底层的差异。这些接口会被各个平台以不同的实现来提供支持,比如上图的渲染层面,在iOS系统中,Safari是交给CoreGraphics处理,而在Android系统中,Webkit则是交给Skia。介绍了WebKit Embedding API,Platform API后接下来介绍WebKit最重要的两个部分WebCore和JavaScriptCore两大部分。

WebKit会有多个版本,但是WebCore这个部件是所有WebKit所共享的,不同版本WebKit之间的差异一般在于JavaScript Core, 各个基于WebKit内核的浏览器厂商一般会对各自浏览器的JS引擎进行性能优化,优化的部分就是针对JavaScript Core。这里我们来介绍下WebCore和JavaScriptCore两大部分的分工,我们在打开一个网页的时候,首先会通过网络去下载对应的HTML,CSS,JavaScript 脚本,其中HTML,CSS会被WebCore的 HTML Parser, CSS 解析成DOM树以及CSSOM树,然后以HTML为骨架,CSS为样式将DOM树以及CSSOM树合并,最终生成渲染树。

这时候JavaScript脚本通过词法分析,生成一个个Token序列,这个过程称为分词,分词是通过Lexer来完成的。这个步骤关注的是Token的提取,而不关注每个Token之间的关系。

将词法提取成功后接下来就是语法分析,这个步骤的任务就是如何将词法分析获得的Token序列通过处理形成一个抽象语法树(AST).

然后字节码生成器就会根据AST来生成JavaScript Core 字节码。一般的编译型语言会将这些生成的字节编码存放在内存或者硬盘之中,而对于解释型语言会将生成的字节码立即给JavaScript Core这台虚拟机进行逐行解释执行。一般会由低延时LLint(低级解释器)来解释执行生成的字节码,当遇到多次重复调用或者递归调用,以及循环等情况的时候会通过OSR切换成JIT(实时编译)进行解释执行,JIT主要有三种:

* Baseline JIT 基线JIT
* DFG 低延迟优化的JIT
* FTL 高通量优化的JIT

我们再次回到WebCore 流程:在JavaScript在 JavaScriptCore中 被解释执行的时候,会对WebCore中生成的渲染树有影响,它有可能添加DOM节点,删除DOM节点,修改DOM节点,最终经过修改后的渲染树会通过调用Native API通过平台的渲染框架渲染到屏幕上。

下图是JavaScriptCore 描述得比较好的一张结构图:

JavaScript Core的单线程机制:

在理解JavaScriptCore 机制的时候需要注意的是它的单线程机制,整个JS代码是执行在一条线程里的,它不具备多线程处理能力,它不像OC语言那样可以在自己的执行环境里面申请到多条线程去处理一些耗时任务,从而避免主线程被阻塞。但是接触过JS的伙伴可能会产生疑问,为什么记得也存在多线程异步的概念。这就得益于强大的事件驱动机制。

在遇到耗时任务的时候,JS会吧这个任务抛给由JS宿主提供的工作线程去处理,工作线程处理完毕之后,会发送一个消息通知JS线程当前任务已经执行完毕了,JS 线程得知这个消息后就会转而响应这个消息。但是这里需要注意的是JS线程和工作线程并不在一个运行环境,所以它们不共享一个作用域,所以工作线程中不能操作DOM.

iOS 中的JavaScript Core

Apple通过将WebKit的JS引擎用OC封装,提供了一套JS运行环境以及Native与JS数据类型之间的转换桥梁.整个架构大致长这样:

iOS 中的JavaScript Core 主要由如下类组成:

* JSVirtualMachine
* JSContext
* JSValue
* JSExport

举个例子:

  • 在虚拟机中执行某段代码:
    //创建虚拟机
    JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];
    //创建上下文
    JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];
    //执行JavaScript代码并获取返回值
    JSValue *value = [context evaluateScript:@"1+1"];
    //转换成OC数据并打印
    NSLog(@"value = %d", [value toInt32]);
  • 在Native中运行JS方法
<script type="text/javascript">
var nativeCallJS = function (parameter) {
alert(parameter);
};
</script>
JSContext *jsContext = [webView valueForKeyPath:@“documentView.webView.mainFrame.javaScriptContext”];
JSValue *jsMethod = jsContext[@"nativeCallJS"];
[jsMethod callWithArguments:@[ @"Hello JS, I am iOS" ]];
  • 在JS中运行Native方法
//Html中按钮点击调用一个OC方法
<button type="button" onclick="jsCallNative('Hello iOS', 'I am JS');">调用OC代码</button>
JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
jsContext[@"jsCallNative"] = ^() {
NSArray *args = [JSContext currentArguments];
for (JSValue *obj in args) {
NSLog(@"%@", obj);
}
};
  • 异常处理:
JSContext *jsContext = [[JSContext alloc] init];
jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
NSLog(@"JS Error: %@", exception);
};
[jsContext evaluateScript:@"(function errTest(){ return a+1; })();"];
  • JSVirtualMachine

JSVirtualMachine是一个完整独立的JavaScript的执行环境,它主要完成如下两个任务:

* 支持并发的JS调用
* JS和Native桥接对象的内存管理

上面提到的单线程机制指的是在一个JSVirtualMachine中,只有一条线程可以跑JS代码,所以我们无法在一个JSVirtualMachine中进行多线程处理JS任务,如果需要多线程处理那么就需要创建多个JSVirtualMachine来分别跑这些任务,从而达到多线程处理的目的。

  • JSContext

一个JSContext对象代表一个JavaScript执行环境,它支持在Native代码中使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问Native的对象、方法、函数.

每个JSContext都只能属于一个JSVirtualMachine,但是一个JSVirtualMachine可以有多个JSContext。并且这些多个JSContext可以相互传值。由于每个JSVirtualMachine都是独立且完整的,这里的独立指的是独立的堆空间以及垃圾回收器,某个虚拟机中的GC无法处理别的虚拟机堆中的对象。所以JSVirtualMachine之间不允许相互传值。

  • JSContext执行JS代码
    JSContext执行JS代码 是通过调用 evaluateScript函数来完成的,它可以向global对象添加函数和对象定义,它的返回值是JavaScript代码中最后一个生成的值。

  • JSContext访问JS对象

可以通过三种方式来访问JavaScript对象,以及设置对应的属性。

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式
JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];
NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);

context[@"makeColor"] = ^(NSDictionary *rgb){
float r = [rgb[@"red"] floatValue];
float g = [rgb[@"green"] floatValue];
float b = [rgb[@"blue"] floatValue];
return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f) alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeColor({red:12, green:23, blue:67})"];

相关的API:

@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:(JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;

@property (copy) void(^exceptionHandler)(JSContext *context, JSValue *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);
@end
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end
  • JSValue

一个JSValue实例就是一个JavaScript值的引用。通过JSValue可以在JavaScript和Native之间进行一些基本类型的数据转换,也可以使用它来创建包裹了自定义Native对象的JavaScript对象。甚至可以创建由Native方法或者block实现的JavaScript函数.

每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有,这个JSContext就会一直存活。

同时需要注意的是每个JSValue都通过其JSContext间接关联了一个特定的JSVirtualMachine对象。只能将一个JSValue对象传给由相同虚拟机管理的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发Objective-C异常。

Apple提供了JSValue与Native进行相互转换的接口:
大家可以查看:JSValue API 苹果官方文档

  • JSExport

通过实现JSExport协议可以开放OC类和它们的实例方法,类方法,以及属性给JS调用。

这个在介绍之前介绍UIWebView的时候已经介绍过了,例子大家可以查看上面的部分。

  1. Hybird

为啥要使用Hybird,为解答这个疑问可以看下Hybird有哪些优点:


* 能够在不更新应用版本的情况下进行功能的快速更新迭代
* 多端复用,减少不同平台重复开发的工作量

就目前而言,除了一些小规模的应用外,大部分的应用都会引入混合开发技术,混合开发技术也从简单的基于WebView + JSBridge的实现,到React Native、Weex这些比较成熟的开源库。关于跨平台开发技术的总结大家可以查看我之前写的 《在部门内分享的Flutter材料》那里有对整个跨平台技术进行了比较全面的总结,这里就不详细展开了。

相信通过上面两部分的介绍大家对WKWebView,以及JavaScriptCore技术有了一个比较清楚的了解了,这两部分是接下来介绍Hybird的重要基础,接下来想和大家聊下Hybird框架的设计,这里只是谈论下Hybird设计的一些关键技术以及思想,后续会带大家解析一些较为流行的Hybird框架,后续也会将自己的一套简单的Hybird框架开源出来,所以这里大家先一起来了解一个Hybird框架应该包含哪些部分,具体涉及到哪些技术。各个部分在设计的时候应该注意哪些问题。

一个完整的 Hybrid 框架主要包括 WebView容器、Bridge、预加载、缓存等模块,还需要发布平台、灰度平台、增量更新、CDN 平台等等,这是很庞大的一个平台,并不是简单的 WebView容器 + Bridge. 当然如果只是简单的项目WebView容器 + Bridge已经足够支持。

下面将针对如下几点进行简要介绍一个Hybird的设计:

1.  WebView容器设计
2. JSBridge设计
2.1 Router 设计
2.2 Actions 设计
3. 预加载和缓存设计
4. 发布 && 灰度平台设计
5. 增量更新设计

WebView容器设计

在前面介绍WebView的时候已经介绍过UIWebView将在iOS 13上废弃使用了,所以除非你的项目需要兼容iOS7版本的话,否则不应该还使用UIWebView。所以剩下的问题就是如何通过WKWebView来设计我们的Hybird框架了。

JSBridge设计

JSBridge是整个Hybird的最重要部分,它是Web与Native交互的核心部分,一般是通过Native与Web商定协议,通过协议告诉对方需要调用哪个方法,使用哪些参数,目前用得比较多的是基于URL的拦截机制,在收到URL请求的时候对URL进行校验提取:

首先校验是否是该项目的Hybird请求,如果是的话从url中提取出请求类别,是Router还是某个对应的Action,具体类别的行为,比如当前请求类别是Action那么具体是调起通话action还是打开通讯录action,请求参数等等。下面将针对如下两点进行展开:

  • 如何注入JSBridge功能:

就目前方案而言用得较多的就是基于URL的拦截机制,也就是在每次收到请求的时候WebView都会有机会对当前请求做个拦截决定是否继续加载某个页面,通过这个时机如果发现是我们定义的JSBridge请求那么就拦截不进行跳转,转而调用本地的方法。这也是我们项目目前采用的一种方法。它的优点是比较通用Android 和 iOS都有这一套机制。所以实现的时候可以很方便得保持一套思路。有助于框架的统一。

另一种是iOS所独有的是WKWebView引入的上面也介绍过了,它就是基于scriptMessageHandler注入,不通过任何拦截的办法,而是直接将一个Native对象或者函数注入到JS里面,可以由web的js代码直接调用。

注入方式如下:

[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeObj"];

这里有个十分关键的地方:如果当前WKWebView没用了需要销毁,需要先移除这个对象注入,否则会造成内存泄漏,WebView和所在VC循环引用,无法销毁。可以通过如下方式进行移除:

[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeObj"];

相较而言个人还是比较推荐通过scriptMessageHandler方式注入,但是它不如拦截通用,意味着Androd需要另外一套,不过个人还是比较固执得推荐通过scriptMessageHandler来注入,嗯,是的就是固执。

  • 如何通过JSBridge进行实现Web与Native的相互调用:

在iOS平台上个人比较偏好采用scriptMessageHandler方式实现js调用native。通过evaluatingJavaScript实现native调用js。
下面是两者的基本使用方式:

通过 scriptMessageHandler 注入对象:

方法1:

[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeObject"];

方法2:

WKUserContentController *userContent = [[WKUserContentController alloc] init];
[userContent addScriptMessageHandler:id<WKScriptMessageHandler> name:@"nativeObject"];

WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
config.userContentController = userContent;

WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];

其实两者是一样的看你个人喜欢,还要时刻牢记在不用的时候及时销毁。

实现WKScriptMessageHandler代理:

-(void)userContentController:(WKUserContentController *)userContentController 
didReceiveScriptMessage:(WKScriptMessage *)message
{
NSDictionary *msgBody = message.body;
//1 取出协议前缀,通过协议前缀进行校验
//2 取出指令类别
//3 取出具体指令
//4 取出协议版本
//5 取出指令参数
//6 取出回调
//6 根据上述内容调用对应的本地方法
}

JS端调用:

var data = {
module:'xxxx',
type:'xxxx',
action:'xxxx',
version:'xxxx',
params:'xxxx',
callback:'xxxx',
//其他通用参数
};
//传递给客户端
window.webkit.messageHandlers.nativeObject.postMessage(data);

Native 调用 JS

NSString* javascriptCommand = [NSString stringWithFormat:@"calljs('%@');", paramsString];
if ([[NSThread currentThread] isMainThread]) {
[self.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
} else {
__strong typeof(self)strongSelf = self;
dispatch_sync(dispatch_get_main_queue(), ^{
[strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
});
}

这里需要注意的是在调用的时候需要对各个请求鉴权,哪些请求需要登录态,哪些请求不能访问某些本地资源,对某些敏感的页面请求甚至需要设置黑白名单。

在加载DOM的时候调用JS:

上面介绍的evaluatingJavaScript是在客户端执行这条代码的时候立刻去执行当条JS代码,而还可以通过WKUserScript预先准备好JS代码,当WKWebView加载Dom的时候,执行当条JS代码下面是一个简单例子:

WKUserScript *script = [[WKUserScript alloc]initWithSource:source
injectionTime:time
forMainFrameOnly:mainOnly];
WKUserContentController *userController = webView.userContentController;
[userController addUserScript:script]

JSBridge 应该包括那些内容:

1.方法的注入:

* H5注入方法给Native调用
* Native注入方法给H5调用

2.H5 与 Native 的相互调用

* H5主动调用Native
* H5主动调用Native后回调

* Native主动调用H5
* Native主动调用H5后回调

3.获取当前版本JS桥所支持的的方法列表

* H5 获取本地可以支持的方法列表
* Native 获取H5所支持的方法列表

4.协议设计(包含鉴权)

5.异常处理

下面不会对全部进行介绍只会针对关键环节进行简要说明:

  • 启动阶段

JS 桥在启动的时候H5会通过JS桥初始化参数来主动向Native获取一些公共参数,比如当前平台名,设备信息,当前应用版本,用户信息,国家区域信息等。然后Native 和 H5通过上面介绍的方法各自往JSBridge 注入各自的方法。

  • 协议设计

协议主要用于告诉表调用方,我是要使用哪些参数调用哪个模块的哪个方法。下面是我自己实现的一个Hybird的协议,分得比较细,主要是为了将协议归类.待引入组件化后可以根据所属组件分发,整个逻辑会显得更加清晰。

module:             模块名
type: 协议类型,是路由协议还是指令调用协议
action: 指令或者路由名
version: JS桥版本
params: 协议参数
from: 来源
callbackId: 回调id 唯一
callbackFunction: 回调处理函数
//其他通用参数
  1. H5 与 Native 的相互调用
  • H5主动调用Native

H5端调用:

window.webkit.messageHandlers.IDLJSBridge.postMessage(data);

Native端:

实现WKScriptMessageHandler代理,对消息进行处理,为了避免WKScriptMessageHandler过大还可以划分多个WKUserContentController进行处理。或者将处理方法划分到不同对分类中。

-(void)userContentController:(WKUserContentController *)userContentController 
didReceiveScriptMessage:(WKScriptMessage *)message
{
NSDictionary *msgBody = message.body;
//1 取出协议前缀,通过协议前缀进行校验
//2 取出指令类别
//3 取出具体指令
//4 取出协议版本
//5 取出指令参数
//6 取出回调
//6 根据上述内容调用对应的本地方法
}
  • H5主动调用Native后回调

对于有回调的方式和无回调的方式的区别在于在向客户端传递消息的时候不但需要传递必须的数据,还需要传递callbackID以及callbackFunction。callbackId需要保证每次通信都唯一。可以通过“时间戳+随机数”,callbackFunction是回调的统一处理函数。
在JSBridge中存在一个map msgCallbackMap用于存放回调的信息,key为callbackId 值为待调用的callback函数。

在发起调用的时候如果需要回调则在下面结构的callbackId填写生成的id以及callbackFunction。

module:             模块名
type: 协议类型,是路由协议还是指令调用协议
action: 指令或者路由名
version: JS桥版本
params: 协议参数
from: 来源
callbackId: 回调id 唯一
callbackFunction: 回调处理函数
//其他通用参数

一旦H5发起请求后请求到达Native的userContentController 代理方法中,Native根据协议调用对应的本地方法处理请求后查看callbackId如果有值,就拼接请求:callbackFunction(callbackId,result) 通过evaluateJavaScript来执行。这时候流程回到H5,H5再使用callbackId 从 msgCallbackMap中取出对应的handler,调用handler(result)进行处理。

  • Native主动调用H5

这里包括两个部分:

  1. JSBridge注册供调用的方法:
window.registerHandlerForNative: function (handlerName, handler) {
var handlerArr = this.handlerForNativeMap[handlerName];
if (handlerArr === undefined) {
handlerArr = [];
this.handlerForNativeMap[handlerName] = handlerArr;
}
if (handler !== undefined) {
handlerArr.push(handler);
}
},

这时候就可以通过注册对应的handler进行处理了。比如:

window.registerHandlerForNative('callByNative', function () {
console.log('call by native')
});

native调用中转:

window.nativeCallDispatcher: function (handlerName, resultjson) {
var handlerArr = this.handlerForNativeMap[handlerName];
for (var key in handlerArr) {
if (handlerArr.hasOwnProperty(key)) {
var handler = handlerArr[key];
if (handler && typeof (handler) === 'function') {
var resultObj = resultjson ? JSON.parse(resultjson) : {};
handler(resultObj);
}
}
}
},

这样本地就可以通过构建window.nativeCallDispatcher(handlerName,resultjson)字符串,然后通过evaluateJavaScript进行调用了。

  • Native主动调用H5后回调

这个其实比H5回调Native后回调要简单得多evaluateJavaScript原生支持:

[self.webView evaluateJavaScript:javascriptCommand completionHandler:handler];

上面的handler就是在执行完毕后返回被调用的。所以我们只需要在处理函数中return一个结果就可以了。

window.nativeCallDispatcher: function (handlerName, resultjson) {
var handlerArr = this.handlerForNativeMap[handlerName];
for (var key in handlerArr) {
if (handlerArr.hasOwnProperty(key)) {
var handler = handlerArr[key];
if (handler && typeof (handler) === 'function') {
var resultObj = resultjson ? JSON.parse(resultjson) : {};
var responseObj = handler(resultObj);
return responseObj;
}
}
}
},

Router && Actions 设计
这些都需要在本地封装成一个简洁的模块后直接调用,具体开放的接口一般会依照业务而定,不建议一上来就开放很多接口。这里还需要注意的是在调用这些方法之前需要事先鉴权,或者登录态校验,以及针对敏感性资源建立黑白名单等。

换句话说Hybird开放接口需要一套权限分级的逻辑控制相关的接口调用,最常规的做法就是对开放接口建立分级的管理,不同权限的H5页面只能调用各自权限内的接口。同时支持动态拉取权限白名单从而可以灵活得配置H5页面的权限。

下面摘自:一文掌握iOS开发中的全部web知识

Hybird高阶话题 优化部分

在很多年前刚接触Hybird的时候,我记得问过一个问题,Hybird为啥要将H5页面打包到应用中,不会增大整个包体积吗?因为在当时的印象中Hybird就是一个WebView加载一个Web页面,再由JSBridge提供Web与Native交互的接口。在加载完某个页面后再将当前页面缓存下来供下次调用不就行了,后续接触多了之后发现但是有这个错误的理解是因为当时接触使用Hybird做的业务都是很简单的业务。大部分的业务还都是放在Native 端做,只不过在某些活动页或者说明页的情景下会采用Hybird,但是对于像淘宝,京东那样内容需要快速更新的场景,有可能就需要某个业务的全部页面都要使用Hybird实现。这种情况下就对我们的Hybird框架有很高的要求了。这就涉及到如何优化这部分设计了。

1.离线包机制

对于某个功能一般在应用打包的时候会内置一份全量离线包,以及本地配置文件。在首次打开的时候会先查看本地配置文件是否归属于当前应用版本,以及配置文件的md5值是否和保存在应用中的配置文件md5值相等,如果不相等则拿应用的版本信息从服务端请求一份最新的离线包配置文件保存到本地,如果相等则校验全量包的md5值是否等于本地配置文件中的md5值。如果不相等则删除本地全量包从后台再下载一份。如果相等说明离线包配置和全量包匹配,这时候就可以对全量包进行解压。

在后续访问H5页面的时候,先进行拦截,如果离线包中有对应的内容那么就直接访问本地资源,否则访问线上资源。

接下来就需要考虑一个问题:应用如何感知到某个离线包升级了。这里有两种方式,一种是通过长链接下发,主动通知应用,这种情况可以在每次发布新的离线包的时候,下发配置文件的md5值,客户端在收到这个md5值后和本地配置文件的md5值进行对比,如果不相同则请求最新的配置文件保存到本地。一种是采用轮询的方式比如每10分钟轮询一次,两种都可行,但是个人比较推荐第一种。为了避免App长时间停留在后台而导致无法及时收到更新的通知,我们还需要在App后台进入前台的时候,主动请求一次。

对于单个离线包本地配置文件可以如下设置:


{
"latestVersion": "最新版本号",
"md5": "zip包 MD5值",
"url": "zip包下载地址",
"packages": [
{
"version": "某主包版本号",
"url": "差分包下载地址",
"md5": "差分包md5值"
},
{
"version": "某主包版本号",
"url": "差分包下载地址",
"md5": "差分包md5值"
}
]
}

每次拿到最新的配置文件后就拿本地的离线包版本号与最新的版本进行比较,如果等于最新版本号那么就什么都不干直接返回,如果不等于最新版本号,就拿着当前的版本号,去packages数组中去找,找到version等于当前本地离线包版本的那个配置,拿到补丁包
的下载地址,下载完毕后通过md5校验通过后将其合并到本地包,修改本地包的版本信息。如果在packages中没有找到那么就通过latestVersion对应的URL地址去下载全量包,再解压。

2.差分包制作

客户端在内置了全量包的基础上,为了减少每次下载更新的资源包的体积,一般都采用增量更新策略:每次发布版本的时候,如果此业务线之前已有离线包,则通过离线系统生成差分包发布。增量更新的策略使用的是基于node的 bsdiff/bspatch 二进制差量算法工具npm包bsdp。客户端下载差分包后使用bspatch合成更新包。

3.容错机制

由于网络因素,有可能导致包下载过程由于某些原因导致包下载失败,所以我们在设计过程中需要尽量增加整个系统的容错机制。即使使用非最新的包也不能导致页面异常的现象。所以在整个过程中需要注意如下几点:

* 在没有下载完成并通过md5校验之前不要删除本地离线包。
* 在弱网状态下先使用本地离线包,待切换到Wifi或者网络信号强的情况下开始下载。
* 为了减少网络因素导致的失败,需要增加重试策略.

针对各个业务最后的兜底选择就是每次都使用线上资源。所以如果发现:

* 内置的主包解压失败
* 离线系统接口超时
* 离线资源下载失败
* 增量的离线资源合并失败

这些情况先切换到线上资源,再尝试从后台下载离线包恢复。

一般为了避免离线资源使用带来的不可预料的风险,一般对每一个业务都需要加入使用离线资源的开关和灰度放量的控制

4.数据一致性校验与数据安全性

为了防止客户端下载离线资源时数据被篡改,导致下载的离线包无法解压,我们需要从服务端那边获得离线包的md5值,客户端下载后通过计算得到的资源包的md5值与之比较,可以保证数据的一致性。同时为了保证传输过程中,资源文件不被篡改,对于md5值的传输需要通过RSA加密算法进行加密。在服务端和客户端分别使用一对非对称的密钥进行加解密。

5.离线包下载优化

在介绍网络性能优化的时候介绍过如何加快网络的性能,里面的一些建议都可以借鉴,比如我们在可以在存放离线包资源的CDN中使用HTTP/2协议,这样客户端与CDN只需要建立一次连接,就可以并行下载所有的资源。 在需要下载离线包个数较多的情况下,会比传统的HTTP1有更快的传输速度。同时,客户端只需要运行一次。减少多次触发下载对手机资源的消耗。

在下载离线包的过程中可以通过离线包下载的断点续传和分块下载的功能来加快整个离线包下载的总进度,在发布离线包的时候可以使用CDN就近存储。

6.打包 && 发布 && 离线包管理系统设计

今天写得有点累了,不多说直接贴图,其实图还是蛮直观的:

7.其他

数据,模板分离、预加载内容页数据机制。

1.开篇叨叨

这是这个系列的倒数第二篇博客,如果看过我之前总结的Android性能优化的博客,大家可以发现很多适用于Android的性能优化在iOS上也适用,举个例子来说我们需要请求某个接口,如果不成功的需要间隔一段时间进行重试,这部分就可以做个优化,可以将发起重试请求的间隔时间以线性增长的方式递增。到达一定阈值后认定该请求失败。这种优化是属于从需求层面上的性能优化,对于这种性能优化往往是平台无关的可以通用。另一种则是平台相关的,比如渲染方面的优化,但是不管是哪种两个平台的优化思想都可以相互借鉴。性能优化作为初级中级到高级开发的一道门槛,它要求我们不但要吃透需求,还需要对平台底层原理有较为深刻的理解。

在进行性能优化之前要遵循两大原则:

* 不要过早优化,也不能过度优化

我个人的理解项目的每个阶段都有它的关键任务,项目初期我们应该关注的是项目框架的搭建,技术的选型,整体方案的确定,代码规范的约定,到了项目中期我们往往是以产品需求为导向,关注的是需求按时保质保量地上线,以及产品数据的收集。性能,质量监控系统的搭建以及数据的收集,那么什么时候才是优化的最好时间点呢?个人认为要在性能采集系统搭建完成并且收集到较为充分数据这之后才介入性能优化工作,并且由于性能优化很难在产品营收层面有所贡献,所以很多以产品为导向的项目中,很难给我们充裕的时间专门进行优化,因此性能优化不应该一步到位,如果我们的项目是按月规划的,在一个月或者一个季度可以抽出一定的时间对这个阶段的需求进行代码层面以及性能层面的优化,个人以为这种是一种比较合理的方式。至于过度优化,指的是在没有实际数据指标的基础上,做一些盲目的优化工作,或者为了优化性能而过度增加系统复杂度和维护成本。虽然可能性能上带来了一定的提升,但是这么做显然是得不偿失的。

* 所有的优化都要有数据的支撑,平台底层原理的指导

性能优化其实包含两大部分:一部分是性能检测,另一部分才是性能优化,第一部分往往是人们最容易忽视的,性能优化之所以不能引起产品的兴趣是因为很多的优化是看不见的效果,我们很难说服产品,我们某项优化的必要性,这归根到底是由于我们没有数据的支撑,而性能数据不但有衡量我们优化效果的作用,还能在优化过程中起到指引作用,所以一般大厂都会有自己的一套性能检测系统,也就是常听说的APM。他们对于关键的页面以及关键环节都会进行性能监控。有了数据的情况下,我们不能单纯靠尝试或者经验来优化,还应该从平台底层原理出发,找到问题的关键所在,然后才进行有针对性地优化。

总而言之对于性能优化我们有两大任务一个是性能的检测,性能数据的收集。有了这些数据的支撑后我们再从业务逻辑,以及系统平台两大方面入手分析解决,也就是我们这个博客所涉及的重点 – 性能优化
简而言之 – “两大任务,两个方面”

2.性能监控与数据收集

在进行性能监控与数据收集之前需要先明确性能优化的目标有哪些?最常用的几个性能优化点有如下几点,后续也会对这些部分进行展来来介绍:

  • 内存优化:
    对于内存优化,衡量的最重要指标就是是否有内存泄漏,是否有在短时间创建和释放大量对象造成内存波动。以及在哪个关键点会消耗较大的内存,最好能够提供一个较为实时的查看内存耗用的途径。

  • 卡顿优化:
    卡顿是用户最为直观的性能问题,因此一般优先级会设得比较高,衡量卡顿的最总要指标就是FPS(帧率),如果帧率小于60fps就说明存在卡顿掉帧问题。如果条件允许的话最好还要能够实时呈现CPU和GPU的使用百分比情况。

  • 启动优化:
    应用启动可以分成两个阶段,两个阶段的分界点是main函数,main之前为动态库以及可执行文件的加载过程,main之后包括runtime环境初始化,load,直到Appdelegate didFinishLaunchingWithOptions为止,这个也是用户十分直观的性能指标之一,衡量的最总要的指标就是两个阶段的耗时时间,一般建议控制在400ms之内。

  • 包体积优化:
    包体积也是一个很重要的指标,如果包太大会直接影响到用户下载安装的欲望,这部分的指标也很明确就是整个包的大小数据,最好能够精确到各个资源占用的大小。

  • 网络优化:
    一般现在的应用很少是离线的,所以都会涉及到网络访问,衡量这部分的指标也很明确

1. 关键接口的响应速率
2. 关键接口的成功率,失败率
3. 关键页面的加载速率
  • 电量优化:

对于电量优化这个没有十分明确的衡量标准,因为电量是多个应用共同享用的,所以很难清楚得说明是否耗电增加了,因此这部分只会对关键的模块进行同等条件下专门对比测试,一般这部分只要遵循一定的规则就不会有太大的问题。如果非要有个衡量指标的话,我个人会去收集关键模块从开始进入到使用完毕退出这个阶段,单位时间的耗电情况,这里不会去设置同等情况下的对比环境,因为如果用户量大的话其实也是很能说明问题的,但是相对于前面几项数据优化点来说这个一般优先级会比较低,当然这也是仅仅相对而言比较低而已,对于直播类或者游戏类的应用,电量的消耗优化还是十分重要的。

3.性能优化

接下来我们正式开始介绍性能优化的内容,每个内容都包括:底层原理介绍,如何测试和收集性能参数,性能优化Tip三大部分。

3.1 内存优化

对于iOS的内存管理大家可以参看之前的iOS内存管理总结这篇博客,我们先来了解下这部分的底层原理:

底层原理

对于安装在同一个设备上的应用来说内存是有限的共享资源,如果我们的应用占用的内存过多,那么留给其他应用的内存就更少,在系统内存紧张的时候它会根据每个应用使用内存的实际情况,将内存耗用高的应用先回收 ,因此我们在编码过程中应该尽量注意整个应用的内存使用情况,并在内存不足的时候释放无用的内存空间,以降低我们的应用在系统内存空间不足的情况下被回收的可能性。

要强调的是我们这里谈论的减少内存空间的占用针对的是iOS的的虚拟内存空间,在iOS中采用了虚拟内存技术来突破物理内存容间的限制,系统为每个应用的进程都分配了一定大小的虚拟内存空间,这些虚拟内存空间是由一个个逻辑页构成的,处理器和内存管理单元 MMU管理着由逻辑地址空间到物理地址的映射表。之所以说是虚拟的是因为这些内存空间远大于实际的物理内存空间,在要使用某个虚拟内存空间的时候,MMU会将当前虚拟内存空间所在的page映射到物理内存页面上。当程序访问逻辑内存地址时由 MMU 根据映射表将逻辑地址转换为真实的物理地址。

在早期的iOS设备中,每个page的大小为4KB;基于A7和A8处理器的系统为64位程序提供了16KB的虚拟内存分页和4KB的物理内存分页,而在A9之后虚拟内存和物理内存的分页大小都达到了16KB。

前面提到在内存空间不足的时候应用会被系统回收,在macOS中会将一部分对内存空间占用较多,并且优先级不那么高的应用数据挪到磁盘上,这个操作称为Page Out,之后再次访问这块数据的时候会将它重新搬回内存空间,也就是所谓的Page In操作,但是考虑到太过频繁的磁盘IO操作会降低存储设备的寿命,目前iOS使用的是压缩内存的方式来释放内存空间,它会在内存资源紧张的时候对不使用的内存进行压缩处理,在下次访问的时候再对这部分内容进行解压。

在这种模式下iOS系统有三种类型的内存块:

  • Clean Memory

    Clean Memory指的是可以被Page Out的内存,包括已经被加载到内存中的文件,以及应用中使用到的frameworks。

  • Dirty Memory

    Dirty Memory 指的是那些写入过数据的内存空间,包括Heap区域的对象、图像解码缓冲空间。这里需要强调的是应用所使用的frameworks 不同段类型是不同的,_DATA_CONST段最初是Clean Memory类型,但是一旦在应用使用到了某个framework的时候_DATA_CONST 的内存就会由 Clean 变为 Dirty。而_DATA 段和 _DATA_DIRTY 段,它们的内存类型固定是Dirty Memory。

  • Compressed Memory

    这就是上面提到的压缩内存,在内存资源不足的时候压缩,在下次访问的时候解压。

这部分大家可以看下这篇文章:WWDC 2018:iOS 内存深入研究

XCode内存检测工具

  • Memory Report 内存使用报告

Memory Report 是可以实时查看整个应用当前应用内存使用情况的工具,但是它只能用于初略得定位哪些页面有可能有内存泄漏,或者哪个时间段有内存抖动问题。具体的定位还是需要Allocations工具

  • Analyze 静态分析工具

Analyze主要用于从代码层面上进行语义分析,主要可以用于分析如下几类问题:

逻辑错误:访问空指针或未初始化的变量等;
内存管理错误:如内存泄漏等;
声明错误:从未使用过的变量;
Api调用错误:未包含使用的库和框架

Analyze 分析出的问题并不一定是真正意义上的问题,它只是一个理论上的预测过程,具体是不是需要解决要我们自己去分析排查。

  • Allocations 观察内存的分配情况
Xcode -> Product -> Profile -> Allocations

我们一般会先看下Allocation Summary页面,比较重要的有三行:

All Heap & Anonymous VM: 所有堆内存和虚拟内存
All Heap Allocations: 所有堆内存,堆上malloc分配的内存,不包过虚拟内存区域
All Anonymous VM: 所有虚拟内存,就是Allocations不知道是你哪些代码创建的内存,也就是说这里的内存你无法直接控制。像memory mapped file,CALayer back store等都会出现在这里。这里的内存有些是你需要优化的,有些不是。

每行都包含如下几个重要的列:

Persistent :未释放的对象个数
Persistent Byte :未释放的字节数
Transient :已释放的临时对象个数
Total Byte :总使用字节数
Total :所有对象个数
Persistent/Total Bytes : 已经使用的内存对象占全部的百分比

当我们看到如下的阶梯的时候就说明有内存泄漏问题:

下面这种就是说明没有内存泄漏,但是存在内存抖动现象。

正常的情况下应该是比较平稳的没有尖峰的曲线。

在我们遇到内存问题的时候首先会先看Statistics分类:

我们一般会先查看前几项比较主要的。勾选后就会出现在上面的曲线中。

点击你觉得比较有嫌疑的项的箭头处,就可以看到具体的内存分配,以及右边面板上的调用堆栈,这部分堆栈可以隐藏显示系统调用的:


点击进去就可以看到具体的代码,代码旁边会以百分比以及占用内存的尺寸等形式标记出来

还可以使用:

  • Generation:对内存的增量进行分析

  • Call Tree:分析代码是如何创建内存的。

  • Allocations list:观察内存的分配的列表

  • Debug Memory Graph 图形化内存表

Debug Memory Graph 是Xcode8中增加的调试技能,在App运行调试过程中,点击即可实时看到内存的分配情况以及引用情况,可用于发现部分循环引用问题,为了能看到内存详细信息,需要打开Edit Scheme–>Diagnostics, 勾选 Malloc Scribble 和 Malloc Stack。同时在 Malloc Stack 中选择 Live Allocations Only:

运行应用,点击Xcode 如下按钮:

整个界面如下图所示:

可以先通过左下脚的过滤按钮,筛选出只属于项目的类,以及只显示存在内存泄漏的项,有内存泄漏的项的右边会有感叹号,可以定位到右边的图然后右击跳到生成这个对象的代码。

这个个人在项目中用得不是很多,主要是生成图的过程太耗时了,很经常卡得不要不要的。

  • Leaks 内存泄漏检测工具

Leaks 用起来还是蛮好用的,不过在有了后面介绍的一款工具后也慢慢少用了,至于什么工具先卖个关子,我们这里先给大家介绍下Leaks工具的使用。

Xcode -> Product -> Profile -> Leaks

启动 Instruments 操作应用,如果有内存泄漏情况,会打叉提示,我们可以看到到底泄漏了多少内存,以及对应的方法调用栈。可以很快速地定位到内存泄漏点的位置。



上面介绍的工具虽然是官方推出的工具,但是实际上并不是十分好用,需要我们一个个场景去重复的操作,还有检测不及时,并且Instuments工具永久了不是一般的卡,在开发过程中上面几种工具用得比较多的就是Memory Report,Analyze 以及 Leaks,更多的是结合一些开源库来实时检测内存泄漏,这里推荐的是微信推出的MLeaksFinder,它能较为实时地检测内存泄漏问题,一旦有内存泄漏立刻弹窗提示,这种方式从很大角度上加快了我们发现问题解决问题的速度。

MLeaksFinder GitHub地址

MLeaksFinder 可以算得上是一个很好的检查内存泄漏的辅助工具,它有如下特点:

使用简单,不侵入业务逻辑代码,不用打开 Instrument
不需要额外的操作,你只需开发你的业务逻辑,在你运行调试时就能帮你检测
内存泄露发现及时,更改完代码后一运行即能发现(这点很重要,你马上就能意识到哪里写错了)
精准,能准确地告诉你哪个对象没被释放
MLeaksFinder 目前能自动检测 UIViewController 和 UIView 对象的内存泄露,而且也可以扩展以检测其它类型的对象

具体的实现细节可以看官方的博客,由于篇幅原因,这里只提炼一些重要的内容做介绍,后面会针对MLeaksFinder写一篇源码解析的文章来介绍它的实现:

MLeaksFinder 通过AOP技术 hook UIViewController 和 UINavigationController 的 pop 跟 dismiss 方法,这种做法的优点就是不会侵入项目工程。MLeaksFinder会在UIViewController被pop或dismiss一小段时间后,检测该 UIViewController的view,以及view 的 subviews 等等是否还存在,具体的方法是,为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在一小段时间(2秒)后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。我们可以在一个 UIViewController被pop或dismiss时遍历该 UIViewController上的所有view依次调 -willDealloc 这样如果2秒后它们被释放成功,weakSelf 就指向 nil,不会调用到 -assertNotDealloc 方法,也就不会中断言,如果它没被释放,-assertNotDealloc 就会被调用中断言。通过这种方式可以找出具体是哪个地方发生了内存泄露。最新版本的MLeaksFinder 还结合了FBRetainCycleDetector通过MLeaksFinder查找可能存在内存泄漏的对象,然后通过FBRetainCycleDetector来查看是否存在循环引用。

- (BOOL)willDealloc {

// 当前的类是否再白名单中,如果是的话就不会进行检测是否泄漏
NSString *className = NSStringFromClass([self class]);
if ([[NSObject classNamesWhitelist] containsObject:className])
return NO;

NSNumber *senderPtr = objc_getAssociatedObject([UIApplication sharedApplication], kLatestSenderKey);
if ([senderPtr isEqualToNumber:@((uintptr_t)self)])
return NO;

//延迟2秒尝试调用assertNotDealloc
__weak id weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__strong id strongSelf = weakSelf;
[strongSelf assertNotDealloc];
});
return YES;
}

- (void)assertNotDealloc {
if ([MLeakedObjectProxy isAnyObjectLeakedAtPtrs:[self parentPtrs]]) {
return;
}
[MLeakedObjectProxy addLeakedObject:self];

NSString *className = NSStringFromClass([self class]);
NSLog(@"Possibly Memory Leak.\nIn case that %@ should not be dealloced, override -willDealloc in %@ by returning NO.\nView-ViewController stack: %@", className, className, [self viewStack]);
}

需要注意的是这里的遍历需要遍历基于UIViewController的整棵View-ViewController树,对于某些 ViewController,如 UINavigationController,UISplitViewController 等,还需要遍历 viewControllers 属性。

MLeaksFinder在发现可能的内存泄漏对象并给出 alert 之后,还会进一步地追踪该对象的生命周期,并在该对象释放时给出 Object Deallocated 的 alert,所以有时候你会发现弹出一个内存泄漏的弹窗后,你以为内存泄漏了检查了好久发现没有,重复尝试后你会发现在这个弹窗之后还会出现Object Deallocated弹窗,这种其实是某个对象延迟释放了,并不是发生了内存泄漏。

所以在使用MLeaksFinder的时候一般会有如下几种情况:

  • 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,即不报 Object Deallocated,也不报 Memory Leak。这种情况下我们可以确定该对象被设计成单例或者缓存起来了。

  • 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,对于同一个类不断地报 Object Deallocated 和 Memory Leak。这种情况属于释放不及时的情况,不算内存泄漏。

  • 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,不报Object Deallocated,但每次 pop 之后又报 Memory Leak,这种才算是真正的内存泄漏。

其他关于内存检测较好的开源库:

比较常见的内存问题简要总结

该部分见《iOS 内存管理总结》博客一文

3.2 卡顿优化

在之间的博客中已经介绍过了iOS的布局渲染机制,在介绍卡顿优化之前大家可以通过之前的博客回顾下一个界面是怎样绘制到屏幕上的。然后再针对各个环节考虑优化步骤,一般一个应用都有首页这个关键页面,它是用户最频繁访问的,也是一个应用的门面,因此首页的优化至关重要;列表是一个应用最常用的组件,并且列表是可滑动的它的卡顿往往是最明显的,所以这里也会介绍下怎么针对列表进行卡顿检测和优化。

底层原理

iOS界面的渲染主要由CPU和GPU协同下完成的,CPU一般用于逻辑部分的处理,而GPU由于高性能的并发计算能力和浮点计算能力,所以在渲染方面比CPU来得高效很多,因此我们在代码实现过程中尽量将图形显示相关的工作交给GPU,而将逻辑处理,线程调度工作交给CPU。只有CPU和GPU二者达到性能最优的平衡点才能获得最佳的界面效果,无论过度优化哪一方导致另一方压力过大都会造成整体FPS性能的下降。寻找这个均衡点十分关键,在介绍这部分优化之前我们分别看下二者在整个过程中完成了哪些工作:

CPU 部分

在整个界面显示过程中:对象创建,对象属性调整,对象销毁,Autolayout约束,布局计算,文本计算,文本渲染,图片的解码,图像的绘制,提交纹理数据。这些工作都是在CPU上进行的,所以CPU部分的优化就需要弄清楚这些环节都做了哪些工作,并且哪些是有可能导致性能瓶颈的点。

对象创建

  • 首先我们会创建需要添加到界面上的对象,并且设置其属性,以及在整个过程中属性的调整,这些都是在CPU中完成的。

优化点 1:
对象的创建伴随着内存的分配,会消耗CPU资源,所以这个部分可以使用轻量对象的就不使用重的对象,比如能用CALayer的就不用UIView,但是这也有一个也有个比较坑的地方就是,某个控件初期的时候可能不涉及事件处理所以用Layer会比较轻量,但是后续可能要添加事件处理,这样改起来就比较麻烦了,因此在实际开发中除非可以确定是不涉及事件处理否则还是会用UIView。

优化点 2:
在创建对象的时候尽量推迟某个对象的创建时间,可以通过懒加载的方式在使用的时候创建对象,可以通过缓存或者对象池来避免重复频繁创建对象。

优化点 3:

对UIView属性的更改是比较耗费资源的,所以尽量减少不必要的属性修改,避免视图层次的调整以及视图的添加移除,尽量使用hide等属性来代替视图移除添加。

视图布局

  • 在创建完对象后会把视图对象添加到界面上,这时候会触发界面的布局,在布局方面目前用得比较多的有两大类,一种是基于frame的,一种是基于AutoLayout的,前者的缺点就是要我们自己计算好各个尺寸数据,但是性能上是最快的,而AutoLayout在使用上对于描述布局来说是十分方便,直观的,但是在性能上随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升(在iOS12上这个问题已经有所好转)因此从性能角度上看建议建议使用基于frame的布局。

除了布局框架选型外,在布局方面还可以在计算布局的时候放在子线程中计算,在最后设置frame的时候回到主线程,对于某些布局参数还可以适当缓存,特别是列表的cell布局尺寸,往往是可以进行缓存的。

文本计算及渲染

  • 常见的文本控件比如UILabel,UITextView,它们的排版和绘制都是在主线程进行的,当需要显示大量文本的时候会给CPU带来很大的压力,所以可以使用TextKit或者CoreText自定义的文本控件来代替,对于宽高计算推荐在后台使用[NSAttributedString boundingRectWithSize:options:context:],并且适当地缓存某些内容不变的文本尺寸数据。

子线程计算文本宽高,文本绘制:

// 文字计算
[@"iOS" boundingRectWithSize:CGSizeMake(100, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];

// 文字绘制
[@"iOS" drawWithRect:CGRectMake(0, 0, 100, 100) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];

图片解码及绘制

  • 当我们创建图片的时候图片数据并不会立刻解码,只有在图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前CGImage 中的数据才会得到解码。这是在主线程中由CPU执行的。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到CGBitmapContext中,然后从Bitmap直接创建图片。由于 CoreGraphic 方法通常都是线程安全的,所以图像的绘制可以很容易的放到后台线程进行。
- (void)loadImage {

//.......

dispatch_async(dispatch_get_global_queue(0, 0), ^{
CGImageRef cgImage = [UIImage imageNamed:@"avator"].CGImage;

CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}

CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;

size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);

CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
cgImage = CGBitmapContextCreateImage(context);
UIImage *newImage = [UIImage imageWithCGImage:cgImage];

CGContextRelease(context);
CGImageRelease(cgImage);

dispatch_async(dispatch_get_main_queue(), ^{
self.imageView.image = newImage;
});
});
}

在iOS系统中如果GPU不支持某种图片格式这时候就只能通过CPU来渲染,所以我们应该尽量避免这种情况的发生,由于苹果特意为PNG格式做了渲染和压缩算法上的优化,因此尽量使用PNG格式作为图片的默认格式。

提交界面数据

在RunLoop即将进入休眠期间或者即将退出的时候,通过已经注册的通知回调执行_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv函数,在这个函数会递归将待处理的图层进行打包压缩,并通过IPC方式发送到Render Server,这里还需要提到一点:这时候的Core Animation会创建一个OpenGL ES纹理并将CPU绘制位图上传到对应的纹理中。

GPU 部分

Render Server在拿到压缩后的数据的时候,首先对这些数据进行解压,从而拿到图层树,然后根据图层树的层次结构,每个层的alpha值opeue值,RGBA值、以及图层的frame值等对被遮挡的图层进行过滤,最终得到渲染树,渲染树就是指将图层树对应每个图层的信息,比如顶点坐标、顶点颜色这些信息,抽离出来,形成的树状结构。渲染树就是下一步送往GPU进行渲染的数据。

纹理渲染

有时候我们会发现一种现象:在某些包含大量图片的列表页面快速滑动会出现GPU占用率很高但是CPU却相当空闲,规避这种问题的方式就是尽可能得将多张图片合成一张显示,这样可以节省整个纹理渲染的时间。

视图合成

一般我们每个界面都是由多个视图或者CALayer组成,多个视图会一层层得叠在一起,GPU在显示之前会先对这些视图层进行混合,如果视图结构过于复杂就会消耗很多GPU资源,为了解决这个问题就必须在我们界面实现的过程中尽量做到减少视图数量和层次结构,如果顶层的视图的opaque为YES,那么我们在合成的时候就会直接采用最上层的视图,就省去了合成过程,所以这个步骤中可以通过在实现的过程中减少视图的数量和层次,或者将多个视图先渲染为一张图片显示,尽量采用不透明的视图,并将视图opaque设置为YES,避免无用的Alpha通道合成(这里需要注意一点即使UIImageView的alpha是1,只要image含有透明通道,则仍会进行合成操作).

像素对齐

我们知道iOS设备上,有逻辑像素point和物理像素pixel之分。point和pixel的比例是通过[[UIScreen mainScreen] scale]来确定的。在没有视网膜屏之前,1 point=1 pixel;但是2x和3x的视网膜屏出来之后,1 point等于2 pixel或3 pixel。设计提供的设计稿标注使用的像素是逻辑像素point而GPU在渲染图形之前,系统会将逻辑像素point换算成物理像素pixel。

如果界面上某个控件的逻辑像素point乘以2(2x的视网膜屏) 或3(3x的视网膜屏)得到整数值或者得到的是浮点数但是小数点后都是0的,这种情况就是像素对齐,否则就是像素不对齐,对于像素不对齐还有一种情况就是图片的size和显示图片的imageView的size以及逻辑像素point不相等。

出现像素不对齐的情况,会导致在GPU渲染时,对没对齐的边缘,需要进行插值计算,这个插值计算的过程会有性能损耗,像素不对称齐的元素一般为UILabel或UIImageView(UILabel宽度不为整数时并没有有像素不对齐,但x、y、height不为整数就会导致像素不对齐)。

解决像素不对齐的问题可以使用如下措施:

  • frame设置时候,使用整数;计算frame时候,计算的结果使用ceil处理一下,避免小数点后有非0数存在。
  • 设置imageView的size要和切图的size(逻辑像素point)相等。
  • 如果图片是从服务端获取到的,这时候要注意图片大小,保证获取的图片的size要缩放成和imageView的size(逻辑像素poin)相等。缩放后的图片的scale和[UIScreen mainScreen].scale要相等,缩放操作放在子线程中做,并且做好缓存,避免每次显示都要缩放。
  • 在使用Group Style的UITableview时,如果tableView:heightForHeaderInSection:回调返回0,系统会认为没有设置header的高度而重新提供一个默认的header高度,导致在UITableview中看到一个空白的header。这个可以通过在回调里返回一个很小的高度,比如0.01,这样能达到隐藏header的效果,但也造成了此处的像素不对齐问题。
    可以通过如下方法解决:
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
return CGFLOAT_MIN;
}

图片缩放方法:

- (UIImage *)scaleImageWithSize:(CGSize)boxSize{
if (CGSizeEqualToSize(boxSize, self.size)) {
return self;
}

CGFloat screenScale = [[UIScreen mainScreen] scale];
CGFloat rate = MAX(boxSize.width / self.size.width, boxSize.height / self.size.height);
CGSize resize = CGSizeMake(self.size.width * rate , self.size.height * rate );
CGRect drawRect = CGRectMake(-(resize.width - boxSize.width) / 2.0 ,
-(resize.height - boxSize.height) / 2.0 ,
resize.width,
resize .height);
boxSize = CGSizeMake(boxSize.width, boxSize.height);
UIGraphicsBeginImageContextWithOptions(boxSize, YES, screenScale);
[self drawInRect:drawRect];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}

放在异步缩放后设置到UIImageView上

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
UIImage *image = [[UIImage imageNamed:self.cellModel.iconImageName] scaleImageWithSize:_iconImageView.frame.size];
dispatch_sync(dispatch_get_main_queue(), ^{
_iconImageView.image = image;
_iconImageView.hidden = (image != nil) ? NO : YES;
});
});

文本计算高度时候使用ceil进行像素对齐

- (CGSize)textSizeWithFont:(UIFont*)font{

CGSize textSize = [self sizeWithAttributes:@{NSFontAttributeName:font}];
textSize = CGSizeMake((int)ceil(textSize.width), (int)ceil(textSize.height));
return textSize;
}

- (CGSize)textSizeWithFont:(UIFont*)font
numberOfLines:(NSInteger)numberOfLines
lineSpacing:(CGFloat)lineSpacing
constrainedWidth:(CGFloat)constrainedWidth
isLimitedToLines:(BOOL *)isLimitedToLines{
if (self.length == 0) {
return CGSizeZero;
}
CGFloat oneLineHeight = font.lineHeight;
CGSize textSize = [self boundingRectWithSize:CGSizeMake(constrainedWidth, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:font} context:nil].size;
CGFloat rows = textSize.height / oneLineHeight;
CGFloat realHeight = oneLineHeight;
if (numberOfLines == 0) {
if (rows >= 1) {
realHeight = (rows * oneLineHeight) + (rows - 1) * lineSpacing;
}
}else{
if (rows > numberOfLines) {
rows = numberOfLines;
if (isLimitedToLines) {
*isLimitedToLines = YES; //被限制
}
}
realHeight = (rows * oneLineHeight) + (rows - 1) * lineSpacing;
}
return CGSizeMake(ceil(constrainedWidth),ceil(realHeight));
}
@end

局部图形的生成

在我们通过CALayer设置视图的border、圆角、阴影、遮罩的时候不会直接在当前屏幕进行渲染,而是会在当前缓冲区以外的控件预先进行渲染,然后再绘制到当前屏幕上,这就是所谓的离屏渲染。离屏渲染之所以会很耗性能是因为它需要创建一个新的缓存区,并且在需要在当前屏幕缓冲区和离屏缓冲区之间进行切换。比较极端的情况:当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。这种情况下可以通过把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。

界面渲染

在经过上面处理后GPU会对上面的界面进行渲染,然后将渲染后的结果提交到帧缓冲区去。在VSync信号到来后视频控制器会从当前帧缓冲区中取出数据进行显示。如果在一个VSync时间内CPU 或者 GPU 没有完成内容提交,那么这一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就导致了界面的卡顿。

所以从上面整个流程来看在16ms时间内不论是CPU还是GPU压力过大导致没有在这段时间内提交界面渲染的数据都会导致界面的掉帧。

这里还需要注意的是iOS使用的是双缓冲机制:GPU会先将渲染好一帧放入当前屏幕帧缓存器供视频控制器读取,当下一帧渲染好后,GPU会直接把视频控制器的指针指向第二个缓冲器。当视频控制器读完一帧,准备读取下一帧的时候,GPU会在显示器的VSync信号发出后,快速切换两个帧缓冲区,这其实算是系统内机制的一种优化,现在大部分系统一般都会采用多缓冲机制。

下面对上面的优化点做个总结罗列:

1.  能用CALayer的就不用UIView。
2. 在创建对象的时候尽量推迟某个对象的创建时间,可以通过懒加载的方式在使用的时候创建对象。
3. 对UIView属性的更改是比较耗费资源的,所以尽量减少不必要的属性修改,避免视图层次的调整以及视图的添加移除,尽量使用hide等属性来代替视图移除添加。
4. 尽量采用frame布局框架
5. 在计算布局的时候可以放在后台线程中进行计算,在最后设置frame的时候切换到主线程
6. 对布局计算结果进行缓存,可以在获取数据之后,异步计算Cell高度以及各控件高度和位置,并储存在Cell的LayouModel中,当每次Cell需要高度以及内部布局的时候就可以直接调用,不需要进行重复计算。对于自动缓存高度可以考虑使用FDTemplateLayoutCell来解决这个问题。
7. 对于大文本可以使用TextKit或者CoreText自定义的文本控件代替UILabel,UITextView
8. 对于图片可以在子线程预解码,主线程直接渲染.
9. 尽量不要用JPEG的图片,应当使用PNG或者WebP图片。
10. 尽量使用不包含透明通道的图片资源
11. 尽可能将多张图片合成为一张进行显示,图片的 size 最好刚好跟 UIImageView 的 size 保持一致
12. 将opaque设置为YES,减少性能消耗,因为GPU将不会做任何合成,而是进行简单的层拷贝
13. 尽量避免圆角(layer.cornerRadius && layer.masksToBounds = YES || view.clipsToBounds = YES )、阴影(layer.shadows)、遮罩(layer.mask),layer.allowsGroupOpacity为YES,layer.opacity的值小于1.layer.shouldRasterize = YES,layer.edgeAntialiasingMask,layer.allowsEdgeAntialiasing 这些情况出现,对于圆角可以靠绘制来完成或者直接让美工把图片切成圆角进行显示,这是效率最高的一种方案。
14. 合理使用光栅化 shouldRasterize,因为一旦开启光栅化CALayer会被光栅化为bitmap,这时候shadows、cornerRadius等效果会被缓存,所以对于那些不经常改变的界面比较合适,但是对于哪些内容动态改变的界面就不太合适了,因为更新已经光栅话的layer会导致离屏渲染。
15. 最小化界面刷新,刷新一个cell就能解决的,坚决不刷新整个 section 或者整个tableView。
16. 在滑动停止的时候再加载内容,对于一闪而过的内容没有必要加载,可以使用默认的占位符填充内容。
17. 在cellForRowAtIndexPath:回调的时候只创建实例,快速返回cell,不绑定数据。在willDisplayCell:forRowAtIndexPath:的时候绑定数据
18. 对于列表来说,在tableView滑动时,会不断调用heightForRowAtIndexPath:当高度需要自适应的时候每次回调都要计算高度,因此在这种情况下要对高度进行缓存,避免无意义的高度计算。
19. GPU 能处理的最大纹理尺寸是 4096x4096,一旦超过这个尺寸,就会占用 CPU 资源进行处理,所以纹理尽量不要超过这个尺寸.
20. 对于界面上需要频繁从数据库中读取数据进行展示的可以考虑为这些数据增加缓存。
21. 如果引入异步绘制框架成本可以接受可以考虑引入Texture或者类似的异步绘制框架。
22. 采用预加载机制,在一个列表中,滑动到一个可以设定的位置的时候,如果数据获取比较耗时可以考虑提前获取下载下一页的数据。
23. 尽量避免像素不对齐的现象发生。
24. 避免使用CGContext在drawRect:方法中绘制,大部分情况下会导致离屏渲染,哪怕仅仅是一个空的实现也会导致离屏渲染。

如何使用XCode进行界面调优:

XCode相关的界面调优项目前都移动到了Debug -> View Debuging -> Rendering下,下面将对这里的每一项功能进行介绍:

  • Color Blended Layers 图层混合检查

这个选项是用于检测哪里发生了图层混合,哪块区域显示红色就说明发生了图层混合,所以我们的目的就是将红色区域消减的越少越好。那么如何减少红色区域的出现呢?上面提到了只要设置控件不透明即可。

1)设置opaque 属性为YES。
2)给View设置一个不透明的颜色。

这里需要要再强调下UIImageView控件比较特殊,不仅需要自身这个容器是不透明的,并且imageView包含的内容图片也必须是不透明的。

  • Color Hits Green and Misses Red 命中缓存的layer位图检查

这个选项主要是检测我们是是否正确使用layer的shouldRasterize属性,shouldRasterize = YES开启光栅化,光栅化会将一个layer预先渲染成bitmap,再加入缓存中,成功被缓存的layer会标注为绿色,没有成功缓存的会标注为红色,如果我们在一个界面中使用了光栅化,刚进去这个页面所有使用了光栅化的控件layer都会是红色,因为还没有缓存成功,如果上下滑动你会发现,layer变成了绿色。所以这一项的目标还是减小红色区域。

正确使用光栅化可以得到一定程度的性能提升,但是这只对于内容不变的情况下,也就是上面提到的静态页面,如果内容变更频繁那么就不要打开光栅化,否则会造成性能的浪费,例如我们在使用tableViewCell中,一般不要用光栅化,因为tableViewCell的绘制非常频繁,内容在不断的变化,如果使用了光栅化,会造成大量的离屏渲染降低性能。

对于光栅化需要注意两点
(1) 系统给光栅化缓存分配了的空间有限,不要过度使用,如果超出了缓存造成离屏渲染。
(2) 如果在100ms内没有使用缓存的对象,则会从缓存中清除。

  • Color Copied Images 检查是否对图片进行格式转换操作

如果GPU不支持当前图片的颜色格式,那么就会将图片交给CPU预先进行格式转化,并且将这张图片标记为蓝色,目前苹果的GPU只解析32bit的颜色格式,所以如果使用Color Copied Images去调试发现是蓝色,就可以找设计看下是否图片的颜色格式不对了。

  • Color Misaligned Images

这个选项可以帮助我们查看图片大小是否正确显示。如果image size和imageView size不匹配,image会出现黄色。要尽可能的减少黄色的出现。

  • Color Offscreen-Rendered Yellow 离屏渲染

这个选项可以帮助我们查看离屏渲染的,开启后会把那些需要离屏渲染的图层高亮成黄色,这就意味着黄色图层可能存在性能问题。关于离屏渲染见上面介绍。

  • Flash Updated Regions 闪烁重绘区域

这个选项会对重绘的内容高亮成黄色,绘制会损耗一定的性能,因此重绘区域应该越小越好。

这部分有一个比较好的文章可以供大家学习:iOS显示性能优化过程讲解

界面卡顿这部分可用的工具有如下几种:

  1. Debug –> View Debuging –> 可以用于检测:图层混合,光栅化,图片格式,像素对齐,离屏渲染等
  2. Product –> Profile -> Core Animation :可以用于检测CPU/GPU耗时情况,以及帧率等数据,和CPU 的Time Profiler

3.3 电量优化

设备在休眠的情况下几乎不会消耗任何电量,一旦某些硬件设备被唤醒就会开始消耗电量,因此电量优化的目标就是在不影响应用性能的情况下找到消耗电量的大户尽量减少它的功耗消耗。
下面是几个耗电量大户:

  • Processing 包括CPU,GPU在内的处理器件
  • Location 定位服务
  • Display 屏幕亮度
  • Network 网络
  • Accessories 蓝牙
  • Multimedia 音视频器件
  • Camera 摄像头

在给出电量优化建议之前我们还需要了解两个概念“固定能耗” “动态能耗”

  • 固定能耗是指在任务执行前后把系统和各种资源调用起来和关闭所消耗的能量。
  • 动态能耗动态能耗指的是app实际工作消耗的能量。

下面是这两个概念的示意图:

Idle状态 :这时候应用处于休眠状态几乎不使用电量。
Active状态 :这时候应用处于前台用电量比较高。
Overhead状态:唤醒硬件来支持应用某项功能所消耗的电量。即使我们的应用只做了一点点事,Overhead 所带来的电量消耗一点也不会减少。

横线以下所包区域是固定能耗,横线以上区域是动态能耗。

从这个角度触发我们可以通过分批执行,或者降低执行频率来避免产生零散的任务,比如将任务同时放到多个线程中并行执行。这样虽然在给定时间内做了更多的工作,看似消耗了更多的能量,导致了更大的前期动态功耗,但是由于缩短了工作的时间,更早使得CPU回到闲置状态,其他元件也更快地断电,所以固定功耗减少了。从整体上看,这会降低极大地节省功耗消耗。

下面针对上面介绍的几个耗电大户给出电量优化的建议,供大家在平时开发中参考:

1. 在不需要使用定时器的时候要记得及时关闭重复性的定时器,定时器的时间间隔不宜太短,如果定时器触发太频繁,能耗影响是比较大的。
2. 尽量减少数据传输的数据量,可以考虑使用protobuf代替JSON格式,如果允许尽量降低上传或下载的多媒体内容质量和尺寸等。
3. 如果不是非常紧急的数据可以考虑延迟将多个数据合并后,统一打包上传,在下载某些数据的时候可以一次性多下载一部分。避免频繁网络请求。比如,下载视频流时,不要传输很小的数据包,直接下载整个文件或者一大块一大块地下载。如果提供广告,一次性多下载一些,然后再慢慢展示。如果要从服务器下载电子邮件,一次下载多条,不要一条一条地下载。网络操作能推迟就推迟。如果通过HTTP上传、下载数据,建议使用NSURLSession中的后台会话,这样系统可以针对整个设备所有的网络操作优化功耗。
4. 对于不是非常实时的数据可以考虑使用缓存减少下载相同的数据
5. 使用断点续传,避免网络不稳定导致多次传输相同的内容。
6. 网络不可用的时候不要发起网络请求,在网络失败的情况下不断增加重试的间隔时间。
7. 尽量只在WiFi的情况下联网传输数据
8. 设置合适的网络超时时间,及时取消长时间运行或者速度很慢的网络操作
9. 将可以推迟的操作尽量推迟到设备充电状态并且连接Wi-Fi时进行,比如同步和备份工作。
10. 尽量减少动画,动画尽可能用较低的帧率,在不展示的时候不要更新动画。
11. 在定位方面,如果只需要快速确定一下用户位置,而不需要实时更新位置,记得在定位完成后及时关闭定位服务。尽量降低定位精度。如果需要后台更新位置的时候尽量把pausesLocationUpdatesAutomatically设为YES,如果用户不太可能移动的时候系统会自动暂停位置更新。总而言之定位和蓝牙按需取用,定位之后要关闭或降低定位频率。
12. 遵守前面提到的界面优化的内容,这会降低CPU和GPU的负担
13. 线程适量,不宜过多
14. 应用每次执行I/O任务,比如写文件,会导致系统退出闲置模式。而且写入缓存格外耗电,因此尽量不要频繁写入小数据,最好把多个更改攒到一起批量一次性写入,如果只有几个字节的数据改变,不要把整个文件重新写入一次。
15. 尽量顺序读写数据,在文件中跳转位置会消耗一些时间,如果数据由随机访问的结构化内容组成,建议将其存储在数据库中.
16. 读写大量重要数据时,考虑用 dispatch_io,其提供了基于 GCD 的异步操作文件 I/O 的 API。用 dispatch_io 系统会优化磁盘访问,数据量比较大的,建议使用数据库。
17. 用户移动、摇晃、倾斜设备时,会产生动作事件,这些事件由加速计、陀螺仪、磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件。
18. 如果某个通知不依赖外部数据,而是需要基于时间的通知,应该用本地通知,这样可以减轻网络模块的耗电。
19. 如果不是真的需要即时推送,尽量使用延时推送。

如何检测电量的性能:

  1. 最直观地观察打开某个应用的时候是否存在耗电特别快,手机特别烫手的现象。
  2. 用Xcode Energy impact测量功耗:
  3. 通过设备记录电池Log后导入到Instruments进行分析:
    ->在设备上进入设置 
    -> 开发者
    -> Logging
    -> Enery打开
    -> Networking打开
    -> 点击Start Recording
    -> 然后点开我们想要测试的App,进行测试,定好时间,
    -> 时间到了后点击Stop Recording
    -> 在Instruments中选择好设备进入Energy Log
    -> 选择File
    -> Import Logged Data from Device
3.4 启动优化

应用启动有两种类型冷启动和热启动,冷启动指的是应用从无到有,它的进程不在系统里,需要系统新创建一个进程加载镜像,运行程序,再到展现在界面上,而热启动指的是应用还在后台的情况下启动应用。

一般我们的应用需要在400ms内启动完成,如果启动时间大于20s将会被系统杀掉。我们可以使用Edit scheme -> Run -> Arguments -> DYLD_PRINT_STATISTICS设置为1 或者更详细地通过DYLD_PRINT_STATISTICS_DETAILS设置为1。


下面的博客将会介绍

  • Mach-O镜像结构,加载过程
  • 启动的各个阶段
  • 如何优化启动时间

首先我们看下Mach-O文件,可能大家比较对ELF会比较熟悉,它是UNIX环境下的可移植二进制文件,而Mach-O是苹果所独有的可执行二进制文件格式。主要包括下面几种类型:

  • Executable:应用的主要可执行文件
  • Dylib:动态链接库
  • Bundle:资源包,不能被链接,只能在运行时使用dlopen加载
  • Image:包含Executable,Dylib和Bundle是上面三者的集合
  • Framework:包含Dylib、资源文件和头文件的文件夹

下面是之前一篇博客的一张图,介绍了整个Mach-O文件的结构以及编译过程与Mach-O文件的关系。

我们这里主要接这张图关注下Mach-O文件的结构:每个Mach-O文件一般都由三大部分:Header,loadCommand,Segment构成,每个Segment都包含了一个或者多个Section信息。

  • Header中包含了大小端信息,CPU架构信息,当前二进制文件类型,dyld过程中的一些参数。
  • LoadCommands用于指示每个Segment的加载方式。
  • 每个Segment定义了虚拟内存中的一块区域,这些区域会被动态链接器链接到程序的具体内存地址。

Segment由如下几个部分构成:

__TEXT 用于存放被执行的代码以及只读常量,属于只读可执行区域(r-x)。
__DATA 包含全局变量,静态变量等。属于可读写区域(rw-)。
__LINKEDIT 包含了加载程序的元数据,比如函数的名称和地址。属于只读区域(r–)
__PAGEZERO 用于捕获空指针陷阱段,用于捕捉对NULL指针的引用
__OBJC 包含一些会被Objective Runtime 使用到的一些数据。

有了上面的介绍我们就可以来介绍应用的加载过程了。整个应用的启动分成两大阶段:
Pre-main 阶段和 main 阶段:

先用一张图来概括下这两个阶段的整体流程:

  • Pre-main 阶段:

fork/exec

我们知道当启动一个应用的时候系统会调用fork和execve两个方法,前者会创建一个进程,然后让这个新创建的进程去执行execve方法将程序加载到内存并映射到新的地址空间运行,fork新建的进程,父进程与子进程共享同一份代码段,但是数据段是分开的,但是父进程会把自己数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。在映射过程中为了防止被黑客注入代码,重写内存,在地址映射阶段会采用ASLR(地址空间布局随机化)技术,在进程每次启动的时候,随机偏移地址空间,并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行,NULL 指针引用和指针截断误差都会被它捕获。

简而言之这个阶段的任务就是新建进程并根据指定的文件名找到可执行文件,用它来取代进程的内容。

动态库加载

在开始介绍这个过程之前需要了解一点:动态链接库和静态链接库的区别:
首先,对于动态链接库而言,它们不会在编译的时候打包到应用中,而静态链接库是会和应用一起打包,所以相对而言静态链接库会占用更大的空间,并且在内存和磁盘中动态链接库只保留一份,这样便于集中管理,更新。但是它最大的缺点是加载会耗时,我们就来看下动态链接库是怎么被加载的?

在这个阶段系统会从可执行文件(Mach-O文件)中获取dyld路径,然后加载dyld,dyld会先去初始化运行环境,并开启缓存策略,接着dyld会从主执行文件的Header中获取到需要加载的依赖动态库列表,然后根据这个列表,找到每个dylib文件,对动态库文件进行头部信息校验,保证是Mach-O文件,接着找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap(),应用所依赖的dylib文件可能会再依赖其他dylib,所以这个过程是递归依赖的集合加载过程。这是一个耗时点之一,但是这部分的动态库大部分都是系统dylib,它们会被预先计算和缓存起来(因为操作系统自己要用部分framework所以在操作系统开机后就已经加载到内存了),dyld对这部分动态库加载速度很快,并且dyld在加载一个Mach-O文件的时候动态链接器首先会检查共享缓存看看是否存在,如果存在那么就直接从共享缓存中拿出来使用,这个共享缓存是公用的,每个进程都会将这个共享缓存映射到自己的地址空间。因此我们要优化的是除了系统动态库,以及共享动态链接库外的非系统动态链接库部分,我们在开发过程中尽量减少这部分动态库的数量来减少这部分的运行时间。

下面对每个动态库加载过程做个简单总结:

在每个动态库的加载过程中,dyld都需要:

* 分析所依赖的动态库
* 找到动态库的Mach-O文件
* 打开文件
* 验证文件
* 在系统核心注册文件签名
* 对动态库的每一个segment调用mmap()

rebase/bind

由于ASLR机制的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要进行重定向;
在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,所以需要进行动态库的绑定;

rebase修复的是指向当前镜像内部的资源指针;而bind指向的是镜像外部的资源指针。整个过程会先执行rebase,这个阶段会把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,而bind阶段需要查询符号表来指向跨镜像的资源,也就是将这个二进制调用的外部符号进行绑定的过程。比如我们objc代码中需要使用到NSObject, 即符号_OBJC_CLASS_$_NSObject,但是这个符号又不在我们的二进制中,在系统库 Foundation.framework中,因此就需要bind这个操作将对应关系绑定到一起,这阶段优化的关键点在于减少__DATA segment中的指针数量。

在这个阶段验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT 中。从而使得文件每页的内容都能及时被校验确并保不被篡改。

虚拟内存的作用:我们开发过程中所接触到的内存均为虚拟内存,虚拟内存使我们认为App拥有一个连续完整的地址空间,而实际上它是分布在多个物理内存碎片组成,系统的虚拟内存空间映射vm_map负责虚拟内存和物理内存的映射关系。

Objc setup && Initializers

Objc setup主要是在objc_init完成的:

void _objc_init(void) {
static bool initialized = false;
if (initialized) return;
initialized = true;
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
_dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}

在objc_init方法中主要绑定了三个回调函数map_2_images,load_images和unmap_image。在bind阶段完成后会发出dyld_image_state_bound,这时候map_2_images方法就会被调用,进行镜像加载,在这个方法中主要完成如下几件事:

* 读取二进制文件的 DATA 段内容,找到与objc相关的信息
* 注册Objc类ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个dylib时,其定义的所有的类都需要被注册到这个全局表中
* 读取protocol以及category的信息,将分类插入到类的方法列表里
* 确保selector的唯一性

而在镜像加载结束后系统会发出dyld_image_state_dependents_initialize通知,这时候load_images会被调用,在这里类的load方法会被调用。然后调用mapimages做解析和处理,接下来在loadimages中调用callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其Category的+load方法,并完成C/C++静态初始化对象和标记__attribute__(constructor)的方法调用。

这里做个简单总结,整个过程如下:

dyld首先会将程序二进制文件初始化后交给镜像加载器读取程序镜像中的类、方法等各种符号,由于runtime向dyld绑定了回调,所以当image被加载到内存后,dyld会通知runtime对镜像进行处理
runtime接手后调用mapimages做解析处理,接下来loadimages中调用callloadmethods方法遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其Category的+load方法
至此可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被runtime所管理,再这之后,runtime 的那些方法(如动态添加Class、swizzle等方法)才能生效。所有初始化工作结束后dyld调用真正的main函数。如果程序刚刚被运行过,那么程序的代码会被dyld缓存(操作系统对于动态库有一个共享的空间,在这个空间被填满,或者没有其他机制来清理这一块的内存之前,动态库被加载到内存后就一直存在),因此即使杀掉进程再次重启加载时间也会相对快一点,如果长时间没有启动或者当前dyld的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点,这就是前面提到的冷启动和热启动两种情况。

在介绍完Pre-main 阶段我们看下有哪些节点可以优化:

  1. 动态库加载阶段可以通过减少非系统,非共享动态库的依赖或者合并动态库来优化速度(可以借助linkmap来分析)
  2. 使用静态库而不是动态库,但是这会带来包体积增加的风险,需要权衡。
  3. Rebase/Bind阶段可以通过减少Objc类数量以及selector数量来减少这部分运行时间
  4. 将不必须在+load方法中做的事情延迟到+initialize中.能用dispatch_once()来完成的,就尽量不要用__attribute__((constructor))以及load方法(attribute((constructor))的函数调用会在+load函数调用之后)
  5. 合并Category和功能类似的类,删除无用的方法和类(可以借助FUI来扫描,但是需要注意的是它处理不了动态库和静态库里提供的类,也处理不了C++的类模板)。
  6. 替代部分庞大的库,采用更轻量级的解决方案。
  • main 阶段:

这个阶段包括:main方法执行之后到AppDelegate类中的didFinishLaunchingWithOptions方法执行结束前这段时间.下面是这个阶段的示意图:

  • 这部分的优化主要是属于业务层面的优化,需要梳理下各个业务初始化的优先级以及依赖关系,能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台线程中初始化,可以通过启动项自注册来完成启动项解耦。

有些启动项是需要刚启动就执行的操作,如Crash监控、统计上报等,否则会导致信息收集的缺失,对于基于位置的服务,需要先拿到定位位置后才能针对区域拿到对应数据,因此定位的优先级也会比较高,针对项目的配置可以拆分成两大类,一类是优先级比较高的,比如首页需要的某些配置,另一类是优先级比较低的比如对某些公共模块配置的项,这种可以延迟到首页加载完成之后加载。还有一些路由配置,网络配置都是一些相对优先级比较高的启动项目。而对于其他模块的初始化以及某些非首页需要的SDK初始化都应该延后到首页加载之后。

  • 通过instruments的Time Profiler初始化过程中比较耗时的操作并有针对性地优化。比如每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log。

  • 在rootViewController加载过程中也存在优化的点:在启动过程中只加载tabbarVC的主VC即可,而且主VC中的ViewDidLoad方法中也只加载需要立即显示出来的view,其他视图均使用懒加载,数据进行异步加载。

如果想深入继续了解这部分可以参阅如下博客:

3.5 体积优化

随着应用功能不断增加,App 的大小也会随着增加,App 的包体积优化的目的就是为了节省用户流量,提高用户的下载速度,以及节省更多空间。另外 App Store 官方规定 App 安装包如果超过 150MB,那么不可以在OTA环境下下载。如果我们的应用 需要适配 iOS7、iOS8 那么官方规定主二进制 text 段的大小不能超过 60MB。如果不能满足这个标准,则无法上架 App Store。

iOS 安装包的组成

将一个应用程序,后缀由.ipa改为.zip,然后解压,可以看到Payload文件夹,里面有个.app 的文件,右键显示包内容可以看到很多的文件,但是大部分我们就针对这里面重点的几个文件:

1. 可执行文件:一般应用而言最大的文件应该是可执行文件。
2. Assets.car:我们在开发过程中会将图片放在Assets.xcassets中,在打包后会将这些图片统一压缩成一个Assets.car的文件,大大减小包的大小。car文件可以使用[cartool](https://github.com/steventroughtonsmith/cartool)来获取具体资源。还可以通过[Assets.carTool](https://github.com/yuedong56/Assets.carTool)来以图形化方式解压car文件。
3. Bundle文件
4. _CodeSignature 文件夹,下面包含一个CodeResources文件,里面是一个属性列表,包含bundle中所有其他文件的列表,这个属性列表只有一项files这是一个字典键是文件名值通常是Base64格式的散列值。它的作用是用来判断一个应用程序是否完好无损,能够防止不小心修改或损坏资源文件。
5. XXX.lproj 多语言字符串
6. Frameworks 文件夹
7. info.plist
8. 图片资源,音频资源,数据库资源,本地json配置文件,LaunchImage/AppIcon

通过上面内容可以看出可优化的最大块内容在于可执行文件,Assets.car,图片,音视频文件这几大方面。

资源文件瘦身

对于一个App来说主要的资源文件就是图片,所以资源文件的瘦身主要针对的是图片的瘦身,可以通过删除无用图片、去除重复图片、压缩图片。

  • 使用LSUnusedResources删除无用图片资源:

通过LSUnusedResources来扫描项目中无用的图片文件,但是这里会误判所以最终还是要一个个确认是否真的再项目中无用。

  • 使用Fdupes去除各模块中的重复图片:

Fdupes能够找到给定的目录和子目录集中的重复文件。 它通过比较文件的MD5签名,然后进行字节到字节的比较来识别重复,它依次通过:

大小比较 > 部分MD5签名比较 > 全MD5签名比较 > 字节到字节的比较

安装:

brew install fdupes 

最常用命令:

$ fdupes -r path                # 搜索重复子文件在终端展示
$ fdupes -Sr path # 搜索重复子文件&显示每个重复文件的大小在终端展示
$ fdupes -Sr path > log.txt # log输出到指定路径文件夹

最近还发现一款软件也是可以删除重复点资源文件:Gemini

  • 使用图片压缩工具压缩图片资源:

无损压缩工具: ImageOptim会对每张图片分别应用以上几种压缩算法,然后对比每种压缩算法产出的图片,选取最小的那张作为输出结果。
ImageOptim

有损压缩工具:

ImageAlpha
tinypng

这里有一篇PNG 图片压缩对比分析可供大家参考。

使用WebP格式的图片

WebP格式的优点:

压缩率高。支持有损和无损2种方式,比如将 Gif 图可以转换为 Animated WebP,有损模式下可以减小 64%,无损模式下可以减小 19%
WebP 支持 Alpha 透明和 24-bit 颜色数,不会像 PNG8 那样因为色彩不够出现毛边。

WebP格式的缺点:

WebP 在 CPU 消耗和解码时间上会比 PNG 高2倍,所以我们做选择的时候需要取舍。

对单色纯色图标使用矢量图

对于App里面的单色纯色图标都是可以使用矢量图代替,它的好处是不需要添加 @2x、@3x 图标,节省了空间。具体使用可以看IconFont_Demo

  • 使用On Demand Resources技术

具体见后文介绍,在这方面还可以将一些非必须的资源放在服务端,待需要的时候再进行下载。

  • 图片压缩的相关编译选项

Compress PNG Files 打包的时候自动对图片进行无损压缩,使用的工具为 pngcrush
Remove Text Medadata From PNG Files 移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息。

这里总结下Bundle 和 Asset Catalog 管理的图片的区别:

* 工程中所有使用Asset Catalog 管理的图片,最终输出的时候,都会被放置到 Assets.car内。在编译的时候XCode会对这部分资源进行压缩处理。而Bundle的资源需要我们使用外部压缩工具进行手动压缩处理。
* xcassets 里的 2x 和 3x,会根据具体设备分发,不会同时包含。而Bundle会都包含。
* xcassets内,可以对图片进行 Slicing。Bundle 不支持
* xcassets 里面的图片,只能通过imageNamed加载。Bundle内的资源还可以通过 imageWithContentsOfFile 等方式加载。但是需要注意的是使用 imageNamed 创建的 UIImage,会立即被加入到 NSCache中,在收到内存警告的时候,会自动释放不在使用的 UIImage。而使用imageWithContentsOfFile加载的图片每次加载都会重新申请内存,相同图片不会缓存。这也是我们常说的xcassets内的图片,加载后会产生缓存。

对于图片的压缩个人的习惯是开启Compress PNG Files,再用ImageOptim对Bundle内的图片以及项目中所有JPEG 格式的图像进行压缩(xcassets 里面的 png文件不用压缩,压缩反而会增大包体积),因为 Bundle内的图片以及JPEG 格式的图片是直接拷贝进项目的,并不会被Xcode进行压缩,所以两部分是需要我们手动进行资源压缩处理。一般还有个通识:对于常用的,较小的图片,应该使用Asset Catalog 管理,而对于大的图片,可以选择直接废弃2x尺寸的图片,全部使用3x大小的jpg压缩后放在Bundle内管理。同时还需要注意xcassets 中要避免使用 JPEG 图像,这会导致打包会变大。对于一些比较大体积的背景图片可以压缩成.jpg的格式放在Bundle中。

对于音视频文件的优化可以通过:删除无用的音视频文件,降低音频文件的采样率。

可执行文件瘦身

  1. 使用Fui(Find Unused Imports)清理不用的类

Fui(Find Unused Imports)是分析不再使用的类的一个开源工具,准确率相对较高,但是这种删代码的事情最好要确认后手动一一删除,并且配合Git,SVN等工具,最终还得经过review,整体测试后才能放出版本,它也有比较大的问题:它处理不了动态库和静态库里提供的类,也处理不了C++的类模板。

  • 安装Fui
gem install fui
  • 查看帮助
fui help
  • 最常用的命令形式
fui -v --path=./ --ignore-path=Pods find
  1. 使用LinkMap分析安装包

我们编写的源码需要经过编译链接,最终生成一个可执行文件,在编译阶段每个类会生成对应的.o文件(目标文件)。在链接阶段,会把.o文件和动态库链接在一起。但是生成的可执行文件为二进制文件,我们很难看明白它的具体内容,而linkMap 很好得帮我们解决了这个问题。linkMap是一个记录链接相关信息的纯文本文件,里面记录了可执行文件的路径、CPU架构、可执行文件内存分布、类符号,方法符号等信息。

(1) 通过LinkMap可以通过Symbols还原出奔溃时候的源码位置
(2) 还可以比较直观得查看整个内存的分段情况
(3) 可以分析可执行文件中哪个类或者库占用的空间比较大,从而为我们这里需要介绍的安装包瘦身做指导
  • 生成linkMap文件:
Xcode开启编译选项 Write Link Map File:

XCode -> Project -> Build Settings ->map -> 把Write Link Map File选项设为YES,并指定好linkMap的存储位置.

生成的linkMap文件位于:

~/Library/Developer/Xcode/DerivedData/IDLFundation-dzjqiskjttzspjcrncvbbssnqtok/Build/Intermediates.noindex/IDLFundation.build/Debug-iphonesimulator/IDLFundationTest.build/IDLFundationTest-LinkMap-normal-x86_64.txt
  • linkMap文件结构解析:

LinkMap 文件分为3部分:Object File、Section、Symbols,如下图所示:

Object File:包含了代码工程的所有文件
Section:描述了代码段在生成的 Mach-O 里的偏移位置和大小
Symbols:会列出每个方法、类、Block,以及它们的大小

基础信息

包括可执行文件路径,可执行文件架构。

# Path: /Users/huya/Library/Developer/Xcode/DerivedData/IDLFundation-dzjqiskjttzspjcrncvbbssnqtok/Build/Products/Debug-iphonesimulator/IDLFundationTest.app/IDLFundationTest
# Arch: x86_64

类表: 保存了所有用到的类生成的.o文件.
这个类表用于在后续类方法,类名查看等用到,后续方括号里面的数字就是对应的类序号。

# Object files:
[ 0] linker synthesized
[ 1] dtrace
[ 2] /Users/huya/Library/Developer/Xcode/DerivedData/IDLFundation-dzjqiskjttzspjcrncvbbssnqtok/Build/Intermediates.noindex/IDLFundation.build/Debug-iphonesimulator/IDLFundationTest.build/Objects-normal/x86_64/AFURLSessionManager.o

段表: 描述了不同功能的数据保存的地址,通过这个地址就可以查到对应内存里存储的是什么数据。其中第一列是起始地址,第二列是段占用的大小,第三个是段类型,第四列是段名称。

# Sections:
# Address Size Segment Section
0x100001DC0 0x00A38B42 __TEXT __text
0x100A3A902 0x00001C9E __TEXT __stubs
0x100A3C5A0 0x0000204A __TEXT __stub_helper
0x100A3E5EC 0x000231B0 __TEXT __gcc_except_tab
0x100A617A0 0x000178D0 __TEXT __const
0x100A79070 0x0008A5DA __TEXT __cstring
0x100B0364A 0x0005F462 __TEXT __objc_methname
0x100B62AAC 0x00008794 __TEXT __objc_classname
0x100B6B240 0x0003B4EB __TEXT __objc_methtype
0x100BA672C 0x00001742 __TEXT __ustring
0x100BA7E6E 0x00000172 __TEXT __entitlements
0x100BA7FE0 0x0000037B __TEXT __dof_RACSignal
0x100BA835B 0x000002E8 __TEXT __dof_RACCompou
0x100BA8644 0x00012964 __TEXT __unwind_info
0x100BBAFA8 0x00000058 __TEXT __eh_frame
0x100BBB000 0x00000008 __DATA __nl_symbol_ptr
0x100BBB008 0x00000BD8 __DATA __got
0x100BBBBE0 0x00002628 __DATA __la_symbol_ptr
0x100BBE208 0x00000070 __DATA __mod_init_func
0x100BBE280 0x0001CEE0 __DATA __const
0x100BDB160 0x00039CA0 __DATA __cfstring
0x100C14E00 0x00002B00 __DATA __objc_classlist
0x100C17900 0x000000A0 __DATA __objc_nlclslist
0x100C179A0 0x00000680 __DATA __objc_catlist
0x100C18020 0x000000D0 __DATA __objc_nlcatlist
0x100C180F0 0x00000638 __DATA __objc_protolist
0x100C18728 0x00000008 __DATA __objc_imageinfo
0x100C18730 0x001252F8 __DATA __objc_const
0x100D3DA28 0x000150B0 __DATA __objc_selrefs
0x100D52AD8 0x00000150 __DATA __objc_protorefs
0x100D52C28 0x00002A38 __DATA __objc_classrefs
0x100D55660 0x000019F8 __DATA __objc_superrefs
0x100D57058 0x000085E8 __DATA __objc_ivar
0x100D5F640 0x0001AE00 __DATA __objc_data
0x100D7A440 0x0000CC70 __DATA __data
0x100D870B0 0x00004698 __DATA __bss
0x100D8B750 0x00001298 __DATA __common

下面是一些重要段名的解释:

__TEXT段

1. __text: 代码节,存放机器编译后的代码
2. __stubs: 用于辅助做动态链接代码(dyld).
3. __stub_helper:用于辅助做动态链接(dyld).
4. __objc_methname:objc的方法名称
5. __cstring:代码运行中包含的字符串常量,比如代码中定义`#define kGeTuiPushAESKey @"DWE2#@e2!"`,那DWE2#@e2!会存在这个区里。
6. __objc_classname:objc类名
7. __objc_methtype:objc方法类型
8. __ustring:
9. __gcc_except_tab:
10. __const:存储const修饰的常量
11. __dof_RACSignal:
12. __dof_RACCompou:
13. __unwind_info:

__DATA段

1. __got:存储引用符号的实际地址,类似于动态符号表
2. __la_symbol_ptr:lazy symbol pointers。懒加载的函数指针地址。和__stubs和stub_helper配合使用。具体原理暂留。
3. __mod_init_func:模块初始化的方法。
4. __const:存储constant常量的数据。比如使用extern导出的const修饰的常量。
5. __cfstring:使用Core Foundation字符串
6. __objc_classlist:objc类列表,保存类信息,映射了__objc_data的地址
7. __objc_nlclslist:Objective-C 的 +load 函数列表,比 __mod_init_func 更早执行。
8. __objc_catlist: categories
9. __objc_nlcatlist:Objective-C 的categories的 +load函数列表。
10. __objc_protolist:objc协议列表
11. __objc_imageinfo:objc镜像信息
12. __objc_const:objc常量。保存objc_classdata结构体数据。用于映射类相关数据的地址,比如类名,方法名等。
13. __objc_selrefs:引用到的objc方法
14. __objc_protorefs:引用到的objc协议
15. __objc_classrefs:引用到的objc类
16. __objc_superrefs:objc超类引用
17. __objc_ivar:objc ivar指针,存储属性。
18. __objc_data:objc的数据。用于保存类需要的数据。最主要的内容是映射__objc_const地址,用于找到类的相关数据。
19. __data:暂时没理解,从日志看存放了协议和一些固定了地址(已经初始化)的静态量。
20. __bss:存储未初始化的静态量。比如:`static NSThread *_networkRequestThread = nil;`其中这里面的size表示应用运行占用的内存,不是实际的占用空间。所以计算大小的时候应该去掉这部分数据。
21. __common:存储导出的全局的数据。类似于static,但是没有用static修饰。比如KSCrash里面`NSDictionary* g_registerOrders;`, g_registerOrders就存储在__common里面

Symbols 字段

Symbols简单来说就是类名,变量名,方法名,这个在Crash信息中很常见:

代码节点

# Symbols:
# Address Size File Name
0x100001DC0 0x000007D0 [ 2] -[AFURLSessionManagerTaskDelegate initWithTask:]
0x100002590 0x00000040 [ 2] ___48-[AFURLSessionManagerTaskDelegate initWithTask:]_block_invoke
0x1000025D0 0x00000030 [ 2] ___copy_helper_block_e8_32w
0x100002600 0x00000020 [ 2] ___destroy_helper_block_e8_32w
  • 第一列是起始地址位置
  • 第二列是大小,通过这个可以算出方法占用的大小
  • 第三列是归属的类(.o文件)

方法名节点

0x100B0364A	0x00000005	[  2] literal string: init
0x100B0364F 0x00000005 [ 2] literal string: data
0x100B03654 0x00000019 [ 2] literal string: initWithParent:userInfo:
0x100B0366D 0x00000018 [ 2] literal string: arrayWithObjects:count:
0x100B03685 0x0000002B [ 2] literal string: countByEnumeratingWithState:objects:count:
0x100B036B0 0x00000013 [ 2] literal string: setTotalUnitCount:
0x100B036C3 0x00000010 [ 2] literal string: setCancellable:
0x100B036D3 0x00000007 [ 2] literal string: cancel
0x100B036DA 0x00000018 [ 2] literal string: setCancellationHandler:
0x100B036F2 0x0000000D [ 2] literal string: setPausable:
0x100B036FF 0x00000008 [ 2] literal string: suspend
0x100B03707 0x00000013 [ 2] literal string: setPausingHandler:
0x100B0371A 0x00000007 [ 2] literal string: resume
0x100B03721 0x00000014 [ 2] literal string: setResumingHandler:

类列表节点

0x100C14E00	0x00000018	[  2] anon
0x100C14E18 0x00000008 [ 3] anon
0x100C14E20 0x00000008 [ 4] anon
0x100C14E28 0x00000008 [ 5] anon
0x100C14E30 0x00000008 [ 6] anon
0x100C14E38 0x00000008 [ 7] anon
0x100C14E40 0x00000008 [ 9] anon
0x100C14E48 0x00000010 [ 10] anon

第一次看这个的时候会比较迷惑到底到哪里找某个数据,其实Sections字段是整个linkMap的目录,通过它的起始地址就可以找到对应区段的位置,所以我们需要做的就是明确各个Session的意义。

通过linkMap找到无用代码的过程大致思路如下:

* 找到类和方法的全集
* 找到使用过的类和方法集合
* 取2者差集得到无用代码集合

Objective-C 中的方法都会通过 objc_msgSend 来调用,而 objc_msgSend 在 Mach-O 文件里是通过_objc_selrefs 这个 section 来获取 selector 这个参数的。所以,_objc_selrefs 里的方法一定是被调用了的。_objc_classrefs 里是被调用过的类, objc_superrefs 是调用过 super 的类(继承关系)。通过 _objc_classrefs 和 _objc_superrefs,我们就可以找出使用过的类和子类。

Mach-O 文件中的_objc_selrefs、_objc_classrefs、_objc_superrefs 可以通过MachOView进行查看。

除了使用linkMap外还可以使用otool,它不需要额外安装,只要你安装了XCode它就顺带安装了Otool。

这里大家可以结合Mach-O文件来了解整个用法,遇到不明白的可以通过otool来输出help信息:

Usage: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/otool [-arch arch_type] [-fahlLDtdorSTMRIHGvVcXmqQjCP] [-mcpu=arg] [--version] <object file> ...
-f print the fat headers
-a print the archive header
-h print the mach header
-l print the load commands
-L print shared libraries used
-D print shared library id name
-t print the text section (disassemble with -v)
-x print all text sections (disassemble with -v)
-p <routine name> start dissassemble from routine name
-s <segname> <sectname> print contents of section
-d print the data section
-o print the Objective-C segment
-r print the relocation entries
-S print the table of contents of a library (obsolete)
-T print the table of contents of a dynamic shared library (obsolete)
-M print the module table of a dynamic shared library (obsolete)
-R print the reference table of a dynamic shared library (obsolete)
-I print the indirect symbol table
-H print the two-level hints table (obsolete)
-G print the data in code table
-v print verbosely (symbolically) when possible
-V print disassembled operands symbolically
-c print argument strings of a core file
-X print no leading addresses or headers
-m don't use archive(member) syntax
-B force Thumb disassembly (ARM objects only)
-q use llvm's disassembler (the default)
-Q use otool(1)'s disassembler
-mcpu=arg use `arg' as the cpu for disassembly
-j print opcode bytes
-P print the info plist section as strings
-C print linker optimization hints
--version print the version of /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/otool

关于Mach-O大家可以查看之前的博客或者Mach-O 文件格式探索这篇博客。

3.无用第三方库清理

4.通过 AppCode 查找无用代码

可以借助AppCode提供的Inspect Code来诊断代码,通过它可以查找无用代码的功能。

Unused class 无用类
Unused import statement 无用类引入声明
Unused property 无用的属性
Unused method 无用的方法
Unused parameter 无用参数
Unused instance variable 无用的实例变量
Unused local variable 无用的局部变量
Unused value 无用的值;
Unused macro 无用的宏。
Unused global declaration 无用全局声明

5.静态库瘦身

对于静态库可以通过lipo 工具来瘦身。

  • 静态库指令集信息查看:
lipo -info libXXXX.a
  • 静态库拆分合并
静态库拆分:lipo 静态库文件路径 -thin CPU架构 -output 拆分后的静态库文件路径
静态库合并:lipo -create 静态库1文件路径 静态库2文件路径... 静态库n文件路径 -output 合并后的静态库文件径

举个例子:

首先我们通过lipo -info 查看libWeiboSDK.a支持的架构类型:

lipo -info libWeiboSDK.a
//Architectures in the fat file: libWeiboSDK.a are: armv7 arm64 i386 x86_64

发现它支持armv7 arm64 i386 x86_64,但是我们只需要 armv7 和 arm64,这时候就需要使用lipo拆分出需要的架构下的静态库后再合并:

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

通过上面的操作我们将静态库里面支持模拟器的指令集给去掉了,所以模拟器是无法跑的,所以平时可以使用包含模拟器指令集的静态库,在App发布的时候去掉。如果使用 Cocoapods 管理可以使用2份 Podfile 文件。一份包含指令集一份不包含,发布的时候切换 Podfile 文件即可。或者一份 Podfile 文件,但是配置不同的环境设置.

编译选项优化

  1. Generate Debug Symbols 这个开关如果打开的话,信息的详情可以通过“Level of Debug Symbols”项进行配置,如果设置为NO则ipa中不会生成Symbols文件,虽然可以减少ipa大小。但会影响到崩溃的定位。所以不是非必须的情况下,建议还是打开。万不得已的情况下Release版本设置为NO,Debug版本设置为YES.

2.在Build Settings中可以指定工程被编译成支持哪些指令集类型,而支持的指令集越多,就会编译出多个指令集代码的数据包,对应生成二进制包就越大,因此可以根据你的产品的目标对象决定是否可以舍弃一些CPU架构,比如armv7用于支持4s和4,但是这部分用户已经很少,所以一般可以考虑舍弃这部分用户。这里可以通过Valid Architectures里面去选择,如果需要去掉更多大家可以根据下表进行对应删剪:

armv6: iPhone, iPhone 3G, iPod 1G/2G
armv7: iPhone 3GS, iPhone 4, iPhone 4S, iPod 3G/4G/5G, iPad, iPad 2, iPad 3, iPad Mini
armv7s: iPhone 5, iPhone 5c, iPad 4
arm64: iPhone X,iPhone 8(Plus),iPhone 7(Plus),iPhone 6(Plus),iPhone 6s(Plus), iPhone 5s, iPad Air(2), Retina iPad Mini(2,3)
arm64e: iPhone XS\XR\XS Max

下面顺带介绍下Architectures编译选项:
Architectures
指定工程被编译成支持哪些指令集类型,默认的情况这里的值为:

Standard architectures- $(ARCHS-STANDARD)

ARCHS-STANDARD具体的值可以查看:

Valid Architectures

该编译项指定可能支持的指令集,该列表和Architectures列表的交集,将是Xcode最终生成二进制包所支持的指令集。举个例子,比如,你的Valid Architectures设置的支持arm指令集版本有:armv7/armv7s/arm64,对应的Architectures设置的支持arm指令集版本有:arm64,这时Xcode只会生成一个arm64指令集的二进制包。

Build Active Architecture Only
指明是否只编译当前连接设备所支持的指令集。默认Debug的时候设置为YES,Release的时候设置为NO。设置为YES是只编译当前的architecture版本,生成的包只包含当前连接设备的指令集代码。设置为NO,则生成的包包含所有的指令集代码(上面的Valid Architectures跟Architectures的交集)。因此为了调试速度更快,则Debug应该设置为YES。

3.Dead Code Stripping

查看 DEAD_CODE_STRIPPING 是否为 YES。设置为YES静态链接的可执行文件中未引用的代码将会被删除。实际上Xcode 默认会开启此选项,C/C++/Swift 等静态语言编译器会在 link 的时候移除未使用的代码,但是对于 Objective-C 等动态语言是无效的。因为 Objective-C 是建立在运行时上面的,底层暴露给编译器的都是 Runtime 源码编译结果,所有的部分都是会被判别为有效代码。

4.Compress PNG Files 打包的时候自动对图片进行无损压缩,使用的工具为 pngcrush

5.Remove Text Medadata From PNG Files 移除 PNG 资源的文本字符,比如图像名称、作者、版权、创作时间、注释等信息。

6.将Asset Catalog Compiler optimization 选项设置为 space

7.Apple Clang - Code Generation

该选项下的Optimization Level 编译参数决定了程序在编译过程的编译速度以及编译后的可执行文件占用的内存以及编译之后可执行文件运行时候的速度。它有六个级别:

None[-O0]: Debug 默认级别。不进行任何优化,直接将源代码编译到执行文件中,结果不进行任何重排,编译时比较长。主要用于调试程序,可以进行设置断点,改变变量,计算表达式等调试工作。

Fast[-O,O1]。最常用的优化级别,不考虑速度和文件大小权衡问题。与-O0级别相比,它生成的文件更小,可执行的速度更快,编译时间更少。

Faster[-O2]。在-O1级别基础上再进行优化,增加指令调度的优化。与-O1级别相,它生成的文件大小没有变大,编译时间变长了,编译期间占用的内存更多了,但程序的运行速度有所提高。

Fastest[-O3]。在-O2和-O1级别上进行优化,该级别可能会提高程序的运行速度,但是也会增加文件的大小。

Fastest Smallest[-Os]。Release 默认级别。这种级别用于在有限的内存和磁盘空间下生成尽可能小的文件。由于使用了很好的缓存技术,它在某些情况下也会有很快的运行速度。

Fastest, Aggressive Optimization[-Ofast]。 它是一种更为激进的编译参数, 它以点浮点数的精度为代价。

默认情况下Debug 设定为 None[-O0] ,Release 设定为 Fastest,Smallest[-Os],所以这里一般会采用默认的设置。这个选项会开启那些不增加代码大小的全部优化,让可执行文件尽可能小

8.Swift Compiler - Code Generation

这个主要是针对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 是首选,否则建议使用-O

9.Exceptions

可以通过设置Enable C++ Exceptions 和 Enable Objective-C Exceptions 为 NO,并且Other C Flags添加-fno-exceptions 可以去掉异常支持从而减少可行性文件的大小,但是除非非常必要的情况下,这个一般保持打开。

10.Link-Time Optimization

在Link中间代码时,对全局代码进行优化。这个优化是自动完成的,因此不需要修改现有的代码。这项优化主要包括:

去除多余代码:如果一段代码分布在多个文件中,但是从来没有被使用,普通的 -O3 优化方法不能发现跨中间代码文件的多余代码,因此是一个“局部优化”。但是Link-Time Optimization 技术可以在 link 时发现跨中间代码文件的多余代码。

跨过程优化:这是一个相对广泛的概念。举个例子来说,如果一个 if 方法的某个分支永不可能执行,那么在最后生成的二进制文件中就不应该有这个分支的代码。

内联优化:内联优化形象来说,就是在汇编中不使用 “call func_name” 语句,直接将外部方法内的语句“复制”到调用者的代码段内。这样做的好处是不用进行调用函数前的压栈、调用函数后的出栈操作,提高运行效率与栈空间利用率。

开启这个优化后,一方面会减少了汇编代码的体积,另一方面还会提高了代码的运行效率,所以建议在项目中开启该优化,并设置为优化方式 Incremental。

11.Framework动态库包空间瘦身
Framework 文件夹存放的是动态库。这部分内容会在启动的时候被链接和加载,这里面主要放的是我们引入的其他依赖库,但是需要注意的一点是,如果我们项目中打开了Swift 混编的情况下会多出Swift 标准库,这部分的占用大概在7-8M左右。所以出于这方面考虑,如果非必要的情况下建议只使用Objective-C 开发。目前大多数大项目都采用这种模式。

Swift 标准库和自己引入的其他依赖库

App Thinning技术

App Thinning 技术是 iOS9引入的它主要用于解决目前某些地区国家流量费用过高、iOS设备的存储空间有限的问题,但是实际上也是从应用瘦身的角度出发去进行改善。所以也顺带在这里介绍了。iOS 9之前的版本要求用户下载整个app文件,即使用户使用的是iPhone也需要下载他们绝不会使用到的ipad图像文件,这主要出于能够更好得适配各种不同的应用角度出发,但是App Thinning 技术的引进改善了这个问题,App Thinning会自动检测用户的设备类型并且只下载当前设备所适用的内容。

App Thinning 技术主要包括三大方面:App Slicing,Bitcode。

  • App Slicing

当开发者向App Store Connect 上传 .ipa 后,App Store Connect 构建过程中,会自动分割该 App,创建特定的变体,以适配不同设备。然后用户从 App Store 中下载到的安装包,即这个特定的变体。也就是说App Slicing仅向设备传送与之相关的资源(图片,指令架构相关),需要注意的是:App Slicing对于图片资源的划分,需要要求图片放在 Asset Catalog 中管理,Bundle 内的则还是会同时包含。

  • On Demand Resources

On Demand Resources 所管理的资源是托管在 App Store 和 app相关的Bundle包分开下载,这部分资源由操作系统负责下载和存储。它可以是bundle所支持文件类型除了可执行文件以外的任何文件。按需加载资源的总计大小不能超过20GB。它的大小不算在app bundle的大小中。

按需加载资源主要可以带来以下的几种好处:

  • 初始资源的延迟加载: app有一些资源是主要功能要用到的,但在启动时并不需要。将这些资源标记为“初始需要”。操作系统在app启动时会自动下载这些资源。例如,图片编辑app有许多不常用的滤镜。
  • app资源的延迟加载: app有一些只在特定情景下使用的资源,当应用可能要进入这些场景时,会请求这些资源。例如,在一个有很多关卡的游戏中,用户只需要当前关卡和下一关卡的资源。
  • 不常用资源的远程存储: app有一些很少使用的资源,当需要这些资源时会去请求它们。例如,当app第一次打开时会展示一个教程,而这个教程之后就可能不会在用到。app在第一次启动时请求教程的资源,这之后只在需要展示教程或者添加了新功能才去请求该资源。
  • 应用内购买资源的远程存储: app提供包含额外资源的应用内购买。app会在启动完成后请求已购买模块的资源。例如,用户在一个键盘app内购买了SuperGeeky表情包。应用程序会在启动完成后请求表情包的资源。
  • 第一次启动时必需资源的加载: app有一些资源只在第一次启动时需要,之后的启动不再需要。例如,app有一个只在第一次启动时展示的教程。

我们在开发的时候需要给按需加载资源分配一个字符串标识符tag来区分这些资源在我们的应用中是如何使用的。在运行的时候,通过指定tag来请求访问远程资源。操作系统会下载和这个tag关联的所有资源,然后保留在存储中,直到app不再使用它们。当操作系统需要更多的存储空间,它会清理一个或多个不再使用的tag关联的资源。tag关联的资源在被清理之前可能会在设备中保存一段时间。
我们可以为tag设置保存优先级来影响清理的顺序。

On Demand Resources 主要可以分成三类:

  • Initial install tags: 只有在初始安装tag下载到设备后,app才能启动。这些资源会在下载app时一起下载。这部分资源的大小会包括在App Store中app的安装包大小。如果这些资源从来没有被NSBundleResourceRequest对象获取过,就有可能被清理掉。

  • Prefetch tag order: 在app安装后会开始下载tag。tag会按照此处指定的顺序来下载。

  • Dowloaded only on demand: 当app请求一个tag,且tag没有缓存时,才会下载该tag

开启关闭On Demand Resources

project navigator中选择工程文件。
project editor中选择对应的target
选择Build Settings选项卡。
展开Assets分类。
设置Enable On-Demand Resources的值。

文章:

On-Demand Resources Guide中文版 上

On-Demand Resources Guide中文版 下

视频:

  • Bitcode

Bitcode是一种程序中间码。包含Bitcode配置的程序将会在App Store Connect上被重新编译和链接,进而对可执行文件做优化。这部分都是在苹果服务端自动完成的,所以即使后续Apple推出了新的CPU架构或者以后LLVM推出了一系列优化,我们也不再需要为其发布新的安装包,Apple Store 会为我们自动完成这步,然后提供对应的变体给具体设备。

Bitcode开启:

采用Bitcode之后需要注意两个方面:

(1) 一旦开启Bitcode那么我们依赖的静态库、动态库,都必须包含 Bitcode,另外用 Cocoapods 管理的第三方库,都需要开启 Pods 工程中的 BitCode。否则会编译失败。

可以将下面的配置添加到主Podfile中:

post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ENABLE_BITCODE'] = 'YES'
end
end
end

(2) 开启 Bitcode 后,最终的可执行文件是 Apple 自动生成的,这就会导致我们无法使用自己包生成的 dSYM 符号化文件来进行符号化。这个问题可以在上传到 App Store 时需要勾选 “Include app symbols for your application…” ,勾选之后 Apple 会自动生成对应的 dSYM,然后可以在 Xcode —> Window —> Organizer 中, 或者 Apple Store Connect 中下载对应的 dSYM 来进行符号化:

这里有一篇比较好的文章介绍了对应的技术以及怎样进行测试初探iOS 9的 App 瘦身功能

3.6 网络优化

要对网络进行优化需要先了解一次网络请求的流程:

1. 在开始请求某个地址之前,会先进行DNS解析,通过DNS解析获取到对应域名的IP.
2. 使用IP与目标服务器建立连接,这里包括tcp三次握手等流程
3. 建立完连接后客户端和服务端交换数据

上述的三个阶段都存在可优化的点,我们一一进行介绍:

首先第一个环节,主要的性能损耗在DNS解析这块,DNS解析的缺点如下:

* DNS解析环节主动权在域名解析方,这就导致容易在域名解析方被解析到第三方IP地址,从而遭受域名劫持,运营商劫持,中间人攻击。
* DNS解析过程不受我们控制,无法保证解析到的地址是最快的IP.
* 一次请求只能解析一个域名,容易达到性能瓶颈。

为了解决这些问题,我们可以考虑自己来管理域名与IP的映射关系,也就是我们常说的HTTPDNS。具体的实现可以看App域名劫持之DNS高可用 - 开源版HttpDNS方案详解,它的主要思想就是通过HTTP请求后台去拿域名,IP映射表,后续的网络请求就可以通过这个映射表来获得对应的IP地址,这样的好处就是不用在DNS解析上耗费时间,并且域名IP的映射关系可控,不会受到劫持,可以确保根据用户所在地返回就近的 IP 地址,或根据客户端测速结果使用速度最快的 IP。

对于第二个环节主要的性能瓶颈点在于连接的建立。这里可以通过复用连接,从而避免每次请求都重新建立连接。HTTP 协议里有个 keep-alive属性,如果开启的情况下请求完成后不立即释放连接,而是放到连接池中,如果这时候另一个请求发出,并且域名和端口是一致的,这时候就直接使用连接池中已有的连接。从而少了建立连接的耗时。

但是keep-alive也是有明显的缺点的就是,keep-alive连接每次都只能发送接收一个请求,在上一个请求处理完成之前,无法接受新的请求,所以如果同时多个请求被发送,就会有两种现象:

如果请求是串行发送的,那么就可以一直复用同一个连接,每个请求都需要等待上一个请求完成后再进行发送。

如果请求是并行发送的,那么从第二次开始可以复用连接池里的连接,这种情况如果对连接池不加限制,会导致连接池中保留的连接过多,对服务端资源将会带来较大的浪费,如果限制那么超过的部分仍然需要重新建立连接。

但是在别无选择的情况下,还是会采取这种方案。

后续HTTP2的推出,采用多路复用来解决需要频繁建立连接的问题。对于Http2的多路复用机制还是通过复用连接,但是它复用的这条连接同时能够支持同时处理多条请求,所有的请求都可以在这条连接上并发进行,它把在连接里传输的数据都封装成一个个stream,每个stream都有标识,stream的发送和接收可以是乱序的,不依赖顺序,也就不会有阻塞的问题,接收端可以根据stream的标识去区分属于哪个请求,再进行数据拼接,得到最终数据。iOS9 以上 NSURLSession 原生支持 HTTP2,只要服务端也支持就可以直接使用。

但是HTTP2还有一个比较大的问题就是TCP队头阻塞,我们知道TCP 协议为了保证数据的可靠性,若传输过程中一个 TCP 包丢失,会等待这个包重传后,才会处理后续的包。在HTTP2中多路复用的情况下,如果中间有一个包丢失,就会阻塞等待重传,进而阻塞所有请求。这个问题是TCP协议本身的问题,为了解决这个问题,Google提出了QUIC 协议,它是在UDP协议之上再定义一套可靠传输协议来解决队头阻塞问题。

除了采用HTTP2多路复用技术外,还可以通过长连接的手段减少网络服务时间,我们知道每次TCP三次握手连接需要耗费客户端和服务端各一个 RTT时间才能完成,就意味着 需要大致100-300 毫秒的延迟;为了克服这个问题可以使用长连接池的方式来使用长连接,长连接池中维护了多个保持和服务端的 TCP连接,每次网络服务发起后会从长连接池中获取一个空闲长连接,完成网络服务后再将该TCP连接放回长连接池。

介绍完DNS环节优化,以及连接建立的优化后,我们看下从数据交换层面上如何进行优化,这方面主要可以优化的点可以分成两部分,一部分在于对数据的压缩率上,一部分在于解压缩,序列化反序列化的速度上,在数据交换格式的选择上对于数据量比较大的情景下可以使用protobuf替换json 格式,protobuf是基于二进制的所以在整体数据量以及序列化速度上都远胜于json 格式在内的其他格式,但是它有一个不足的地方就是数据不直观。在调试定位问题的时候比较难定位。

在进行数据交换之前可以对body数据进行压缩或者加密处理,Z-standard是目前压缩率表现最好的算法。它支持多种开发语言。对于Header协议头数据,Http2已经对其进行了压缩处理。具体的压缩技术可以查看HTTP/2 头部压缩技术介绍

除了一些敏感数据之外,不建议对数据进行加密,因为加解密是比较耗时的,加解密处理会增加整个请求发送和处理的速度,实际上标准协议 TLS 已经能够很好得保证了网络传输的安全,所以除非是十分敏感的数据加密只会加重性能的负担。

除了上述介绍的网络优化外,还可以引入网络服务优先级机制,高优先级服务优先使用长连接,低优先级服务默认使用短连接以及网络服务依赖机制,主服务失败时,子服务自动取消。

4 较好的文章推荐

iOS Performance Optimization

这篇博客大家的关键点放在上架需要哪些材料上面,具体的步骤大家理解下就可以了,因为官网随时会变更,这篇博客也没办法实时更新,由于我个人没有开发者账号,用公司账号演示的话担心会泄密,因此这篇文章用了比较多的网络图片,在此也谢谢原作者。OK,我们进入正文:

1.Apple开发者证书类型

如果你是刚开始接触iOS可能会很疑惑:到底哪种开发者账号服务适合我们?有时候我们会有种假象就是在不明白要什么东西的时候,买同类中最贵的一定是最全的,但是我们要知道最贵的苹果的企业账号是不能发布到AppStore的,到底哪种是我们真正需要的大家在购买前建议大家看下苹果官网中关于每种服务的介绍,下图是各个账号所提供的服务,这里简单做个总结:

苹果对开发者主要分为4类:免费个人,个人开发者、组织(公司、企业)、教育机构,其中组织又分为公司和企业,下面一一介绍他们的区别:

  • 免费个人

如果只是个人学习使用,我们可以不用购买任何账号服务,只要有一个Apple ID,我们就可以下载Xcode,文档,样例代码,并且在真机上测试,但是每个手机上安装的测试应用是有限制的,这种归于免费个人用户,免费个人如何使用真机调试,具体可以参照该文章:Xcode 10 无开发者账号真机调试

  • 个人开发者

个人开发者账号每年要缴纳99美元,一般我们如果想自己开发应用并上传AppStore的话只需要个人开发者账号就可以了,它的限制在于协作人员只限制1人,最大UUID支持数只有100,也就是说只有100个机器可以添加到设备白名单中,一般来说够用了。

  • 公司账户

公司账户费用和个人开发者一样也是99美元,并且支持上传AppStore上架,也有最大UUID数100的限制,但是它的好处是可以支持多人协作,比个人开发者账户多一些帐号管理的设置,可设置多个Apple ID。但是比个人开发者证书申请过程麻烦在:需要填写公司的邓白氏编码(DUNS Number),个人开发者可以申请升级到公司账户。

  • 企业账户

这个是最初让我搞混的一个概念,最早以为要上架只有企业账户才有权限,为啥?因为贵啊,但是恰恰是这种证书不能支持AppStore上架,它的价格是每年299美元,它的好处是最大UUID支持数不受限制,所以一般适用于企业内部应用使用,也可以支持多人协作开发。和公司账户类似的是它也需要填写公司的邓白氏编码(DUNS Number)

  • 教育机构

只能教育机构或学院内部使用。必须是苹果iOS开发者计划授权机构。不能对外正式发布iOS应用程序。价格也是免费的。

所以如果只是个人学习的话申请免费个人账户就可以了,如果要自己个人开发应用,并且没有成立公司的独立开发者,可以申请个人开发者,公司开发的应用一般需要使用公司账户,如果开发一些不提供给外部使用,只限制在公司内部的应用,则使用企业账户,如果某些学校内部的应用,并且通过了iOS开发者计划授权的,可以使用教育机构账户。

2.注册成为Apple开发者
2.1 注册Apple ID

Apple ID 申请入口

Apple ID申请的过程还是蛮简单的,只需要在上面的页面上填写姓名,国家地区,出生日期,邮箱,密码,三个安全提示,联系电话,验证方式等,这时候就会收到验证信息,通过验证就可以成功获得你的Apple ID了。

这里需要注意的是注册Apple Id的邮箱必须是企业邮箱,并且与公司网站的域名保持一致,比如公司网站为abc.com那么公司邮箱就必须长成xxxxxx@abc.com。还有公司开发者账号与企业开发者账号不能共用同一个Apple id。

2.2 申请邓白氏码

邓白氏码申请入口

这里需要注意的是不要直接去邓白氏公司直接申请,因为这种途径是需要交钱的,而通过上面的地址申请是免费的。






上面Your Contact Information一栏中的邮箱填成个人的,后续的邮件将会发送到这个邮箱上,并且确保手机号码是正确,这个很重要。

如果申请成功就会在你留的个人邮箱上收到*D-U-N-S Number Request/Update Confirmation**邮件,上面会有一个DUNS码请求ID,以及需要在什么时间之前完成申请工作,这个只是请求码而不是邓白氏码,这个用于后续的沟通。

Thank you for submitting your D-U-N-S Number request / update to D&B. It should be completed by xx/xx/xxxx, or sooner.
Your request id is: xxxxxx-xxxxxx.
A D&B representative may be contacting you directly.  Your cooperation will help to expedite the resolution of this request.
Please contact applecs@dnb.com if you have any questions.

收到上述的邮件后还需要等待,邓白氏公司发邮件让我们提供公司的资料:

这一步需要注意如下几点:

* 营业执行公章最好提供原件的照片
* 企业类型、主营业务,见公司的营业执照
* 要记住提供经过盖章签名后的确认咨询函
* 苹果联系人,一定要填写对公司信息比较了解的领导信息,后面邓白氏公司会通过这个联系方式与该人进行通话,确定一些公司的信息。
* 申请邓白氏之后,需要时刻注意联系邮箱上面收到的回复邮件,邓白氏要求在规定时间内完成公司信息的填写并回复,所以一般在申请之前就要准备好对应的材料。

材料提供完后就耐心等待,这时候邓白氏公司就会有人与你留下的苹果联系人联系,核对信息,通过后就会收到申请成功的邓白氏码邮件,这里还需要注意的是不能心急,收到邓白氏码后立刻去申请苹果开发者账号,因为这部分信息同步到苹果公司数据库还需要一定时间,否则在苹果数据库更新完成之前申请失败超过3次,就需要重新申请邓白氏码,这个时间在邮件上面已经写明白了是14天,一般都会等上20天后去尝试。

2.3 开发者证书申请

个人开发者/公司账户申请入口

企业开发者账号申请入口


这个阶段需要注意如下几点:

  1. 切记一定要等到邓白氏码在苹果网站上生效后去注册
  2. 填写申请开发者账号的选项的时候最好选择第二项–公司授权,当然如果是自己公司那就选择第一项,还需要注意的是,这里的联系方式也要写对公司情况比较了解的人的信息,因为苹果审核的时候会和这个联系人联系,不要一问三不知。

等到收到下图邮件的时候就可以支付费用了,这里仅支持Visa卡和万事达卡支付,同时支持开普通发票。

支付完就完成开发者证书的申请了。

3.开发过程需要的证书申请

首先我们必须明确到底需要哪些材料:

  1. 开发者证书 (iOS Development 开发者证书,iOS Distribution 发布证书)这个用于证明自己开发者身份的,是一个最基础的证书。

  2. AppID用于标示一个应用,这个AppId绑定了系列的应用信息以及这个应用开通的各个服务,比如常见的应用都会开通Push服务。

  3. 推送证书 一般应用都会开通Push服务,推送证书一般也会有两种,一种是开发环境推送证书(APNs Development iOS),一种是正式环境推送证书(APNs Distribution iOS).

  4. Provisioning Profiles 简称PP证书,该文件将上面的AppID,开发者证书,硬件设备信息,绑定到一块,可以在开发者中心配置好后添加到Xcode,也可以直接在Xcode上连接开发者中心生成,真机调试时需要在PP证书中添加真机的UDID.

3.1 创建开发者证书

使用已经开通了Apple开发者服务的账号登录苹果官网,进入Certificates, Identifiers & Profiles,我们需要的证书,AppID,PP证书都是在这个地方生成的。

如下图所示选择iOS, tvOS, watchOS –> 选择All –> 点击右上角新添加证书

然后会让我们选择是开发证书还是生产证书,开发证书用于我们日常开发使用,而生产证书用于我们往AppStore中上传App时使用。

接下来创建CSR(证书签名请求文件)这个是证书申请者在申请数字证书的时候由加密服务提供者在生成私钥的同时也生成证书请求文件,证书申请者只要把CSR文件提交给证书颁发机构后,证书颁发机构使用它的根证书私钥签名就可以生成证书公钥文件也就是颁发给用户的证书。
 

下面是在Mac系统上生成CSR文件的过程:点击钥匙串访问 -> 证书助理 ->从证书颁发机构请求证书.




上传CSR文件


下载经过签名的发布证书,同理可以创建开发者证书

运行之后,在钥匙串里生成证书,确保证书有效.

如果你的App Store Ad Hoc 前面的按钮不能选择,则代表你的这个账号无法再创建新的生产证书了,这时候可以有两种方式,一种比较直接:从现有的证书中选择一个删除,但是这个需要注意的是如果删除一个证书,那么正在使用这个证书的人将不能再继续使用了。

还有一种方式是使用P12证书。

双击安装证书后,打开钥匙串访问,选择安装的证书右键单击

选中导出证书:

将证书存储为.p12形式:

可以为该证书设置密码这样会比较安全点,别人在安装这个证书的时候就需要输入密码。

如果需要在其它电脑上发布App,那么就必须要安装这个发布证书。

3.2 创建APPID

点击App IDs,点击”+”号.

填写应用描述和Bundle ID,如果后续修改了工程里面的Bundle Identifier的话,需要重新进入到开发者账号里面绑定。

选择开通的服务,默认开通了游戏中心和内购服务



3.3 创建推送证书

如果选中开通推送服务还需要完成如下配置:




3.4 创建PP证书

PP描述文件是描述哪台电脑能对指定Bundle Identifier的工程进行打包测试或发布。






4.在AppStore创建应用

或者在开发者中心选中iTunes Connect进入:

输入账号密码:

选择我的App

点击左上角的➕

选择平台,名称,主要语言套装ID以及SKU其中SKU和套装ID建议都填Bundle Id

选择应用类别和次要类别

如果要收费的应用要继续填写价格、销售范围、批量购买计划.

选择上传3.5寸、4寸、4.7寸、5.5寸预览图片,每个尺寸都要至少3张.

填写应用描述和关键词

上传1024 * 1024尺寸的应用图标,填写版权信息和用户等级,以及审核信息


5.使用证书打包上传AppStore提交审核

在进行打包的之前需要找到刚刚下载的发布证书(后缀为.cer)或者p12文件,和PP文件,双击安装,如果已经安装过就不用执行该操作了。

这里仅仅介绍使用Xcode打包应用的过程,一般公司应用都会使用自动构建系统来打包,这个放在后续介绍。

首先在上传给苹果公司审核之前我们需要事先对照 App Store 审核指南过一遍,避免因为某些低级错误导致审核不通过,第一个版本审核一般时间都比较长,腾讯还推出了iOS预审服务,有需要的可以使用这些三方服务来提高审核通过的概率。但是一般来说第一次会比较久,并且如果有内购功能会更久,后续版本会快很多,当然也有很多旁门左道,大家可以到网上去搜索,这里就不公开了,一般为了保守起见预留个 15~20天是比较靠谱的,但是一般第一个版本7-10天基本上都会过,后续更新的话3-4天就会通过。当然有些是苹果审核人员误解了,我们可以积极得和苹果审核人员进行沟通。

好了我们开始介绍打包上传到AppStore并提交审核的过程:

进入AS后台会有提示可以使用Xcode打包或者通过构建系统打成ipa包后通过Application Loader上传,一般推荐后一种。

在打包之前需要检查下一些内容:

  • 设备需要选择Generic iOS Device.
  • 检查横竖屏支持情况
  • 检查发布版本以及构建号
  • iOS最低可运行的版本
  • 应用支持设备类型
  • 签名证书






把Run、Test、Profile、Analyze、Archive中的Build Configuration全部改为Release.之后Close. Control + B 开始构建应用。





编译成功,选择Product -> Archive.进行打包,在进行上传之前最好建议先点击验证,避免因为版本等问题上传过程中失败,这样会更节省时间









当然还可以使用Application Loader上传,这种方式主要分成两步:

  1. 导出ipa包






  1. 通过Application Loader上传

如果没有Mac的话还可以使用跨平台的APP开发助手,可能你会疑惑,开发苹果应用的居然没有Mac不可能啊,但是你要想啊,一般这些上传工作不一定是开发上传的哈,往往这些都是由产品或者项目管理者负责上传的,并且AS账号也是他们管理的。

http://www.appuploader.net/

上传到AS后就可以在后台看到刚刚上传的应用了,这时候点击提交以供审核就OK了。




0. 开篇叨叨

一般稍大点的应用都会接入支付功能,对于iOS应用而言,支付渠道主要分成两类:

  • 第三方支付:

第三方支付包括:支付宝、微信、银联等支付方式,这些一般都需要有公司的形式才能接入,个人是无法接入的。

  • 应用内支付

应用内支付简称内购,IAP说的都是同一个东西,它是指在应用程序内销售虚拟商品,如果我们在App Store上销售,将收到支付金额的70%,苹果公司会抽走30%。并且需要注意的是在AS上一次性消耗商品,价格不能超过99.99美刀不然会被拒绝的。

这篇博客主要介绍比较常用的支付宝、微信以及AS内购三种支付方式,对于这块的内容大家只要记住这里的原理就可以了,各个渠道的SDK以及部分流程都会时常更新,所以不用太在意具体的差异。接入的时候还是要仔细阅读对应平台的SDK说明文档。

对于内购GitHub上面也有一些比较好的开源项目大家也可以作为借鉴:RMStore 以及 CargoBay

1. 第三方支付
1.1 支付宝平台
1.1.1 简介

支付宝接入文档地址如下:
https://docs.open.alipay.com/204/105051

在我们需要通过支付宝三方支付的时候,我们的应用调用支付宝提供的SDK,如果用户已经安装了支付宝 APP,这时候支付宝APP将会被调起来接管后续的支付流程,支付完成后跳回我们的应用展示支付结果,如果用户没有安装支付宝 APP,这时候将会调起支付宝网页支付收银台,用户登录支付宝账户,支付完后展示支付结果。

但是支付宝只针对企业或者个体户才能申请接入,对于一般的开发者是无法接入的

下面是接入支付宝的条件:

  • 申请前必须拥有经过实名认证的支付宝账户;
  • 企业或个体工商户可申请;
  • 需提供真实有效的营业执照,且支付宝账户名称需与营业执照主体一致;
  • 网站能正常访问且页面显示完整,网站需要明确经营内容且有完整的商品信息;
  • 网站必须通过ICP备案。如为个体工商户,网站备案主体需要与支付宝账户主体名称一致;
  • 如为个体工商户,则团购不开放,且古玩、珠宝等奢侈品、投资类行业无法申请本产品。
1.1.2 接入前准备

在接入前需要在支付宝开放平台创建应用,我们需要提供我们应用的基本信息,以及授权回调,加密公钥等信息,如果创建成功会获得一个APPID,通过 APPID 才能调用开放产品的接口能力。具体查看
开放平台应用创建指南

创建成功后我们就可以往我们注册的应用上添加功能,对于某些需要签约功能的应用,要先完成签约后才能使用。

为了保证交易双方的身份和数据安全,我们在调用接口前,需要配置双方密钥,并通过双方密钥对交易数据进行双方校验,支付宝使用RSA加解密技术,整个过程我们需要应用私钥(APP_PRIVATE_KEY)、应用公钥(APP_PUBLIC_KEY),以及支付宝公钥(ALIPAY_PUBLIC_KEY),我们首先会在本地生成一对应用私钥(APP_PRIVATE_KEY)、应用公钥(APP_PUBLIC_KEY),并且在上述添加应用的时候把应用公钥(APP_PUBLIC_KEY)通过开放平台给支付宝,这样我们使用应用私钥(APP_PRIVATE_KEY)加密后的数据支付宝就可以通过我们给它的应用公钥解开了。同理,支付宝那边的返回数据也可能是加密的,对于这些数据我们可以使用支付宝公钥(ALIPAY_PUBLIC_KEY)进行解密,这部分可以查看签名专区

1.1.3 支付流程

1.1.4 iOS 支付宝SDK集成

具体的集成过程可以查看集成文档

首先我们点击购买某个商品的时候会发送信息告诉我们业务服务端我们需要购买的商品信息,比如商品id等。服务端后台会生成订单ID,并将包括订单ID,订单价格等信息打包起来,在服务端使用应用私钥对这些信息加密后传递给客户端,客户端收到这个请求后直接传递给支付宝SDK发起请求,这里之所以将加密放在服务端是为了避免公私钥数据泄露,在使用的时候一定要注意构造交易数据并签名必须在商户服务端完成,商户的应用私钥绝对不能保存在商户 APP 客户端中,也不能从服务端下发。

下面是来自支付宝官网的Demo,在该Demo中为了简单它将加密放在了客户端,在实际应用的时候需要注意这一点。

//将商品信息赋予AlixPayOrder的成员变量
Order* order = [Order new];

// NOTE: app_id设置
order.app_id = appID;

// NOTE: 支付接口名称
order.method = @"alipay.trade.app.pay";

// NOTE: 参数编码格式
order.charset = @"utf-8";

// NOTE: 当前时间点
NSDateFormatter* formatter = [NSDateFormatter new];
[formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
order.timestamp = [formatter stringFromDate:[NSDate date]];

// NOTE: 支付版本
order.version = @"1.0";

// NOTE: sign_type设置
order.sign_type = @"RSA";

// NOTE: 商品数据
order.biz_content = [BizContent new];
order.biz_content.body = @"我是测试数据";
order.biz_content.subject = @"1";
order.biz_content.out_trade_no = [self generateTradeNO]; //订单ID(由商家自行制定)
order.biz_content.timeout_express = @"30m"; //超时时间设置
order.biz_content.total_amount = [NSString stringWithFormat:@"%.2f", 0.01]; //商品价格

//将商品信息拼接成字符串
NSString *orderInfo = [order orderInfoEncoded:NO];
NSString *orderInfoEncoded = [order orderInfoEncoded:YES];
NSLog(@"orderSpec = %@",orderInfo);

// NOTE: 获取私钥并将商户信息签名,外部商户的加签过程请务必放在服务端,防止公私钥数据泄露;
// 需要遵循RSA签名规范,并将签名字符串base64编码和UrlEncode
id<DataSigner> signer = CreateRSADataSigner(privateKey);
NSString *signedString = [signer signString:orderInfo];

// NOTE: 如果加签成功,则继续执行支付
if (signedString != nil) {
//应用注册scheme,在AliSDKDemo-Info.plist定义URL types
NSString *appScheme = @"alisdkdemo";

// NOTE: 将签名成功字符串格式化为订单字符串,请严格按照该格式
NSString *orderString = [NSString stringWithFormat:@"%@&sign=%@",
orderInfoEncoded, signedString];

// NOTE: 调用支付结果开始支付
[[AlipaySDK defaultService] payOrder:orderString fromScheme:appScheme callback:^(NSDictionary *resultDic) {
NSLog(@"reslut = %@",resultDic);
}];
}

处理的结果分成两种方式返回:

  • 同步通知:同步通知指的是支付宝SDK对支付请求处理完毕后将结果返回给商户App端
  • 异步通知:异步通知指的是支付宝SDK处理完结果后将结果通过异步通知地址notify_url,通过POST方式将结果回传给我们业务后台。

同步返回的数据,只是一个简单的结果通知,商户确定该笔交易付款是否成功需要依赖服务端收到支付宝异步通知的结果进行判断。具体返回的结果可以查看通知参数说明

同时不要忘记在info.plist 注册的 scheme。接下来就是在AppDelegate中处理支付宝返回的处理结果,这个回调会在这笔交易被买家支付成功后支付宝收银台上显示该笔交易成功,并提示用户“返回”的时候被调用。

- (BOOL)application:(UIApplication *)application
openURL:(NSURL *)url
sourceApplication:(NSString *)sourceApplication
annotation:(id)annotation {

if ([url.host isEqualToString:@"safepay"]) {
//跳转支付宝钱包进行支付,处理支付结果
[[AlipaySDK defaultService] processOrderWithPaymentResult:url standbyCallback:^(NSDictionary *resultDic) {
//在这里处理对应的支付结果
}];
}
return YES;
}

// NOTE: 9.0以后使用新API接口
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<NSString*, id> *)options
{
if ([url.host isEqualToString:@"safepay"]) {
//跳转支付宝钱包进行支付,处理支付结果
[[AlipaySDK defaultService] processOrderWithPaymentResult:url standbyCallback:^(NSDictionary *resultDic) {
//在这里处理对应的支付结果
}];
}
return YES;
}

resultDic整个结构如下所示:

{
"memo" : "xxxxx",
"result" : "{
\"alipay_trade_app_pay_response\":{
\"code\":\"10000\",
\"msg\":\"Success\",
\"app_id\":\"2014072300007148\",
\"out_trade_no\":\"081622560194853\",
\"trade_no\":\"2016081621001004400236957647\",
\"total_amount\":\"0.01\",
\"seller_id\":\"2088702849871851\",
\"charset\":\"utf-8\",
\"timestamp\":\"2016-10-11 17:43:36\"
},
\"sign\":\"NGfStJf3i3ooWBuCDIQSumOpaGBcQz+aoAqyGh3W6EqA/gmyPYwLJ2REFijY9XPTApI9YglZyMw+ZMhd3kb0mh4RAXMrb6mekX4Zu8Nf6geOwIa9kLOnw0IMCjxi4abDIfXhxrXyj********\",
\"sign_type\":\"RSA2\"
}",
"resultStatus" : "9000"
}

整个交互流程如下图所示:

下图是支付失败时候的交互流程图:

对应的错误码可以查看官方的错误文档

商户系统接收到通知以后,必须通过验签来确保支付通知是由支付宝发送的。
除了正常的支付交易流程外,支付宝也提供交易查询、关闭、退款、退款查询以及对账等配套API详细可以查看官方文档。

1.1.5 调试上线

在上线之前可以在沙箱环境开发联调,联调通过后就可以在支付宝上线应用了,只有上线状态下的应用才能够调用生产环境的接口,在申请上线之前建议认真对照[开放平台第三方应用安全开发指南](https://docs.open.alipay.com/common/105912),避免审核不通过被打回来。

1.2 微信支付

微信支付目前也是仅接受公司主体的移动应用申请微信APP支付权限,目前手续费和支付宝一样都是0.6%到1%。具体的概述见微信APP支付接入商户服务中心

下面是微信支付的整个交互时序图:

1.2.1
  • 申请Appid

和支付宝支付类似在接入SDK之前需要到微信开放平台申请开发APP应用之后会返回一个唯一标识APPID:

在URL Types中中添加一项URL Schemes为刚刚申请的Appid的项。

  • 导入WechatOpenSDK

通过CocoaPods导入SDK

pod 'WechatOpenSDK'
  • 导入WXApi

import WXApi.h 头文件,并增加 WXApiDelegate 协议。


#import <UIKit/UIKit.h>
#import "WXApi.h"

@interface AppDelegate : UIResponder<UIApplicationDelegate, WXApiDelegate>

@property (strong, nonatomic) UIWindow *window;

@end

  • 向微信注册应用

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {    
    //向微信注册
    [WXApi registerApp:@"wxd930ea5d5a258f4f"];
    return YES;
    }
  • 重写AppDelegate的handleOpenURL和openURL方法:

- (BOOL)application:(UIApplication *)application handleOpenURL:(NSURL *)url {   
return [WXApi handleOpenURL:url delegate:self];
}

- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation {
return [WXApi handleOpenURL:url delegate:self];
}
  • 接下来需要实现WXApiDelegate协议的两个方法:
-(void) onReq:(BaseReq*)reqonReq

这个是是微信终端向我们应用发起请求的回调,我们调用完后必须调用sendRsp返回。

-(void) onResp:(BaseResp*)resp

如果我们应用向微信发送了sendReq的请求,那么onResp会被回调。`

如果我们的应用需要发送消息给微信,那么需要调用
[4] 如果你的程序要发消息给微信,那么需要调用WXApi的sendReq函数:

调用WXApi的sendReq函数

-(BOOL) sendReq:(BaseReq*)req

下面是接入微信支付的文档,遇到问题的时候可以查阅相关文档。

微信支付开发文档

微信支付开放平台

2. 应用内购 IAP

在介绍应用内购之前需要明确下哪些商品是属于内购范围,应用内购主要是指购买应用内需要付费使用的产品功能或虚拟商品/服务,但是那些实体商品或者购买来不是在应用内部使用的虚拟商品都不是IAP所涵盖的范围,苹果有明确规定在应用内适用的虚拟商品或者服务必须使用IAP进行购买支付,不允许使用包括Apple Pay在内的其它第三方支付,也不允许以任何方式引导用户通过应用外渠道购买。

应用内购准备工作主要包括三个部分:

  • 配置内购商品
  • 填写银行卡信息
  • 配置沙盒账号
2.1 App,商品信息,银行卡信息,测试账号配置

首先进入Itunes Connect网站,在这之前需要确保你已经拥有了个人开发者账号,公司开发者账号,但是不能是企业级开发者账号,并且公司开发者账号下的Member权限不能进入。

* 我的App主要用于管理自己的应用比如应用信息编辑,上架,下架操作。
* 销售和趋势主要是来查看App在各个平台的下载量,收入等方面数据。
* 付款和财务报告显示的是我们的收入以及付款等相关信息。
* 用户和职能用于生成相应账号,例如苹果沙河测试账号。
* 协议,税务和银行业务则是我们银行相关账户的信息设置。

商品信息配置

创建应用在之前的《iOS 上架流程》中已经介绍过了,这里就不重复了,在创建完应用后进入下面的页面:

点击加号就可以准备添加内购商品了。

在AppStore中售卖的商品可以分成如下几类:消耗型,非消耗型,自动续期订购,非续期订购这几种,详细说明可以见下图:

填写对应的内购项目信息:

产品 ID 必须具有唯一性,通常我们使用Bundle Identidier作为前缀,后面拼接上唯一的商品名或者 ID,这里需要注意的是一旦建立一个内购商品,那么它的产品ID将永远被占用,即使产品被删除也会一直存在,所以配置的时候要格外慎重。另外还需要注意价格等级这个概念,内购商品的定价只能从苹果提供的价格等级去选择,这个价格等级是固定的,同一价格等级会对应各个国家的货币,内购商品的价格是根据 Apple ID 所在区域的货币进行结算。

最后还可以配置App Store推广的功能。这样用户就可以在App Store 内 App 的下载页面内直接购买应用的内购商品

银行卡信息配置

点击协议,税务和银行业务,进行银行卡信息配置。这里我们需要申请的是iOS Paid Application合同,这里主要有Contract Info(联系信息),Bank Info(银行信息),Tax Info(税务信息)三大块。这部分配置可以参照iOS内购一条龙——账户信息填写来配置。

沙盒测试账号配置

沙盒在之前的支付宝,微信支付也提到过,为什么需要沙盒测试?因为如果我们用正式账号进行测试的话,即便是自己购买自己的东西,也会有30%的利润进入苹果的口袋,这对于测试那些比较贵的商品是很致命的,苹果为了解决这个问题,提供了一套沙盒体系,在沙盒里面,用户用的是沙盒账号,走的是沙盒接口,购买后的费用会原样退回。

从用户和职能入口进入添加沙盒测试的入口。


这里需要注意的是,电子邮件不能是已经注册过AppleId的邮箱,选择的地区会影响到结算的价格,如果需要测试多个不同地区的情况,可以申请多个不同地区的账号测试。沙盒测试不支持直接从AS下载的安装包,必须使用发布测试的ad hoc 证书或者Develop 证书签名过后的包,并且使用真机环境下进行测试。

在开始测试的时候先退出真机的App Store的真实Apple ID 账号,退出之后不需要在App Store 里面登录沙箱测试账号,然后去 App 里面测试购买商品,会弹出登录框,选择“使用现有的 Apple ID”然后登录沙箱测试账号,登录成功之后会弹出购买提示框,点击购买,然后会弹出提示框完成购买,弹窗上面有表明当前的环境是沙盒环境。

Xcode设置相关


2.2 IAP 流程

上面介绍了支付宝的交易流程,这里来看下IAP 流程。

  1. 首先我们进入购买页面的时候会从我们自己的服务器那边拉取Product ID列表。
  2. 拿到服务器返回的Product ID列表后会将这个列表发送到App Store获取用于展示的商品信息。
  3. 用户选中要购买的物品,这时候会向App Store发送一个交易请求。
  4. App Store 查询该交易的有效性后会通过delegte回调应用的方法,在这个方法中,应用将交易添加到交易队列从而发起交易,同时由于添加了交易Observer所以App Store交易状态改变的时候会通过Observer通知应用。
  5. App Store 调起弹窗让用户确认购买,输入密码,苹果服务器验证用户请求并从用户帐号扣款,一旦交易成功就会回调App, 通知购买成功,并把收据数据(里面记录了本次交易的证书和签名信息)写入到APP沙盒中。
  6. APP从沙盒中获取服务器下发的收据信息,并将收据信息上传给业务服务器。(其实这里既可以直接在 App 端验证也可以让服务器去验证,但是现在大多数是放在后台去做这一步校验)
  7. 业务服务器在收到客户端上传上来的收据数据后,会去App Store检查收据的有效性,验证成功之后在后台收据需要和自己的订单号进行映射并且记录在数据库,之后每次验证之前都需要先判断收据是否存在,防止App端重复上传相同的收据,重复发放内购商品,这之后通知客户端,同时发放商品,客户端收到结果后将该交易从支付队列中移除。

在获取商品列表的时候如果遇到获取不到商品信息的情况的话,可以从如下几个方面进行排查:

  1. 确定内购商品是否添加到正确的App中,虽然很少会犯这个错误,但是还是需要检查下。
  2. 使用非越狱的真机进行测试,看下是否能够拉取到。
  3. 后台配置的App Bundle ID是否和当前应用的App Bundle ID 一致。
2.3 相关代码
2.3.1 导入相关库

支付需要用到StoreKit.framework,在开始的时候需要导入到项目中。

#import <Foundation/Foundation.h>

typedef enum {
IDLIAPPurchSuccess = 0, // 购买成功
IDLIAPPurchFailed = 1, // 购买失败
IDLIAPPurchCancle = 2, // 取消购买
IDLIAPPurchVerFailed = 3, // 订单校验失败
IDLIAPPurchVerSuccess = 4, // 订单校验成功
IDLIAPPurchNotArrow = 5, // 不允许内购
} IDLIAPPurchStatus;

typedef void (^IDLIAPCompletionHandle)(IDLIAPPurchStatus status, NSData *data);

@interface IDLIAPManager : NSObject

+ (instancetype)shareManager;

- (void)purchWithID:(NSString *)purchID completeHandle:(IDLIAPCompletionHandle)handle;

@end


#import "IDLIAPManager.h"
#import <StoreKit/StoreKit.h>

@interface IDLIAPManager()< SKPaymentTransactionObserver , SKProductsRequestDelegate >

@property(nonatomic, copy , readwrite) IDLIAPCompletionHandle completeHandler;
@property(nonatomic, strong, readwrite) NSString *purchID;

@end


@implementation IDLIAPManager

#pragma mark - Singleton
+ (instancetype)shareManager{
static IDLIAPManager *manager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken,^{
manager = [[IDLIAPManager alloc] init];
});
return manager;
}

#pragma mark - Initializer
- (instancetype)init{
if (self = [super init]) {
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}
return self;
}

#pragma mark - Dealloc
- (void)dealloc{
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}

#pragma mark - Public
- (void)purchWithID:(NSString *)purchID completeHandle:(IDLIAPCompletionHandle)handle{
if (purchID) {
//先检查是否有付款权限
if ([SKPaymentQueue canMakePayments]) {
self.purchID = purchID;
self.completeHandler = handle;
//使用productIdentifiers 生成 SKProductsRequest
NSSet *productIdentifiers = [NSSet setWithArray:@[purchID]];
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
request.delegate = self;
[request start];
}else{
//没有权限购买
[self handleActionWithStatus:IDLIAPPurchNotArrow data:nil];
}
}
}

#pragma mark - Private

- (void)handleActionWithStatus:(IDLIAPPurchStatus)status data:(NSData *)data{
if(_completeHandler){
_completeHandler(status,data);
}
}

#pragma mark - SKProductsRequestDelegate

//[request start] 通过这个回调返回,这里会返回配置的商品信息
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
NSArray *product = response.products;
if([product count] <= 0){
return;
}

SKProduct *p = nil;
for(SKProduct *pro in product){
if([pro.productIdentifier isEqualToString:_purchID]){
p = pro;
break;
}
}
SKPayment *payment = [SKPayment paymentWithProduct:p];
[[SKPaymentQueue defaultQueue] addPayment:payment]; //这里发起一个购买的操作
}

//如果交易成功会通过之前监听的交易通知到这里
#pragma mark - SKPaymentTransactionObserver
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{
for (SKPaymentTransaction *tran in transactions) {
switch (tran.transactionState) {
case SKPaymentTransactionStatePurchased:
[self completeTransaction:tran];
break;
case SKPaymentTransactionStatePurchasing:
break;
case SKPaymentTransactionStateRestored:
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
break;
case SKPaymentTransactionStateFailed:
[self failedTransaction:tran];
break;
default:
break;
}
}
}

- (void)completeTransaction:(SKPaymentTransaction *)transaction{

NSURL *receiptUrl = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [NSData dataWithContentsOfURL:receiptUrl];

if (receiptData > 0) {
// 向自己的服务器验证购买凭证
// 如果校验成功调用[[SKPaymentQueue defaultQueue] finishTransaction:transaction]; 结束交易
}
}

- (void)failedTransaction:(SKPaymentTransaction *)transaction{
if (transaction.error.code != SKErrorPaymentCancelled) {
[self handleActionWithStatus:SIAPPurchFailed data:nil];
}else{
[self handleActionWithStatus:IDLIAPPurchCancle data:nil];
}
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
@end

为了不导致整个文章篇幅太长上面只是IAP的简要代码,真正的流程包括重试机制等。后续会把这部分代码整理下放到github上。

2.4 内购过程中可能会遇到的问题

这部分大家可以参阅:

开篇叨叨

目前推送已经成为了大多数应用的标配,通过推送能够及时地将信息送达到用户手中,可以说极大多数的产品运营都会借助推送功能来提升产品的打开率、使用率、存活率。 该篇博客将从如下几个方面对推送进行比较细致得介绍:

  1. 推送的种类
  2. 本地推送和远程推送
  3. 远程推送的服务扩展和内容扩展
推送的种类

在iOS开发中一般会遇到如下的三种推送类型:

  • 在线推送:在线推送一般指的是消息通过应用自建的网络长链接通道推送到应用本身。这种推送是应用自身的行为与苹果系统无关,与设置中是否打开“通知”无关,是基于自建的长链接来实现的,这个不是这篇文章的重点。后续如果有机会会另外开一篇博客专门介绍在线推送的细节。

  • 本地推送:这种推送是应用自身行为,不需要设备联网,也不经过APNs,但是它需要设备打开”通知”设置,比较常见的比如闹钟通知,待办事项提醒等。

  • 远程推送:远程推送是这篇博客的重心,它需要开启“通知”设置,并且需要联网状态,因为它需要设备与APNs保持着长链接。远程推送又成为离线推送,当APP在离线状体(kill掉进程、切到后台、锁屏)时可以正常收到推送的消息。

远程推送还可以细分为两种类型:普通远程推送和静默远程推送:
普通远程推送在收到推送的时候有文字和声音,点开通知,进入应用后才会执行:

[UNUserNotificationCenterDelegate didReceiveNotificationResponse:withCompletionHandler:]

而静默推送是没有文字和声音的,在不点开通知,不打开应用的情况下就能执行,用户完全感知不到。

[UIApplicationDelegate application:didReceiveRemoteNotification:fetchCompletionHandler:] 

下面是几种推送的简要对比:

|推送类型|是否需要打开“通知”|是否需要APNs|是否需要在前台|
|-|-|-|-|-|
|在线推送|不需要|不需要|需要|
|远程推送|需要|需要|不需要|
|本地推送|需要|不需要|不需要|

本地推送
  • 发送本地推送:
- (void)sendLocalNotification {

NSString *title = @"本地推送通知-title";
NSString *subtitle = @"本地推送通知-subtitle";
NSString *body = @"这是一条本地推送通知,请查收";
NSInteger badge = 1;
NSInteger timeInteval = 5;
NSDictionary *userInfo = @{@"userid": @"123456"};
if (@available(iOS 10.0, *)) {
// 1.创建通知内容
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.sound = [UNNotificationSound defaultSound];//通知的声音时长必须在30s内,否则将被默认声音替换,并且需要注意声音文件必须放到main bundle中
content.title = title;
content.subtitle = subtitle;
content.body = body;
content.badge = @(badge);
content.userInfo = userInfo;
// 2.设置通知附件内容
NSError *error = nil;
NSString *path = [[NSBundle mainBundle] pathForResource:@"testImage" ofType:@"png"];
UNNotificationAttachment *attr = [UNNotificationAttachment attachmentWithIdentifier:@"attr" URL:[NSURL fileURLWithPath:path] options:nil error:&error];
if (error) {
//附件添加错误
}
content.attachments = @[attr];
content.launchImageName = @"icon_launch";
// 3.触发模式
UNTimeIntervalNotificationTrigger *trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:timeInteval repeats:NO];
// 4.设置UNNotificationRequest
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:kLocalNotificationReqestIdentifer content:content trigger:trigger];
// 5.把通知加到UNUserNotificationCenter, 到指定触发点会被触发
[[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {}];
} else {
UILocalNotification *localNotification = [[UILocalNotification alloc] init];
// 1.设置触发时间,如果立即触发则不用设置该项
localNotification.timeZone = [NSTimeZone defaultTimeZone];
localNotification.fireDate = [NSDate dateWithTimeIntervalSinceNow:5];
// 2.设置通知标题
localNotification.alertBody = title;
// 3.设置通知动作按钮的标题
localNotification.alertAction = @"详情";
// 4.设置提醒的声音
localNotification.soundName =UILocalNotificationDefaultSoundName;
// 5.设置通知的 传递的userInfo
localNotification.userInfo = userInfo;
// 6.在规定的日期触发通知
[[UIApplication sharedApplication] scheduleLocalNotification:localNotification];
// 7.立即触发一个通知
//[[UIApplication sharedApplication] presentLocalNotificationNow:localNotification];
}
}

  • AppDelegate 回调:

iOS 10之前:

// 如果应用已经完全退出:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;
//获取userInfo可以通过如下方式获取:
// NSDictionary *userInfoLocal = (NSDictionary *)[launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];

// 如果应用还在运行,不论是在前台还是后台运行都会调用如下方法
- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification;

iOS 10之后:

- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler;

- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler;

远程推送

首先先放上官方远程推送的指导链接APNs Overview

远程推送证书申请

关于证书的申请可以参照下面这篇博客,写得比较详细之前也是参照这篇博客进行配置的。
iOS 远程推送证书详细制作流程

远程推送流程

远程推送涉及到如下几个方面:

  1. iOS 设备
  2. APNs
  3. 业务推送服务端

简单将整个推送的数据流向可以分成如下三个步骤:

1. 业务推送服务端将消息先发送到苹果的APNs
2. 由苹果的APNs将消息推送到客户iOS设备端
3. 由iOS系统将接收到的消息传递给相应的App

如下图所示:

APNs是Apple Push Notification service(苹果推送通知服务)的缩写,在Android平台上,推送是靠应用自身在后台维持客户端与业务推送服务器之间的一个长链接来实现的,但是在iOS系统中为了节省手机电池电量的损耗,系统不允许应用在后台进行过多的操作,一旦应用进入后台,系统只会分配少量时间给应用做必要的收尾工作,虽然我们可以再次申请,但是也是难以做到在后台长期驻留的,应用进入后台后虽然还不会从内存中移除,但是已经不运行任何代码了,这就导致了一旦应用进入后台将不能收到实时的信息。为了解决这个问题苹果推出了APNs,iOS系统自己做了个长连接,依托一个或几个系统常驻内存的进程运作,保持与APNs之间的通讯,接管所有应用的消息推送,独立于应用之外,而且这种长连接即使在手机休眠的时候也一直保持,iOS设备上的所有应用共用这一套长链接,包括iOS版本更新提示,手机时钟校准什么的也都是通过这个链接通知到设备上,设备上的应用通过deviceToken进行区分。

下面是远程推送的详细过程:

在我们应用启动的时候会调用向iOS系统请求deviceToken,iOS系统再将请求转发给APNs服务,这时候APNs在对设备进行校验之后就会把当前设备加入到Push服务的设备列表中,同时返给我们一个deviceToken。
下面是请求deviceToken的代码:

- (void)registerForRemoteNotifications:(UIApplication *)application{
if (@available(iOS 10.0, *)) {
UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert|
UNAuthorizationOptionSound|
UNAuthorizationOptionBadge;
[[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:authOptions completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (error) {
//........
}
if(!granted){
//用户关闭了权限
}
}];
[UNUserNotificationCenter currentNotificationCenter].delegate = self;
} else {
UIUserNotificationType allNotificationTypes =
(UIUserNotificationTypeSound | UIUserNotificationTypeAlert | UIUserNotificationTypeBadge);
UIUserNotificationSettings *settings =
[UIUserNotificationSettings settingsForTypes:allNotificationTypes categories:nil];
[application registerUserNotificationSettings:settings];
}
[application registerForRemoteNotifications];
}

在注册远程通知后,会通过如下的delegate方法返回deviceToken:

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken;

如果获取失败则会通过下面的方法返回错误信息:

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error;

deviceToken在app卸载后重装等情况时会变化,因此为确保deviceToken变化后app仍然能够正常接收服务器端发送的通知,建议每次启动应用都将获取到的deviceToken传给业务推送服务器,业务服务器将会这些deviceToken存到数据库中。当有需要被推送的消息时,业务推送服务器会按照苹果官方的消息格式组织消息内容,并带上目标应用的deviceToken 一并发给APNs服务器,下面是一个消息的内容例子:

{
"aps":{
"alert":{
"title":"这是一条推送测试消息标题",
"subtitle":"这是一条推送测试消息副标题"",
"body":"这是一条推送测试消息",
"title-loc-key":"TITLE_LOC_KEY",
"title-loc-args":[
"idl_01",
"idl_02"
],
"loc-key":"LOC_KEY",
"loc-args":[
"idl_01",
"idl_02"
]
},
"sound":"sound01.wav",
"badge":1,
"mutable-content":1,
"category":"realtime"
},
"msgid":"123456"
}

payload在iOS8之前最大限制是256字节,iOS8之后大小限制为2K字节,目前payload大小可以达到4K,对于VoIP最大尺寸可以达到5K.
APN (Apple Push Notification) payload size limit

APNs收到业务推送服务器发来的消息后,会在自己维护的Push设备列表中查找,找到匹配的设备后,由于我们的设备和APNs维持一个基于SSL协议的TCP流通讯长连接,APNs就可以通过这个长链接将新消息推送到我们设备上,然后由系统将消息呈现出来。

但是如果推送的时候deviceToken对应的机器在APNs服务器上处于离线状态,苹果会保存推送信息一段时间,在离线的设备恢复在线状态时,重新将推送信息到该机器。对于连续推送的情况下,APNs永远只存储最新的一条,上一条信息将会被抛弃,如果某个设备长时间不在线,APNs也会将消息丢掉。如果有多条推送任务时,苹果推荐使用单个连接持续发送,而不是重复的开关连接,否则会被苹果认为DOS攻击而断开连接。如果有多台服务器,可以并发连接到APNS,分摊推送任务,可以更高效的执行任务,发送多条推送任务时,如果其中有一条推送使用了错误的deviceToken,那么这条连接就会被断掉,导致后面的推送任务停止执行。

上面介绍的是正常的流程,但是如果应用从设备卸载后推送的消息又如何处理呢?如何让APNs和业务推送服务器都知道不去向这台卸载了应用的设备推送消息呢?这是通过APNs的Feedback service,APNs会持续的更新Feedback service的列表,当我们的业务推送服务器将信息发给APNs推送到我们的设备时,如果这时设备无法将消息推送到指定的应用,就会向APNs服务器报告一个反馈信息,而这个信息就记录在Feedback service中。因此业务推送服务器可以通过定时的去检测Feedback service的列表,然后删除在自己数据库中记录的存在于反馈列表中的 deviceToken,从而不再向这些设备发送推送信息,下面是从Feedback Service中读取到的过期设备的信息结构:

第一部分是设备失效后的时间信息
第二个部分是deviceToken的长度
第三部分就是失效的deviceToken

如果一个消息发送失败呢? 比如通知格式不正确或APNs无法解析,这时候APNs 会在大约 500ms 后断掉链接,在断链前发送的消息还没到达设备的消息也会发送失败,并在断链之前返回一个错误应答,带上发消息时的 Identifier 和一个错误码。如下图所示,这样我们就可以定位到到底那条消息发送失败,以及失败的错误码了,我们就可以根据返回的错误信息,对这部分消息进行重发,重发是通过发送缓存做的,维持一个较小的缓存,当收到APNs的错误信息时,从缓存中去除出错的那一条消息,剩下的进行重发。

下面是可能返回的错误码:

Status       codeDescription
0 No errors encountered
1 Processing error
2 Missing device token
3 Missing topic
4 Missing payload
5 Invalid token size
6 Invalid topic size
7 Invalid payload size
8 Invalid token
10 Shutdown
255 None (unknown)

设备收到消息会回调应用中的方法:

// iOS<10时,且app被完全杀死
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;

// 支持iOS7及以上系统
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler;

// iOS>=10: app在前台获取到通知
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler;

// iOS>=10: 点击通知进入app时触发(杀死/切到后台唤起)
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler;
Notification Extension 通知扩展

通知扩展是iOS 10之后添加的功能,它包括两个方面:

扩展 说明
通知服务扩展(UNNotificationServiceExtension) 在收到通知后且展示通知前允许开发者做一些事情,比如添加附件、加载网络请求等
通知内容扩展(UNNotificationContentExtension) 在展示通知时展示一个自定义的用户界面

通知服务扩展

上图是Service Extension的大致流程图,Notification的playload下发到达设备之前会经过Notification Service Extension,因此我们可以用extension修改推送内容,下载推送相关的资源,可以在extension中解密和加密的数据或下载推送相关的图片

在NotificationService.m文件中,有两个回调方法:

// 系统接到通知后,有最多30秒在这里重写通知内容,一般我们会在这里下载附件
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent *contentToDeliver))contentHandler;
// 如果修改内容任务没有完成,系统会调用 serviceExtensionTimeWillExpire 方法,给我们提供最后一次提供修改内容的机会。如果还是没有修改远程推送成功,系统将会展示远程推送最原始的内容。
- (void)serviceExtensionTimeWillExpire;

下面是NotificationService的一个模版:

#import "NotificationService.h"

@interface NotificationService ()

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@end

@implementation NotificationService

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {

self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];

//修改UNNotificationContent内容
self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [title modified]", self.bestAttemptContent.title];

NSDictionary *userInfo = self.bestAttemptContent.userInfo;
NSString *mediaUrl = userInfo[@"media"][@"url"];
NSString *mediaType = userInfo[@"media"][@"type"];
if (!mediaUrl.length) {
self.contentHandler(self.bestAttemptContent);
} else {
[self loadAttachmentForUrlString:mediaUrl withType:mediaType completionHandle:^(UNNotificationAttachment *attach) {
if (attach) {
self.bestAttemptContent.attachments = [NSArray arrayWithObject:attach];
}
self.contentHandler(self.bestAttemptContent);
}];
}
}

- (void)loadAttachmentForUrlString:(NSString *)urlStr withType:(NSString *)type completionHandle:(void(^)(UNNotificationAttachment *attach))completionHandler {
__block UNNotificationAttachment *attachment = nil;
NSURL *attachmentURL = [NSURL URLWithString:urlStr];
NSString *fileExt = [self getfileExtWithMediaType:type];
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
[[session downloadTaskWithURL:attachmentURL completionHandler:^(NSURL *temporaryFileLocation, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"加载多媒体失败 %@", error.localizedDescription);
} else {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *localURL = [NSURL fileURLWithPath:[temporaryFileLocation.path stringByAppendingString:fileExt]];
[fileManager moveItemAtURL:temporaryFileLocation toURL:localURL error:&error];
NSMutableDictionary * dict = [self.bestAttemptContent.userInfo mutableCopy];
[dict setObject:[NSData dataWithContentsOfURL:localURL] forKey:@"image"];
self.bestAttemptContent.userInfo = dict;
NSError *attachmentError = nil;
attachment = [UNNotificationAttachment attachmentWithIdentifier:@"attachment_downloaded" URL:localURL options:nil error:&attachmentError];
if (attachmentError) {
NSLog(@"%@", attachmentError.localizedDescription);
}
}
completionHandler(attachment);
}] resume];
}

- (void)serviceExtensionTimeWillExpire {
self.contentHandler(self.bestAttemptContent);
}

@end

测试消息格式如下:这样就会把图片下载并缓存到本地。


"aps":{
"alert":{
"title":"测试标题",
"subtitle":"子标题",
"body":"消息内容"
},
"sound":"default",
"badge":1,
"mutable-content":1,
"category":"CategoryIdentifier"
},
"msgid":"123456",
"media":{
"type":"image",
"url":"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1565629545485&di=7905ab7e4e3d0f6e4bea6385a481adc5&imgtype=0&src=http%3A%2F%2Fimg4.duitang.com%2Fuploads%2Fitem%2F201409%2F08%2F20140908154922_Gw3h5.jpeg"
}
}

这里需要注意如下几点:

1. didReceiveNotificationRequest里面加载数据的时间上限为30秒,如果在30秒时间内没有完成内容的加载,通知按系统默认形式弹出
2. UNNotificationAttachment的url接收的是本地文件的url,因此如果图片使用的是网络上的url那么就需要加载到本地并将本地url赋给UNNotificationAttachment
3. aps字符串中的mutable-content字段需要设置为1,才能修改
4. 在对NotificationService进行debug时,需要在Xcode顶栏选择编译运行的target为NotificationService,否则无法进行实时debug。
5. UNNotificationAttachment:attachment支持如下类型:
* 音频5M(kUTTypeWaveformAudio/kUTTypeMP3/kUTTypeMPEG4Audio/kUTTypeAudioInterchangeFileFormat)
* 图片10M(kUTTypeJPEG/kUTTypeGIF/kUTTypePNG)
* 视频50M(kUTTypeMPEG/kUTTypeMPEG2Video/kUTTypeMPEG4/kUTTypeAVIMovie)
通知内容扩展
#import "NotificationService.h"

@interface NotificationService ()

@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;

@end

@implementation NotificationService

- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
// 设置UNNotificationAction
UNNotificationAction * actionA =[UNNotificationAction actionWithIdentifier:@"ActionA" title:@"RequiredStyle" options:UNNotificationActionOptionAuthenticationRequired];
UNNotificationAction * actionB = [UNNotificationAction actionWithIdentifier:@"ActionB" title:@"DestructiveStyle" options:UNNotificationActionOptionDestructive];
UNNotificationAction * actionC = [UNNotificationAction actionWithIdentifier:@"ActionC" title:@"ForegroundStyle" options:UNNotificationActionOptionForeground];
UNTextInputNotificationAction * actionD = [UNTextInputNotificationAction actionWithIdentifier:@"ActionD"
title:@"InputDestructiveStyle"
options:UNNotificationActionOptionDestructive
textInputButtonTitle:@"Send"
textInputPlaceholder:@"input some words here ..."];
NSArray *actionArr = [[NSArray alloc] initWithObjects:actionA, actionB, actionC, actionD, nil];
NSArray *identifierArr = [[NSArray alloc] initWithObjects:@"ActionA", @"ActionB", @"ActionC", @"ActionD", nil];
UNNotificationCategory * notficationCategory = [UNNotificationCategory categoryWithIdentifier:@"IDL_CategoryIdentifier"
actions:actionArr
intentIdentifiers:identifierArr
options:UNNotificationCategoryOptionCustomDismissAction];
[[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObject:notficationCategory]];
// 设置categoryIdentifier
self.bestAttemptContent.categoryIdentifier = @"IDL_CategoryIdentifier";
//......
}
//.......
@end
#import "NotificationViewController.h"
#import <UserNotifications/UserNotifications.h>
#import <UserNotificationsUI/UserNotificationsUI.h>

@interface NotificationViewController () <UNNotificationContentExtension>

@end

@implementation NotificationViewController

- (void)viewDidLoad {
[super viewDidLoad];
//这里设置界面
}

- (void)didReceiveNotification:(UNNotification *)notification {
NSData *data = notification.request.content.userInfo[@"xxx"];
//这里根据收到的通知修改界面
}

//这里处理事件
- (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption))completion {
NSString identifier = response.actionIdentifier;
if ([identifier isEqualToString:@"ActionA"]) {

} else if ([response.actionIdentifier isEqualToString:@"ActionB"]) {

} else if ([response.actionIdentifier isEqualToString:@"ActionC"]) {

} else if ([response.actionIdentifier isEqualToString:@"ActionD"]) {

} else {
completion(UNNotificationContentExtensionResponseOptionDismiss);
}
completion(UNNotificationContentExtensionResponseOptionDoNotDismiss);
}

- (void)mediaPlay {
//按下播放按钮
}

- (void)mediaPause {
//按下停止播放按钮
}

@end

需要注意的点:

  1. 有如下几处CategoryIdentifier需要保持一致:

*收到消息中的category字段

{
"aps":{
"category":"IDLCategoryIdentifier"
}
}
  • 在NotificationService.m didReceiveNotificationRequest中设置category的值:
self.bestAttemptContent.categoryIdentifier = @"IDLCategoryIdentifier";
  • info.plist中关于category的配置
NSExtension ---> NSExtensionAttributes --> UNNotificationExtensionCategory ---> "IDLCategoryIdentifier"
  1. Notification Service Extension 的 target和Notification Conten Extension 的 target在配置中所支持的系统版本要在iOS10及以上
推送统计

在推送业务服务端在push中加一个类似pushId的字段在Notification Service Extension 中的didReceiveNotificationRequest方法中获取self.bestAttemptContent.userInfo
从中获取到pushId并通过post请求发给推送业务服务端进行push到达率统计。

如何提高消息送达率
1. 可以借助三方推送平台的统计功能,统计出每天送达率较高的时间段,在这个时间段内推送比较重要的,或者量比较大的消息
2. 通过APNs Feedback Service 定期清除错误的deviceToken
3. 我们在发送累计达到一定数值后sleep几秒,从而降低推送速率
4. 借助自建的在线推送渠道,如果用户在线则通过在线推送,只有用户不在线的情况下使用离线推送
各个版本推送演进过程
  1. playload 容量变化
版本 限制
x < iOS8 256字节
x >= iOS8 && x < iOS10 2KB
x >= iOS10 4KB
  1. 推送通知呈现样式变化
版本 呈现形式
x < iOS8 只有3个地方展示
x >= iOS8 && x < iOS10 提供Actions功能
x >= iOS10 提供快捷回复TextInput
  1. 证书文件的有效期
证书类型 有效期
Development Push SSL Certificate 大概四个月
ProductionPush SSL Certificate 大约一年
Others
  1. 免费用户不能使用推送
  2. 模拟器不能测试推送
  3. 目前比较流行的三方推送平台:个推,极光,信鸽,Firebase 等
  4. 推送测试软件:SmartPush,Knuff都一样,随便选一个。

Cocopods GitHub
Cocopods 使用说明

开篇叨叨

一般一款软件都往往由几个人同时协作开发,并且开发期间会使用多个开源库,从而避免重复造轮子,这样可以加快开发的速度,但是这也带来了一个问题:如何管理这些依赖,这是一个十分浪费时间并且容易出错的工作,我们不但要定期管理这些三方库的更新,还需要往目标项目工程上添加三方库所需要的依赖库,对于某些开源库可能还需要添加某些编译参数。稍稍完备一点的编程语言一般都会引入依赖管理工具,来减轻这些负担。比如Java语言的Maven,Nodejs的npm,Android的Gradle,Ruby的gem。iOS也有它的依赖管理工具 — CocoaPods。

CocoaPods 安装
  1. CocoaPods 是使用Ruby 实现的,它使用gem命令进行下载并安装,在安装前最好使用下面的命令来更新下gem
sudo gem update --system
  1. gem是从对应的Ruby软件库中下载对应的软件的,默认情况下使用的是https://rubygems.org它托管在亚马逊云服务上,国内可能会被墙,所以一般会使用国内的源来替换,如果你遇到了这个问题可以使用下面的命令来替换Ruby软件库:
gem sources -l                                  //查看Ruby数据源
gem sources --remove https://rubygems.org/ //移除原有的Ruby软件库
gem sources -a https://ruby.taobao.org/ //使用国内淘宝的Ruby软件库
  1. 安装最新版本的CocoaPods
sudo gem install cocoapods
  1. 安装指定版本的CocoaPods
sudo gem install cocoapods -v 1.4.0
  1. 查看当前本地安装的CocoaPods版本
gem list cocoapods
  1. 卸载当前安装的CocoaPods
sudo gem uninstall cocoapods

有时候卸载不干净可以通过上面的gem list cocoapods列出所有相关的库,并通过下面命令卸载

gem uninstall cocoapods
gem uninstall cocoapods-core
gem uninstall cocoapods-downloader
gem uninstall cocoapods-plugins
gem uninstall cocoapods-search
gem uninstall cocoapods-stats
gem uninstall cocoapods-trunk
gem uninstall cocoapods-try
  1. 初始化CocoaPods repo
pod setup

这一步是比较耗时的,它是将 pod repo 的 镜像索引信息下载到 ~/.cocoapods/repos目录下,如果这个进度实在等得太久了可以试着 cd 到那个目录,用du -sh *来查看下载进度,我们怎么知道我们有哪些repo呢?

可以使用pod repo命令来查看:

master
- Type: git (master)
- URL: https://github.com/CocoaPods/Specs.git
- Path: /Users/huya/.cocoapods/repos/master

URL表示远程的repo库地址,Path表示下载存放的地址。Type 是代码库类型,上图表示的是从https://github.com/CocoaPods/Specs.git这个地址,将镜像索引下载到本地的/Users/huya/.cocoapods/repos/master 文件夹下。

镜像索引其实是一个配置文件,里面包含了某个库当前的版本,这些库的地址,如下图所示:

我们可以手动添加删除pod repo库

pod repo remove master
pod repo add master https://gitcafe.com/akuandev/Specs.git

当然可以通过在Podfile中通过source来添加,这个后面会举例说明。

一旦setup之后,后续如果需要更新repo库可以通过来更新。

pod repo update
CocoaPods 常用命令行
  1. 初始化Pod
pod init

这时候会在当前目录下新创建一个Podfile,后面将专门介绍如何编写Podfile

  1. 安装依赖
pod install

这时候会参照Podfile从~/.cocoapods/repos/对应的目录下寻找对应库的下载地址并将库下载到Pod文件夹下,然后生成对应的workspace文件,这个在后面介绍原理的时候再详细介绍

  1. 查找对应的pod库
pod search

如果只是开发的话一般上面几个命令就够用了

  1. 使用指定版本执行命令
pod 1.4.0 install
  1. install 的时候输出详细过程Log
pod install –verbose
  1. 更新某一个组件
// 不添加组件名则更新所有
pod update [组件名]
  1. 更新本地依赖

如果 github 或者私有仓库上面有最新版本,本地搜到的还是旧版本。如果 Podfile 中使用新的版本号,这样是无法执行成功的,这时候必须对本地依赖库进行一次更新。

pod repo update
多版本CocoaPods管理

有时候会遇到比如工作中我们协商好都用1.4.0 但是我们自己的某些练习项目需要用到1.8.4 这时候是最头疼的一件事情,为了解决这个问题我目前使用Bundler来管理各个项目的CocoaPods版本:

安装bundler:

gem install bundler

和Cocoapods的Podfile文件一样,我们需要创建一个Gemfile文件,文件位置和Podifle所在位置相同即可,可以使用:

bundle init

在Gemfile文件中,配置所需的Cocoapods版本:

source "https://rubygems.org"
gem 'cocoapods', '1.8.4'

执行bundle install

之后就可以在相应位置,执行bundle exec pod xxxxx 就可以了。

****Podfile ****

最全面的还是官方文档:Podfile 官方文档

一个比较简单的Podfile如下所示

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '9.0'

target 'IDLFundation' do
# Pods for IDLFundation
end

target 'IDLFundationTest' do
# Pods for IDLFundationTest
end
1. install

这个命令是cocoapods声明的一个安装命令,用于安装引入Podfile里面的依赖库,也就是pod install执行时的一些参数设置,在项目中没有使用过,在这里先不做过多介绍,后面如果有机会会带大家过一下CocoaPods源码,到时候再看下这个配置的作用。

2. source

source 用于指定specs的位置,sources的顺序是有关系的。CocoaPods将使用pod第一次出现的source中的最高版本.cocoapods 官方source是隐式的需要的,如果只有一个cocoapods官方的source则可以省去不写,但是一旦你指定了其他source 你就需要也把官方的指定上。

source 'https://github.com/CocoaPods/Specs.git'
3. target

target指明当前的依赖是针对哪个项目target的,可以在target块里面为某个指定的target定义依赖项,一般我们会通过def定义一个依赖集合,然后在不同的target引用,

def common_Pods
#架构基本
pod 'ReactiveObjC', '~> 3.1.1'
pod 'PromiseKit', '~> 1.0'
pod 'Aspects', '~> 1.4.1'
pod 'Objection', '~> 1.6.1'
pod 'BlocksKit', '~> 2.2.5'

//.....
end

platform :ios, '9.0'


target 'IDLFundation' do
common_Pods
# Pods for IDLFundation
end

target 'IDLFundationTest' do
common_Pods
# Pods for IDLFundationTest
//........
pod 'LookinServer', :configurations => ['Debug']
pod 'Reveal-SDK', :configurations => ['Debug']
end
4. platform

platform指定了静态库应该被编译在哪个平台.下面是各个平台platform的默认值。

iOS -> 4.3
OS X -> 10.6
tvOS -> 9.0
watchOS -> 2.0
5. inhibit_all_warnings

inhibit_all_warnings! 用于屏蔽cocoapods库里面的所有警告,它也可以用于屏蔽某个库的编译警告

pod 'SSZipArchive', :inhibit_warnings => true

6. use_frameworks

use_frameworks用于告诉CocoaPods我们想使用Frameworks而不是Static Libraries。不显式指定的话会默认使用Static Libraries,会在Pods工程下的Products目录下生成.a的静态库,如果指定的话会在Pods工程下的Frameworks目录下生成依赖库的framework,由于Swift不支持静态库,所以如果项目中使用到Swift库的话就必须使用use_frameworks!,纯OC项目是不用use_frameworks的。

7. Pod

指定每个target的依赖项

  • 如果后面不写依赖库的具体版本号,那么cocoapods会默认选取最新版本,一般不推荐使用这种方式,因为库升级的时候可能会带来不兼容或者其他怪异的问题,一般我们项目中都不会使用这种方式,而是在使用库的时候明确指明库对应的版本,如果要升级库也要我们明确,库的升级是否会给我们带来问题,并通过测试后才能升级发布,否则一个项目中有几十个库,很难追踪问题是哪个库升级导致的。
pod 'SSZipArchive'
  • 要特定的依赖库的版本,只需要在后面写上具体版本号即可:
pod 'Objection', '0.9'
  • 当然除了指定特定版本外还可以通过下面的来指定版本范围,但是个人还是推荐明确指明所依赖的库的版本。
* > 0.1 高于0.1版本(不包含0.1版本)的任意一个版本
* >= 0.1 高于0.1版本(包含0.1版本)的任意一个版本
* < 0.1 低于0.1版本(不包含0.1版本)的任意一个
* <= 0.1低于0.1版本(包含0.1版本)的任意一个
* ~> 0.1.2 版本 这个例子等效于>= 0.1.2并且 <0.2.0,并且始终是你指定范围内的最新版本,一般同一个版本簇的API都是需要兼容的,所以这也是一个比较合理的方式,但是很难避免某些三方库的开发人员没有这方面的考虑和支持,因此还是推荐明确指定使用的版本。
  • pod 中还可以指定当前依赖只在某些给定的build configuration中被启用,比如:
pod 'LookinServer', :configurations => ['Debug']
  • 正常情况下我们会通过依赖库的名称来引入,但是有些依赖库是有多个子依赖的,比如:
pod 'Firebase/Analytics'
pod 'Firebase/Performance'
pod 'Firebase/RemoteConfig'

这种除了使用上面写法外,还可以通过Subspecs来代替,但是个人还是推荐上面的写法比较简单:

pod 'Firebase', :subspecs => ['RemoteConfig']
  • 使用本地依赖
pod 'AFNetworking', :path => '~/Documents/AFNetworking'
  • 使用指定远程依赖

引入master分支(默认)

pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git'

引入指定的分支

pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :branch => 'dev'

引入某个Tag标签的代码

pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :tag => '0.7.0'

引入某个特殊的提交节点

pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :commit => '082f8319af'
  • 从另一个源库引入
pod 'PonyDebugger', :source => 'https://github.com/CocoaPods/Specs.git'
pod 'JSONKit', :podspec => 'https://example.com/JSONKit.podspec'

podspec 用于指定podspec的信息:

# 不指定表示使用根目录下的podspec,默认一般都会放在根目录下,并且使用库名作为名称
podspec
# 如果podspec的名字与库名不一样,可以通过这样来指定
podspec :name => 'DemoPodSpect'
# 如果podspec不是在根目录下,那么可以通过:path来指定路径
podspec :path => '/Documents/PrettyKit/PrettyKit.podspec'
8. project

用于指定当前target要应用到哪个project,这个用在多project的情况下,如果没有指定说明该target要应用到Podfile目录下与target同名的工程

# MusicApp这个target只有在MyMusic工程中才会链接
target 'MusicApp' do
project 'MyMusic'
...
end

# NotesApp这个target只有在MyNotes工程中才会链接
target 'NotesApp' do
project 'MyNotes'
...
end
9. workspace

默认情况下不需要指定,直接使用与Podfile所在目录的工程名一样就可以了。如果要指定另外的名称,而不是使用工程的名称,可以使用workspace来指定:

workspace 'MyWorkspace'
10. def

def命令来声明一个pod集, 然后在需要引入的target中引入:

def common_Pods
#架构基本
pod 'ReactiveObjC', '~> 3.1.1'
pod 'PromiseKit', '~> 1.0'
pod 'Aspects', '~> 1.4.1'
pod 'Objection', '~> 1.6.1'
pod 'BlocksKit', '~> 2.2.5'

//.....
end

platform :ios, '9.0'

target 'IDLFundation' do
common_Pods
# Pods for IDLFundation
end

target 'IDLFundationTest' do
common_Pods
# Pods for IDLFundationTest
//........
pod 'LookinServer', :configurations => ['Debug']
pod 'Reveal-SDK', :configurations => ['Debug']
end
11. pre_install

这个允许用户在Pods下载完成,但还未安装前对Pods做一些修改,它有一个唯一参数Pod::Installer

pre_install do |installer|
#......
end
12. post_install

当我们安装完成,但是生成的工程还没有写入磁盘之时,我们可以指定要执行的操作。比如,我们可以在写入磁盘之前,修改一些工程的配置:
这两个在项目中暂时没有遇到过。

post_install do |installer| installer.pods_project.targets.each do |target| 
target.build_configurations.each do |config|
config.build_settings['GCC_ENABLE_OBJC_GC'] = 'supported'
end
end
end

下面是一个比较全的一个Podfile模版,供大家参考:

source 'https://github.com/CocoaPods/Specs.git' # 组件依赖文件所存放仓库,根据需求可引入多个
source 'https://github.com/artsy/Specs.git'

platform :ios, '8.0' #
inhibit_all_warnings! # 忽视引用的代码中的警告
workspace 'CocoaPodsDemo' # 指定生成的 workspace 名字

def common_pods # 如果有多个 target,可以将公共部分进行 def 定义再引入
pod 'xxx'
end

target 'CocoaPodsDemo' do
project 'DemoProject' # 可用于指定实际的工程
use_frameworks! # 是否以 framework 形式引入。swift 必须有这个关键字
common_pods # 公共引入的组件
pod 'SSipArchive', :inhibit_warnings => true # 屏蔽某个 pod 的 warning
pod 'AFNetworking', '3.2' # 使用 3.2 版本
pod 'YYCache', '~> 0.3' # pod update 时最高升级到 < 1.0,不包括 1.0

# Build 环境配置
pod 'PonyDebugger', :configurations => ['Debug', 'Beta']
pod 'PonyDebugger', :configuration => 'Debug'

# 使用具体的某个 subspec
pod 'QueryKit/Attribute'
pod 'QueryKit', :subspecs => ['Attribute', 'QuerySet']

# 引用本地组件
pod 'AFNetworking', :path => '~/Documents/AFNetworking'

# 使用具体仓库
pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git'
# 使用具体仓库具体分支
pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :branch => 'dev'
# 使用具体仓库的某个 tag
pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :tag => '0.7.0'
# 使用具体仓库的某个 commit
pod 'AFNetworking', :git => 'https://github.com/gowalla/AFNetworking.git', :commit => '082f8319af'

# 使用指定路径的 spec 文件
pod 'JSONKit', :podspec => 'https://example.com/JSONKit.podspec'

target 'ShowsApp' do
pod 'ShowsKit'

# Has its own copy of ShowsKit + ShowTVAuth
target 'ShowsTV' do
pod 'ShowTVAuth'
end

# Has its own copy of Specta + Expecta
# and has access to ShowsKit via the app
# that the test target is bundled into
target 'ShowsTests' do
# inherit! 有三种类型:':complete' 继承父级所有行为;':none' 什么行为都不继承;':search_paths' 继承父级的 search paths
inherit! :search_paths
pod 'Specta'
pod 'Expecta'
end
end
end

# hook 配置, 在 preparing 阶段后,install 之前
pre_install do |installer|

end

# hook 配置,在 pod install 之后,可用于修改工程配置等
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['GCC_ENABLE_OBJC_GC'] = 'supported'
end
end
end

CocoaPods 工作原理

关于CocoaPods的工作原理的细节大家可以翻看下CocoaPods的源码看下整个流程:
下面推荐几篇CocoaPod源码解析文章给大家,大家可以结合这些文章对源码进行解析:

这里只想从使用者的角度来介绍下CocoaPods工作原理,主要包括

  1. CocoaPods的组成
  2. CocoaPods的工作流
  3. pod install,pod update,Podfile.lock,Manifest.lock
  • CocoaPods的组成

CocoaPods 本身是由Ruby编写的,它是由多个Ruby包组成的,最主要包括CocoaPods Core,CocoaPods Downloader,Xcodeproj,CLAide,Molinillo这些Ruby包,这些包是通过gem进行管理的,当我们执行pod 命令的时候就会调用对应的模块完成对应的任务,下面是对应模块的源码地址,作用,以及对应的帮助文档,大家在遇到问题的时候可以查找这些资料来解决问题。

模块 说明 帮助文档
CocoaPods/Specs 三方库Podspec文件托管仓库 Doc
CocoaPods CocoaPod 命令行工具 Guide
CocoaPods Core CocoaPods 相关文件(Specification,Podfile,Source)的处理 Guide
CocoaPods Downloader 支持git/SVN/等多种协议的代码下载器 Guide
Xcodeproj 创建和修改Xcode projects文件 Guide
CLAide 命令行接口框架 Guide
Molinillo 通用依赖解析器 Guide
  • CocoaPods的工作流

整个CocoaPods涉及到了三方组件库的开发者,CocoaPods Repo,三方组件库的使用者,三方面对象,整个关系如下图所示:

  1. 首先三方组件库的开发者编写完三方库的代码后会将组件代码上传到GitHub/SVN等代码托管仓库,然后创建一个 podspec 文件,该文件包含了该组件包含的代码及资源,以及依赖关系,版本信息,以及该组件的存储地址,然后将这个podspec推送到CocoaPods共有仓库或者私有Spec管理仓库。

  2. 在运行pod setup或者pod install的时候会查看source指令,将source指令所指定的repo 下载到/.cocoapods/repos目录下。这个文件夹下包含了各个三方组件对应版本以及该版本的podspec文件和podspec.json文件,后续需要下载的时候就可以根据podspec中指定的下载路径下载三方组件了。如果有一个第三方库发布了一个最新的版本,如果不执行pod repo update,那么本地是不会知道有一个最新版本的,还一直以本地的资源目录为准。那么我们永远都拿不到这个库的最新版本,但是有时候我们不执行pod repo update发现也可以拿到最新的库,那是因为pod update会先拉取远程最新目录,再根据目录中的资源重新更新一遍pod,但是如果podfile没有为每个库指定明确的版本,那么每次都会拉取一遍最新库,这时候如果不想每次都拉取,可以使用pod update –no-repo-update。正常情况下pod repo update 会将/.cocoapods/repos/下的所有组件库都更新一遍,如果只想更新某个私有库那么只需要带上需要更新的文件夹就可以只更新某个repo了。

    pod repo update ~/.cocoapods/repos/XXX/
  3. 三方组件库使用者要使用某个组件,需要在Podfile中指定组件名字,版本,repo 源,然后运行pod install命令,CocoaPods 会首先使用eval运行Podfile,并开始解析Podfile。

    pod install可以分成如下阶段

  • prepare 准备阶段
    在该阶段首先会先检查当前运行目录是否是项目根目录,为啥要在根目录?因为pod init的时候需要从.xcodeproj中获取target信息,这就导致了生成的Podfile在项目根目录,而pod install需要Podfile所以也必须需要在根目录。接着检查Podfile.lock文件cocoapods和当前的cocoapods版本是否一致,Podfile中的plugin插件是否已经安装完成并加载,一旦这些检查完毕就会创建Pods以及子目录,并运行pre_install。

  • resolve_dependencies 解决依赖冲突
    这个阶段主要是解析podfile文件中的pod 以及 target等信息以及之间的关系,存储到对应的数据结构中。如果Podfile中有删除的库, 先进行文件清理。

  • download_dependencies 下载依赖
    这个阶段会拿着组件名,组件版本到~/.cocoapods/repos/中去寻找对应的podspec,在podspec文件中找到三方组件存放的地址,从而从远程下载。当然不是每个都需要下载,如果某个组件已经下载,并且版本没有变化的情况下就不会从远程下载。

  • validate_targets target校验
    这阶段将会对下载的依赖进行校验,比如校验是否有多重引用framework 或者 library 的情况,检查不同target所使用的swift版本是否相同,如果使用swift的情况下,检查Podfile是否添加了use_frameworks!。

  • generate_pods_project 生成 Pod project 文件
    这阶段将会生成Pods.xcodeproj工程文件,并将下载的依赖文件,Library 加入工程,处理 target 依赖,并将项目文件以及Pods项目文件添加到新生成的.xcworkspace文件。

  • pod install,pod update,Podfile.lock,Manifest.lock

其实上面已经对这部分内容有所介绍了,这里将这些内容放在一起对比下会更加明显:

在项目第一次使用Cocoapods时候,或者在podfile文件中增加或者删除某个组件的时候需要使用pod install而不是pod update。

当运行pod install,它只解析Podfile.lock中没有的pod的依赖库.对于Podfile.lock中已经有的组件库, Podfile.lock不会尝试检查~/.cocoapods/repos是否有更新的版本.对于没有在Podfile.lock中列出的组件库,pod会搜索与Podfile匹配的版本或最新的版本,并将每个组件已经安装的版本写入到Podfile.lock中.也就是说Podfile.lock 的功能是用于跟踪每个组件的已安装版本并锁定这些版本。

简单说:pod install会优先考虑Podfile里指定的版本信息,其次考虑Podfile.lock 里指定的版本信息来安装对应的依赖库,而不会每次都考虑~/.cocoapods/repos的最新版本

当运行pod update的时候CocoaPods将尝试查找更新的组件版本, 并且会忽略掉Podfile.lock中已经存在的版本.

在多人协作的项目中一般需要将Podfile.lock文件提交到版本控制库中,这样大家就会保证同一时刻使用的三方组件库是一致的,只有在确认某个更新需要同步的时候,某人运行pod update后将Podfile.lock再次提交到代码仓库,其他人拉取到这个最新的Podfile.lock的时候就会提示需要更新,这时候其他人就也需要使用pod update 更新本地的三方组件,但是不论怎样,整个团队的三方组件库总是一致的。

上面介绍了Podfile.lock 那么 Manifest.lock 的作用又是什么?Manifest.lock 是 Podfile.lock 的副本,每次只要生成 Podfile.lock 时就会生成一个一样的 Manifest.lock 存储在 Pods 文件夹下。在每次项目 Build 的时候,会跑一下脚本检查一下 Podfile.lock 和 Manifest.lock 是否一致。也就是说Manifest.lock是用于记录本地/Pod目录下各个组件库的版本信息,那不是和Podfile.lock重复了么?不是的,因为一般我们会将Podfile.lock放到代码仓库中,这部分是有可能其他人远程改动后,我们拉下来,这时候有可能导致Podfile.lock中指定的组件版本和本地的组件版本不一致,那么我们怎么发现这种不一致呢?靠Podfile?这是不可能的,一般就是因为Podfile改了,pod update后导致Podfile.lock发生改动,这样如果没有Manifest.lock 就难以判断远程的是否和本地的版本有差异了,也就是说Manifest.lock 用于标记本地sandbox中三方组件的版本,这也是为什么有了Podfile.lock之后还需要Manifest.lock 的原因了。

CocoaPods 私有库/共有库 制作依赖库
1 创建组件库工程

将代码clone到本地,在根目录运行:

pod lib create IDLUtils

会自动引导我们创建一个组件库:

这时候会生成IDLUtils.podspec文件,文件内容如下:

#
# 在提交前需要先运行 pod lib lint IDLUtils.podspec 来对当前文件进行校验,并且删除包括所有无用的注释。
#
# 如果有关于Podspec 的可以查看 https://guides.cocoapods.org/syntax/podspec.html 文档
#
#

Pod::Spec.new do |s|

# 组件名
s.name = 'IDLUtils'
# 组件版本号,命名规则遵循 https://semver.org/
s.version = '0.0.1'
# 概要
s.summary = 'IDLUtils is a Collection of Utils Help to speed up your develop.'

# 这个描述会出现在搜索结果上,因此我们可以描述以下信息:
#
# * 这个库的功能是什么?为什么要实现它,它的关注点在哪里
# * 尽量简单明了.
# * 在下面的DESC分隔符之间写下描述
# * 最后,不要担心缩进,CocoaPods会自动删除它

s.description = "IDLUtils is a Collection of Utils Help to speed up your develop. Hope you like it "

# 仓库主页
s.homepage = 'http://coderlin.coding.me'
# s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
# 遵循的协议
s.license = { :type => 'MIT', :file => 'LICENSE' }
# 作者
s.author = { 'tbfungeek' => 'tbfungeek@163.com' }
# 在线源码仓库
s.source = { :git => 'https://github.com/tbfungeek/IDLUtils.git', :tag => s.version.to_s }
# 作者社交联系地址
# s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'

# 目标版本
s.ios.deployment_target = '9.0'
# 源文件
s.source_files = 'IDLUtils/Classes/**/*'

#资源
# s.resource_bundles = {
# 'IDLUtils' => ['IDLUtils/Assets/*.png']
# }

#公开的头文件
# s.public_header_files = 'Pod/Classes/**/*.h'
#依赖的库
# s.frameworks = 'UIKit', 'MapKit'
# s.dependency 'AFNetworking', '~> 2.3'
end

下面是pod spec create 创建的大家可以对比下,这种方式生成的选项更多,但是pod lib create 对于简单的组件已经够用了。

#
# 在提交前需要先运行 pod spec lint IDLKeyChainTool.podspec 来对当前文件进行校验,并且删除包括所有无用的注释。
#
# 如果要查看Podspec的属性可以查看:http://docs.cocoapods.org/specification.html
#

Pod::Spec.new do |s|

# ――― Spec Metadata ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 这些信息主要用于方便使用者能够快速查找到我们这个组件库,所以最好写得简单明了
#
# 组件名
s.name = "IDLKeyChainTool"
# 组件版本号,命名规则遵循 https://semver.org/
s.version = "0.0.1"
# 概要
s.summary = "A short description of IDLKeyChainTool."

# 这个描述会出现在搜索结果上,因此我们可以描述以下信息:
#
# * 这个库的功能是什么?为什么要实现它,它的关注点在哪里
# * 尽量简单明了.
# * 在下面的DESC分隔符之间写下描述
# * 最后,不要担心缩进,CocoaPods会自动删除它
# 详细描述,很重要的
s.description = "This is a KeyChain Tool for Objective C"
# 仓库主页
s.homepage = "http://EXAMPLE/IDLKeyChainTool"
# 展示截图
# s.screenshots = "www.example.com/screenshots_1.gif", "www.example.com/screenshots_2.gif"

# ――― Spec License ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 当前组件库所使用的证书,可以查看 http://choosealicense.com 对应的说明,CocoaPods将会查看对应的组件库是否有文件名为LICENSE*的文件
# 比较常见的有 'MIT', 'BSD''Apache License, Version 2.0' 类型
#
# 许可证
s.license = "MIT (example)"
# s.license = { :type => "MIT", :file => "FILE_LICENSE" }

# ――― Author Metadata ――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 这里可以指定作者名称,邮箱,以及社交网站主页地址
#
s.author = { "Xiaohai.lin" => "tbfungeek@163.com" }
# Or just: s.author = "Xiaohai.lin"
# s.authors = { "Xiaohai.lin" => "tbfungeek@163.com" }
# s.social_media_url = "http://twitter.com/Xiaohai.lin"

# ――― Platform Specifics ――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 这里可以指定对应的平台信息
#
# s.platform = :ios
s.platform = :ios, "9.0"

# When using multiple platforms
# s.ios.deployment_target = "5.0"
# s.osx.deployment_target = "10.7"
# s.watchos.deployment_target = "2.0"
# s.tvos.deployment_target = "9.0"
# ――― Source Location ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 这里用于指定到哪里获取代码,CocoasPods支持git, hg, bzr, svn, HTTP等协议的代码托管地址
#

s.source = { :git => "https://github.com/tbfungeek/IDLKeyChainTool.git", :tag => "#{s.version}" }

# ――― Source Code ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# CocoaPods 中一般使用一个文件夹作为代码文件夹,文件夹下可以包含
# swift, h, m, mm, c/cpp文件,只要在s.source_files中指定就好
# s.exclude_files中指定的文件会被剔除,而s.public_header_files文件将会对全局可见
#

s.source_files = "Classes", "Classes/**/*.{h,m}"
s.exclude_files = "Classes/Exclude"
# 引入的共有头文件
# s.public_header_files = "Classes/**/*.h"
# 引入的私有头文件
#spec.private_header_files = 'Headers/Private/*.h'

# ――― Resources ――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#
# 下面指定的资源将会通过build phase脚本拷贝到指定的bundle。
#

# s.resource = "icon.png"
# s.resources = "Resources/*.png"
# s.preserve_paths = "FilesToSave", "MoreFilesToSave"

# ――― Project Linking ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 该组件库相关的frameworks以及libraries,其中libraries不用包括它的名字前缀
#

# s.framework = "SomeFramework"
# s.frameworks = "SomeFramework", "AnotherFramework"

# s.library = "iconv"
# s.libraries = "iconv", "xml2"

# ――― Project Settings ――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 在这里可以指定对应的编译选项以及依赖的三方库
#

s.requires_arc = true
# s.xcconfig = { "HEADER_SEARCH_PATHS" => "$(SDKROOT)/usr/include/libxml2" }
# s.dependency "JSONKit", "~> 1.4"

# ――― Subspecs ――――――――――――――――――――――――――――――――――――――――――――――――――――――――― #
#
# 将组件分为多个子组件,接入方可以根据需求只接入几个子组件,减少包体积
#subspec 'Twitter' do |sp|
# sp.source_files = 'Classes/Twitter'
#end

# 测试组件
#spec.test_spec do |test_spec|
# test_spec.source_files = 'NSAttributedString+CCLFormatTests.m'
# test_spec.dependency 'Expecta'
#end
# 默认子组件。也就是当接入方不作区分时,直接使用组件名引入时,所引入子组件
#spec.default_subspec = 'Core'
end

 

1 创建GitHub 代码仓库

这里需要创建两个GitHub库,一个是Spec仓库,一个是组件代码仓库,我们这里先介绍组件代码仓库,最后的时候会给大家介绍Spec仓库:

首先创建一个代码仓库用于存放组件代码,如下所示:

https://github.com/tbfungeek/IDLUtils.git

git add .
git commit -m "xxxxxxxxxx"
git remote add origin https://github.com/tbfungeek/IDLUtils.git
//git pull --rebase origin master
//解决冲突
git push -u origin master

将组件代码push到远端仓库,新建tag

git tag 0.0.1

将本地的tag推送到远程仓库

git push --tags

注意tag号要和s.version保持一致

在上面工作完成后运行pod lib lint IDLUtils.podspec对podspec文件进行校验。如果遇到有问题可以通过****–verbose****来看详细的过程。

下面是可能会遇到的导致校验不过的可能问题:

情景一:

[!] IDLUtils did not pass validation, due to 1 warning (but you can use `--allow-warnings` to ignore it).
You can use the `--no-clean` option to inspect any issue.

这种情况下通过****–verbose**** 看下如果warning没问题可以通过****–allow-warnings**** 忽略错误。

情景二:

.podspec error - source_files` pattern did not match any file

这种一般是只是将文件放置到Class目录,没有将Class添加到XCode的引用关系中。

情景三:

Could not find a `ios` simulator, Ensure that Xcode -> Window -> Devices has at least on

升级cocoaPods版本。

情景四:

[!] Found multiple specifications

将私有仓库拉到本地时可能会存在两个。移除重复的库。

有时候你会发现什么都对,但是结果不对,你就可以进入对应的缓存中查看下实际的内容是怎样的?或者使用下面的命令清除缓存看下:

rm ~/Library/Caches/CocoaPods/search_index.json
rm -fr ~/Library/Caches/CocoaPods/Pods/External/IDLUtils
rm -fr ~/Library/Caches/CocoaPods/Pods/Specs/External/IDLUtils/
3 注册CocoaPods
pod trunk register tbfungeek@163.com 'tbfungeek' --description='tbfungeek'

这时候注册邮箱会收到一份验证邮件,通过邮件链接可以完成注册

4 将podspec push 到 CocoaPods Specs
pod trunk push IDLUtils.podspec
创建私有Spec 仓库
1.新建私有仓库

目前github也支持私有仓库了,所以可以在github,gitlab上创建一个。

https://github.com/tbfungeek/IDLPodSpecs.git
2.将私有仓库添加到本地
pod repo add IDLPodSpecs https://github.com/tbfungeek/IDLPodSpecs.git

这时候会将IDLPodSpecs clone 到 ~/.cocoapods/repos 目录

3. 提交 podspec 至私有 Spec 仓库
pod repo push IDLPodSpecs IDLUtils.podspec
4. 在项目中应用该私有库

在Podfile 头部添加repo 源

source 'https://github.com/tbfungeek/IDLPodSpecs.git'
source 'https://github.com/CocoaPods/Specs.git'

接下来就可以使用了

更深入学习

学习了上面的技术够一般项目使用了,但是随着项目推进,你会发现项目编译速度会越来越慢,甚至达到难以容忍的地步,这时候就需要进行CocoaPods 的组件二进制化,这样就省去了各个组件库的编译速度。这个会在后续章节中专门开一篇博客进行介绍,大家如果感兴趣可以事先了解下,下面是一篇个人认为写得比较好的一篇博客,推荐给大家。

基于 CocoaPods 的组件二进制化实践
Google – CocoaPods 的组件二进制化

开发中常用的LLDB调试命令

启动                      run
调试可执行文件 lldb /Projects/Sketch/build/Debug/Sketch.app
调试运行时带参数的可执行文件 lldb -- DebugDemo.run [参数列表]
调试某个正在运行中的进程
1.启动lldb lldb
2.附到某个进程 process attach --pid 9939 或者 process attach --name Safari
查看代码 list 或者 l
看其他文件的代码 list 文件名 然后在用l来查看
看某个函数的代码 list main

breakpoint [断点管理]
breakpoint set --file foo.c --line 12
b main.m:127 推荐这种写法
breakpoint set --selector alignLeftEdges:
b functionName:
b +[NSSet setWithObject:]


breakpoint list
breakpoint enable <breakpointID>
breakpoint disable <breakpointID>
breakpoint delete <breakpointID>

调试
c[继续运行] n[Step over] s[Step into] finish[Step out]

变量输出
p 变量 [打印出某个变量的详细信息]
默认的格式 p 16
十六进制: p/x 16
二进制: p/t 16
po 变量 [打印出某个变量的简要信息]

p $0 = 23 [修改变量值]

[代码帧调试]
bt 当前栈信息 配合up down 指令使用
frame select 0 [查看某个栈代码]
frame variable [查看方法的调用者及方法名称]

[地址映射到代码]
image lookup -a 栈地址 寻找栈地址对应的代码位置

安装 LLDB插件 [chisel 及 LLDB]
![chisel](https://github.com/facebook/chisel)
![LLDB](https://github.com/DerekSelander/LLDB)

查看某个类或者实例的方法
methods IDLZipTool

taplog 点击控件,会打印控件的地址,大小及透明度等信息 后面跟的控件id可以用于后续操作
flicker 控件会闪烁
hide 0x7f7edd64b280 隐藏控件
show 0x7f7edd64b280 显示控件
border 0x7f7edd64b280 -c red -w 10 给控件加边框
pclass encryptStr 打印继承关系
presponder 0x7faa9455d2f0 打印响应链

给某个控件设置背景
(lldb) p 0x7faa9455d2f0
(long) $44 = 140370609820400
(lldb) p (void)[$44 setBackgroundColor:[UIColor redColor]]
(lldb) caflush
pviews 打印继承树

较好的文章推荐

[1]. LLDB调试利器及高级用法
[2]. 使用LLDB调试程序
[3]. Chisel-LLDB命令插件,让调试更Easy
[4]. GDB to LLDB command map
[5]. The LLDB Debugger
[6]. Debug on the iOS Simulator with LLDB
[7]. 跳舞吧!与LLDB共舞华尔兹

XCode 常用快捷键

1. 工程导航器:Command+1
在光标定位到导航栏区域后按左右键可用于展开和合并目录
Command + 上下按钮用于在不显示具体文件的情况下移动选中文件
如果不按command 只按上下键 则边移动边选中文件

显示/隐藏实用工具面板:Command+Option+0
Command 1 - 8 对应导航栏面板上的8个按钮
显示/隐藏导航器面板:Command+0
c.command + shift + Y 隐藏调试栏

2. 快速查找某个文件 Command + Shift + O
在文件导航栏上定位文件 Command + Shift + J
快速跳转到类的特定行command + L
文件跳转栏:Control+6(键入方法/变量名+Enter跳转)
Show Related ItemsControl + 1(注:可以查看光标所在方法的callers和callees)
类文件".h"与“.m”之间切换:control+command+↑/control+command+↓

3. 代码操作快捷键

注释代码: 快捷键:command + /
代码右缩进 : command + [
代码左缩进 : command + ]
快速创建文件 command + N
删除整行:先把光标移到行末,再操作Command + delete
向上/下 移动整行:Option + Command + [ / ]
双击某个分隔符(如()、[]、{} 等),Xcode会选中匹配代码块

在项目导航器中选中文件执行Option+左键点击操作。
搜索导航器(Find Navigator,也就是搜索):Command+Shift+F

3. 调试快捷键
运行app: Command + R
清除工程: Command + Shift + K
清除控制台打印信息:command+K
F6单步调试、F7跳入,F8继续
Command + \: 设置或取消断点
Command + Option + \: 允许或禁用当前断点

1. 多线程时间分片模式:

多数现代操作系统使用时间分片模式来管理线程的,它会将CPU运行时间分割成一个个时间片,一般在10-100毫秒之间不等,当线程被分配到执行时间后,系统将会将该线程的堆栈以及寄存器加载到CPU,并将旧线程的堆栈和寄存器数据保存起来,这就是所谓的上下文切换,这个切换也是需要耗费时间的,如果将时间片分割得太短就会导致过度频繁的上下文切换,从而导致大量的CPU时间浪费在切换上。这也是自旋锁存在的原因,这个在后面会详细介绍。

今天介绍的主题是线程安全,之前我们说过同一进程的线程之间是共享资源的,并且多线程是并发执行的,在这种并发环境下资源共享带来的一个问题就是资源竞争,竞争的资源可以是:锁、网络连接、通知事件,磁盘、带宽,以及一切可以被称作“资源”的东西,如果没有对共享资源进行保护,就会导致多个线程对资源进行修改,从而导致资源状态不确定,也就是我们所说的数据竞态

但是并不是所有情况下多线程访问共享资源都会存在这些问题。
以下这些情况就不存在线程安全的情况:

  • 多线程串行访问共享资源,这也是很多时候解决数据竞态的一种方案,将访问资源的操作串行化。
  • 多线程并行情况下但是这些线程都是访问共享资源而不去修改共享资源,比如多个线程同时读一个文件。

所以发生数据竞态的条件有两个:

1. 至少有两个线程同时访问同一个资源
2. 至少其中有一个是改变资源的状态,比如写操作
2. 基础概念
  1. 临界区:指的是一块对公共资源进行访问的代码
  2. 竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件
  3. 死锁:是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去

死锁发生的条件:

  • 互斥条件:线程对资源的访问是排他性的,如果一个线程占用了某个资源,那么其他线程等待,直到锁被释放。
  • 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。
  • 保持和请求条件:线程T1至少已经保持了一个资源R1占用,但又提出对另一个资源R2请求,而此时,资源R2被其他线程T2占用,于是该线程T1也必须等待,但又对自己保持的资源R1不释放。
  • 环路等待条件:在死锁发生时,必然存在一个“线程-资源环形链”,即:{p0,p1,p2,…pn},进程p0(或线程)等待p1占用的资源,p1等待p2占用的资源,pn等待p0占用的资源。
  1. 资源饥饿: 当一个线程一直无法得到自己的资源而一直无法进行后续的操作时,我们称这个线程会饥饿而死。

  2. 优先级反转:
    优先级反转是在高优级(假设为A)的任务要访问一个被低优先级任务(假设为C)占有的资源时被阻塞.
    而此时又有优先级高于占有资源的任务(C),而低于被阻塞的任务(A)的优先级的中间优先级任务(假设为B)进入时,这时候,占有资源的任务(C)就被挂起(占有的资源仍为它占有),因为占有资源的任务优先级很低,所以,它可能一直被另外的任务挂起.而它占有的资源也就一直不能释放,这样,引起任务A一直没办法执行.而比它优先低的任务却可以执行.

 

解决方案:

  • 优先级继承:将低优先级任务的优先级提升到等待它所占有的资源的最高优先级任务的优先级.当高优先级任务由于等待资源而被阻塞时,此时资源的拥有者的优先级将会自动被提升.
  • 优先级天花板:将申请某资源的任务的优先级提升到可能访问该资源的所有任务中最高优先级任务的优先级
    两者区别:
    优先级继承,只有当占有资源的低优先级的任务被阻塞时,才会提高占有资源任务的优先级,而优先级天花板,不论是否发生阻塞都提升.
  1. 原子操作: 一条不可打断的操作,在单处理器环境下,一条汇编指令是原子操作,但一句高级语言的代码却不是原子的,因为它最终是由多条汇编语言完成
  2. 可重入:当子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的
  3. 线程安全:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
3. iOS多线程开发中的常用锁:
3.1 互斥锁

互斥锁在出现锁的争夺时,未获得锁的线程会主动让出时间片,阻塞线程并睡眠,CPU会通过上下文切换,让其它线程继续运行。互斥锁用于保证某个资源只允许一个线程访问。

3.1.1 NSLock

NSLock 内部封装了一个 pthread_mutex,属性为 PTHREAD_MUTEX_ERRORCHECK, 它比pthread_mutex多了错误提示,也正式这个原因它比pthread_mutex性能上要慢,但是由于它在内部使用了缓存机制,所以性能上不会相差很多。但是它使用的时候需要注意的是加锁和解锁需要成对出现,并且在解锁之前不可以进行再次加锁。否则会造成死锁

NSLock *lock = [NSLock alloc] init];

// 加锁
[lock lock];
/*
* 被加锁的代码区间,在这里可以访问需要锁保护的资源
*/
// 解锁
[lock unlock];

NSLock死锁的例子:

- (void)recursiveFunc:(NSInteger)value {
[self.lock lock];
if(value != 0) {
--value;
[self recursiveFunc:value];
}
[self.lock unlock];
}

如果不清楚的话展开就应该很容易明白了

[self.lock lock];
if(value != 0) {
--value;
[self.lock lock];//其实这里就因为等待锁而阻塞了
if(value != 0) {
--value;
}
//...........
}
[self.lock unlock];

最后再强调下在使用NSLock的时候注意加解锁需要在统一线程中,并且在解锁之前不能重复加锁。

3.2 递归锁
3.2.1 NSRecursiveLock

递归锁在被同一线程重复获取时不会产生死锁。它会记录上锁和解锁的次数,当二者平衡的时候,才会释放锁,其它线程才可以上锁成功

@property (nonatomic, strong) NSRecursiveLock *recursiveLock;
_recursiveLock = [[NSRecursiveLock alloc] init];

[_recursiveLock lock];
/*
* 被加锁的代码区间,在这里可以访问需要锁保护的资源
*/
[_recursiveLock unlock];

3.2.2 synchronized

@synchronized 结构在工作时为传入的对象分配了一个递归锁。它需要使用一个唯一的标识用来区分保护锁。


@try {
objc_sync_enter(obj);
// do work
} @finally {
objc_sync_exit(obj);
}

当调用 objc_sync_enter(obj) 时,它用 obj 内存地址的哈希值查找合适的 SyncData(包含传入对象和一个递归锁的结构体),然后将其上锁。当你调用 objc_sync_exit(obj) 时,它查找合适的 SyncData 并将其解锁。

@synchronized(object)指令使用的 object 为该锁的唯一标识,只有当标识相同时,才满足互斥

优点:使用起来十分简单不需要在代码中显式的创建锁对象,便可以实现锁的机制,并且不用担心忘记解锁的情况出现。同时synchronized不需要像NSLock一样需要考虑在加解锁时需要在同一线程中的问题,也不需要考虑同一个线程中连续加锁的问题。
缺点:性能较差,一般用在多线程情况下访问属性的情况

注意:如果在 @sychronized(object){} 内部object 被释放或被设为nil,没有问题,但如果 object 一开始就是nil,则失去了锁的功能。

- (void)setIntegerValue:(NSInteger)intValue {
@synchronized (self) {
_intValue = intValue;
}
}

3.3 自旋锁:

自旋锁与互斥锁有点类似,只是自旋锁被某线程占用时,其他线程不会进入睡眠状态等待,而是一直轮询查询直到锁被释放
由于不涉及用户态与内核态之间的切换,它的效率远远高于互斥锁。
但是自旋锁也有很明显的不足:

  • 自旋锁一直占用CPU,在未获得锁的情况下会占用着CPU一直运行,如果不能在很短的时间内获得锁,这无疑会使CPU效率降低。
  • 自旋锁可能会引起优先级反转问题。如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,自旋锁会处于忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。

所以一般自旋锁只有在内核可抢占式比较适用,在单CPU且不可抢占式的内核下自旋锁只适用于锁使用者保持锁时间比较短的情况

3.3.1 OSSpinLock
// 初始化
spinLock = OS_SPINKLOCK_INIT;
// 加锁
OSSpinLockLock(&spinLock);
// 解锁
OSSpinLockUnlock(&spinLock);

目前OSSpinLock存在优先级反转的问题,在使用的时候需要十分注意。
不再安全的 OSSpinLock

3.3.2 os_unfair_lock

os_unfair_lock是替代OSSpinLock的产物,iOS 10.+ 之后添加的,也是属于忙等锁。

#import <os/lock.h>

os_unfair_lock_t unfairlock = &(OS_UNFAIR_LOCK_INIT);

os_unfair_lock_lock(unfairlock);
/*
* 被加锁的代码区间,在这里可以访问需要锁保护的资源
*/
os_unfair_lock_unlock(unfairlock);
3.4 信号量

加锁时会把信号量的值减一,并判断是否大于零。如果大于零,立刻执行。如果等于零的时候将会等待,在资源使用结束的时候释放信号量让信号量增加1。并唤醒等待的线程。
信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。

3.4.1 dispatch_semaphore
dispatch_semaphore_t signal = dispatch_semaphore_create(1);
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 3.0f * NSEC_PER_SEC);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
dispatch_semaphore_wait(signal, timeout);
/*
* 被加锁的代码区间,在这里可以访问需要锁保护的资源
*/
dispatch_semaphore_signal(signal);
});
3.5 条件锁

条件锁一般常用于生产者–消费者模式

3.5.1 NSCondition

NSCondition同样实现了NSLocking协议,可以当做NSLock来使用解决线程同步问题,用法完全一样。但是性能相对更差点,除了lock 和 unlock,NSCondition提供了更高级的用法wait/signal/broadcast:wait 进入等待状态,当其它线程中的该锁执行signal 或者 broadcast方法时,线程被唤醒,继续运行之后的方法。其中 signal 和 broadcast 方法的区别在于,signal 只是一个信号量,只能唤醒一个等待的线程,想唤醒多个就得多次调用,而 broadcast 可以唤醒所有在等待的线程。

@property (nonatomic, strong) NSCondition *condition;
_condition = [[NSCondition alloc] init];

- (void)conditionLockTest {
dispatch_queue_t queue = dispatch_queue_create("com.idealist.locktest", DISPATCH_QUEUE_CONCURRENT);
for (NSInteger i=0; i<10; i++) {
dispatch_async(queue, ^{
[self conditionAdd];
});
}
for (NSInteger i=0; i<10; i++) {
dispatch_async(queue, ^{
[self conditionRemove];
});
}
}

- (void)conditionAdd {
[_condition lock];
// 生产数据
NSObject *object = [NSObject new];
[_ticketsArr addObject:object];
[_condition signal];

[_condition unlock];
}

- (void)conditionRemove {

[_condition lock];
if (!_ticketsArr.count) {
[_condition wait];
}
[_ticketsArr removeObjectAtIndex:0];
[_condition unlock];
}

3.5.2 NSConditionLock

NSConditionLock 借助 NSCondition 来实现,内部持有一个 NSCondition 对象,以及 _condition_value 属性

lockWhenCondition:方法是当condition参数与当前condition相等时才可加锁
unlockWithCondition:方法是解锁之后修改 Condition 的值

// 设置条件
#define IDL_NO_DATA 100
#define IDL_HAS_DATA 101

// 初始化条件锁对象
@property (nonatomic, strong) NSConditionLock *conditionLock;
// 实例化
_conditionLock = [[NSConditionLock alloc] initWithCondition:IDL_NO_DATA];

// 调用测试方法
- (void)conditionLockTest {
dispatch_queue_t queue = dispatch_queue_create("com.idealist.conditionlocktest", DISPATCH_QUEUE_CONCURRENT);

for (NSInteger i=0; i<10; i++) {
dispatch_async(queue, ^{
[self conditionLockAdd];
});
}

for (NSInteger i=0; i<10; i++) {
dispatch_async(queue, ^{
[self conditionLockRemove];
});
}
}

- (void)conditionLockAdd {

[_conditionLock lockWhenCondition:IDL_NO_DATA];

NSObject *object = [NSObject new];
[_ticketsArr addObject:object];
[_condition signal];

[_conditionLock unlockWithCondition:IDL_HAS_DATA];
}

- (void)conditionLockRemove {

[_conditionLock lockWhenCondition:IDL_HAS_DATA];

if (!_ticketsArr.count) {
[_condition wait];
}
[_ticketsArr removeObjectAtIndex:0];
[_conditionLock unlockWithCondition:IDL_NO_DATA];
}

3.6 读写锁

读写锁把对共享资源的访问者划分成读和写,在多处理器系统中,它允许同时有多个读来访问共享资源,最大可能的读者数为实际的逻辑CPU数。但是写操作是排他性的。
所以如下情况可以并发进行:

* 多个读,没有写操作
* 一个写操作,多个读操作
3.6.1 dispatch_barrier_async / dispatch_barrier_sync

具体用法见iOS多线程总结 基本用法

  • 共同点:1、等待在它前面插入队列的任务先执行完;2、等待他们自己的任务执行完再执行后面的任务。
  • 不同点:1、dispatch_barrier_sync将自己的任务插入到队列的时候,需要等待自己的任务结束之后才会继续插入被写在它后面的任务,然后执行它们;2、dispatch_barrier_async将自己的任务插入到队列之后,不会等待自己的任务结束,它会继续把后面的任务插入队列,然后等待自己的任务结束后才执行后面的任务。
3.6.1 pthread_rwlock
#import <pthread.h>
__block pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock,NULL);
pthread_rwlock_rdlock(&rwlock);
pthread_rwlock_unlock(&rwlock);
pthread_rwlock_wrlock(&rwlock);
3.7 atomic

atomic用于保证属性setter、getter的原子性操作,在getter和setter内部加了线程同步的锁,但是它并不能保证使用属性的过程是线程安全的

4. 其它保证线程安全的方式

使用异步串行队列来将访问资源的操作串行化

5. pthread的各种同步机制
6. 性能对比

在介绍多线程编程的时候我们需要明确一般会在什么场合上使用多线程,我们知道每个进程一定有一个线程–主线程,在这个线程中一般用于更新界面相关的任务,一般任务又可以分成耗时的和非耗时操作,计算密集型的任务和IO密集型任务就属于耗时任务,比如读写数据库,读写磁盘文件,访问网络等,这些一般放在子线程中完成,但是一般在任务完成等时候都会将结果呈现在界面上,这时候就需要在主线程中完成。这个大家应该都知道,但是往往很多人会有误区,是不是线程越多越好,答案是否定的,创建的线程过多有如下问题:

  • 从空间角度来看:每个线程都需要占用一定的内存空间,如果开启大量的线程,会占用大量的内存空间,降低程序的性能
  • 线程切换需要上下文切换,这就需要耗费一定的时间,线程越多,CPU在调度线程上的开销就越大,同样会降低程序的性能
  • 线程越多,线程关系越复杂,线程竞争,线程管理,以及死锁等其他多线程问题发生的概率就会相应的增加。

因此合理得管理多线程是十分必要的工作。

下面将从:
1.多线程基本概念
2.线程通讯
3.线程同步,线程安全

三个大方面对iOS多线程技术进行一个简要的总结

1.多线程基本概念
1.1 多线程编程的基本概念

进程: 进程是指系统中正在运行的一个应用程序,进程之间是独立的,有自己专用且受保护的内存空间。
线程: 是操作系统能够进行运算调度的最小单位,是一个CPU执行的一条无分叉的命令序列,进程是由至少一个线程(主线程)构成,同一进程中的多个线程将共享该进程中的全部系统资源,如虚拟地址空间、文件描述符等。但每个线程都拥有自己的栈,寄存器,本地存储
并行,串行: 是针对线程队列的,表示一次可以执行多少个线程,串行队列每次只能执行一个线程,并行队列可以同时执行多个线程。
同步,异步: 是针对线程行为的,指明线程的执行是否要等到任务返回后再往下执行。
线程安全: 指代码在多线程或者并发任务下能够被安全调用,而不会引起任何问题
线程生命周期

线程生命周期可以分成如下5个阶段:

  • NEW - [新建状态] 表示线程被新建的状态
  • RUNNABLE - [可运行状态] 新建的线程并不一定会马上被CPU调度,而是进入一个中间状态RUNNABLE状态,等待被CPU调度,处于阻塞状态的线程恢复后不会立刻切换到RUNNING状态,而是先切换到RUNNABLE状态。
  • RUNNING - [正在运行状态] 当CPU调度发生,并任务队列中选中了某个RUNNABLE线程时,该线程会进入RUNNING 执行状态。
  • BLOCKED - [阻塞状态] 由于调用了睡眠,IO阻塞,等待锁的时候会处于阻塞状态。
  • TERMINATED - [终结状态] 终结状态是线程的最终状态,处于此状态中的线程不会切换到以上任何状态,一旦线程进入了终结状态,就意味着这个线程生命的终结。

我们来简单介绍下线程这五种状态的切换图:

新创建的线程会进入NEW状态,如果有线程正在运行则处于NEW状态的线程会转换到RUNNABLE状态,等待被CPU调度,一旦正在运行的线程让出了CPU时间,CPU就会从处于RUNNABLE状态的线程中取出优先级最高的线程,进入RUNNING状态,处于RUNNING状态的线程一旦调用了睡眠,IO阻塞,等待锁的时候会处于BLOCKED状态。一旦阻塞的条件解除了就会进入RUNABLE状态,不论是处于RUNNING状态还是RUNNABLE状态,还是BLOCKED状态只要调用了stop方法,就会进入TERMINATED状态

1.2 iOS多线程实现方案对比

iOS 多线程方案有如下几种:

  • pthread
    语言: C 语言
    优点:跨平台,可移植
    缺点:需要自己管理线程生命周期,所以用得比较少

  • NSThread
    语言: OC 语言
    优点:是针对pthread的面向对象的封装
    缺点:需要自己管理线程生命周期,使用上还是显得比较麻烦,一般用于查看当前线程状态等不涉及线程周期的场景。

  • GCD
    语言: C 语言
    优点:能够充分发挥多核的特性,自动管理线程生命周期不需要手动管理

  • NSOperation&&NSOperationQueue
    语言: OC 语言
    优点:基于GCD 底层的面向对象封装,添加了线程依赖,并发数控制等功能

1.3 iOS多线程组成:

iOS平台多线程的组成如下图所示:

顶层包括两大部分,一部分是基于OC语言的NSThread和NSOperationQueue类,它们建立在Core Services层的Foundation框架之上,同时也提供了一套基于C语言的GCD线程池函数库来支持多线程的处理应用,这两部分的底层都是基于POSIX标准中的pthread线程库。用户态下的线程创建通过系统调用到达内核态的BSD层并创建bsdthread对象,而BSD层则调用Mach层的ksthread对象来完成最终线程的创建和调度的。

1.4 NSThread的使用

NSThread的创建:

/* 
param 1:要执行的方法,
param 2:提供selector的对象,通常是self,
param 3:传递给selector的参数
这种方式是默认start的
*/
[NSThread detachNewThreadSelector:(nonnull SEL)> toTarget:(nonnull id) withObject:(nullable id)]

/*
param 1:提供selector的对象,通常是self
param 2:要执行的方法
param 3:传递给selector的参数
这种方式需要手动start
*/
NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(doSomething) object:nil];

/*
param 1:调用的方法
param 2:传给selector方法的参数
隐式创建线程
*/
- (void)performSelectorInBackground:(SEL)aSelector withObject:(id)arg

NSThread常见属性:

//只读属性,线程是否在执行
thread.isExecuting;
//只读属性,线程是否被取消
thread.isCancelled;
//只读属性,线程是否完成
thread.isFinished;
//是否是主线程
thread.isMainThread;
//线程的优先级,取值范围0.0到1.0,默认优先级0.5,1.0表示最高优先级,优先级高,CPU调度的频率高
thread.threadPriority;
//线程的堆栈大小,线程执行前堆栈大小为512K,线程完成后堆栈大小为0K
thread.stackSize;

NSThread常用方法:

[thread start]; 启动线程
[NSThread exit]; 退出线程
[NSThread isMainThread]; 当前线程是否为主线程
[NSThread isMultiThreaded]; 是否多线程
[NSThread mainThread]; 返回主线程的对象
[NSThread currentThread];(1 表示主线程,其他表示后台线程)
[NSThread sleepUntilDate:[NSDate date]]; (休眠到指定时间)
[NSThread sleepForTimeInterval:4.5]; (休眠指定时长)

线程之间的通信:

// 在主线程上执行操作
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray<NSString *> *)array;

// 在指定线程上执行操作
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait modes:(NSArray *)array;
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL)wait;

// 在当前线程上执行操作,调用 NSObject 的 performSelector:相关方法
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
1.5 GCD 的使用

使用GCD 需要明确:需要执行哪些操作,要投递到哪种分发队列,怎么执行这些任务串行还是并行。它有两个核心概念“任务”和“队列”,我们只需专注于想要执行的“任务” block,然后添加到适当的“队列”中,剩余的多线程生命周期管理以及多CPU任务分配问题都是GCD来替我们完成。

1.5.1 GCD 的队列类型

GCD 有两大类队列:

  • 串行队列(Serial Dispatch Queue):

串行队列每次只能执行一个任务,但是在应用中可以创建多个串行队列

dispatch_queue_t queue = dispatch_queue_create(“com.idealist.test”, DISPATCH_QUEUE_SERIAL);

iOS 默认创建的主线程就是串行队列,获取串行队列可以通过如下方法获取:

dispatch_get_main_queue()

串行队列的一个很重要的用途就是用于解决数据竞争,因为处于同一个串行队列中两个任务不可能并发运行,所以就没有可能会同时访问同一个临界区的风险。所以仅对于这些任务而言,这种运行机制能够保护临界区避免发生竟态条件

  • 并行队列(Concurrent Dispatch Queue):

并行队列和散弹枪一样每次可以同时执行多个任务,但是在系统中对同时执行的任务数是有限制的,这取决于CPU核数以及CPU负载等因素决定。

dispatch_queue_t queue = dispatch_queue_create(“com.idealist.test”, DISPATCH_QUEUE_CONCURRENT);

和串行队列一样iOS系统为创建并行队列增加了全局并行队列:

dispatch_get_global_queue()

全局队列有四个优先级:

DISPATCH_QUEUE_PRIORITY_HIGHT       高优先级
DISPATCH_QUEUE_PRIORITY_DEFAULT 默认先级
DISPATCH_QUEUE_PRIORITY_LOW 低先级
DISPATCH_QUEUE_PRIORITY_BACKGROUND 后台优先级

1.5.2 任务派发函数
dispatch_sync(queue, ^{
// 这里放同步执行任务代码
});

dispatch_async(queue, ^{
// 这里放异步执行任务代码
});

dispatch_sync 函数将一个任务添加到一个队列中,会阻塞当前线程,直到该任务执行完毕。dispatch_async 不会等待任务执行完,当前线程会继续往下走,不会阻塞当前线程。

在使用的时候需要特别注意不要往当前队列中使用dispatch_sync抛任务,这样很容易造成死锁。

1.5.3 GCD 停止和恢复
dispatch_suspend(queue) //暂停某个队列  
dispatch_resume(queue) //恢复某个队列
1.5.4 任务和队列的搭配情况

我们知道任务有同步任务和异步任务,队列有串行队列和并行队列之分。所以具体就有4种组合:

在介绍这四种方式的行为之前大家要记住一点,同步方式是不会创建新线程的,异步方式会创建新线程。

  • [同步 + 串行]:

这种方式没有开启新线程,串行执行任务,并且要注意这种情况很容易造成死锁,如果一个消息队列向自己消息队列中投放任务这时候就会造成死锁。

dispatch_queue_t disqueue =  dispatch_queue_create("com.idealist.test", DISPATCH_QUEUE_SERIAL);
dispatch_sync(disqueue, ^{
NSLog(@"disqueue队列向disqueue队列投放任务");
dispatch_sync(disqueue, ^{
NSLog(@"这里由于死锁不能执行");
});
});

只有在发送任务的队列和任务队列不是同一个队列的时候才会正常执行,注意这里是在同一个线程

dispatch_queue_t disqueue =  dispatch_queue_create("com.idealist.disqueue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t workqueue = dispatch_queue_create("com.idealist.workqueue", DISPATCH_QUEUE_SERIAL);
dispatch_sync(disqueue, ^{
NSLog(@"在disqueue任务队列上向workqueue队列发送同步任务");
dispatch_sync(workqueue, ^{
sleep(10);
NSLog(@"这里会阻塞disqueue队列");
});
NSLog(@"workqueue执行完毕后才会继续往下执行");
});

这种用得不多。

  • [异步 + 串行]:

这种情况只会生成一个线程,同一个队列的任务在同一个线程执行。

dispatch_queue_t disqueue =  dispatch_queue_create("com.idealist.disqueue", DISPATCH_QUEUE_SERIAL);
dispatch_async(disqueue, ^{
sleep(8);
});
dispatch_async(disqueue, ^{
sleep(10);
});

这种比较常用,在不阻塞工作线程外,还能避免资源的竞争。

  • [同步 + 并行]:

这种情况下没有开启新的线程,并且任务也是一个个运行的。虽然并发队列可以开启多个线程同时执行多个任务。但是因为同步任务不具备开启新线程的能力,只有当前线程这一个线程,所以也就不存在并发。而且同步任务需要等待队列的任务执行结束之后,才能继续接着执行下面的操作,所以任务只能一个接一个按顺序执行,不能同时执行。

  • [异步 + 并行]:
    这种情况下可开启多个线程,同时执行多个任务,但是这种由于任务并行的顺序不确定性,会很容易出错。

1.5.5 GCD 任务组 (dispatch_group)
  • “对于并行队列,以及多个串行、并行队列混合的情况我们如何知道所有任务都已经执行完了”
  • “如何在某些任务执行完毕后,执行一个操作“

遇到这种情况我们就可以使用GCD 任务组来解决,GCD 任务组 能够在任务组中的任务执行完毕后,执行某个任务。

//创建调度组
dispatch_group_t group = dispatch_group_create();

//将调度组添加到队列,执行 block 任务,如果提交到queue中的block全都执行完毕会调用dispatch_group_notify并且dispatch_group_wait会停止等待。
dispatch_group_async(group, queue, block);

//阻塞当前线程,等待 group 关联的所有 block 执行完毕或者到达指定时间。如果到达指定时间后,所有任务并没有全部完成,那么 dispatch_group_wait 返回一个非 0 的数,可以根据这个返回值,判断是否等待超时。如果设置为 DISPATCH_TIME_FOREVER ,意思是永远等待,直到所有 block 执行完毕,
//需要注意的是dispatch_group_wait是同步的所以不能放在主线程执行。
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

//当调度组中的所有任务执行结束后,获得通知,统一做后续操作,一个group可以关联多个任务队列dispatch_group会等和它关联的所有的dispatch_queue_t上的任务都执行完毕才会发出同步信号
dispatch_group_notify(group, dispatch_get_main_queue(), block);


//如果我们要提交到调度组中的操作是同步的,可以通过dispatch_group_async,但是如果操作是异步的,那么就需要借助dispatch_group_enter///dispatch_group_leave 两个操作了,这两个操作功能如下:

//将任务放到任务组
void dispatch_group_enter(dispatch_group_t group);
//将任务从任务组取出
void dispatch_group_leave(dispatch_group_t group);

//需要注意的是:
//* dispatch_group_enter必须在dispatch_group_leave之前出现,当dispatch_group_leave比dispatch_group_enter多调用了一次或者说在dispatch_group_enter之前被调用的时候会触发程序的崩溃。

//* dispatch_group_enter和dispatch_group_leave必须成对出现,当调用了dispatch_group_enter而没有调用dispatch_group_leave时dispatch_group_notify中的任务无法执行或者dispatch_group_wait收不到信号而卡住线程。

dispatch_group_async(group, queue, ^{ 
  // 同步耗时操作
});

等价于

dispatch_group_enter(group);
dispatch_async(queue, ^{
  // 同步耗时操作
  dispatch_group_leave(group);
});

dispatch_group 例子:

dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
writeFile();
});

dispatch_group_enter(group);
[self sendHttpRequestWithCompletion:^(id response) {
//操作
dispatch_group_leave(group);
}];

dispatch_group_enter(group);
[self sendHttpRequestWithCompletion:^(id response) {
//操作
dispatch_group_leave(group);
}];

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"完成任务!");
});
1.5.5 GCD 其他方法
  • GCD 栅栏方法:dispatch_barrier_async

要异步执行多组操作,且前一组操作执行完之后,才能开始执行后一组操作。这种情况就需要用到栅栏来隔离。

dispatch_queue_t queue = dispatch_queue_create("com.dnduuhn.test", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue, ^{
// 追加任务1
});
dispatch_async(queue, ^{
// 追加任务2
});

dispatch_barrier_async(queue, ^{
// 追加任务 barrier
});

dispatch_async(queue, ^{
// 追加任务3
});
dispatch_async(queue, ^{
// 追加任务4
});

在上面任务中任务3,任务4 会在 任务1,任务2之后执行。

  • GCD 延时执行方法:dispatch_after

延迟一段时间后执行某个操作:

dispatch_after函数传入的时间参数,并不是指在这时间之后开始执行处理,而是在指定时间之后将任务追加到队列中。并且这个时间不是绝对准确时间,但是可以满足对时间不是很严格的延迟要求。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{

});
  • GCD 一次性代码:dispatch_once

使用 dispatch_once 函数能保证某段代码在程序运行过程中只被执行1次,并且即使在多线程的环境下,dispatch_once也可以保证线程安全。

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// 只执行1次的代码(这里面默认是线程安全的)
});
  • GCD 快速迭代方法:dispatch_apply

dispatch_apply按照指定的次数将指定的任务追加到指定的队列中,并等待全部队列执行结束。

如果是在串行队列中使用 dispatch_apply,那么就和 for 循环一样,按顺序同步执行。可这样就体现不出快速迭代的意义了。
我们可以利用并发队列进行异步执行。比如说遍历 0~5 这6个数字,for 循环的做法是每次取出一个元素,逐个遍历。dispatch_apply 可以 在多个线程中同时(异步)遍历多个数字。还有一点,无论是在串行队列,还是异步队列中,dispatch_apply 都会等待全部任务执行完毕

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(6, queue, ^(size_t index) {
// 迭代任务
});

上面会跑6个任务,这6个任务同时并行,index代表第几个任务。这些任务是无序得并发执行。

  • GCD 信号量: Dispatch Semaphore

信号量是持有计数的信号,使用它控制对有限资源的使用和访问。假设有一间房子,它对应一个进程,房子里的两个人就对应两个线程。这个房子(进程)有很多资源,比如花园、客厅、卫生间等,是所有人(线程)共享的。但是有些地方,比卫生间,最多只能有1个人能进去。怎么办呢,在卫生间门口挂1把钥匙。进去的人(线程)拿着钥匙进去(信号量 -1),外面的人(线程)没有钥匙就在门口等待,直到里面的人出来并把钥匙重新放回门口(信号量+1),此时外面等待的人再拿着这个钥匙进去,所有人(线程)就按照这种方式依次访问卫生间这个有限的资源。门口的钥匙数量就称为信号量(Semaphore)。信号量为0时需要等待,信号量不为零时,减去1而且不等待。


dispatch_semaphore_create:创建一个Semaphore并初始化信号的总量
dispatch_semaphore_signal:解锁,释放一个信号量,使得信号量值加1
dispatch_semaphore_wait:加锁,信号量减去1,当信号总量为0时就会阻塞所在线程,否则就可以正常执行。

例子:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
for (int i = 0; i < 10; i++) {
dispatch_async(queue, ^{
//加锁 信号量 -1
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

//访问临界区数据

//解锁 信号量 +1
dispatch_semaphore_signal(semaphore);
});
}
1.6 NSOperation && NSOperationQueue的使用

下图中列举了NSOperation 以及NSOperationQueue的一些重要概念:

在介绍NSOperation之前我们需要知道NSOperation其实是GCD的一种封装,但是它的调度形式和GCD有着明显区别,GCD中的调度是以FIFO形式进行调度的,但是添加到NSOperationQueue中的任务会先进入RUNABLE状态,然后按照操作的优先级进行调度,并且通过设置队列的最大并发数来控制任务队列的串行,并行行为。

****1.6.1 创建操作NSOperation ****

NSOperation 有三种方式可以创建,一种是NSInvocationOperation,一种是NSBlockOperation,还有一种通过自定义NSOperation

NSInvocationOperation:
// 1.创建 NSInvocationOperation 对象
NSInvocationOperation *invocationOperation = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(operation1) object:nil];
// 2.调用 start 方法开始执行操作
[invocationOperation start];

这种形式任务会运行在当前线程。

NSBlockOperation:

NSBlockOperation 是否开启新线程,取决于操作的个数。如果添加的操作的个数多,就会自动开启新线程。

// 1.创建 NSBlockOperation 对象
NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
// do something
}];

// 2.调用 start 方法开始执行操作
[op start];

通过 addExecutionBlock 添加额外操作,这些操作(包括blockOperationWithBlock中的操作)可以在不同的线程中并发执行。只有当所有相关的操作已经完成执行时,才视为操作完成

// 1.创建 NSBlockOperation 对象
NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{
// do something
}];

// 2.添加额外的操作
[op addExecutionBlock:^{
// do additional something
}];

// 3.调用 start 方法开始执行操作
[op start];
自定义NSOperation:
@interface IDLOperation : NSOperation

@end

#import "IDLOperation.h"

@implementation IDLOperation

- (void)main {
if (!self.isCancelled) {
//执行对应的操作
}
}
@end
1.6.2 创建操作队列

NSOperationQueue 为我们提供了两种不同类型的队列:主队列和自定义队列。主队列运行在主线程之上,而自定义队列在后台执行。

NSOperationQueue *queue = [NSOperationQueue mainQueue];

自定义队列创建方法:添加到这种队列中的操作,就会自动放到子线程中执行

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
1.6.4 将操作添加到操作队列

addOperation

[operationQueue addOperation:op1];
[operationQueue addOperation:op2];
[operationQueue addOperation:op3];

addOperationWithBlock

[operationQueue addOperationWithBlock:^{
// do something
}];

通过上述两种方式将操作加入到操作队列后能够开启新线程,进行并发执行

1.6.3 设置操作队列属性

最大并发操作数:maxConcurrentOperationCount

  • maxConcurrentOperationCount 默认情况下为-1,表示不进行限制,可进行并发执行。
  • maxConcurrentOperationCount 为1时,队列为串行队列。只能串行执行,一个操作完成之后,下一个操作才开始执行。
  • maxConcurrentOperationCount 大于1时,队列为并发队列。操作并发执行,可以同时执行多个操作。开启线程数量是由系统决定的,不需要我们来管理。

我们一般通过maxConcurrentOperationCount来控制操作队列的串并行执行顺序。

1.6.4 设置操作间依赖,及优先级,启动操作
  • 设置操作间依赖

NSOperation 还有一个比较强大的功能就是可以设置操作直接的依赖,依赖的操作会等被依赖的操作执行完毕后执行。

- (void)addDependency:(NSOperation *)op; 添加依赖,在操作op完成之后才执行当前操作。
- (void)removeDependency:(NSOperation *)op; 移除依赖,取消当前操作对操作 op 的依赖。

还可以通过

@property (readonly, copy) NSArray<NSOperation *> *dependencies;

来获取当前操作开始执行之前完成执行的所有操作对象数组。

  • 设置操作优先级

优先级的取值如下,可以通过****setQueuePriority:****方法来设置优先级。

typedef NS_ENUM(NSInteger, NSOperationQueuePriority) {
NSOperationQueuePriorityVeryLow = -8L,
NSOperationQueuePriorityLow = -4L,
NSOperationQueuePriorityNormal = 0,
NSOperationQueuePriorityHigh = 4,
NSOperationQueuePriorityVeryHigh = 8
};

优先级只是一个参考,如果一个低优先级的任务,准备就绪了,但是一个高优先级的尚未准备就绪,就会先跑低优先级的任务。

1.6.5 NSOperation 线程切换
NSOperationQueue *queue = [[NSOperationQueue alloc]init];
[queue addOperationWithBlock:^{
// 异步进行耗时操作
// .....
// 回到主线程
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 更新UI操作
// ...
}];
}];