iOS开发读书笔记:Effective Objective-C 2.0 52个有效方法 - 篇4/4

iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇1/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇2/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇3/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇4/4

  • 第六章 块与大中枢派发
    • 第37条:理解“块”这一概念
    • 第38条:为常用的块类型创建typedef
    • 第39条:用handler块降低代码分散程度
    • 第40条:用块引用其所属对象时不要出现保留环
    • 第41条:多用派发队列,少用同步锁
    • 第42条:多用GCD,少用performSelector系列方法
    • 第43条:掌握GCD及操作队列的使用时机
    • 第44条:通过Dispatch Group机制,根据系统资源状况来执行任务
    • 第45条:使用DIspatch_once来执行只需一次的线程安全代码
    • 第46条:不要使用dispatch_get_current_queue
  • 第七章 系统框架
    • 第47条:熟悉系统框架
    • 第48条:多用块枚举,少用for循环
    • 第49条:对自定义其内存管理语义的collection使用无缝桥接
    • 第50条:构建缓存时选用NSCache而非NSDictionary
    • 第51条:精简initialize与load的实现代码
    • 第52条:别忘了NSTimer会保留其目标对象

第六章 块与大中枢派发

当前多线程编程的核心就是“块"(block)与“大中枢派发”(GrandCentral Dispatch,GCD)。这虽然是两种不同的技术,但它们是一并引入的。
GCD是一种与块有关的技术,它提供了对线程的抽象,而这种抽象则基于“派发队列” (dispatch queue) 。

第37条:理解“块”这一概念

块的基础知识

“块”是一种可在C、C++及Objective-C代码中使用的“词法闭包"(lexical closure)。

  1. 块是一种代替函数指针的语法结构,所以它是匿名的;
  2. 可将块像对象一样传递;
  3. 在定义“块”的范围内,它可以访问到其中的全部变量;

块可以实现闭包。块用^符号(脱字符)来表示,后面跟着一对花括号,括号里面是块的实现代码。 例如,下面就是个简单的块

^ {
  //Block implementation here
}

块其实就是个值。与int、float或Objective-C对象一样,也可以把块赋给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。
块类型的语法结构如下:

return_type (block_name)(parameters)
int (^addBlock) (int a, int b) = ^(int a, int b) { 
  return a + b;
};

定义好之后,就可以像函数那样使用了。比方说,addBlock块可以这样用:

int add = addBlock (2 + 5) ; // add = 7

块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。
默认情况下,为块所捕获的变量,是不可以在块里修改的,编译器会报错。不过,声明变量的时候可以加上__block修饰符,这样就可以在块内修改了。

如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他Objective-C对象所能响应的选择子中,有很多是块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。 回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。

块总能修改实例变量,所以在声明时无须加__block。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把self变量一并捕获了,因为实例变量是与self所指代的实例关联在一起的。直接访问实例变量和通过self来访问是等效的。

self->_anInstanceVariable = @"Something";

之所以要捕获self变量,原因正在于此。然而,一定要记住:self也是个对象,因而块在捕获它时也会将其保留。如果self所指代 的那个对象同时也保留了块,那么这种情况通常就会导致‘保留环’。

块的内部结构

每个Objective-C对象都占据着某个内存区域。因为实例变量的个数及对象所包含的关联数据互不相同,所以每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向Class对象的指针,该指针叫做isa(参见第14条)。其余内存里含有块对象正常运转所需的各种信息。


块对象的内存布局.png

在内存布局中,最重要的就是invoke变量,这是个函数指针,指向块的实现代码。函数原型至少要接受一个void*型的参数,此参数代表块。刚才说过,块其实就是一种代替函数指针的语法结构,原来使用函数指针时,需要用“不透明的void指针”来传递状态。而改用块之后,则可以把原来用标准C语言特性所编写的代码封装成简明且易用的接口。

descriptor变量是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了copydispose这两个辅助函数所对应的函数指针。辅助函数在拷贝及丟弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。

块还会把它所捕获的所有变量都拷贝一份。捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke函数为何需要把块对象作为参数传进来呢?原因就在于,执行块时,要从内存中把这些捕获到的变量读出来。

全局块、栈块及堆块

定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。为解决此问题,可给块对象发送copy消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。如果不再使用这个块,那就应将其释放,在ARC环境下会自动释放,而手动管理引用计数时则需要自己来调用release方法。当引用计数降为0后,“分配在堆上的块”(heap block)会像其他对象一样,为系统所回收。而“分配在栈上的块”(stack block)则无须释放,因为栈内存本来就会自动回收。

除了“桟块”和“堆块”之外,还有一类块叫做“全局块”(global block)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。
由于运行该块所需的全部信息都能在编译期确定,所以可把它做成全局块。

要点:

  1. 块是C、C++、Objective-C中的词法闭包。
  2. 块可接受参数,也可返回值。
  3. 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的Objective-C对象一样,具备引用计数了。

第38条:为常用的块类型创建typedef

每个块都具备其“固有类型”(inherenttype),因而可将其赋给适当类型的变量。如果想把块赋给变量,则需注意其类型。变量类型及相关赋值语句如下:

int (^variableName) (BOOL flag, int value) = ^(BOOL flag, int value) {
  //Implementation return somelnt;
}

这个类型似乎和普通的类型大不相同,然而如果习惯函数指针的话,那么看上去就会觉得眼熟了。

为了隐藏复杂的块类型,需要用到C语言中名为“类型定义”(type definition)的特性。 typedef关键字用于给类型起个易读的别名。比如:

typedef int(^EOCSomeBlock)(BOOL flag, int value);

上面这条语句向系统中新增了一个名为EOCSomeBlock的类型。此后,不用再以复杂的块类型来创建变量了,直接使用新类型即可:

EOCSomeBlock block = ^(BOOL flag, int value){
  // Implementation
};

若不定义别名,则方法签名会像下面这样:

- (void)startWithCompletionHandler:(void(^)(NSData *data, NSError *error))completion;
- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;

要点:

  1. 以typedef重新定义块类型,可令块变量用起来更加简单。
  2. 定义新类型时应遵从现有的命名习惯,勿使其名称与别的类型相冲突。
  3. 不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需修改相应typedef中的块签名即可,无须改动其他typedef。

第39条:用handler块降低代码分散程度

要点:

  1. 在创建对象时,可以使用内联的handler块将相关业务逻辑一并声明。
  2. 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler块来实现,则可直接将块与相关对象放在一起。
  3. 设计API时如果用到了handler块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。

第40条:用块引用其所属对象时不要出现保留环

使用块来编程时,很容易导致“保留环”(retain cycle)。要想清楚块可能会捕获并保留哪些对象。如果这些对象又直接或间接保留了块,那么就要考虑怎样在适当的时机解除保留环。

要点:

  1. 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题。
  2. 一定要找个适当的时机解除保留环,而不能把责任推给API的调用者。

第41条:多用派发队列,少用同步锁

在Objective-C中,如果有多个线程要执行同一份代码,那么有时可能会出问题。这种情况下,通常要使用锁来实现某种同步机制。在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block):

- (void)synchronizedMethod {
  @synchronized(self) {
    //Safe
  }
}

这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。在本例中,同步行为所针对的对象是self。这么写通常没错, 因为它可以保证每个对象实例都能不受干扰地运行synchronizedMethod方法。然而,滥用@synchronized(self)则会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行。若是在self对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
另一个办法是直接使用NSLock对象:

_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
  [_lock lock];
  //Safe
  [_lock unlock];
}

也可以使用NSRecursiveLock这种“递归锁’(recursive lock) ,线程能够多次持有该锁,而不会出现死锁(dead lock)现象。

这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。

-(NSString*)someString {
  @synchronized(self) {
    return _someString;
  }
}

- (void)setSomeString:(NSString*)someString { 
  @synchronized(self) {
    _someString = someString;
  }
}

刚才说过,滥用@synchronized(self)会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多个属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完毕才能执行,这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。

使用GCD栅栏(barrier),下列函数可以向队列中派发块,将其作为栅栏使用:

void dispatch_barrier_async(dispatch_queue_t queue,dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue,dispatch_block_t block);

在队列中,栅栏块必须单独执行,不能与其他块并行。这只对并发队列有意义,因为串行队列中的块总是按顺序逐个来执行的。并发队列如果发现接下来要处理的块是个栅栏块 (barrier block) ,那么就一直要等当前所有并发块都执行完毕,才会单独执行这个栅栏块。待栅栏块执行过后,再按正常方式继续向下处理。

可以用栅栏块来实现属性的设置方法。在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写入操作却必须单独执行了。

_syncQueue = dispatch_qet_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);

- (NSString *)someString {
  __block NSString *localSomeString; 
  dispatch_sync(_syncQueuef ^{
    localSomeString = _someString;
  });
  return localSomeString;
};

- (void)setSomeString: (NSString*)someString { 
  dispatch_barrier_async(_syncQueue,^{ 
    _someString = someString;
  });
}
并发队列.png

在这个并发队列中,读取操作是用普通的块来实现的,而写入操作则是用栅栏块来实现的。 读取操作可以并行,但写入操作必须单独执行,因为它是栅栏块。

这种做法肯定比使用串行队列要快。注意,设置函数也可以改用同步的栅栏块(synchronous barrier)来实现,那样做可能会更髙效。

要点:

  1. 派发队列可用来表述同步语义(synchronization semantic),这种做法要比使用@synchronized块NSLock对象更简单。
  2. 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
  3. 使用同步队列及栅栏块,可以令同步行为更加髙效。

第42条:多用GCD,少用performSelector系列方法

NSObject定义了几个方法, 令开发者可以随意调用任何方法。

[object performSelector: selector];

这种方式看上去似乎多余。如果某个方法只是这么来调用的话,那么此方式确实多余。 然而,如果选择子是在运行期决定的,那么就能体现出此方式的强大之处了。这就等于在动态绑定之上再次使用动态绑定。

performSelector系列方法还有个功能,就是可以延后执行选择子,或将其放在另一个线程上执行。下面列出了此方法中一些更为常用的版本:

- (void)performSelector:(SEL)selector withObject:(id)argument afterDelay:(NSTimelnterval)delay 
- (void)performSelector:(SEL)selector onThread:(NSThread *)thread withObject:(id)argument waitUntilDone:(BOOL)wait 
- (void)performSelectorOnMainThread:(SEL)selector withObject:(id)argument waitUntilDone:(BOOL)wait

要点:

  1. performSelector系列方法在内存管理方面容易有疏失。它无法确定将要执行的选择子具体是什么,因而ARC编译器也就无法插入适当的内存管理方法。
  2. performSelector系列方法所能处理的选择子太过局限了,选择子的返回值类型及发送 给方法的参数个数都受到限制。
  3. 如果想把任务放在另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。

第43条:掌握GCD及NSOperationQueue(操作队列)的使用时机

在执行后台任务时,GCD并不一定是最佳方式。还有一种技术叫做NSOperationQueue,“操作队列” (operation queue)在GCD之前就有了,其中某些设计原理因操作队列而流行,GCD就是基于这些原理构建的。实际上,从iOS4与Mac OSX 10.6开始,操作队列在底层是用GCD来实现的。

在两者的诸多差别中,首先要注意:GCD是纯C的API,而操作队列则是Objective-C 的对象。在GCD中,任务用块来表示,而块是个轻量级数据结构(参见第37条)。与之相 反,“操作"(operation)则是个更为重量级的Objective-C对象。虽说如此,但GCD并不总是最佳方案。有时候采用对象所带来的开销微乎其微,使用完整对象所带来的好处反而大大超过其缺点。

使用NSOperation及NSOperationQueue的好处如下:

  1. 取消某个操作。运行任务之前, 可以在NSOperation对象上调用cancel方法,该方法会设置对象内的标志位,用以表明此任务不需执行,不过,已经启动的任务无法取消。若是不使用操作队列,而是把块安排到GCD队列,那就无法取消了。那套架构是“安排好任务之后就不管了"(fire and forget)。
  2. 指定操作间的依赖关系。使特定的操作必须在另外一个操作顺利执行完毕后方可执行。
  3. 通过键值观测机制监控NSOperation对象的属性。NSOperation对象有许多属性都适合通过键值观测机制(简称KVO)来监听,比如可以通过isCancelled属性来判断任务是否已取消,又比如可以通过isFinished属性来判断任务是否已完成。如果想在某个任务变更其状态时得到通知,或是想用比GCD更为精细的方式来控制所要执行的任务,那么键值观测机制会很有用。
  4. 指定操作的优先级。操作的优先级表示此操作与队列中其他操作之间的优先关系。优先级高的操作先执行,优先级低的后执行。操作队列的调度算法(scheduling algorithm)虽“不透明"(opaque),但必然是经过一番深思熟虑才写成的。反之,GCD 则没有直接实现此功能的办法。GCD的队列确实有优先级,不过那是针对整个队列来说的,而不是针对每个块来说的。
  5. 重用 NSOperation 对象。系统内置了一些 NSOperation 的子类(比如 NSBlockOperation) 供开发者调用,要是不想用这些固有子类的话,那就得自己来创建了。这些类就是普通的Objective-C对象,能够存放任何信息。对象在执行时可以充分利用存于其中的信息,而且还可以随意调用定义在类中的方法。这就比派发队列中那些简单的块要强大许多。这些NSOperation类可以在代码中多次使用,它们符合软件开发中的“不重复” (Don ’ t Repeat Yourself, DRY )原则。

有一个API选用了操作队列而非派发队列,这就是NSNotificationCemer,开发者可通过其中的方法来注册监听器,以便在发生相关事件时得到通知,而这个方法接受的参数是块, 不是选择子。方法原型如下:

- (id)addObserverForName:(NSString *)name object:(id)object queue:(NSOperationQueue *)queue usingBlock: (void(^)(NSNotification *))block

本来这个方法也可以不使用操作队列,而是把处理通知事件所用的块安排在派发队列里。但实际上并没有这样做,其设计者显然使用了高层的Objective-C API。在这种情况下, 两套方案的运行效率没多大差距。设计这个方法的人可能不想使用派发队列,因为那样做将依赖于GCD,而这种依赖没有必要,前面说过,块本身和GCD无关,所以如果仅使用块的话,就不会引入对GCD的依赖了。也有可能是编写这个方法的人想全部用Objective-C来描述,而不想使用纯C的东西。

经常会有人说:应该尽可能选用高层API,只在确有必要时才求助于底层。笔者也同意这个说法,但我并不盲从。某些功能确实可以用高层的Objective-C方法来做,但这并不等于说它就一定比底层实现方案好。要想确定哪种方案更佳,最好还是测试一下性能。

要点:

  1. 在解决多线程与任务管理问题时,派发队列并非唯一方案。
  2. 操作队列提供了一套高层的Objective-C API,能实现纯GCD所具备的绝大部分功能, 而且还能完成一些更为复杂的操作,那些操作若改用GCD来实现,则需另外编写代码。

第44条:通过Dispatch Group机制,根据系统资源状况来执行任务

dispatch group 是GCD的一项特性,能够把任务分组。调用者可以等待这组任务执行完毕,也可以在提供回调函数之后继续往下执行,这组任务完成时,调用者会得到通知。这个功能有许多用途,其中最重要、最值得注意的用法,就是把将要并发执行的多个任务合为一组,于是调用者就可以知道这些任务何时才能全部执行完毕。

创建dispatch group:

dispatch_group_dispatch_group_create();

想把任务编组,有两种办法:
第一种:

void dispatch_group_async(dispatch_group_t group,dispatch_queue_t queue, dispatch_block_t block);

第二种:

void dispatch_group_enter(dispatch_group_t group); //使分组里正要执行的任务数递增
void dispatch_group_leave(dispatch_group_t group);//使之递减

调用了 dispatch_group_enter以后,必须有与之对应的dispatch group leave才行。这与引用计数(参见第29条)相似。在使用dispatch group时,如果调用enter之后,没有相应的leave操作,那么这一组任务就永远执行不完。

下面这个函数可用于等待dispatch group执行完毕:

long dispatch_group_wait(dispatch_group_t group,dispatch_time_t timeout);

timeout参数表示函数在等待dispatch group执行完毕时,应该阻塞多久。如果执行dispatch group所需的时间小于timeout,则返回0,则返回非0值。此参数也可以取常量DISPATCH_ TIME_FOREVER,这表示函数会一直等着dispatch group执行完,而不会超时(time out)。

若当前线程不应阻塞,则可用notify函数来取代wait:

void dispatch_group_notify(dispatch_group_t group,dispatch_queue_t queue, dispatch_block_t block);

模拟多线程耗时操作(比如异步请求网络):

dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);

dispatch_group_enter(group);
dispatch_group_async(group, globalQueue, ^{
    sleep(3);
    NSLog(@"thread:%@ block1结束",[NSThread currentThread]);
    dispatch_group_leave(group);
  });

dispatch_group_enter(group);
dispatch_group_async(group, globalQueue, ^{
    sleep(3);
    NSLog(@""thread:%@ block2结束",[NSThread currentThread]);
    dispatch_group_leave(group);
});
    
dispatch_group_notify(group, dispatch_get_global_queue(0, 0), ^{
    //全部结束
});

开发者未必总需要使用dispatch group。有时候采用单个队列搭配标准的异步派发,也可实现同样效果。

遍历某个collection,并在其每个元素上执行任务,dispatcl_apply会持续阻塞,直到所有任务都执行完毕为止。

void dispatch_apply(size_t iterations,dispatch_queue_t queue, void(^block)(size_t));

要点:

  1. 一系列任务可归入一个dispatch group之中。开发者可以在这组任务执行完毕时获得通知。
  2. 通过dispatch group,可以在并发式派发队列里同时执行多项任务。此时GCD会根据系统资源状况来调度这些并发执行的任务。开发者若自己来实现此功能,则需编写大量代码。

第45条:使用dispatch_once来执行只需运行一次的线程安全代码

void dispatch_once (dispatch_once_t *token,dispatch_block_t block);

此函数接受类型为dispatch_once_t的特殊参数,笔者称其为“标记”(token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必定会执行,且仅执行一次。首次调用该函数时,必然会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。请注意,对于只需执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在static或global作用域里,可以保证编译器在每次执行sharedlnstance方法时都会复用这个变量,而不会创建新变量。

+ (id)sharedlnstance {
  static EOCClass *sharedlnstance = nil; 
  static dispatch_once_t onceToken;   
  dispatch_once(&onceToken, ^{
    sharedlnstance = [[self alloc] init];
  });
  return sharedlnstance;
}

此外,dispatch_once更高效。它没有使用重量级的同步机制,若是那样做的话,每次运行代码前都要获取锁,相反,此函数采用“原子访问”(atomic access)来査询标记,以判断其所对应的代码原来是否已经执行过。笔者在自己装有64位Mac OS X 10.8.2系统的电脑上简单测试了性能,分别采用@synchronized方式及dispatch once方式来实现sharedlnstance方法,结果显示,后者的速度几乎是前者的两倍。

要点:

  1. 经常需要编写“只需执行一次的线程安全代码”(thread-safe single-code execution)。通过GCD所提供的dispatch_once函数,很容易就能实现此功能。
  2. 标记应该声明在static或global作用域中,这样的话,在把只需执行一次的块传给dispatch once函数时,传进去的标记也是相同的。

第46 条:不要使用dispatch_get_current_queue (待补充)

使用GCD时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时, 更是如此。文档中说,此函数返回当前正在执行代码的队列,该函数有种典型的错误用法(antipattem,“反模式”)。

要点:

  1. dispatch_get_current_queue函数的行为常常与开发者所预期的不同。此函数已经废弃, 只应做调试之用。
  2. 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
  3. dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用“队列特定数据”来解决。

第七章 系统框架

虽说不使用系统框架也能编写Objective-C代码,但几乎没人这么做。即便是NSObject这个标准的根类,也属于Foundation框架,而非语言本身。若不使用Foundation,就必须自己编写根类。

第47条:熟悉系统框架

将一系列代码封装为动态库(dynamic library),并在其中放入描述其接口的头文件,这样做出来的东西就叫框架。有时为iOS平台构建的第三方框架所使用的是静态库(static library),这是因为iOS应用程序不允许在其中包含动态库。这些东西严格来讲并不是真正的框架,然而也经常视为框架。不过,所有iOS平台的系统框架仍然使用动态库。

在为Mac OS X或iOS系统开发“带图形界面的应用程序"(graphical application)时,会用到名为Cocoa的框架,在iOS上称为Cocoa Touch。其实Cocoa本身并不是框架,但是里面集成了一批创建应用程序时经常会用到的框架。

开发者会碰到的主要框架就是Foundation,像是NSObject、NSArray、NSDictionary等类都在其中。Foundation框架中的类,使用NS这个前缀,此前缀是在Objective-C语言用作NextStep操作系统的编程语言时首度确定的。Foundation框架真可谓所有Objective-C应用程序的“基础”,若是没有它,那么本书大部分内容就不知所云了。

还有个与Foundation相伴的框架,叫做Core Foundation。虽然从技术上讲,Core Foundation框架不是Objective-C框架,但它却是编写Objectve-C应用程序时所应熟悉的重要框架, Foundation框架中的许多功能,都可以在此框架中找到对应的C语言API。Core Foundation 与Foundation不仅名字相似,而且还有更为紧密的联系。有个功能叫做“无缝桥接”(toll- free bridging ), 可以把Core Foundation中的C语言数据结构平滑转换为Foundation中的Objective-C对象,也可以反向转换。比方说,Foundation框架中的字符串是NSString,而它可以转换为Core Foundation里与之等效的CFString对象。无缝桥接技术是用某些相当复杂的代码实现出来的,这些代码可以使运行期系统把Core Foundation框架中的对象视为普通的Objective-C对象。但是,像无缝桥接这么复杂的技术,想自己编写代码实现它,可不太容易。开发程序时可以使用此功能。

除了 Foundation与Core Foundation之外,还有很多系统库,其中包括但不限于下面列出的这些:

  1. CFNetwork此框架提供了C语言级别的网络通信能力
  2. CoreAudio该框架所提供的C语言API可用来操作设备上的音频硬件。
  3. AVFoundation此框架所提供的Objective-C对象可用来回放并录制音频及视频
  4. CoreData此框架所提供的Objective-C接口可将对象放入数据库
  5. CoreText此框架提供的C语言接口可以高效执行文字排版及渲染操作。

可以看出Objective-C编程的一项重要特点,那就是:经常需要使用底层的C语言级API。用C语言来实现API的好处是,可以绕过Objective-C的运行期系统,从而提升执行速度。当然,由于ARC只负责 Objective-C的对象(参见第30条),所以使用这些API时尤其需要注意内存管理问题。若想使用这种框架,一定得熟悉C语言基础才行。

读者可能会编写使用UI框架的Mac OS X或iOS应用程序。这两个平台的核心UI框架分别叫做AppKit及UIKit,它们都提供了构建在Foundation与Core Foundation之上的Objective-C类。在这些主要的UI框架之下,是Core AnimationCore Graphics框架。

Core Animation是用Objective-C语言写成的,它提供了一些工具,而UI框架则用这些工具来渲染图形并播放动画。Core Animation本身并不是框架,它是QuartzCore框架的一部分。

Core Graphics框架以C语言写成,其中提供了2D渲染所必备的数据结构与函数,例如, 其中定义了CGPoint、CGSize、CGRect等数据结构,比如UlKit框架中的UlView类在确定视图控件之间的相对位置时,这些数据结构都要用到。

要点:

  1. 许多系统框架都可以直接使用。其中最重要的是Foundation与Core Foundation,这两个框架提供了构建应用程序所需的许多核心功能。
  2. 很多常见任务都能用框架来做,例如音频与视频处理、网络通信、数据管理等。
  3. 请记住:用纯C写成的框架与用Objective-C写成的一样重要,若想成为优秀的Objective-C开发者,应该掌握C语言的核心概念。

第48条:多用块枚举,少用for循环

// 数组
NSArray *anArray = /* ••• */;
[anArray enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop){
  //Do something with object
   if (shouldStop) {
      *stop = YES;
  }
}];

// Dictionary
NSDictionary *aDictionary = /* ••• */;
[aDictionary enumerateObjectsUsingBlock:^(id key, id object, BOOL *stop){
  //Do something 
   if (shouldStop) {
      *stop = YES;
  }
}];

//Set
NSSet *anSet = /* ••• */;
[anSet enumerateObjectsUsingBlock:^(id object, BOOL *stop){
  //Do something with object
   if (shouldStop) {
      *stop = YES;
  }
}];

优点:

  1. 遍历时可以直接从块里获取更多信息;
  2. 可以通过设定stop变量值来终止循环,当然,使用其他几种遍历方式时,也可以通过break来终止循环;
  3. 能够修改块的方法签名,以免进行类型转换操作,如果能够确知某collection里的对象是什么类型, 那就应该使用这种方法指明其类型。

反向遍历

向其传入 NSEnumerationReverse“选项掩码”(option mask):

- (void)enumerateObjectsWithOptions:(NSEnumerationOptions)options usingBlock:(void(^)(id obj, NSUInteger idx, BOOL *stop))block
- (void)enumerateKeysAndObjectsWithOptions:(NSEnumerationOptions)options usingBlock:(void(^) (id key, id obj, BOOL *stop) ) block

NSEnumerationOptions类型是个enum,其各种取值可用“按位或”(bitwise OR)连接,用以表明遍历方式。例如,开发者可以请求以并发方式执行各轮迭代。如果使用此选项,那么底层会通过GCD来处理并发执行事宜,具体实现时很可能会用到dispatch group (参见第44条)。不过,到底如何来实现,不是本条所要讨论的内容。

要注意:只有在遍历数组或有序set等有顺序的collection时,这么做才有意义。

要点:

  1. 遍历collection有四种方式。最基本的办法是for循环,其次是NSEnumerator遍历法 及快速遍历法,最新、最先进的方式则是“块枚举法”。
  2. “块枚举法”本身就能通过GCD来并发执行遍历操作,无须另行编写代码。而采用其 他遍历方式则无法轻易实现这一点。
  3. 若提前知道待遍历的collection含有何种对象,则应修改块签名,指出对象的具体类型。

第49条:对自定义其内存管理语义的collection使用无缝桥接

使用“无缝桥接”技术,可以在定义于Foundation框架中的Objective-C类和定义于 Core Foundation框架中的C数据结构之间互相转换。笔者将C语言级别的API称为数据结构,而没有称其为类或对象,这是因为它们与Objective-C中的类或对象并不相同。

NSArray *anNSArray = @[@1, @2, @3, @4, @5];
CFArrayRef aCFArray = (__bridge CFArrayRef)anNSArray;
NSLog(@"Size of array = %li",, CFArrayGetCount (aCFArray));
//Output: Size of array = 5

转换操作中的__bridge告诉ARC (参见第30条)如何处理转换所涉及的Objective-C对象。__bridge本身的意思是:ARC仍然具备这个Objective-C对象的所有权。而_bridge_retained则与之相反,意味着ARC将交出对象的所有权。若是前面那段代码改用它来实现, 那么用完数组之后就要加上CFRelease(aCFArray)以释放其内存。与之相似,反向转换可通过_bridge_transfer来实现。比方说,想把CFArrayRef转换为NSArray *,并且想令ARC获得对象所有权,那么就可以采用此种转换方式。这三种转换方式称为“桥式转换"(bridged cast)。

在使用Foundation框架中的字典对象时会遇到一个大问题,那就是其键的内存管理语义为“拷贝”,而值的语义却是“保留”。除非使用强大的无缝桥接技术,否则无法改变其语义。

要点:

  1. 通过无缝桥接技术,可以在Foundation框架中的Objective-C对象与Core Foundation框架中的C语言数据结构之间来回转换。
  2. 在Core Foundation层面创建collection时,可以指定许多回调函数,这些函数表示此collection应如何处理其元素。然后,可运用无缝桥接技术,将其转换成具备特殊内存管理语义的Objective-C collection。

第50条:构建缓存时选用NSCache而非NSDictionary

NSCache类是Foundation框架专为处理内存缓存而设计的
NSCache胜过NSDictionary之处在于以下几个方面:

  1. 当系统资源将要耗尽时,NSCache可以自动删减缓存,还会先行删减“最久未使用的"(lease recently used)对象。 如果采用普通的字典,那么就要自己编写挂钩,在系统发出“低内存”(low memory)通知时手工删减缓存,十分复杂。
  2. NSCache并不会“拷贝”键,而是会“保留”它。NSCache对象不拷贝键的原因在于:很多时候,键都是由不支持拷贝操作的对象来充当的。因此,NSCache不会自动拷贝键,所以说, 在键不支持拷贝操作的情况下,该类用起来比字典更方便。
  3. NSCache是线程安全的。 而NSDictionary则绝对不具备此优势,意思就是:在开发者自己不编写加锁代码的前提下,多个线程便可以同时访问NSCache。对缓存来说,线程安全通常很重要,因为开发者可能要在某个线程中读取数据,此时如果发现缓存里找不到指定的键,那么就要下载该键所对应的数据了。而下载完数据之后所要执行的回调函数,有可能会放在背景线程中运行,这样的话,就等于是用另外一个线程来写入缓存了。
  4. 开发者可以操控缓存删减其内容的时机。有两个与系统资源相关的尺度可供调整,其一是缓存中的对象总数,其二是所有对象的“总开销"(overall cost)。开发者在将对象加入缓存时,可为其指定“开销值”。当对象总数或总开销超过上限时,缓存就可能会删减其中的对象了,在可用的系统资源趋于紧张时,也会这么做。然而要注意,“可能”会删减某个对象, 并不意味着“一定”会删减这个对象。删减对象时所遵照的顺序,由具体实现来定。这尤其说明:想通过调整“开销值”来迫使缓存优先删减某对象,不是个好主意。

注意:向缓存中添加对象时,只有在能很快计算出“开销值”的情况下,才应该考虑采用这个尺度。若计算过程很复杂,那么照这种方式来使用缓存就达不到最佳效果了,而缓存的本意则是要增加应用程序响应用户操作的速度。

如何使用:比如下载数据所用的URL,就是缓存的键。若缓存未命中(cache miss) ,则下载数据并将其放入缓存。而数据的“开销值”则设为其长度。创建NSCache时,将其中可缓存的总对象数目上限设为100,将“总开销’上限设为5MB,不过,由于‘开销值’以‘字节’ 为单位,所以要通过算式将MB换箅成字节。

还有个类叫做NSPurgeableData,和NSCache搭配起来用,效果很好,此类是NSMutiableData的子类,而且实现了NSDiscardableContent协议。如果某个对象所占的内存能够根据需要随时丢弃,那么就可以实现该协议所定义的接口。这就是说,当系统资源紧张时,可以把保存NSPurgeableData对象的那块内存释放掉。NSDiscardableContent协议里定义了名为isContentDiscarded的方法,可用来査询相关内存是否已释放。

要点:

  1. 实现缓存时应选用NSCache而非NSDictionary对象。因为NSCache可以提供优雅的自动删减功能,而且是“线程安全的”,此外,它与字典不同,并不会拷贝键。
  2. 可以给NSCache对象设置上限,用以限制缓存中的对象总个数及“总成本”,而这些尺度则定义了缓存删减其中对象的时机。但是绝对不要把这些尺度当成可靠的“硬限制”(hard limit),它们仅对NSCache起指导作用。
  3. NSPurgeableDataNSCache搭配使用,可实现自动清除数据的功能,也就是说,当NSPurgeableData对象所占内存为系统所丢弃时,该对象自身也会从缓存中移除。
  4. 如果缓存使用得当,那么应用程序的响应速度就能提高。只有那种“重新计算起来很费事的”数据,才值得放入缓存,比如那些需要从网络获取或从磁盘读取的数据。

第51条:精简initialize与load的实现代码

在Objective-C中,绝大多数类都继承自NSObject这个根类,而该类有两个方法,可用来实现这种初始化操作:

+ (void)load
+ (void)initialize

首先要讲的是load方法:

对于加入运行期系统中的每个类(class)及分类(category)来说,必定会调用此方法,而且仅调用一次。当包含类或分类的程序库载入系统时,就会执行此方法,而这通常就是指应用程序启动的时候,若程序是为iOS平台设计的,则肯定会在此时执行。Mac OS X应用程序更自由一些,它们可以使用“动态加载”(dynamic loading)之类的特性,等应用程序启动好之后再去加载程序库。如果分类和其所属的类都定义了load方法,则先调用类里的,再调用分类里的。

load方法的问题在于,执行该方法时,运行期系统处于“脆弱状态"(fragilestate)。在执行子类的load方法之前,必定会先执行所有超类的load方法,而如果代码还依赖了其他程序库,那么程序库里相关类的load方法也必定会先执行。然而,根据某个给定的程序库,却无法判断出其中各个类的载入顺序。因此,在load方法中使用其他类是不安全的。

有个重要的事情需注意,那就是load方法并不像普通的方法那样,它并不遵从那套继承规则。如果某个类本身没实现load方法,那么不管其各级超类是否实现此方法,系统都不会调用。此外,分类和其所属的类里,都可能出现load方法。此时两种实现代码都会调用,类的实现要比分类的实现先执行。

而且load方法务必实现得精简一些,也就是要尽量减少其所执行的操作,因为整个应用程序在执行load方法时都会阻塞。如果load方法中包含繁杂的代码,那么应用程序在执行期间就会变得无响应。 其真正用途仅在于调试程序,比如可以在分类里编写此方法,用来判断该分类是否已经正确载入系统中。

想执行与类相关的初始化操作,还有个办法,就是覆写initialize方法:

对于每个类来说,该方法会在程序首次用该类之前调用,且只调用一次。它是由运行期系统来调用的,绝不应该通过代码直接调用。

其虽与load相似,但却有几个非常重要的区别:

  1. 它是“惰性调用的”,也就是说,只有当程序用到了相关的类时,才会调用。 因此,如果某个类一直都没有使用,那么其initialize方法就一直不会运行。这也就等于说,应用程序无须先把每个类的initialize都执行一遍,这与load方法不同,对于load来说,应 用程序必须阻塞并等着所有类的load都执行完,才能继续。
  2. 此方法与load还有个区别,就是运行期系统在执行该方法时,是处于正常状态的,因此,从运行期系统完整度上来讲,此时可以安全使用并调用任意类中的任意方法。而且,运行期系统也能确保initialize方法一定会在“线程安全的环境”(thread-safe environment)中执行。
  3. initialize方法与其他消息一样,如果某个类未实现它,而其超类实现了,那么就会运行超类的实现代码。
#import <Foundation/Foundation.h>

@interface EOCBaseClass : NSObject
@end
@implementation EOCBaseClass 
+ (void)initialize {
  NSLog (@"%@ initialize", self);
}
@end

@interface EOCSubClass : EOCBaseClass
@end
@implementation EOCSubClass 
@end

即便EOCSubClass类没有实现initialize方法,它也会收到这条消息。由各级超类所实现的initialize也会先行调用。所以,首次使用EOCSubClass时,控制台会输出如下消息:

EOCBaseClass initialize 
EOCSubClass initialize

与其他方法(除去load) —样, initialize也遵循通常的继承规则,所以,当初始化基类EOCBaseClass时,EOCBaseClass中定义的initialize方法要运行一遍,而当初始化子类EOCSubClass时,由于该类并未覆写此方法,因而还要把父类的实现代码再运行一遍。鉴于此,通常都会这么来实现initialize方法:

+ (void)initialize {
  if(self == [EOCBaseClass class]) {
    NSLog (@"%@ initialize", self);
  }
}

加上这条检测语句之后,只有当开发者所期望的那个类载入系统时,才会执行相关的初 始化操作。如果把刚才的例子照此改写,那就不会打印出两条记录信息了,这次只输出一条:

EOCBaseClass initialize

若某个全局状态无法在编译期初始化,则可以放在initialize里来做:

//EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject 
@end
//EOCClass .m 
#import "EOCClass.h"
static const int klnterval = 10; 
static NSMutableArray *kSomeObjects;
@implementation EOCClass
+ (void)initialize {
  if (self == [EOCClass class]) {
    kSomeObjects = [NSMutableArray  new];
  }
}

整数可以在编译期定义,然而可变数组不行,因为它是个Objective-C对象,所以创建实例之前必须先激活运行期系统。注意,某些Objective-C对象也可以在编译期创建,例如NSString实例。然而,创建下面这种对象会令编译器报错:

static NSMutableArray *kSomeObjects = [NSMutableArray new];

编写load或initialize方法时,把代码实现得简单一些,能节省很多调试时间。除了初始化全局状态之外,如果还有其他事情要做,那么可以专门创建一个方法来执行这些操作,并要求该类的使用者必须在使用本类之前调用此方法。比如说,如 果“单例类"(singleton class)在首次使用之前必须执行一些操作,那就可以采用这个办法。

要点:

  1. 在加载阶段,如果类实现了load方法,那么系统就会调用它。分类里也可以定义此方法,类的load方法要比分类中的先调用。与其他方法不同,load方法不参与覆写机制。
  2. 首次使用某个类之前,系统会向其发送initialize消息。由于此方法遵从普通的覆写规则,所以通常应该在里面判断当前要初始化的是哪个类。
  3. load与initialize方法都应该实现得精简一些,这有助于保持应用程序的响应能力,也能减少引入“依赖环”(interdependency cycle) 的几率。
  4. 无法在编译期设定的全局常量,可以放在initialize方法里初始化。

第52条:别忘了NSTimer会保留其目标对象

计时器要和“运行循环"(runloop)相关联,运行循环到时候会触发任务。
方法1:创建计时器,并将其预先安排在当前运行循环中:

+ (NSTimer *)scheduledTimerWithTimelnterval:(NSTimelnterval)seconds target:(id)target selector:(SEL)selector userlnfo:(id)userinfo repeats:(BOOL)repeats;

方法2:先创建timer,然后再将其加入到当前的runloop中,并指定mode:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

方法3:iOS10.0及之后的系统才可以使用的,解决了内存泄露的问题:

+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block 

无论采用哪种方式,只有把计时器放在运行循环里,它才能正常触发任务。
targetselector参数表示计时器将在哪个对象上调用哪个方法。计时器会保留其目标对象,等到自身“失效”时再释放此对象。调用invalidate方法可令计时器失效。
由于计时器会保留其目标对象,所以反复执行任务通常会导致应用程序出问题。也就是说, 设置成重复执行模式的那种计时器,很容易引入“保留环”。
这个问题可通过“块”来解决。虽然计时器当前并不直接支持块,但是可以用下面这段代码为其添加此功能:

#import <Foundation/Foundation.h>
@interface NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimelnterval:(NSTimelnterval)interval block:(void(^)())block repeats:(BOOL)repeats;
@end

@implementation NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimelnterval:(NSTimelnterval)interval block:(void(^)())block repeats:(BOOL)repeats {
  return [self scheduledTimerWithTimelnterval:interval target:self selector:@selector(eoc_blocklnvoke:) userlnfo:[block copy] repeats:repeats];
}
+ (void)eoc_blocklnvoke:(NSTimer *timer { 
  void (^block)() = timer.userlnfo;
  if (block) { 
    block();
  }
}
@end

这个办法为何能解决“保留环”问题呢?这段代码将计时器所应执行的任务封装成“块”,在调用计时器函数时,把它作为userlnfo参数传进去。该参数可用来存放“不透明值”(opaque value) ,只要计时器还有效,就会一直保留着它。传入参数时要通过copy方法将block拷贝到“堆”上(参见第37条),否则等到稍后要执行它的时候,该块可能已经无效了。计时器现在的target是NSTimer类对象,这是个单例,因此计时器是否会保留它,其实都无所谓。此处依然有保留环,然而因为类对象(classobject)无须回收,所以不用担心。

还没有圆满的完成,还有两个问题要解决:还是存在保留环+需要停止计时器:

- (void)startPolling {
  weak EOCClass *weakSelf = self;
  _pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval:5.0 block:^{
    EOCClass *strongSelf = weakSelf;
    [strongSelf p_doPoll];
  } repeats:YES];
};

- (void)dealloc {
    [_pollTimer invalidate];
}

这段代码采用了一种很有效的写法,它先定义了一个弱引用,令其指向self,然后使块捕获这个引用,而不直接去捕获普通的self变量。也就是说,self不会为计时器所保留。当块开始执行时,立刻生成strong引用,以保证实例在执行期间持续存活。

采用这种写法之后,如果外界指向EOCClass实例的最后一个引用将其释放,则该实例就可为系统所回收了。回收过程中还会调用计时器的invalidate方法,这样的话,计时器就不会再执行任务了。此处使用weak引用还能令程序更加安全,因为有时开发者可能在编写dealloc时忘了调用计时器的invalidate方法,从而导致计时器再次运行,若发生此类情况,则块里的weakSelf会变成nil。

要点:

  1. NSTimer对象会保留其目标,直到计时器本身失效为止,调用invalidate方法可令计时器失效,另外,一次性的计时器在触发完任务之后也会失效。
  2. 反复执行任务的计时器(repeating timer),很容易引入保留环,如果这种计时器的目标对象又保留了计时器本身,那肯定会导致保留环。这种环状保留关系,可能是直接发生的,也可能是通过对象图里的其他对象间接发生的。
  3. 可以扩充NSTimer的功能,用“块”来打破保留环。不过,除非NSTimer将来在公共接口里提供此功能,否则必须创建分类,将相关实现代码加入其中。

推荐阅读更多精彩内容