1 了解 Objective-C 起源
Objective-C 使用“消息结构”而非“函数调用”。
使用“消息结构”的语言,其运行时所执行的代码由运行环境来决定。
使用“函数调用”的语言,则由编译器决定。
分配在堆内存必须直接管理,而分配在栈上用于保存变量的内存则会在栈帧弹出时自动清理。
Objective-C 将堆内存管理抽象出来了,不需要 malloc 及 free 来分配或释放对象所占内存。
这部分工作抽象为一套内存管理架构:引用计数。
2 在类的头文件中尽量少引入其他头文件
建议:
- “向前声明”该类
@class XXClass
- 若无法使用“向前声明”,尽量将其挪到实现文件当中。
3 多用字面量语法
即使用语法糖,常用在创建字符串、数组、字典。
当使用语法糖,创建数组、字典时,要避免值中有 nil,否则会有异常。
4 多用类型常量,少用 #define 预处理指令
不要用预处理指令定义常量,因为这样出来的常量,没有类型信息。如果有人重新定义了常量值,编译器也不会报警。
在实现文件中,使用 static const 来定义“只在编译单元内可见的常量”(translation-unit-specific constant),一般以 k 开头。
在头文件中用 extern 声明全局变量,并在实现文件中定义其值,这样的常量会出现在全局符号表中,通常以类型为前缀。
5 用枚举表示状态、选项、状态码
主要使用 NS_ENUM 和 NS_OPTIONS 来定义枚举类型。
在处理枚举类型的 switch 语句中不要实现 default,这样添加类型时,就会收到编译器警告。
6 理解“属性”这一概念
可以使用 @property 来定义对象中所封装的数据。
通过“特质”来指定存储数据所需要的语义,特质有4种:
- 原子性
- 读写权限
- 内存管理语义
- 方法名
在设置属性所对应的实例变量时,一定要遵从该属性所声明的语义。
7 在对象内部尽量直接访问实例变量
在对象内部,读取实例变量时,除非是“懒加载”,否则尽量直接访问实例变量,若是写入数据,应通过属性来写。
在初始化以及 dealloc 方法中,应该直接访问实例变量。
除了此时实例不稳定外,有可能子类会重写 Setter 方法,这样会抛出异常。
8 理解“对象等同性”
若想检测对象的等同性,需要提供 isEqual 和 hash 方法。
相同的对象具有相同的 hash 码,但2个相同 hash 码的对象未必相同。
编写 hash 方法时,应使用计算速度快且 hash 碰撞率低的算法。
9 以“类族”模式隐藏实现细节
常见的有 NSString。
可从类族的公共抽象基类中继承子类,但要格外注意,若有开发文档,应先仔细阅读文档。
10 在既有类中使用关联对象存放自定义数据
当需要给某个对象存放数据,一般是继承该类,然后改用子类。
但有些时候,类是由某种特殊机制产生的,开发者无法使用这种机制创建子类。
这时,可以使用关联对象方式,关键方法:
// 根据给定的键和策略为某对象设置关联对象值
void objc_setAssociatedObject(id object, void*key, id value, objc_AssociationPolicy)
// 根据 key 从某对象中获取相应的关联对象值
id objc_getAssociatedObject(id object, void*key)
// 移除对象的所有关联对象
void objc_removeAssociatedObjects(id object)
通常使用静态全局变量作为 key。
关联类型:
关联类型 | 等效的 @property 属性 |
---|---|
OBJC_ASSOCIATION_ASSIGN | assign |
OBJC_ASSOCIATION_RETAIN_NONATOMIC | nonatomic, retain |
OBJC_ASSOCIATION_COPY_NONATOMIC | nonatomic, retain |
OBJC_ASSOCIATION_RETAIN | retain |
OBJC_ASSOCIATION_COPY | copy |
只有在其他方式无法实现时,才考虑使用关联对象,因为它可能会引用难以发现的 bug。
11 理解 objc_msgSend 的作用
对象调用方法,在 Objective-C 上称为方法调用(pass a message)。
“动态消息派发系统”(dynamic message dispatch system)会查找对应方法,并执行相应代码。
原型如下:
void objc_msgSend(id self, SEL cmd, ...)
objc_msgSend 会根据接收者与选择子的类型来调用适当方法,它会在接收者的方法列表(list of methods)中寻找与选择子名称相符的方法,如果找不到,就执行“消息转发”(message forwarding)。
有些“边界情况(edge case)”需要交由 Objective-C 运行环境中的另外一些函数来处理:
- objc_msgSend_stret
如果待发送消息要返回结构体,那么可交由此函数处理。
只有当 CPU 的寄存器能容纳消息返回类型时,该函数才能处理此信息。
若是无法容纳,那么由另外一个函数执行派发,此时,那个函数会通过分配在栈上的某个变量来处理消息所返回的结构体。 - objc_msgSend_fpret
如果消息返回的是浮点数,可交由此函数处理。
在某些架构的 CPU 上调用函数时,需要对“浮点数寄存器”做特殊处理。 - objc_msgSendSuper
如果是给超类发消息,可交由此函数处理。
也有与上述2个函数等效的方法,用于处理发给 super 的相应消息。
objc_msgSend 等函数一旦找到应该调用的方法实现后,就会“跳转过去”,之所以能这样,是因为 Objective-C 对象的每个方法都可以视为简单的 C 函数,原型如下:
<return type> Class_seletor(id self, SEL _cmd, ...)
每个类里都有一张表格,其中的指针指向这种函数,而选择子的名称正是查表时所用的“键”。
而且原型的样子和 objc_msgSend 函数很像,是为了利用“尾调用优化”技术。
结果缓存在快速映射表(fast map)中,每个类都有这样一块内存,若是稍后还向该类发送同样信息,执行起来就会很快。
12 理解消息转发机制
当对象接收到无法解读的消息后,就会启动“消息转发”机制。
分为2个阶段:
- 动态方法解析(dynamic method resolution)
先征询接收者所属的类,看其是否能动态添加方法,以处理当前这个“未知的选择子”。 - 涉及完整的消息转发机制(full forwarding mechanism)。
首先,请接收者看看有没有其他对象能处理这条消息。若有,则 runtime 会把消息转给那个对象,于是消息转发过程结束。
若没有“备援的接收者”(replacemoent receiver),则启动完整的消息转发机制,runtime 系统会把与消息有关的全部细节都封装到 NSInvocation 对象中,让接收者设法解决当前未处理的这条消息。
动态方法解析
// 对象在收到无法解读的消息后,调用下列方法
+ (BOOL)resolveInstanceMethod:(SEL)selector;
// 若未实现的是类方法,则调用以下方法:
+ (BOOL)resolveClassMethod:(SEL)selector;
使用这种方法的前提,相关方法的实现代码已经写好,只等着运行的时候动态插在类里面即可。
此方案常用来实现 @dynamic 属性。
备援接收者
runtime 询问是否还有别的接收者来处理这条消息,对应的处理方法:
- (id)forwardingTargetForSelector:(SEL)selector;
若当前接收者能找到备援对象,就将其返回,若找不到,就返回 nil。
通过此方案,我们可用“组合(composition)”来模拟出“多重继承”的某些特性。
完整的消息转发
创建 NSInvocation 对象,把尚未处理的那条消息相关的全部细节封于其中:选择子、目标(target)及参数。
在触发 NSInvocation 对象时,消息派发系统(message-dispatch system)把消息指派给目标对象。
调用以下方法:
- (void)forwardInvocation:(NSInvocation *)invocation;
较好的实现方式:
在触发消息前,先以某种方式改变消息内容,比如追加另外一个参数,或是改换选择子。
实现此方法时,若发现某调用操作不应由本类处理,则需调用超类的同名方法。这样,继承体系中的每个类都有机会处理此调用请求,直到 NSObject。
如果最后调用了 NSObject 类的方法,那么该方法还会继而调用 doesNotRecognizeSelector:
以抛出异常,此异常表明最终未能处理。
13 用“方法调配技术”调试黑盒方法
核心方法如下,用来交换两个方法的实现。
void method_exchangeImplementations(Method m1, Method m2)
在运行期,可向类中新增或替换选择子所对应的方法实现。
一般只在调试时使用,不宜滥用这种方法。
14 理解“类对象”含义
从开源的 objc4-723 中可以找到下列声明
// objc.h
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;
// runtime.h
struct objc_class {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
Class _Nullable super_class OBJC2_UNAVAILABLE;
const char * _Nonnull name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list * _Nullable ivars OBJC2_UNAVAILABLE;
struct objc_method_list * _Nullable * _Nullable methodLists OBJC2_UNAVAILABLE;
struct objc_cache * _Nonnull cache OBJC2_UNAVAILABLE;
struct objc_protocol_list * _Nullable protocols OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;
/* Use `Class` instead of `struct objc_class *` */
如上所述,每个实例都有一个指向 Class 对象的指针,用以表明类型,而这些 Class 对象则构成类的继承体系。
类型信息查询方法:isKindOfClass
, isMemberOfClass
。
尽量使用类型信息查询方法确定对象类型,不要直接比较类对象,因为有些类对象可能实现了消息转发功能。
15 用前缀避免命名空间冲突
使用 Objective-C 的常识,不再赘述。
16 提供“全能初始化方法”
即“指定初始化方法”。
若此方法与超类的不同,则需覆写超类中的对应方法。
若超类的初始化方法不适用于子类,则应覆写这个超类方法,并在其中抛出异常。
17 实现 description 方法
自定义某个对象的打印信息。
若想在(使用 lldb)调试时打印出更详细信息,则应实现 debugDescription 方法。
18 尽量使用不可变对象
尽量创建不可变对象。
若某属性仅可用于对象内部修改,则在“class-continuation 分类”中将其由 readonly 属性扩展为 readwrite 属性。
不要把可变的 collection 作为属性公开,而应提供相关方法来修改。
19 使用清晰而协调的命名方式
基本常识,不再赘述。
20 为私有方法加前缀
不要单用一个下划线做私有方法的前缀,因为这种方法是 Apple 官方使用的。
可考虑使用 p_
作为前缀。
21 理解 Objective-C 的错误模型
ARC 在默认情况下不是“异常安全的”,即若抛出异常,那么本应在作用域末尾释放的对象,现在却不会自动释放了。
若想生成“异常安全”的代码,可通过打开编译器的标志:-fobjc-arc-exceptions
实现,并且引入一些额外代码。
异常只用于极严重的错误,其他错误返回 nil/0 或使用 NSError。
使用 NSError,一般有2种方式:
- 指派“委托方法”。
- 把错误信息放在 NSError 中,经由“输出参数”返回给调用者。
22 理解 NSCopying 协议
若想类支持拷贝操作,就要实现 NSCopying 协议:
- (id)copyWithZone:(NSZone *)zone
出现 NSZone 的原因是:以前开发程序时,会据此把内存分成不同的区,而对象会创建在某个区里。现在只有一个默认区,所以不必担心其中的 zone 参数。
若是对象还有可变版本,则需要同时实现 NSCopying 与 NSMutableCopying 协议。
-[NSMutableArray copy] => NSArray
-[NSArray mutableCopy] => NSMutableArray
在可变对象上调用 copy 方法会返回另外一个不可变类的实例。
Foundation 中的所有 collection 类在默认情况下都执行浅拷贝,因为容器内的对象未必都能拷贝,而且调用者也未必想一并拷贝容器内对象。
复制对象时需要决定采用浅拷贝还是深拷贝,一般情况下尽量执行浅拷贝。
如果所写对象需深拷贝,那么可考虑新增一个专门执行深拷贝的方法。
23 通过 delegate 和 datasource 协议进行对象间通信
为了避免“循环引用”,delegate 属性要定义为 weak。
datasource 同样是协议,主要用来从另外一个对象获取数据。
使用 delegate,需要每次都使用 respondToSelector:
来检查对象是否可响应选择子。
如果需要频繁检测,倒不如把是否能响应某个选择子的结果缓存起来,将结果缓存起来的最佳途径:使用 bifield
数据类型:
// 数字代表位数,比如 fieldA 可以代表0-255之间的值
struct data {
unsigned int fieldA : 8;
unsigned int fieldB : 4;
}
如果只是缓存能否响应,那么只需要1位就可以存储结果。
24 将类的实现代码,分配到数个分类中,以便于管理
除了分成多个易于管理的小块外,也可以隐藏实现细节:将应该视为“私有”的方法归入名为 Private 的分类中。
25 总是为第三方类的分类名称加前缀
除了名称外,还有方法名,目标都是为了不与其他库发生冲突。
26 勿在分类中声明属性
虽然在技术上可以使用关联对象实现,但不建议这样做,原因:
- 会有很多重复代码。
- 在内存管理问题上容易出错,因为在为属性实现存取方法时,经常忘记遵从其内存管理语义。
27 使用“class-continuation 分类”隐藏实现细节
即常说的扩展。用法:
- 可用它向类中新增实例变量。
- 如果某个属性在主接口中声明为
readonly
,那么可在类的内存声明扩展,然后再将属性声明为readwrite
。 - 用来声明私有方法的原型,虽然新版编译器不强制使用方法前必须先声明。
- 如果希望所遵循的协议不为人所知,也可在其中声明。
28 通过协议提供匿名对象
可在某种程度上提供匿名对象,具体的对象类型,可以淡化成只要遵从某协议的 id 类型,协议里规定了对象需要实现的方法。
如 NSMutableDictionary
- (void)setObject:(id)object forKey:(id<NSCopying>)key
29 理解引用计数
30 以 ARC 简化引用计数
ARC 回收 Objective-C++ 对象时,待回收对象会调用所有 C++ 对象的析构函数。
31 在 dealloc 方法中只释放引用并解除监听
编译器如果发现对象里有 C++ 对象,就会生成名为:.cxx_destruct
的方法。
32 编写“异常安全代码”时留意内存管理问题
33 以弱引用避免保留环
34 以“自动释放池块”降低内存峰值
35 用“僵尸对象”调试内存管理问题
通过环境变量 NSZombieEnabled
可开启功能。
_NSZombie_
未实现任何方法,而且是个根类,只有一个实例变量 isa
,根据消息转发的规则,发给它的全部消息都要经过“完整的消息转发机制”。
系统会给每个变为僵尸的类创建一个对应的新类,它会把整个 _NSZombie_
类结构拷贝一份,并赋予新的名字。因为如果把所有僵尸对象都归到 _NSZombie_
类里,那么对象原来的类信息就会丢失。
系统会修改对象的 isa 指针,令其指向特殊的僵尸类,从而使该对象变为僵尸对象。
僵尸对象能响应所有的选择子:打印一条消息内容及其接收者的信息,然后终止应用程序。
36 不要使用 retain count
从 29 到 36 多为内存管理相关,可看
《Objective-C 内存管理》
《Objective-C自动引用计数ARC》
37 理解 block
在声明它的范围内,所有变量都可以为其捕获,但默认情况下,被它捕获的变量,是不可以在 block
里修改的。
若是要修改,得添加 __block
修饰符。
block 会把捕获的所有(指针)变量都拷贝一份,放在其结构中。
类型
- NSStackBlock 栈
定义 block 时,其所占内存是分配在栈中。 - NSMallocBlock 堆
如果给某个 block 发送 copy 消息,就可以将其拷贝到堆上,这样它就成为一个有引用计数的对象。 - NSGlobalBlock 全局
像这样的 block,在编译时期就确定了所需的全部信息,那么它就作为一个全局 block。void (^myBlock)() = ^{ NSLog("It is a block"); }
38 为常用的 block 类型创建 typedef
39 用 handler block 降低代码分散程度
40 block 引用其所属对象时,要避免出现保留环
38 - 40 较常见,不再赘述。
41 多用派发队列,少用同步锁
- 同步锁,在极端情况下会导致死锁
@synchronized(id) {
// TODO
}
频繁使用同步锁,会降低代码效率,因为共用同一个锁的那些同步块,都必须按照顺序执行。
- 使用 NSLock 对象
_lock = [[NSLock alloc] init];
[_lock lock];
...
[_lock unlock];
使用 NSRecursiveLock(递归锁),线程能多次持有该锁,而不会出现死锁现象。
替代方案:GCD
// 异步队列
dispatch_async(_syncQueue, ^{
// TODO:
});
如果希望队列单独执行,可用:
void dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
42 多用 GCD,少用 performSelector 系列方法
performSelector 系列方法在内存管理方面容易有疏忽,它无法确定将要执行的选择子具体是什么,所以 ARC 无法添加合适的内存管理方法。
performSelector 系列方法所能处理的选择子过于局限:返回值类型及发送参数个数都有限制。
如果想把任务放在另外一个线程上执行,更应该使用 GCD 的相关方法来实现。
43 掌握 GCD 及操作队列的使用时机
NSOperation 提供一套高层的 Objective-C API,以实现纯 GCD 所具备的大部分功能,且能完成一些更为复杂的操作。
44 通过 Dispatch Group 机制,根据系统资源状况来执行任务
一系列任务可归入一个 dispatch group 中,开发者可在这组任务执行完毕时获得通知。
通过 dispatch group,可以在并发式派发队列里同时执行多项任务,此时 GCD 会根据系统资源状况来调度这些并发执行的任务。
45 使用 dispatch_once 来执行只需执行一次的线程安全代码
常见的是单例实现
+ (id)sharedInstance {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
标记应该声明在 static 或 global 作用域内,这样,在把只需执行一次的 block 交付给 dispatch_once 函数时,传进去的标记也是相同的。
46 不要使用 dispatch_get_current_queue
此函数的行为常常与开发者所预期的不同,目前已废弃,只做调试使用。
由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”。
此函数用于解决由“不可重入”的代码所引发的死锁,然而通用此函数解决的问题,通常也能改用“队列特定数据”解决。
47 熟悉系统框架
基础知识,不再赘述。
48 多用块枚举,少用 :qfor 循环
遍历 collection 有4种方式:
- for 循环
- NSEnumerator 遍历
- 快速遍历:for in
- "块枚举法"
块枚举法本身能通过 GCD 来并发执行遍历操作
若提前知道待遍历的 collection 有何种对象,应修改块签名,指出对象具体类型。
49 对自定义其内存管理语义的 Collection 使用无缝桥接
使用无缝桥接技术,可以在 Foudation 框架中的 Objective-C 对象与 CoreFoundation 框架中的 C 语言数据结构之间来回转换。
在 CoreFoundation 层面创建 collection 时,可以指定许多回调函数,这些函数表示此 collollection 应如何处理其元素,然后,借助无缝桥接技术,将其转换成具备特殊内存管理语义的 Objective-C collection。
50 构建缓存时使用 NSCache 而不是 NSDictionary
原因:
当系统资源将要耗尽时,NSCache 可以自动删减内存,而 NSDictionary 需要自己编写处理:在系统发出 Low Memory 通知时手工删减内存。
且 NSCache 作为 Foundation 的一部分,它能在更深层面进行处理。
NSCache 会先行删减“最久未使用的”对象。NSCache 不会『拷贝』键,而是保留它。
原因:很多时候,键都是由不支持拷贝操作的对象来充当。NSCache 是线程安全的,而 NSDictionary 则绝对不具备这优势。
可以给 NSCache 对象设置上限,用以控制缓存
有2个与系统资源相关的尺度可供调整:
- 是缓存中的对象总数。
- 所有对象的“总开销”。
对象在加入缓存时,可为其指定“开销值”。
在可用资源紧张的时候,可能会删减某个对象,所以通过调整“开销值”来迫使缓存优先删除某对象,不是个好主意。
只有在能很快计算出“开销值”的情况下,才应该考虑这个尺度,比如说 NSData 对象,可把数据大小作为“开销值”来使用,因其数据大小是已知的。
NSPurgeableData 与 NSCache 搭配使用,可实现自动清除数据的功能。
NSPurgeableData 是 NSMutableData 的子类,而且实现了 NSDiscardableContent 协议。
当 NSPurgeableData 对象所占内存被系统丢弃时,该对象自身也会从缓存中移除。
只有那些“重新计算起来很费事的”数据,才值得被放入缓存
如从网络获取或磁盘读取的数据。
51 精简 initialize 与 load 的实现代码
执行 load 时,运行期系统处于“脆弱状态”(fragile state)
在执行子类的 load 方法之前,必定会先执行所有超类的 load 方法,如果代码依赖了其他程序库,那么程序库里相关类的 load 方法,也会被执行。
然而,根据某个给定的程序库,却无法判断其中各个类的加载顺序。
所以,在 load 方法中,使用其他类是不安全的。
关于 load 的继承规则:
- 如果某个类不实现 load 方法,无论超类是否有实现 load 方法,都不会调用。
- 分类和类都有可能出现 load 方法,若是有这种情况,系统先调用类中的,再调用分类的。
但现在已经很少使用 load 了,若是有使用,load 中的代码需要尽量精简。
initialize VS load
- initialize 是惰性调用的,只有程序用到相关类时,才会调用,但对 load 来说,应用程序必须阻塞并等着所有类的 load 都执行完,才能继续。
- 运行期系统在执行 initialize 时,是处于正常状态的,所以可以安全使用并调用任意类中的任意方法,而且运行期系统也会确保 initialize 是线程安全的,即只有执行 initialize 的线程可以操作类或其实例,其他线程会先阻塞,等待 initialize 执行完。
- 关于继承规则,initialize 跟其他消息一样,即使没有实现它,而其超类实现了,就会调用超类的实现代码。
精简 initialize 的原因:
- 大家不希望程序挂起。
对于某个类来说,任何线程都可能成为初次用到它的那个线程,并导致其阻塞,如果那个线程碰巧是 UI 线程,就会导致程序无响应。 - 开发者无法控制类的初始化时机。
运行期系统更新后,也有可能会修改类的初始化方式。 - 如果代码很复杂,可能会用到其他类,系统会迫使其他类初始化。
然而,本类的初始化方法此时尚未运行完毕,其他类在执行 initialize 时,也有可能会用到本类的某些数据,而这些数据可能还未初始化好。
若某个全局状态变量无法在编译时期初始化,那么可以将它放到 initialize 来做,比如说全局 NSArray 对象。
52 别忘了 NSTimer 会保留其目标对象
反复执行任务的计时器,很容易造成循环引用。
可以给 NSTimer 添加 Block 来打破循环引用。