《Effective Objective-C 20 编写高质量i
第1章:熟悉Objective-C
第4条:多用类型常量,少用#define预处理指令
- 声明类型常量好处是,编译器可以知道此变量的类型,且可以防止外部随意修改
- 声明规则:
- 全局常量:使用extern开头,类名作为常量前缀(OC无命名空间,避免冲突),const修饰常量名,头文件声明,实现文件赋值。例如:
// 在ViewController.h中声明通知名
/** ViewController视图已加载通知 */
extern NSString * const ViewControllerViewDidLoadNotification;
// 在ViewController.m中赋值通知名
NSString *const ViewControllerViewDidLoadNotification = @"ViewControllerViewDidLoadNotification";
- **局部常量**(类内使用的):*使用static开头,“k”作为常量前缀,const修饰常量名,在实现文件中声明及赋值*。这种局部常量(static + const)的优点是:**编译器不会创建符号,会像#define一样,把遇到的所有变量直接替换,但是带有类型信息**。
/** 动画时长 */
static const NSTimeInterval kAnimationDuration = 0.3;
第5条:用枚举表示状态、选项、状态码
- 如果枚举值的选项可以进行组合,则使用时,用“按位或”操作(“|”)。声明枚举时,使用二进制进行表示。
- NS_OPTIONS和NS_ENUM其实都是定义的宏,可以向下兼容(编译器支持新枚举特性时生成的enum带有类型声明,否则没有)。
凡是需要以按位或操作来组合的枚举都应使用NS_OPTIONS定义。若是枚举不需要相互组合,则应使用NS_ENUM来定义。
typedef NS_OPTIONS(NSUInteger, UIViewAutoresizing) {
UIViewAutoresizingNone = 0,
UIViewAutoresizingFlexibleLeftMargin = 1 << 0,
UIViewAutoresizingFlexibleWidth = 1 << 1,
UIViewAutoresizingFlexibleRightMargin = 1 << 2,
UIViewAutoresizingFlexibleTopMargin = 1 << 3,
UIViewAutoresizingFlexibleHeight = 1 << 4,
UIViewAutoresizingFlexibleBottomMargin = 1 << 5
};
typedef NS_ENUM(NSInteger, UIViewAnimationTransition) {
UIViewAnimationTransitionNone,
UIViewAnimationTransitionFlipFromLeft,
UIViewAnimationTransitionFlipFromRight,
UIViewAnimationTransitionCurlUp,
UIViewAnimationTransitionCurlDown,
};
- 在switch语句中,使用枚举变量时,不要加default分支,这样编译器会提示是否还有未枚举的分支。
第2章:对象、消息、运行期
第7条:在对象内部尽量直接访问实例变量
- 在对象内部读取数据时,应该直接通过实例变量来读;而写入数据时,则应通过属性来写。
- 在初始化和dealloc方法中,总应该直接通过实例变量来读写数据。
- 有时会使用惰性初始化技术配置某份数据,这种情况下,需要通过属性来读取数据。
第8条:理解“对象等同性”这一概念
- NSObject协议中用于判定对象相等的方法:
- (BOOL)isEqual:(id)object;
- (NSUInteger)hash;
// 协议里hash为只读属性: @property (readonly) NSUInteger hash;
- 如果两个对象相等,则其hash值必然相等;反之则不一定(因为isEqual:方法先回判断两者是否为相同类型)。
- 自定义类型中,若提供判定对象相等方法,可以实现上述方法和属性。
第11条:理解objc_msgSend的作用
-
objc_msgSend使用了“尾调用优化”技术(tail-call optimization),实质上即为函数调用的栈帧的复用**。
- 一般来说,函数调用时,会在栈中生成新的栈帧(申请一块新的栈内存),跳转过去进行新函数的调用。当在函数执行过程中,如果再次调用其他函数,会再次压入新的栈帧并跳转,直到依次调用完毕再返回并释放栈帧的内存。
- 对于OC来说,当函数调用中,如果返回值“仅仅”是调用其他函数(没有其他任何操作,如进行其他运算等),则会把当前函数调用的栈帧直接交给调用的新函数(自身数据变为新函数的)。这样即避免了重复申请内存,而且调用地址不必来回跳转,在多个函数调用时对效率的提升和内存控制尤其有效。
- iOS objc_msgSend尾调用优化机制详解
第12条:理解消息转发机制
- 动态方法解析:在方法未找到时执行的第一步操作,即resolveInstanceMethod和resolveClassMethod方法。在此函数中使用class_addMethod可以为当前类添加动态实例方法或类方法(类方法要添加到class的meta class中)。之后,当前实例即会缓存并调用新添加的方法,且以后会在缓存列表内直接调用,不再进行方法解析步骤。一般来说,我们会在存在@dymamic修饰的属性所属的类中通过此方法来提供实现,比如Core Data的数据类中。
- 备援接收者:动态解析过程若没有提供方法时,则进入此步骤。在方法forwardingTargetForSelector方法中,运行时系统会将此selector对应的消息原封不动转发给返回的对象。利用此方法可以模拟出”多继承“:即将对应方法转发给可以执行的类实例。
- 完整的消息转发机制:上一步只能转发原始消息,如果对方法参数等需要二次修改,则需要通过此步骤(上一步没有成功执行即会进入此步,所以性能代价会大一些)。系统会通过forwardInvocation方法,将原target、selector和parameter等封装为NSInvocation对象。我们需要将此invocation的目标改为对应的接收者,使其执行。也可以对selector进行修改,如增删改参数,甚至改变selector。不能处理时,通过super返回给父类实例进行处理,直到NSObject最终触发doesNotRecognizeSelector方法,抛出异常。
第3章 接口与API设计
第15条:用前缀避免命名空间冲突
- 双字符前缀是苹果官方使用的,自己需要使用至少三字符的前缀,避免冲突。
- 类的实现文件中定义的c函数在编译后的目标文件(.o)中会成为顶级符号(全局符号),所有也需要添加前缀。
- 自己封装的库中,若引用了其他第三方库,应对第三方库的类文件分别添加前缀命名。防止其他项目引入本库后,再引入同样的第三方库产生命名冲突。
第16条:提供“全能初始化”方法
- 类中存在多个初始化方法是,需要指定一个作为“全能初始化”方法(designed initializer),使其余方法都通过此方法进行实例的初始化。对后期修改维护有利。
- 子类继承时,如果子类存在自己独特的“全能初始化”方法,则必须要覆盖父类的全能初始化方法(防止使用父类的方法创建子类实例):
// 父类:矩形类
// EOCRectangle.h
@interface EOCRectangle: NSObject
@property (nonatomic, assign, readonly) float width;
@property (nonatomic, assign, readonly) float height;
- (id)initWithWidth:(float)width
andHeight:(float)height;
@end
// EOCRectangle.m
@implementation EOCRectangle
/** designed initializer */
- (id)initWithWidth:(float)width
andHeight:(float)height {
if (self = [super init]) {
_width = width;
_height = height;
}
return self;
}
/** overrided initializer */
- (id)init {
return [self initWithWidth:5.0f andHeight:10.0f];
}
@end
// 子类:正方形类
// EOCSquare.h
@interface EOCSquare: EOCRectangle
- (id)initWithDimension:(float)dimension;
@end
// EOCSquare.m
@implementation EOCSquare
/** designed initializer */
- (id)initWithDimension:(float)dimension {
return [super initWithWidth:dimension height:dimension];
}
/** overrided initializer */
- (id)initWithWidth:(float)width
andHeight:(float)height {
float dimension = MAX(width, height);
return [self initWithDimension:dimenson];
}
@end
- 如果类中存在多种完全不同的初始化方式,则需要多个“全能初始化”方法。如遵循NSCoding协议的类,需要实现initWithCoder方法作为单独的“全能初始化”方法。
第18条:尽量使用不可变对象
-
需要公开的属性,尽量用“readonly”进行语义修饰,防止外部进行修改;内部修改时,在分类中可以修改为“readwrite”
- 由于iOS的属性一般使用“nonatomic”进行修饰,为了防止极端情况下,内部修改属性时,外部读取产生的数据不统一的情况,可以使用GCD的同步操作对读取和修改进行统一处理。
- 不考虑外部使用“KVC”的方式对只读属性设置,甚至是根据实例内存布局通过内存偏移量对属性进行强制修改的情况,还是应该尽量遵循此属性声明方式。
-
对于可变集合对象,尽量不要作为公开属性。
- 可以声明为分类中的属性,或是类的成员变量。在外部使用不可变的版本进行属性公开。
- 增删改等方式,提供公开方法用以外部调用。
第20条:为私有方法名加前缀
- 为普通类的私有方法(在实现文件中的声明并只提供内部调用的方法)的方法名前,添加“p_”作为前缀,提高调试效率。
- 对于自定义的库文件类,甚至可以使用“类名 + _”的方式为私有方法命名,尽量减少命名冲突。
第21条:理解Objective-C错误类型
- 在ARC下,代码不是“异常安全”(exception safe)的:即当抛出异常时,本应该在作用域末尾自动释放的内存,便不会释放了(MRC下,需要开发者在抛出异常之前手动释放所有内存,极易出错)。
第4章 协议与分类
第23条:通过委托与数据源协议进行对象间通信
-
协议分类及数据流向
- 若某类实例需要让其他对象代替实现某些逻辑功能,则可以把需要实现逻辑的方法抽象为协议,让“其他对象”成为其“委托对象”,实现协议的方法。此模式即为“委托模式”,数据流向为“Class -> Delegate”。如:UITableViewDelegate,tableView实例将处理列表点击等行为委托给代理对象(如UIViewController实例)。
-
若某类实例需要通过一些方法获取数据,则可以把这些方法抽象为协议,让“其他对象”成为其“委托对象”,实现协议的方法。这种委托模式也称为“数据源模式”,数据流向为“Data Source -> Class”。如:UITableViewDataSource,tableView实例从代理对象(如UIViewController实例)中获取列表的数据等。
委托模式.jpg
-
提高协议方法的调用效率
- 众所周知,对于optional的协议方法,调用前需要进行实现判断(使用respondsToSelector方法)。若在委托对象中频繁调用此方法(如下载progress等),每次进行响应判断,意义不大,且效率可能会出现瓶颈。针对此情况,可以使用“位段”方式(bitField)进行优化,对于是否能够响应协议方法调用,只需要一个二进制位(bit)即可进行表示。所以,可以为委托对象所属的类声明一个结构体成员变量。此结构体内部的成员与所有的optional协议方法一一对应,成员值均为0或1。然后,在设置代理对象时,将所有的实现情况进行判断并赋值到此结构体成员变量中。即可在实际回调协议方法是免去每次进行响应判断,提高效率。
@porotocol DownloadProtocol: NSObject
...
@optional
- (void)onDownloadingProgress:(CGFloat)progress;
...
@end
...
#import "DownloadProtocol.h"
@interface MyClass: NSObject
@property (nonatomic, weak) id<DownloadProtocol> delegate;
@end
@interface MyClass () {
// 声明结构体变量成员
struct {
NSInteger onDownloadProgress: 0
} _downloadProtocolFlag;
}
@end
@implementation MyClass: NSObject
// 手动实现delegate的setter,对代理对象的方法实现情况进行检查并缓存
- (void)setDelegate:(id<DownloadProtocol>)delegate {
_delegate = delegate;
// 对所有optional方法依次检查,这里只是一个
_downloadProtocolFlag.onDownloadProgress = [_delegate respondsToSelector:@selector(onDownloadProgress:)];
}
// 实际调用时,即可简化判断,提高效率
- (void)testMyDownloadProgress:(CGFloat)progress {
if (_downloadProtocolFlag.onDownloadProgress) {
[self.delegate onDownloadProgress: progress];
}
@end
第24条:将类的实现代码分散到便于管理的数个分类之中
- 有时无需继承父类时,可有使用分类(Category)方式扩展类的功能。
- 使用分类将不同功能的的代码分隔到不同区块中,可以防止原始类过于庞大,代码逻辑清晰,便于调试(调用信息的符号中会显示为不同的分类)。
- 对于类或模块内部的私有方法,可以创建private分类,且不对外公开。
第25条:总是为第三方类的分类名称加前缀
- 在OC中,由于分类是在运行期进行加载,且各分类的加载顺序依照编译顺序而定(在build phrase中可以手动修改,当然分类的加载是在本类之后),故分类中可以覆盖本类的方法。但是,当多个分类同时覆盖本类方法后,便无法确定调用时的版本了(只能依靠build phrase顺序)【分类的方法在加载时,依照类似链表“头插法”的方式,将方法添加到类的方法列表头部,原始方法则被挤到了后面,所以当调用方法时,系统只找到第一个方法后便直接跳转执行】。所以,为了避免分类异常覆盖本类方法时,需要如下做法进行改进(OC没有命名空间):
- 创建类的分类时,需要为分类名前添加自定义的前缀
- 创建分类的方法时,需要为各方法名前也添加自定义的前缀
第26条:勿在分类中声明属性
-
“属性”实质上是数据的封装,背后有成员变量作为数据支持,系统只是合成了setter和getter。
-
分类中不能生成真正属性的原因:由于编译期,本类的内存布局结构已经确定(实例变量区域的偏移量及大小已经确定),分类在运行期加载时,已经无法在本类的实例变量区域进行操作。
-
分类中声明并实现“属性”的方法:
- 使用正常语法声明,使用相关对象(associate object)实现属性的setter和getter(只是模拟属性,添加了内存管理语义)。
- 使用@dynamic关键字告知编译器,在运行期再提供实现。可以通过方法转发的方式(如resolveInstanceMethod)提供动态实现。
-
由于属性是实例变量的封装,所以建议属性依照实例变量的方式,在主类的头文件中直接进行声明。
-
分类只是用于扩展类的功能,而非提供类的额外存储。
第27条:使用“class-continuation分类”隐藏实现细节
- 此"class-continuation分类"即为类的扩展(extension)。这是唯一可以在其中同时声明实例变量、属性和方法的匿名“分类”。原因:类的扩展是与本类源码一起在编译期共同确定类的内存布局,所以此时会将扩展的实例变量等一同加载到类的对应部分。
- 可以将不对外公开的方法、属性和实例变量等放在扩展中。
- 在扩展中引用C++代码,可以只在当前类中引入C++编译器进行混合编译(类实现文件要用“.mm”为后缀),头文件对外仍然只公布简洁的OC风格接口。
- 可以在头文件中声明只读属性,扩展中重新声明为可读写属性,内部使用setter进行数据修改时,可以正常触发KVO监听回调。
- 在扩展中遵循只在内部使用的协议。
第28条:通过协议提供匿名对象
- 对于无需外部关心的类(只关心提供的功能),可以将所需功能封装为协议,将类名淡化为遵循此协议的匿名对象(id<MyProtocol>)。
- 此种设计方法可以在后端进行实现替换,而对前端调用方完全透明,增加了编码的灵活程度,也降低了耦合(例如使用不同类数据库进行数据存取,协议中只提供通用的连接、断开、增删改查等方法即可)。
第5章 内存管理
第29条:理解引用计数
在手动管理内存模式下,内存管理语义为“strong”的属性,其setter一般都是如此实现的:
- (void)setFoo:(id)foo {
// 1.保留新值
[foo retain];
// 2.释放原有值
[_foo release];
// 3.赋值
_foo = foo
}
其中,为何1和2步骤不能交换呢?
因为,当使用重复的foo对象进行设置时(假设foo对象只被本类实例保留),先释放,会导致foo对象内存被系统回收。致使setter失败【使用ARC即可避免此问题发生】。
第30条:以ARC简化引用计数
-
使用ARC时必须遵循的方法命名规则
- 方法名以“alloc、new、copy和mutableCopy”为开头时,其返回对象的内存管理由调用方负责。即在方法中,系统不会自动添加内存管理语句。
- 方法名以其他方式命名的,其返回对象的内存管理由方法自身负责。即在方法中,系统会自动给返回的对象添加autorelease操作。
- ARC对代码的额外优化:
- 编译期:对于多次的retain和release操作,ARC会根据情况适当成对的抵消掉此调用操作。
- 运行期:例如,对于使用方法调用返回autorelease的对象,ARC会使用objc_autoreleaseReturnValue来替代传统的autorelease方法,此时会检查函数返回之后的代码:若调用方需要对此对象进行保留,则不会执行autorelease操作,而是设置全局标志位;调用方retain时,使用objc_retainAutoreleasedReturnValue函数,检查此标志位,若已置位,则不执行retain操作,直接使用。这样,通过检测标志位代替传统的autorelease和retain,优化了执行效率。
-
ARC如何清理实例变量
- 在ARC环境下,系统会利用Objective-C++的清理例程(cleanup routine)特性,待回收的对象会调用所有C++对象的析构函数。ARC借住此特性,会在dealloc中生成清理内存的代码。
- 对于非OC对象,如CF对象或手动malloc的对象,在dealloc方法中还是需要手动释放这些对象的内存
- 在deallc中无需调用super方法。
-
覆写内存管理方法
- 不能调用或覆盖release和retain等内存管理方法【单例中尤其注意】:ARC会优化retain、release等相关操作,使其不经过OC的消息派发机制(方法调用),底层使用了c函数版本进行了实现。
第31条:在dealloc方法中只释放引用并解除监听
- ARC下,dealloc方法会自动释放创建的OC对象的引用,但需要手动释放Core Foundation创建的对象和其他手动申请内存并创建的c对象。
- dealloc中尽量不要调用其他方法(如实例方法等)
- dealloc过程中,由于实例已经处于“释放状态”,无法确保代码会执行在确定的线程上。故不能在释放是执行异步多线程代码。
- dealloc中释放KVO监听和NSNotificationCenter中注册的通知
- 对于创建和释放开销较大和系统稀缺的资源,一般单独创建自己的清理方法(如数据库对象的“连接”和“关闭”操作等)。使用完成后即提示调用者进行及时的资源清理,而不是在dealloc中释放内存【dealloc中可以检查是否正确清理此对象,没有则自动调用并给出debug提示】。
第35条:用“僵尸对象”调试内存管理问题
- 在“XCode->Scheme->Run->Diagnostics”中,勾选“Enable Zombie Objects”选项,开启功能。
- 在调试模式中,开启此功能,可以防止已释放对象的内存被覆盖重用,可以帮助追溯调用过程和类型信息等。
- 已释放对象转化为“僵尸对象”的过程(“僵尸模式”开启的情况下,NSObject的dealloc会在运行期swizzle成类似以下代码):
// 获取待释放对象所属的类
Class cls = object_getClass(self);
// 获取类名
const char *clsName = class_getName(cls);
// 生成僵尸类名
const char *zombieClsName = "_NSZombie_" + clsName;
// 查看是否存在此类
Class zombieCls = objc_lookUpClass(zombieClsName);
// 不存在,则创建此类
if (!zombieCls) {
// 获取名为“_NSZombie_”的样板类
Class baseZombieCls = objc_lookUpClass("_NSZombie_");
// 复制样板类来创建此类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
// 正常释放实例
objc_destructInstance(self);
// 将实例所属类指向新的僵尸类(替换isa指向)
objc_setClass(self, zombieCls);
// 现在,self即为“_NSZombie_原始类名”的实例了
- “僵尸类“为”根类“,只存在isa指针,没有任何方法实现(样本类的拷贝)。故其实例接收到的所有消息都需要进行完整的消息转发。被转发的消息响应过程的伪代码如下:
// 获取对象类名
Class cls = objc_getClass(self);
// 查看是否为”僵尸类“
if (String_has_prefix(clsName, "_NSZombie_")) {
// 是,则对象是”僵尸对象“
// 获取原始类名(去掉前缀)
const char *originClsName = substring_from(clsName, 10);
// 获取消息的选择器名
const char *selectorName = sel_getName(_cmd);
// 输出消息
Log("*** -[%s %s]: message sent to deallocated instance %p", originClsName, selectorName, self);
// 结束程序
abort();
}
第6章 块与大中枢派发
第37条:理解“块”这一概念
- 块的内部结构(内存布局):
Block | ||
---|---|---|
void* | isa | 指向Class对象的指针 |
int | flags | |
int | reserved | |
void (*)(void *, ...) | invoke | 实现函数的指针 |
struct * | descriptor | 块的描述信息 |
捕获到的外部变量... |
- isa指针,指向的是Block的类型。Block分为三种类型:_NSConcreteStackBlock,_NSConcreteMallocBlock和_NSConcreteGlobalBlock。其中,_NSConcreteStackBlock分配在占内存上;_NSConcreteMallocBlock分配在堆内存上,有引用计数(即为对象),会捕获外部变量;_NSConcreteGlobalBlock不捕获变量(内部不使用外部变量的Block即为全局Block),内存在编译期即可确定,相当于单例。
- 其中,invoke实现函数的参数为Block结构体实例的指针,使用它可以方便地从内存中读取出捕获到的变量。
- 捕获到的变量,对于对象,只是拷贝了其指针(对象的引用计数+1)
descriptor | ||
---|---|---|
unsigned long int | reserved | |
unsigned long int | size | |
void (*)(void *, void *) | copy | 拷贝辅助函数 |
void (*)(void *, coid *) | dispose | 释放辅助函数 |
描述结构体中,copy和dispose函数的作用是拷贝和释放Block实例时,对捕获到的变量进行拷贝和释放操作。
第39条:用handler块降低代码分散程度
- 使用Block代替Delegate,可以使代码分布整体化,降低分散程度。特别是对于自身实例作为多个不同对象的Delegate时(如多个UITableView实例均使用self作为delegate),由于每个Delegate均存在自己单独的handler回调,省去了对于不同对象判断执行的过程,使代码更加简洁。
- 设置带有handler回调的API时,根据需要可以添加执行队列(NSOperation或GCD等)参数。外部调用时,可以根据不同需要灵活配置(如系统添加通知的方法addObserverForName方法)。
第41条:多用派发队列,少用同步锁
我们知道,在iOS端,为了保证性能,类中property的内存语义一般被设置为“nonatomic”。但是极端情况下,为了保证属性的读写为原子操作,需要单独进行处理。一般来说,可以使用synchronized关键字对self进行同步加锁,或者使用NSLock进行锁操作,但是频繁读写时效率会很低。
为了保证效率,使用GCD的派发队列进行property的读写操作优化:
// TestClass.h
@interface TestClass : NSObject
@property (nonatomic, strong) NSString *testName;
@end
// TestClass.m
@interface TestClass () {
NSString *_testName;
dispatch_queue_t _propertyQueue;
}
@end
@implementation
- (instancetype)init {
if (self = [super init]) {
// 创建并发队列
_propertyQueue = dispatch_queue_create("com.TestClass.p_queue", DISPATCH_QUEUE_CONCURRENT);
}
return self;
}
- (NSString *)testName {
// 并发队列读取(使用async是因为需要同步返回函数值,实质上也是并发执行)
__block NSString *tmpValue;
dispatch_sync(_propertyQueue, ^{
tmpValue = self->_testName;
});
return tmpValue;
}
- (void)setTestName:(NSString *)testName {
// 使用barrier,可以保证此处为原子操作(其余操作等待完成后才开始执行)
dispatch_barrier_async(_propertyQueue, ^{
self->_testName = testName;
});
}
@end
注意:
- 使用dispatch_barrier_async或dispatch_barrier_sync方法时,必须保证执行的队列是手动创建的并发队列,不能是串行或者系统的全局队列**。
- 栅栏方法同步和异步版本的区别是:同步添加Block时,队列会等待Block执行完毕后再返回(继续执行下面的代码);异步时,则立即返回,可以将后面的任务派发的队列中(但是不会执行),等待栅栏Block执行完毕后,继续执行其他任务。
- 在property中两种版本均可。但是同步版本的效率更高(异步版本需要拷贝Block)。
对于property,getter使用自定义并发队列的async操作,setter使用自定义并发队列的barrier操作。
第42条:多用GCD,少用performSelector系列方法
除了performSelector系列方法的API局限性以外(不能传多于两个参数、线程相关API参数过少、参数只能为对象类型、不能调用c方法等),最重要的是内存管理方面的缺失。示例如下:
SEL selector;
if (/** 条件1 */) {
selector = @SEL(newObject);
} else if (/** 条件2 */) {
selector = @SEL(copy);
} else {
selector = @SEL(someProperty)
}
// object为类实例,包含newObject构造器、copy方法和someProperty属性
id ret = [object performSelector:selector];
在ARC环境下,如果是条件1和2,编译器会将执行后返回对象的内存管理权交给接收者;如果是直接设置属性,则不进行内存管理。由于现在selector的选择是在运行时进行绑定,编译器就无法根据方法签名(名称、参数及返回值类型)使用ARC进行内存管理了(甚至不知道改selector是否存在)。所以,这种写法下,编译器会提示警告,可能会发生内存泄漏。
使用Block配合GCD的相关方法可以有效解决这些问题,如参数、线程、内存管理等。
第43条:掌握GCD及操作队列的使用时机
- 在解决多线程与任务管理的问题时,可以根据需要灵活选用GCD或操作队列(NSOperationQueue)进行处理。
- 操作队列是Objective-C的API,底层使用GCD进行实现,具备了大多数GCD的功能,其优点如下:
- 可以取消尚未执行的任务(NSOperation对象)。
- 可以对操作的执行状况使用KVO进行监听,如“isCancelled”、“isFinished”等。
- 可以对单个的操作进行优先级设置(即执行线程的优先级);GCD只能对并发的派发队列进行优先级设置,颗粒度不够细。
- 对不同操作之间可以设置依赖关系。通过设置依赖,更容易控制不同任务之间的执行顺序。
- 可以重用任务对象(NSOperation),系统默认实现了NSBlockOperation子类。由于支持了面向对象,我们可以根据需要对父类进行继承,扩展操作对象的功能。
第44条:通过Dispatch Group机制,根据系统资源状况来执行任务:
在后台自动执行一系列任务,完成后通知主线程:
// 对象数组,这里是模拟多个需要执行任务的对象
NSArray *objects = @[...];
// 后台执行,需要获取并发队列
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 一系列任务,可以编写成组
dispatch_group_t group = dispatch_group_create();
// 自动执行:异步加入组中,不能阻塞执行队列
for (id object in objects) {
dispatch_group_async(
group,
globalQueue,
^{ [object performTask]; }
);
}
// 完成后通知主线程
dispatch_group_notify(
group,
dispatch_get_main_queue(),
^{
// 主线程执行代码
}
);
主要是利用了GCD并发队列的强大功能,GCD会根据系统资源占用情况,自动分配CPU核心和不同数量的执行线程去并发执行任务,可以最大限度的优化多线程编程性能。通过dispatch_group_t对象,可以根据需要阻塞(可以使用dispatch_group_wait)执行队列或是监控任务的执行过程。
第46条:不要使用dispatch_get_current_queue
首先结论是:dispatch_get_current_queue对避免代码死锁没有任何作用,因为它返回的只是当前队列的名称,而不是当前执行任务所在的队列!!!
我们以队列层级的例子一步步进行验证:
- 父队列为串行队列rootQueue;子队列有两个,分别为串行队列serialQueue和并行队列concurrentQueue。我们分别向两个子队列中派发任务,看一下实际的执行情况:
// 根队列
const void *rootQueueKey = "com.jiji.rootQueue";
dispatch_queue_t rootQueue = dispatch_queue_create(rootQueueKey, DISPATCH_QUEUE_SERIAL);
// 创建两个队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.jiji.concurrent1", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t serialQueue = dispatch_queue_create("com.jiji.serial1", DISPATCH_QUEUE_SERIAL);
// 设置两个子队列的根队列为rootQueue(串行)
dispatch_set_target_queue(concurrentQueue, rootQueue);
dispatch_set_target_queue(serialQueue, rootQueue);
// 配置任务:
// 并行子队列异步派发两个任务
dispatch_async(concurrentQueue, ^{
NSLog(@"task1");
});
dispatch_async(concurrentQueue, ^{
NSLog(@"task2");
});
// 串行子队列异步派发两个任务
dispatch_async(serialQueue, ^{
NSLog(@"task3");
});
dispatch_async(serialQueue, ^{
NSLog(@"task4");
});
运行结果如下:
TestIos[32776:25904922] task1
TestIos[32776:25904922] task2
TestIos[32776:25904922] task3
TestIos[32776:25904922] task4
以上情况表明:不管子队列是串行还是并行队列,由于根队列为串行,最终任务的执行情况为串行执行。
- 现在,我的疑问是,由于任务串行执行,是否证明真正执行任务的队列是串行队列(根队列)?带着这个疑问,我们先验证一下:
我们修改一下代码,在task1的执行块中,查看一下当前队列:
dispatch_async(concurrentQueue, ^{
NSLog(@"task1");
NSLog(@"%@", dispatch_get_current_queue());
});
执行结果:
TestIos[32776:25904922] <OS_dispatch_queue: com.jiji.concurrent1>
看来不是,还是在并发队列这个子队列中。
那现在做个假设,如果在此任务中,再向其根队列派发一个同步任务进行验证(如果死锁,证明实际任务运行在根队列中;否则就在当前子队列中):
dispatch_async(concurrentQueue, ^{
NSLog(@"task1");
NSLog(@"%@", dispatch_get_current_queue());
// 添加同步任务
dispatch_sync(rootQueue, ^{
NSLog(@"new task!!!");
});
});
结果很显然,发生了死锁,证明实际上此任务是在根队列(target queue)中执行(向serialQueue中派发同步任务也会如此,虽然不会死锁,但线程一直等待,永远不会返回)。
这也就证明了,实际上dispatch_get_current_queue方法并没有返回真正的运行队列**。如果以此API返回值进行判断,则无法保证多线程环境下代码执行的准确性。
- 那若是如此,如何来确保任务执行在正确的队列中?使用dispatch_queue_set_specfic和dispatch_get_specific两个API即可。我们修改一下示例代码:
// 根队列
const void *rootQueueKey = "com.jiji.rootQueue";
dispatch_queue_t rootQueue = dispatch_queue_create(rootQueueKey, DISPATCH_QUEUE_SERIAL);
// 根队列用指定key进行标记
dispatch_queue_set_specific(rootQueue, rootQueueKey, &rootQueueKey, NULL);
// 创建两个队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.jiji.concurrent1", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t serialQueue = dispatch_queue_create("com.jiji.serial1", DISPATCH_QUEUE_SERIAL);
// 设置两个子队列的根队列为rootQueue(串行)
dispatch_set_target_queue(concurrentQueue, rootQueue);
dispatch_set_target_queue(serialQueue, rootQueue);
// 配置任务:
dispatch_async(concurrentQueue, ^{
NSLog(@"task1");
// 根据key获取队列绑定的值(存在,则当前队列即为key所对应的队列)
void *context = dispatch_get_specific(rootQueueKey);
if (context) {
// 当前执行在根队列上,不可以向根队列及任何子队列派发同步任务
NSLog(@"NO!!!!!!");
// 但是,调用dispatch_get_current_queue()返回的队列,可以让你误以为是在本队列执行,所以可能会向其他队列派发同步任务,继而发生死锁。这也就是绝对不要使用此API的原因。
NSLog(@"%@", dispatch_get_current_queue());
} else {
// 可以派发任意任务
NSLog(@"OK~");
dispatch_sync(rootQueue, ^{
NSLog(@"new task!!!");
});
}
});
如代码所示,当dispatch_get_specific返回对应的数据时,证明当前运行队列即为检查的目标队列。如果此时还需要向此队列派发同步任务,只要直接执行任务即可,无需派发。
- 注意:对于实现真正原子操作的property来说,由于实现setter和getter时使用了自定义队列,且setter中使用同步方式返回实例的值。为了防止死锁发生,一定要避免他人使用相同队列对属性进行访问。
第7章 系统框架
第48条:多用块枚举,少用for循环
-
快速枚举(for...in):
- 比传统for循环更高效,与NSEnumerator一样但语法更简洁。
- 可以遍历如NSArray、NSDictionary、NSSet及自定义Collection(需遵循NSFastEnumeration协议)。
- NSEnumerator由于也遵循NSFastEnumeration协议,所以可以支持用快速枚举对集合进行反向遍历。
- 缺点是不支持获取对象索引。
-
使用集合带有Block参数的遍历API进行集合遍历:
- 方便获取对象索引及字典键值
- 可以直接修改Block的参数类型为对象类型,利用编译器特性,省去了进行显示地类型转换
- 对于可以配置NSEnumerationOptions的版本,可以方便设置如反向遍历、并发遍历(底层使用GCD队列)等功能。
第49条:对自定义其内存管理语义的collection使用无缝桥接
-
主要先说一下OC对象和CF变量指针的转换方式:
- __bridge: 互相转换均可,不进行内存所有权转换。即转换后仍然使用原系统对对象或变量进行内存管理(OC使用ARC,CF手动使用CFRelease)
- __bridge_transfer:一般用于CF->OC的过程中,转换所有权。转换后的OC对象,系统自动使用ARC对其进行内存管理。
- __bridge_retained:一般用于OC->CF的过程中,转换所有权。转换后的CF变量,其指针的引用计数+1,需要使用CFRelease等函数进行手动内存管理。
-
可以通过CF框架,使用C语言API创建集合对象,之后利用桥接转换为OC对象,即可得到符合自定义内存管理语义的集合对象。
- 如使用NSDictionary时,需要key无需支持NSCopy协议,则可以使用此方法,创建CFDictionaryRef指针(在CFDictionaryRetainCallBack和CFDictionaryReleaseCallBack中进行修改)后,使用__bridge_transfer转换为OC对象并转换所有权。
第51条:精简initialize与load的实现代码
- +(void)load:
- 运行时系统启动时,加载Class或Category时会调用(只有Class和Category存在此方法,且只执行一次)。
- load方法不遵循继承体系,只有对应的Class活Category实现后才会被调用。
- 系统首先加载所有Class,后加载Category。Class间加载顺序无法确定(不要在Class的load方法中调用其他Class)。
- load方法执行时会阻塞程序运行,所以不要执行复杂任务或加锁。
自己只在Category中利用load方法,swizzle过所属Class的方法,在内部实现自定义功能(如记录日志)。
- +(void)initialize:
- 被调用时为“懒加载”:运行时系统在首次调用Class时,先调用本方法;不访问不调用。
- initialize方法执行时,运行时系统已启动完毕,加载(load)了所有相关类,可以在此调用任意类的任意方法。但是需要注意避免“循环引用”导致的死锁(如ClassA的initialize中调用ClassB,ClassB首次执行,initialize中使用ClassA)。
- 本方法遵循继承体系,本类未实现时会执行父类的版本。
- initialize方法执行时,是“线程安全”的,会阻塞其他类运行,无需加锁。但是,方法无法确定其执行线程,所以仍然不能运行过于复杂的任务(如果是UI线程则会导致APP无响应)。
正确用法举例:
- 在initialize中初始化声明为全局static的OC对象(由于在编译期只能初始化基本数据类型或NSString变量或常量)。
- 单例类可以用于初始化内部数据。
第52条:别忘了NSTimer会保留其目标对象
举例来说,以常用的直接在当前runloop中配置计时器“scheduledTimerWithTimeInterval: target: selector: userInfo: repeats:”为例:
@interface EOCClass: NSObject {
NSTimer *_pollTimer;
}
- (void)startPolling;
- (void)stopPolling;
@end
@implementation EOCClass
- (void)dealloc {
[_pollTimer invalidate];
}
- (void)stopPolling {
[_pollTimer invalidate];
_pollTimer = nil;
}
- (void)startPolling {
_pollTimer = [NSTimer scheduledTimerWithTimeInterval:5.0
target:self
selector:@selector(p_doPoll)
userInfo:nil
repeat:YES
];
}
- (void)p_doPoll {
// ...task
}
@end
以上代码可以很明显地看出,由于_pollTimer的target是self,即NSTimer保留了EOCClass实例,且_pollTimer是EOCClass的实例变量,在ARC下隐含为强引用的内存管理方式,最终导致了引用循环。
解决方法:
由于类对象本身即为单例,使用类对象作为NSTimer的target即可巧妙“避免”此问题发生。
这里使用了NSTimer的Category进行处理,免除使用第三方类或者自定义单例对象:
@interface NSTimer (EOCBlocksSupport)
+ (NSTimer *)scheduledTimerWithTimerInterval:(NSTimeInterval)interval
block:(void (^)())block
repeats:(BOOL)repeats;
@end
@implementation NSTimer (EOCBlocksSupport)
+ (NSTimer *)scheduledTimerWithTimerInterval:(NSTimeInterval)interval
block:(void (^)())block
repeats:(BOOL)repeats {
return [self scheduledTimerWithTimerInterval:interval
target:self
selector:@selector(eoc_blockInvoke:)
userInfo:[block copy]
repeats:repeats
];
}
+ (void)eoc_blockInvoke:(NSTimer *)timer {
// 取出timer中设置的userInfo,并转换为block对象
void (^block)() = timer.userInfo;
if (block) {
// 执行
block();
}
}
@end
配置定时器时,按如下方式:
- (void)startPolling {
// 声明self的弱引用指针(使Block对象通过弱指针保留对象,引用计数不变)
__weak EOCClass *weakSelf = self;
_pollTimer = [NSTimer scheduledTimerWithTimerInterval:5.0
block:^{
// 声明强指针指向weakSelf,可防止使用时对象被释放
__strong EOCClass *strongSelf = weakSelf;
[strongSelf p_doPoll];
}
repeats:YES];
}
如上所示,
- 此时只有EOCClass的实例保留了定时器,定时器对象并不会保留self。完成使用后,self即可被正常释放,同时定时器被取消。
- 使用__weak指针还可以保证更加安全(相比__unsafe_unretain),因为self实例释放后,若是忘记取消定时器,对weak指针发消息是安全的。
注意:NSTimer类在iOS10中新增了带有Block参数的API,不过使用时依然要注意循环引用的问题。