概念篇:
1.运行时
- OC 语言由 Smalltalk(20世纪70年代出现的一种面向对象的语言) 演化而来,后者是消息型语言的鼻祖。
- OC 使用动态绑定的消息结构,在运行时检查对象类型。
- 使用消息结构的语言,其运行时执行的代码由运行环境来决定。而使用函数调用的语言,则由编译器决定。
- OC 对象所占内存总是分配在“堆空间”,绝不会分配在“栈”上。分配在堆中的内存必须直接管理,而分配在栈上用于保存变量的内存会在栈帧弹出时自动清理。
- OC 运行期内存管理架构,名叫“引用计数”。
- OC 中会遇到定义中不含 * 的变量,也是会使用“栈空间”。比如 CoreGraphics 框架的 CGRect,整个系统框架都在使用这种结构体,如果使用 OC 对象,性能会受影响。
2.属性
- 用来封装对象中的数据。
- 如果使用了属性的话,那么编译器就会主动编写访问这些属性所需的方法,此过程叫做“自动合成”。这个过程在编译期执行,点语法是编译时特性。
- @synthesize 语法来指定实例变量的名字。
@synthesize testString = _testString;
- @dynamic 关键字会告诉编译器不要创建实现属性所用的实例变量,也不要为其创建存取方法。而且,在编译访问属性的代码时,即使编译器发现没有定义存取方法也不会报错,它相信这些方法能在运行时找到。
@dynamic testString;
_testString //Use of undeclared identifier '_testString'
- 属性特质的设定也会影响编译器所生成的存取方法。属性特质包括:原子性、读写属性、内存管理语义(assign、strong、weak、unsafe_unretained、copy)、方法名(getter==<name>…)。
- 如果想在其他方法里设置属性值,同样要遵守属性定义中所宣称的语义,因为“属性定义”就相当于“类”和“待设置的属性值”之间所达成的契约
比如在指定构造器中对成员变量的赋值
@interface TestObject ()
//虽然这个属性已经是只读性质,也要写上具体的语义,以此表明初始化方法在设置这些值时所用方法
@property(copy,readonly) NSString *testString;
@end
@implementation TestObject
- initWithString:(NSString *)string {
self = [super init];
if (self) {
//用初始化方法设置好属性值之后,就不要再改变了,此时属性应设为“只读”
_testString = [string copy];
}
return self;
}
@end
3.对象等同性
- “==”操作符比较的是两个指针本身,而不是所指的对象。应该使用 NSObject 协议中声明的“isEqual”方法来判断两个对象的等同性。
NSString *textA = @"textA";
NSString *textAnother = [NSString stringWithFormat:@"textA"];
NSLog(@"%d",textA == textAnother);// 0
NSLog(@"%d",[textA isEqual:textAnother]);// 1
NSLog(@"%d",[textA isEqualToString:textAnother]);// 1
- 在自定义的对象中正确复写“isEqual”"hash"方法,来判定两个方法是否相等。
- 如果 “isEqual”方法判定两个对象相等,那么其 hash 方法也必须返回同一个值。
比如下面这个类
@interface TestObject : NSObject
@property NSString *testString;
@end
@implementation TestObject
- (BOOL)isEqual:(id)object {
if (self == object) return YES;
if ([self class] != [object class]) return NO;
TestObject *otherObject = (TestObject *)object;
if (![self.testString isEqualToString:otherObject.testString]) {
return NO;
}
return YES;
}
-(NSUInteger)hash {
//在没有性能问题下,hash 方法可以直接返回一个数
return 1227;
}
@end
在继承体系中判断等同性,还需判断是否是其子类
相同的对象必须具有相同的哈希码,但是相同哈希码的对象却未必相同
特定类型等同性判断
- 自己创建等同性判断方法,无需检测参数类型,大大提升检测速度。就像“isEqualToString”一样。
- (BOOL)isEqualToTestObject:(TestObject *)testobject {
if (self == testobject) {
return YES;
}
if (![self.testString isEqualToString:testobject.testString]) {
return NO;
}
return YES;
}
- (BOOL)isEqual:(id)object {
if ([self class] == [object class]) {
return [self isEqualToObject:(TestObject *)object];
}else {
return [super isEqual:object];
}
}
- 有时候无需将所有数据逐个比较,只根据其中部分数据即可判明二者是否相等。
比方说一个模型类的实例是根据数据库的数据创建而来,那么其中可能会含有一个唯一标识符(unique identifier),在数据库中用作主键。这时候,我们就可以根据标识符来判定等同性,尤其是此属性声明为 readonly 时更应该如此。只要标识符相等,就可以说明这两个对象是由相同数据源创建,据此断定,其他数据也相等。
当然,只有类的编写者才知道那个关键属性是什么。
要点:不要盲目的逐个检测每条属性,而是应该按照具体需求制定检测方案
4.理解 objc_msgSend
- 在对象上调用方法是 OC 中经常使用的方法。专业术语叫做“传递消息”,消息有名称(或叫选择子),可以接受参数,或许还有返回值。
- 在 OC 中,对象收到消息之后,究竟该调用哪个方法完全于运行期决定,甚至可以在运行时改变,这些特性使 OC 成为一门真正的动态语言。
给对象发送消息可以这样写:
id value = [obj messageName:parameter]
obj 叫做接收者,messageName 叫做 selector,selector 和参数合起来称为消息
编译器看到此消息后,将其转换为一条标准的 C 语言函数调用
void objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
第一个参数代表接收者,第二个代表 selector(SEL是selector类型)
这是个“参数个数可变的函数”,”…“ 代表后续参数,就是消息中的参数
- objc_msgSend 方法在接收者所属的类中搜寻其”方法列表“,如果能找到与 selector 名称相符的方法,就跳至其实现代码。若是找不到,就沿着继承体系继续向上查找,等找到合适的方法之后再跳转。如果最终还是找不到相符的方法,就执行”消息转发“(在之后解释)。
- 看起来,想调用一个方法似乎需要很多步骤。所幸 objc_msgSend 会将匹配结果缓存在”快速映射表“中,每个类都有这样一个缓存,若是后来还向该类发送相同的消息,那么执行起来就会很快了。
- 其他消息发送函数
//Sends a message with a simple return value to the superclass of an instance of a class.
objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
//Sends a message with a data-structure return value to an instance of a class.
objc_msgSend_stret(id _Nullable self, SEL _Nonnull op, ...)
//Sends a message with a data-structure return value to the superclass of an instance of a class.
objc_msgSendSuper_stret(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
- 刚才提到 objc_msgSend 找到合适的方法之后,就会”跳转过去“。之所以可以这样做是因为 OC 对象的每个方法都可以视为简单的 C 函数,原型如下:
<return_type> Class_selector(id self, SEL _cmd, ...)
每个类都有一张表格,selector 的名称就是查表时所用的 key
- 原型的样子和 objc_msgSend 很像,而且函数的最后一项操作是调用另一个函数而且不会将其返回值另作他用,就可以利用”尾调用优化“技术,令”跳至方法实现“变得简单。
尾调用技术:编译器会生成跳转至另一函数所需的指令码,而且不会向调用堆栈中推入新的”栈帧“
- 要点:发给某对象的全部消息都要由“动态消息派发系统”来处理,该系统会查出对应的方法,并执行其代码
5.消息转发机制
-
对象在收到无法解读的消息之后会发生什么情况?
如果在控制台中看到上面这种提示信息,那就说明你给对象发送了一条其无法解读的消息,启动了消息转发机制
- 因为在运行期可以继续向类中添加方法,所以编译器在编译期还无法确知类中是否有某个方法的具体实现。
- 当对象接收到无法解读的消息,就会触发“消息转发机制”,程序员可以经由此过程告诉对象如何处理未知消息
消息转发分为两大阶段
- 第一阶段:征询接收者,能否动态添加方法,处理当前这个“未知的 selector”
//当未知的 selector 是实例方法时的调用
+ (BOOL)resolveInstanceMethod:(SEL)sel ;
//当未知的 selector 是类方法时的调用
+ (BOOL)resolveClassMethod:(SEL)sel;
使用这种办法的前提是:相关方法的实现代码已经写好,只等着运行时加入类里面
- 第二阶段:运行时系统会请求接收者以其他手段处理与消息相关的方法调用,可细分为两小步。
//第一步:询问能不能把未知的消息转给其他接收者处理
- (id)forwardingTargetForSelector:(SEL)aSelector ;
若当前接收者能找到备援接收者,则将其返回,若找不到,则返回 nil
-如果返回一个对象,则运行期系统把消息转给那个对象,于是消息转发结束
如果返回 nil,执行第二步👇
//第二步:把消息相关的细节封装在 NSInvocation对象中,再给接收者最后一次机会
- (void)forwardInvocation:(NSInvocation *)anInvocation
可以在触发消息前,先以某种方式改变消息内容,比如追加一个参数,或改换 selector,等等
如果实现此方法时,发现某调用操作不应由本类处理,则调用超类的调用方法。如果继承体系的类都不处理此调用请求,那就最后调用 NSObject 类的方法,那么该方法会执行👇方法
//抛出异常,表明 selector 未能处理
- (void)doesNotRecognizeSelector:(SEL)aSelector;
- 接收者在每一步均有机会处理消息。步骤越往后,处理消息的代价就越大。最好能在第一步处理完,这样的话运行期系统就可以把消息缓存起来了。如果这个类的实例稍后还收到同名 selector,那么根本无需启动消息转发流程。
- 接下来给一个添加方法的具体实现
+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSString *selString = NSStringFromSelector(sel);
if ([selString hasPrefix:@"set"]) {
//最后一个参数表示待添加方法的类型编码(type encoding)
class_addMethod([self class], sel, (IMP)autoDictionarySetter, "v@:@");
}else {
class_addMethod([self class], sel, (IMP)autoDictionaryGetter, "@@:");
}
return [super resolveInstanceMethod:sel];
}
注
SEL 是方法编号,SEL类型通过 @selector() 提取
IMP 是一个函数指针,保存了方法的地址
type encoding:
v 代表 void
: 代表 (method selector)SEL
@ 代表 object(whether statically typed or typed id);
在 setter 实现的时候给一个字典添加键值对,getter 从字典获取值,这样的实现就在 iOS 的 CoreAnimation 框架中的 CALayer 类里面。CALayer 是一种“兼容于键值编码的”容器类,能向其中随意添加属性,然后以键值对的方式访问。
6.类对象的用意
- 首先开看看 OC 对象的声明
NSString *pointerVariable = @"Some string";
pointerVariable 是存放内存地址的变量,而 NSString 自身的数据就存于那个地址中
//对于通用的对象类型 id ,其本身已经是指针了,可以这样写
id genericTypeString = "id String"
上面两种不同的定义方式的区别在于,如果声明时指定了类型,在实例上调用没有的方法时,编译器会发出警告。而 id 类型,编译器默认它能响应所有本项目中存在的方法。
-
描述 OC 对象所用的数据结构定义在运行期程序库的头文件里
可见对象是一个含有 isa 指针的结构体,isa 指针表示这个对象是一个(is a)什么类。
-
那么 isa 指向的 Class 是什么?
原来 Class 是指向 objc_class 的指针。
-
objc/runtime.h 中 对 objc_class 的定义如下
结构体中包含的具体内容:
1.可以看到,结构体内也有一个 isa 指针,也就是说“类也是有类的”。由此说明,类也是一个对象。
2.下面的内容就是这个类方法列表(method_list)(实际是对象方法)、成员变量列表(ivar_list)、cache 列表、协议列表,还有 super_class 变量,等等
这样 OC 中类和对象的关系就清楚了。对象的 isa 指针指向类对象,类对象的 isa 指针指向 metaClass,译为元类。元类的 isa 指针指向根元类
由此可以看出来 Apple 设计类对象的用意就是为了存储对象的信息,比如对象方法,对象属性,遵守的协议,对象的类的父类,等等,而类对象的相关信息被存储在元类中。
7.理解 Objective-C 错误类型
- 当前很多编程语言都有“异常”机制,OC 也不例外
首先要注意的是,“自动引用计数”(Automatic Reference Counting, ARC)在默认情况下不是“异常安全的“。这意味着:如果抛出异常,那么本应在作用域末尾释放的对象现在却不会自动释放了。
OC 现在采用的办法是:只在极其罕见的情况下抛出异常,异常抛出之后无需考虑恢复问题,而且应用程序此时也应该退出。 -
异常只应该用于极其严重的错误。比如说,你编写了一个抽象基类,它的正确用法是先从中继承一个子类,然后使用这个子类。在这种情况下,可以在那些子类必须复写的超类方法里抛出异常。这样的话,只要有人创建抽象基类并使用它,就会抛出异常。
下面图片是在 Masonry 中的异常的使用
- 异常只用于出现严重错误,当出现“不那么严重的错误”,OC 使用的编程范式是,令方法返回 nil/0,或是使用 NSError,表明其中有错误产生。
比如初始化方法无法根据传入的参数来初始化当前实例,就返回nil/0;
- initWithString:(NSString *)string {
if (self = [super init]) {
if (string == nil) {
self = nil;
}else {
//Initialize instance
}
}
return self;
}
NSError 的用法更加灵活,因为通过此对象,我们可以获知错误的具体信息。NSError 对象里封装了三条消息:
-
Error domain(错误范围,类型为字符串)
错误发生的范围。也就是错误发生的根源。比如在网络请求时获取数据失败或解析数据发生错误,就使用 NSURLEroorDomain 来表示错误范围
-
Error code(错误码,其类型为整数)
独有的错误代码,用来指明在某个范围内具体发生了何种错误。某个特定范围内可能会发生一系列相关错误,这些错误情况通常采用 enum 来定义。例如:当 http 请求出错时,把 HTTP 状态码设为错误码.
User info(用户信息,类型为字典)
有关此错误的额外信息,其中或许包含一段本地化的描述(localized description),或许还含有导致该错误发生的另一个错误。比如 SDWebImage 中
@{NSLocalizedDescriptionKey : @"Image data is nil"}
- 在设计 API 时,NSError 第一种使用方式是通过协议传递此错误。例如 NSURLSessionTaskDelegate中定义的方法:
/* Sent as the last message related to a specific task. Error may be
* nil, which implies that no error occurred and this task is complete.
*/
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error;
这个委托方法未必非得实现不可,是不是必须处理此错误,可交由用户来处理。
NSError 的另一种常见用法:经由方法的“输出参数”返回给调用者。比如在 MJExtension 中:
/**
* 通过字典来创建一个模型
* @param keyValues 字典
* @return 新建的对象
*/
+ (instancetype)objectWithKeyValues:(id)keyValues;
+ (instancetype)objectWithKeyValues:(id)keyValues error:(NSError **)error;
入参是一个指针,指向另一个指针,那个指针指向 NSError 对象。使用如下方式获取错误信息。
NSError *error = nil;
MyModel *model = [MyModel objectWithKeyValues:responseObject error:&error];
if (error) {
//获取 error 信息
}
实际上,在使用 ARC 时,编译器会把方法签名中的 NSError ** 转换成 NSError *__autoreleasing *,也就是说,指针所指的对象会在方法执行完毕后自动释放。
MJ 通过以下代码把 NSError 对象传递到“输出参数”中:
// 构建错误
#define MJBuildError(error, msg) \
if (error) *error = [NSError errorWithDomain:msg code:250 userInfo:nil];
-这段代码以 *error 语法为 error 参数”解引用“,也就是说,error 所指的那个指针现在要指向一个新的 NSError 对象了。在解引用前,必须确保 error 不是 nil,因为空指针解引用会导致“段错误”并使程序奔溃。
- 为自己的程序库中所发生的错误指定一个专用的”错误范围“ 字符串,使用此字符串创建 NSError 对象,返回给库的使用者,这样的话,使用者可以确定:该错误是由你的程序库所回报的。
8.理解引用计数
- Objective-C 语言使用引用计数来管理内存,也就是说,每个对象都有一个可以递增递减的计数器,如果想要保留对象,就递增,用完了之后就递减,当计数为 0 时,对象就销毁。要写出优秀的 OC 代码,除了知道这些,还必须知道其中的原理。
引用计数工作原理
- 在引用计数架构下,每个对象都有一个计数器,用来表示当前一共有多少调用者想让此对象存活。NSObject 协议中有以下三个方法操作计数器
- (void)retain;//使递增
- (void)release;//使递减
- (void)autorelease;//稍后清理“自动释放池”时再进行递减
查看引用计数的方法是 retainCount,不过这个方法不是很准确,苹果官方也不推荐
应用程序在生命期内会创建很多对象,这些对象相互联系着。例如表示个人信息的对象会引用表示名字的字符串对象。对象如果持有其他对象的强引用,那么前者就拥有后者。也就是说,对象想让它所引用的对象继续存活,可将其“保留”。等用完了之后再释放。
下图表示了一个对象从保留到释放的过程
按图可以想象,有一些其他对象想要保持 B 或 C 对象存活,而引用程序中又会有另外一些对象想让这些对象存活。如果按“引用树”回溯,那么最终会发现有一个“根对象”。在 iOS 中,就是 UIApplication 对象。是在应用程序启动时创建的对象。
当对象的引用计数为 0 ,对象所占内存”解除分配“之后,就被放回”可用内存池“。此时再去调用该对象,可能会有不同情况发生:如果此时内存对象已经做了他用,就会引起程序奔溃;如果此时对象内存未被复写,就可能正常运行。由此可见,由过早释放对象而导致的 bug 很难调试。
- 为避免无意间使用无效对象,一般调用完 release 之后都会清空指针。
属性存取方法中的内存管理
- 当对象要保留其他对象的时候,一般通过访问属性来实现。比如一个属性名为 foo,其属性内存管理语义为”strog“,那么他的设置方法为:
- (void)setFoo:(id)foo {
[foo retain];
[_foo release];
_foo = foo;
}
此方法保留新值,释放旧值,然后更新变量指向新值。顺序很重要。如果先释放旧值,那此对象就被回收。后续操作就都没有意义了。
自动释放池
- 调用 release 会立即递减对象的引用计数,如果想要延迟释放,那么可以调用 autorelease,加入自动释放池,它会在之后清空的时候给其中的所有对象发送一条 release 消息。
保留环
- 使用引用计数,经常要注意的问题就是“保留环”,也就是呈环状相互引用的多个对象。
- 可以使用弱引用,或者通过外界命令循环中的一个对象不再保留另一个对象,打破保留环,避免内存泄漏。
9.以 ARC 简化引用计数
此处没有彻底看懂,暂时先放一放
10.理解“块”这一概念
块是一种可在 C 、C++、OC 代码中使用的“词法闭包”,它非常有用,可以把一段代码像对象一样传递。在定义“块”的范围内,它可以访问到其中所有的变量。
说“块”必须离不开说多线程。苹果公司设计的多线程编程的核心就是“块”(block)和“大中枢派发”(GCD),这虽然是两种不同的技术,但他们是一并引入的。
GCD 提供了对线程的抽象,而这种抽象基于“派发队列”。开发者可将块排入队列中,由 GCD 处理所有调度事宜。
块的基础知识
- 块可以实现闭包。这种语言特性是做为扩展加入 GCC 编译器中的,在近期版本中的 Clang 都可以使用。
块其实就是个值,而且自有其相关类型。可以把块赋给变量,语法与函数指针类似。块用符号 “^" 来表示
int adder = 8;
//定义一个变量名为 myBlock 的块
//语法结构:return_type (^blockName) (parameters)
int (^addBlock)(int num) = ^(int num){
//在块内部可以使用外部变量
return num+ adder;
};
int addEight = addBlock(5);
- 默认情况下,在块内部不可以更改变量的值。想要改变值,需要在声明变量时使用 __block 修饰。
这里有个疑问,块里面的实例变量在没有 __block 修饰的情况下却也是可以改变值的,为什么?
- 如果块所捕获的变量是对象,那么就会自动保留它。块也是对象,当块的引用计数为 0 ,系统释放块的时候,也会释放它所捕获的对象,以便平衡捕获时的保留操作。
块的内部结构
块本身是对象,所以他也有内存空间。
- 首个变量是指向 Class 对象的指针,该指针叫做 isa。
- 最重要的是 invoke 变量,是个函数指针,指向块的实现代码。
- descriptor 变量是指向结构体的指针,其中声明了块对象的总体大小,还声明了 copy 和 dispose 这两个辅助函数对应的函数指针。
- 块还会把它捕获到的变量都拷贝一份,放在 descriptor 后面。拷贝的是指向变量的指针。捕获了多少个变量,就要占据多少个内存空间。
全局块 栈块 堆块
- 定义块的时候,其所占的内存区域是在栈中的。也就是说,块只在定义它的那个范围内有效。比如下面代码,就是有危险的:
void (^myBlock)();
if (/* some condition*/) {
myBlock = ^{};
}else {
myBlock = ^{};
}
myBlock();
定义在 if 和 else 里面的两个块都分配在栈内存中。编译器会给每个块分配好栈内存,但是等离开了相应的范围之后,编译器就可能把分配给块的内存覆写掉。这样写出来的代码时而正确时而错误。
为解决此问题,可给块对象发送 copy 消息以拷贝之。这样就把块对象从栈内存移到了堆内存中,拷贝后的块,就可以在定义它的范围之外使用了。而且,一旦复制到堆上,块对象就成了具有引用计数的对象了。后续的复制操作就只是递增引用计数了。
明白了这一点,我们只要给代码加上两个 copy 调用就安全了。
void (^myBlock)();
if (/* some condition*/) {
myBlock = [^{} copy];
}else {
myBlock = [^{} copy];
}
myBlock();
- 还有一种块叫做”全局块”,像这样的
void (^myBlock)() = ^{
NSLog(@"This is a block");
};
由于运行该块的所有信息都能在编译的时候确定,所以可把他做成全局块。
这种块不会捕捉任何状态(比如外围的变量等),可以声明在全局内存中,不需要在每次用到的时候于栈中创建。这种块实际上相当于单例。