Effective Objective-C 学习(四)

掌握GCD及操作队列的使用时机

在执行后台任务时,GCD 并不一定是最佳方式。还有一种技术叫做 NSOperationQueue,它虽然与 GCD 不同,但是却与之相关,开发者可以把操作以 NSOperation 子类的形式放在队列中,而这些操作也能够并发执行。

GCD是纯C的API,而NSOperationQueue是Objective-C的对象。这意味着使用GCD时,任务通过块(block)来表示,而块是一种轻量级的数据结构;而使用NSOperationQueue时,任务通过NSOperation的子类来表示,这是一种更为重量级的Objective-C对象。

虽然GCD提供了一种更轻量级的方式来处理任务,但并不总是最佳选择。有时候,使用NSOperationQueue所带来的开销微乎其微,而使用完整的对象所带来的好处可能会超过其缺点。NSOperationQueue提供了更多的灵活性和控制,例如可以对操作进行取消、暂停和恢复等操作。

NSOperationQueue相比于纯GCD的优势:

  1. 取消操作: 使用NSOperationQueue可以轻松取消操作。可以在NSOperation对象上调用cancel方法来设置取消标志,这使得取消操作变得更加简单。相比之下,如果使用纯GCD,任务一旦被提交到队列中就无法取消。
  2. 指定操作间的依赖关系: NSOperation允许指定操作之间的依赖关系,这使得某些操作必须在其他操作执行完毕后才能执行。这种依赖关系对于需要按特定顺序执行任务的情况非常有用。
  3. 监控操作属性: NSOperation对象的属性可以通过键值观察(KVO)机制进行监控,这使得可以轻松地检测操作的状态变化,例如判断操作是否被取消或完成。
  4. 指定操作的优先级: NSOperation允许指定操作的优先级,这使得可以控制操作执行的顺序。与GCD不同,NSOperation提供了更为灵活的优先级管理机制。
  5. 重用NSOperation对象: NSOperation对象是Objective-C的对象,可以存储任何信息,并且可以多次使用。这使得NSOperation相对于简单的GCD块更为强大,因为它们可以包含更多的逻辑和状态信息。

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

objectivec 复制代码
- (id)addObserverForName:(NSString *)name object:(id)object queue:(NSOperationQueue *)queue usingBlock:(void(^)(NSNotification *))block;
  • 在解决多线程与任务管理问题时,派发队列并非唯一方案。
  • 操作队列提供了一套高层的 Objective-C API,能实现纯 GCD 所具备的绝大部份功能,而且还能完成一些更为复杂的操作,那些操作若改用 GCD 来实现,则需另外编写代码。

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

dispatch group(意为"派发分组"或"调度组") 是 GCD 的一项特性,能够把任务分组。

其中最重要的用法,就是把将要并发执行的多个任务合为一组,于是调用者就可以知道这些任务何时才能全部执行完毕。

把压缩一系列文件的任务表示成 dispatch group,下面这个函数可以创建 dispatch group:

objectivec 复制代码
dispatch_group_t dispatch_group_create();

想把任务编组,有两种办法。第一种是用下面这个函数:

objectivec 复制代码
void dispatch_group_async(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);

它是普通 dispatch_async 函数的变体,比原来多一个参数,用于表示待执行的块所归属的组。还有种办法能够指定任务所属的 dispatch group,那就是使用下面这一对函数:

objectivec 复制代码
void dispatch_group_enter(dispatch_group_t group);
void dispatch_group_leave(dispatch_group_t group);

前者能够使分组里正要执行的任务数递增,而后者则使之递减。调用了 dispatch_group_enter 以后,必须有与之对应的 dispatch_group_leave 才行。这与引用计数相似。

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

objectivec 复制代码
long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);

此函数接受两个参数,一个是要等待的 group,另一个是代表等待时间的 timeout 值。timeout 参数表示函数在等待 dispatch group 执行完毕时,应该阻塞多久。

除了可以用上面那个函数等待 dispatch group 执行完毕之外,也可以换个办法,使用下列函数:

objectivec 复制代码
void dispatch_group_notiy(dispath_group_t group, dispatch_queue_t queue, dispath_block_t block);

不同的是:开发者可以向此函数传入块,等 dispatch group 执行完毕之后,块会在特定的线程上执行。

比方说,在 Mac OS X 与 iOS 系统中,都不应阻塞主线程,因为所有 UI 绘制及事件处理都要在主线程上执行。如果想令数组中的每个对象都执行某项任务,并且想等待所有任务执行完毕,那么就可以使用这个 GCD 特性来实现。

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

objectivec 复制代码
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup, notifyQueue, ^{
    //...
});

也可以把某些任务放在优先级高的线程上执行,同时仍然把所有任务都归入同一个 dispatch group。并在执行完毕时获得通知。

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

为了执行队列中的块,GCD 会在适当的时机自动创建新线程或复用旧线程。如果使用并发队列,那么其中有可能会有多个线程,这也意味着多个块可以并发执行。在并发队列中,执行任务所用的并发线程数量,取决于各种因素,而GCD 只要是根据系统资源状况来判定这些因素的。假如 CPU 有多个核心,并且队列中有大量任务等待执行,那么GCD 就可能会给该队列配备多个线程。通过 dispatch group 所提供的这种简便方式,既可以并发执行一系列给定的任务,又能在全部任务结束时得到通知。

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

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

单例模式常见的实现方式为:在类中编写名为 sharedInstance 的方法,该方法只会返回全类共用的单例实例,而不会在每次调用时都创建新的实例。比如说:

objectivec 复制代码
@implementation EOCClass

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

@end

不过,GCD 引入了一项特性,能使单例实现起来更为容易。所用的函数是:

objectivec 复制代码
void dispatch_once(dispatch_once_t *token, dispatch_block_t block);

此函数接受类型为 dispatch_once_t 的特殊参数,笔者称其为 "标记"(token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必定会执行,且仅执行一次。此操作完全是线程安全的。

刚才实现单例模式所用的 sharedInstance 方法,可以用此函数来改写:

objectivec 复制代码
+ (instancetype)sharedInstance {
    static EOCClass *sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

使用 dispatch_once 可以简化代码并且彻底保证线程安全,开发者根本无须担心加锁或同步。所有问题都由 GCD 在底层处理。由于每次调用时都必须使用完全相同的标记,所以标记要声明成 static。此外,dispatch_once 更高效。

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

不要使用dispatch_get_current_queue

使用 GCD 时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时,更是如此。

dispatch_get_current_queue本来是用于解决由不可重入的代码所引发的死锁,但是因为它已经被废弃,所以可以选择通过 GCD 所提供的功能来设定"队列特有数据"(queue-specific data),此功能可以把任意数据以键值对的形式关联到队列里。最重要之处在于,假如根据指定的键获取不到关联数据,那么系统就会沿着层级体系向上查找,直至找到数据或到达根队列为止。比如说这个例子:

objectivec 复制代码
dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);

dispatch_set_target_queue(queueB, queueA);

static int kQueueSpecific;

CFStringRef queueSpecificValue = CFSTR("queueA");

dispatch_queue_set_specific(queueA, &kQueueSpecific, (void *)queueSpecificValue, (dispatch_function_t)CFRelease);

dispatch_sync(queueB, ^{
    dispatch_block_t block = ^{ NSLog(@"No deadlock!"); };
    CFStringRef retrievedValue = dispatch_get_specific(&kQueueSpecific);
    if (retrievedValue) {
        block();
    } else {
        dispatch_sync(queueA, block);
    }
});

在上面这段代码中:有两个串行队列 queueA 和 queueB。将 queueB 的目标队列设置为 queueA,表示 queueB 依赖于 queueA。定义一个静态整型变量 kQueueSpecific 作为键值,用于关联队列特有数据。使用 dispatch_queue_set_specific 函数将特定值(在这里是 queueA 的标识符)与 queueA 关联起来。在 queueB 上执行同步任务,内部判断是否可以访问 queueA 的特定值。如果能够访问到 queueA 的特定值,则直接执行任务,否则在 queueA 上同步执行任务。

这样,即使在 queueB 上同步执行任务,也不会产生死锁,因为在获取 queueA 的特定值时,会自动向上查找到其父级队列 queueA 的特定值,从而避免了循环等待。

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

熟悉系统框架

编写 Objective-C 应用程序时几乎都会用到系统框架,如果直接使用这些框架中的类,那么应用程序就可以得益于新版系统库所带来的改进,而开发者也就无须手动更新其代码了。

将一系列代码封装为动态库(dynamic library),并在其中放入描述其接口的头文件,这样做出来的东西就叫框架。

各种常用框架:

总结:

  1. Cocoa 和 Cocoa Touch 框架:Cocoa 框架用于 macOS 应用程序开发,而 Cocoa Touch 用于 iOS 应用程序开发。它们集成了一系列常用的框架和工具,用于创建图形界面应用程序。
  2. Foundation 框架:Foundation 框架是 Cocoa 和 Cocoa Touch 中的一个核心框架,包含了诸如 NSObject、NSArray、NSDictionary 等基础类。它提供了许多基础核心功能,例如集合类、字符串处理以及字符串解析等。
  3. CoreFoundation 框架:CoreFoundation 框架不是 Objective-C 框架,而是用 C 语言编写的。然而,它与 Foundation 框架密切相关,提供了一套与 Foundation 中相对应的 C 语言 API。CoreFoundation 中的数据结构可以与 Foundation 中的 Objective-C 对象进行平滑转换,这种技术称为"无缝桥接"。
  4. CFNetwork 框架:此框架提供了C语言级别的网络通信能力,它将"BSD套接字"(BSD socket)抽象成易于使用的网络接口。而 Foundation 则将该框架里的部分内容封装为 Objective-C 语言的接口,以便进行网络通信,例如可以用 NSURLConnection 从 URL 中下载数据。
  5. CoreAudio 框架:该框架所提供的 C语言API 可用来操作设备上的音频硬件。这个框架属于比较难用的那种,因为音频处理本身就很复杂。所幸由这套API 可以抽象出另外一套 Objective-C 式API,用后者来处理音频问题会更简单些。
  6. AVFoundation框架:此框架所提供的Objective-C 对像可用来回放并录制音频及视频,比如能够在 UI 视图类里播放视频。
  7. CoreData 框架:此框架所提供的 Objective-C 接口可将对象放入数据库,以便持久保存。CoreData 会处理数据的获取及存储事宜,而且可以跨越 Mac OS X 及 iOS 平台。
  8. CoreText 框架:此框架提供的C 语言接口可以高效执行文字排版及渲染操作。
  9. AppKit 和 UIKit 框架:AppKit 用于 macOS 应用程序开发,而 UIKit 用于 iOS 应用程序开发。它们是构建在 Foundation 和 CoreFoundation 之上的核心 UI 框架,提供了 UI 元素和粘合机制,用于组装应用程序的所有内容。
  10. CoreAnimation 框架:CoreAnimation 是一个用 Objective-C 编写的框架,它提供了渲染图形和播放动画所需的工具。虽然 CoreAnimation 本身不是一个框架,但它是 QuartzCore 框架的一部分,被广泛用于 UI 框架中。
  11. CoreGraphics 框架:CoreGraphics 是用 C 语言编写的框架,提供了绘制 2D 图形所需的数据结构和函数。UIKit 框架中的 UIView 类在确定视图控件之间的相对位置时会用到 CoreGraphics 中定义的数据结构。
  12. 其他框架:除了上述核心 UI 框架之外,还有许多其他框架构建在 UI 框架之上,例如 MapKit 框架用于为 iOS 应用程序提供地图功能,Social 框架用于为 macOS 和 iOS 应用程序提供社交网络功能。这些框架通常与操作系统平台对应的核心 UI 框架结合使用,以丰富应用程序的功能。

可以看出Objective-C的一项重要特点:经常需要使用底层的 C 语言级 API。用 C 语言来实现 API 的好处是,可以绕过 Objective-C 的运行期系统,从而提升执行速度。

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

多用块枚举,少用for循环

语言中引入"块"这一特性后,又多出来几种新的遍历方式,采用这几种新方式遍历 collection 时,通常会大幅度简化编码过程,笔者下面将会详细说明。

for循环

for循环是大家都很熟悉的写法。但是用它遍历字典或者set就会很麻烦。因为字典与 set 都是"无序的",所以无法根据特定的整数下标来直接访问其中的值。

for循环也可以执行反向遍历,执行反向遍历时,使用 for 循环会比其他方式简单许多。

使用 NSEnumerator 来遍历

NSEnumerator 是个抽象基类,其中只定义了两个方法,供其具体子类(concrete subclass)来实现:

objectivec 复制代码
- (NSArray *)allObjects;
- (id)nextObject;

其中关键的方法是 nextObject,它返回枚举里的下个对象。每次调用该方法时,其内部数据结构都会更新,使得下次调用方法时能返回下个对象。等到枚举中的全部对象都已返回之后,再调用就将返回 nil,这表示达到枚举末端了。

例如遍历字典和set:

objectivec 复制代码
  // Dictionary
  NSDictionary *aDictionary = /*...*/;
  NSEnumerator *enumerator = [aDictionary keyEnumerator];
  id key;
  while ((key = [enumerator nextObject]) != nil) {
    id value = aDictionary[key];
    // Do something with 'key' and 'value'
  }

  // set 
  NSSet *aSet = /*...*/;
  NSEnumerator *enumerator = [aSet objectEnumerator];
  id object;
  while ((object = [enumerator nextObject]) != nil) {
    // Do something with 'object'
  }

在第一段代码中,使用了 NSDictionary 类的 keyEnumerator 方法来获取字典中所有键的枚举器,然后通过枚举器逐个获取键,并使用键来访问字典中的值。在注释部分的代码块中,可以对每个键值对执行相应的操作。

在第二段代码中,使用了 NSSet 类的 objectEnumerator 方法来获取集合中的所有对象的枚举器,然后通过枚举器逐个获取集合中的对象。在注释部分的代码块中,可以对每个对象执行相应的操作。

快速遍历

快速遍历与使用NSEnumerator 来遍历差不多,然而语法更简洁,它就是for...in...

这样写简单多了。如果某个类的对象支持快速遍历,那么就可以宣称自己遵从名为 NSFastEnumeration 的协议,从而令开发者可以采用此语法来迭代该对象。

遍历字典与set 也很简单:

objectivec 复制代码
  // Dictionary 
  NSDictionary *aDictionary = /*...*/;
  for (id key in aDictionary) {
    id value = aDictionary[key];
    // Do something with 'key' and 'value'
  }

  // Set
  NSSet *aSet = /*...*/;
  for (id object in aSet) {
    // Do something with 'object'
  }

由于 NSEnumerator 对象也实现了 NSFastEnumeration 协议,所以能用来执行反向遍历。若要反向遍历数组,可采用下面这种写法:

objectivec 复制代码
  NSArray *anArray =  /*...*/;
  for (id object in [anArray reverseObjectEnumerator]) {

    // Do something with 'object'
  }

基于块的遍历方式

最新引入的一种做法就是基于块来遍历。NSArray 中定义了下面这个方法,它可以实现最基本的遍历功能:

objectivec 复制代码
- (void)enumerateObjectsUsingBlock:(void(^)(id object, NSUInteger idx, BOOL *stop))block;

这个方法的方法名是enumerateObjectsUsingBlock:,它的参数:一个块作为参数,块包含三个参数:object:数组中的元素;idx:元素在数组中的索引;stop:一个指向布尔值的指针,用于控制遍历过程。如果将 *stop 设置为 YES,则遍历会停止。

该方法遍历时既能获取对象,也能知道其下标。此方法还提供了一种优雅的机制,用于终止遍历操作。

用它遍历字典与 set 也同样简单:

objectivec 复制代码
  // Dictonary
  NSDictionary *aDictionary = /*...*/;
  [aDictionary enumerateKeyAndObjectsUsingBlock:
    ^(id key, id object, BOOL *stop){
      // Do something with 'key' and 'object'
      if (shouldStop) {
        *stop = YES;
      }
  }];

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

此方式大大胜过其他方式的地方在于:遍历时可以直接从块里获取更多信息。在遍历数组时,可以知道当前所针对的下标。遍历有序set (NSOrderedSet)时也一样。而在遍历字典时,无须额外编码,即可同时获取键与值,因而省去了根据给定键来获取对应值这一步。

另外一个好处是,能够修改块的方法签名,以免进行类型转换操作。

若已知字典中的对象必为字符串,则用基于块的方式来遍历可以这样编码:

objectivec 复制代码
  NSDictionary *aDictionary = /*...*/;
  [aDictionary enumerateKeysAndObjectsUsingBlock:
    ^(NSString *key, NSString *obj, BOOL *stop) {
      // Do something with 'key' and 'obj'
  }];

之所以能如此,是因为 id 类型相当特殊,它可以像本例这样,为其他类型所覆写。

用此方式也可以执行反向遍历。数组、字典、set 都实现了前述方法的另一个版本,使开发者可向其传入 "选项掩码"(option mask):

objectivec 复制代码
  - (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

NSEnumerationOption 类型是个 enum,其各种取值可用"按位或"(bitwise OR)连接,用以表明遍历方式。

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

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

CoreFoundation 框架也定义了一套C 语言API,用于操作表示这些collection 及其他各种collection 的数据结构。例如,NSArray 是Foundation 框架中表示数组的 Objective-C 类,而CFArray 则是 CoreFoundation 框架中的等价物。这两种创建数组的方式也许有区别,然而有项强大的功能可在这两个类型之间平滑转换,它就是 "无缝桥接"(toll-free bridging)。

使用"无缝桥接"技术,可以在定义于 Foundation 框架中的 Objective-C 类和定义于 CoreFoundation 框架中的C数据结构之间相互转换。

下列代码演示了简单的无缝桥接:

objectivec 复制代码
  NSArray *anNSArray = @[@1, @2, @3, @4, @5];
  CFArrayRef aCFArray = (_bridge CFArrayRef)anNSArray;
  NSLog(@"Size of array = %li", CFArrayGetCount(aCFArray));
  // Output: size of array = 5

这段代码演示了如何将NSArray对象转换为CFArrayRef类型,使用__bridge进行类型转换,将anNSArray转换为CFArrayRef类型的对象aCFArray。

__bridge 本身的意思是:ARC 仍然具备这个 Objective-C 对象的所用权。而 __bridge_retained 则与之相反,意味着ARC 将交出对象的所有权。与之相似,反向转换可通过 __bridge_transfer 来实现。这三种转换方式称为 "桥式转换"。

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

CoreFoundation 框架中的字典类型叫做 CFDictionary。其可变版本称为 CFMutableDictionary 。创建 CFMutableDictionary 时,可以通过下列方法来指定键和值的内存管理语义:

objectivec 复制代码
  CFMutableDictionaryRef CFDictionaryCreateMutable (
    CFAllocatorRef allocator,
    CFIndex capacity,
    const CFDictionaryKeyCallBacks *keyCallBacks,
    const CFDictionaryValueCallBacks *valueCallBacks
  }

首个参数表示将要使用的内存分配器。CoreFoundation 对象里的数据结构需要占用内存,而分配器负责分配及回收这些内存。开发者通常为这个参数传入 NULL,表示采用默认的分配器。第二个参数定义了字典的初始大小。它并不会限制字典的最大容量,只是向分配器提示了一开始应该分配多少内存。最后两个参数定义了许多回调函数,用于指示字典中的键和值在遇到各种事件时应该执行何种操作。

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

构建缓存时选用NSCache而非NSDctionary

实现缓存时使用NSCache 类更好,它是 Foundation 框架专为处理这种任务而设计的。。

NSCache 胜过 NSDictionary 之处在于,当系统资源将要耗尽时,它可以自动删减缓存。

NSCache 并不会"拷贝"键,而是会 "保留"它。NSCache 对象不拷贝键的原因在于:很多时候,键都是由不支持拷贝操作的对象来充当的。

另外,NSCache 是线程安全的。而 NSDictionary 则绝对不具备此优势,意思就是:在开发者自己不编写加锁代码的前提下,多个线程便可以同时访问 NSCache。

开发者可以操控缓存删减其内容的时机。有两个与系统资源相关的尺度可供调整,其一是缓存中的对象总数,其二是所有对象的"总开销"。开发者在将对象加入缓存时,可为其指定"开销值"。当对象总数或总开销超过上限时,缓存就可能会删减其中的对象了,在可用的系统资源趋于紧张时,也会这么做。

向缓存中添加对象时,需要计算对象的"开销值"。这个"开销值"是一个附加因素,通常用于帮助决定何时从缓存中移除对象。

下面这段代码演示了缓存的用法:

objectivec 复制代码
  #import <Foundation/Foundation.h>

  typedef void (^EOCNetworkFetcherCompletionHandler)(NSData *data);
  @interface EOCNetworkFetcher : NSObject 
  - (id)initWithURL:(NSURL *)url;
  - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
  @end

  @interface EOCClass : NSObject

  @end
  @implementation EOCClass {
    NSCache *_cache;
  }

  - (id)init {
    if ((self = [super init])) {
      _cache = [NSCache new];
      _cache.countLimit = 100;
      /**
      * The Size in bytes of data is used as the cost,
      * so this sets a cost limit of 5MB.
      */
      _cache.totalCostLimit = 5 * 1024 * 1024;
    }
    return self;
  }

  - (void)downloadDataForURL:(NSURL *)url {
    NSData *cachedData = [_cache objectForKey:url];
    if (cachedData) {
      // Cache hit
      [self useData:cacheData];
     } else {
      // Cache miss
      EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
      [fetcher startWithCompletionHandler:^(NSData *data) {
        [_cache setObject:data forKey:url cost:data.length];
        [self useData:data];
      }];
     }
    }
  @end

这段代码实现了一个网络数据下载器 EOCNetworkFetcher 和一个使用了缓存的类 EOCClass。

EOCNetworkFetcher 类:负责从指定的 URL 下载数据。它包含了一个初始化方法 initWithURL: 和一个启动下载的方法 startWithCompletionHandler:。

EOCClass 类:包含了一个 NSCache 对象 _cache,用于缓存下载的数据。它还有一个方法 downloadDataForURL:,用于下载指定 URL 的数据,首先检查缓存中是否有数据,如果有则直接使用缓存的数据,如果没有则启动网络下载器进行下载,并将下载的数据存入缓存中。

在本例中,下载数据所用的 URL,就是缓存的键。若缓存中没有访问者所需的数据,则下载数据并将其放入缓存。

还有个类叫做 NSPurgeableData,此类是NSMutableData 的子类,而且实现了 NSDiscardableContent 协议。如果某个对象所占的内存能够根据需要随时丢弃,那么就可以实现该协议所定义的接口。

如果将 NSPurgeableData 对象加入NSCache,那么当该对象为系统所丢弃时,也会自动从缓存中移除。通过 NSCache 的 evictsObjectsWithDiscardedContent 属性,可以开启或关闭此功能。

使用 NSPurgeableData 改写的话:

objectivec 复制代码
  - (void)downloadDataForURL:(NSURL *)url {
    NSPurgeableData *cachedData = [_cache objectForKey:url];
    if (cachedData) {
      [cacheData beginContentAccess];
      [self useData:cachedData];
      [cacheData endContentAccess];
    } else {
      EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
      [fetcher startWithCompletionHandler:^(NSData *data) {
        NSPurgeableData *purgeableData = [NSPurgeableData dataWithData:data];
        [_cache setObject:purgeableData forKey:url cost: purgeableData.length];
        [self useData:data];
        [purgeableData endContentAccess];
       }];
    }    
  }

这段代码通过 _cache 对象使用给定的 url 从缓存中获取数据。这里使用了 NSPurgeableData 类型的 cachedData 对象来存储获取到的数据。

如果缓存中存在数据(即 cachedData 不为 nil),则进入缓存命中的分支。在这个分支中,首先通过 beginContentAccess 方法将数据标记为开始访问状态,然后使用该数据,最后通过 endContentAccess 方法将访问结束。

如果缓存中不存在数据,则进入缓存未命中的分支。在这个分支中,首先创建一个 EOCNetworkFetcher 对象,用于从网络下载数据。在下载完成后,将下载的数据封装为 NSPurgeableData 对象,并存入缓存中。然后使用该数据,最后通过 endContentAccess 方法将访问结束。

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

精简initialize与load的实现代码

有时候类必须先执行某些初始化操作,然后才能正常使用。-(void) load 方法,它是一个类方法,用于在类或分类被加载到运行时系统时执行。每个类和分类都会在程序启动时调用一次 load 方法,且仅调用一次。

load 方法的执行顺序是先执行类的 load 方法,然后执行分类的 load 方法。这意味着,如果一个类有多个分类,并且它们都实现了 load 方法,那么先执行类的 load 方法,然后按照分类的引入顺序依次执行各个分类的 load 方法。

然而,load 方法存在一个问题,即在执行该方法时,运行时系统处于"脆弱状态"。这意味着在 load 方法中使用其他类可能是不安全的,因为在执行子类的 load 方法之前,必定会先执行所有超类的 load 方法。而在加载依赖的其他库时,无法确定其中各个类的加载顺序,因此在 load 方法中使用其他类是不可靠的。

比如说:

objectivec 复制代码
#import "EOCClassA.h"
@implementation EOCClassB
+ (void)load {
    NSLog(@"Loading EOCClassB");
    EOCClassA *object = [EOCClassA new];
  }

在 EOCClassB 的load 方法里使用 EOCClassA 却不太安全,因为无法确定在执行 EOCClassB 的 load 方法之前,EOCClassA 是不是已经加载好了。

load 方法不像普通的方法一样遵循继承规则。如果一个类自身没有实现 load 方法,那么无论其超类是否实现了 load 方法,系统都不会自动调用。这意味着子类的 load 方法不会自动调用其父类的 load 方法,除非子类自己实现了 load 方法并在其中显式调用了父类的 load 方法。

而且 load 方法务必实现得精简一些,也就是要尽量减少其所执行的操作,因为整个应用程序在执行load 方法时都会阻塞。

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

objectivec 复制代码
+ (void)initialize;

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

此方法与 load 还有个区别,就是运行期系统在执行该方法时,是处于正常状态的,因此,从运行期系统完整度上来讲,此时可以安全使用并调用任意类中的任意方法。

最后一个区别是: initialize 方法与其他消息一样,如果某个类未实现它,而其超类实现了,那么就会运行超类的实现代码。即它是可以继承的。

当初始化基类 EOCBaseClass 时,EOCBaseClass 中定义的 initialize 方法要运行一遍,而当初始化 EOCSubClass 时,由于该类并未覆写此方法,因而还要把父类的实现代码再运行一遍。鉴于此,通常都会这么来实现 initialize 方法:

objectivec 复制代码
+ (void)initialize {
  if (self == [EOCBaseClass class]) {
    NSLog(@"%@ initialized", self);
  }
}

load 与 initialize 方法的实现代码要尽量精简。在里面设置一些状态,使本类能够正常运作就可以了,不要执行那种耗时太久或需要加锁的任务。对于 load 方法来说,其原因已在前面解释过了,而 initialize 方法要保持精简的原因,也与之相似。

其二,开发者无法控制类的初始化时机。类在首次使用之前,肯定要初始化,但编写程序时不能令代码依赖特定的时间点,否则会很危险。

最后一个原因,如果某个类的实现代码很复杂,那么其中可能会直接或间接用到其他类。若那些类尚未初始化,则系统会迫使其初始化。

initialize 方法只应该用来设置内部数据。不应该在其中调用其他方法,即便是本类自己的方法,也最好别调用。

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

别忘了NSTimer会保留其目标对象

计时器要和 "运行循环"(run loop)相关联,运行循环到时候会触发任务。创建 NSTimer 时,可以将其"预先安排"在当前的运行循环中,也可以先创建好,然后由开发者自己来调度。无论采用哪种方式,只有把计时器放在运行循环里,它才能正常触发任务。

由于计时器会保留其目标对象,所以反复执行任务通常会导致应用程序出问题。也就是说,设置成重复执行模式的那种计时器,很容易引入 "保留环"。

比如说下列代码:

objectivec 复制代码
  #import <Foundation/Foundation.h>
  @interface EOCClass : NSObject 
  - (void)startPolling;
  - (void)stopPolling;
  @end

  @implementation EOCClass {
    NSTimer *_pollTimer;
  }
  - (id)init {
    return [super init];
  }
  - (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 repeats: YES];
  }
  - (void)p_doPoll {
  }
  @end

这段代码有个问题:当创建 EOCClass 实例并调用其 startPolling 方法时,会创建一个 NSTimer 对象并将其赋值给 _pollTimer 实例变量。由于 NSTimer 对象的目标对象是 EOCClass 实例本身,因此会对 EOCClass 实例进行强引用,导致 EOCClass 实例无法被释放。而 _pollTimer 实例变量也会对 NSTimer 对象进行强引用,使得 NSTimer 对象也无法被释放。这样就形成了一个保留环,即相互持有对方的强引用,导致内存泄漏。

如果想在系统回收本类实例的过程中令计时器无效,从而打破保留环,那又会陷入死结。因为在计时器对象尚且有效时,EOCClass 实例的保留计数绝不会降为 0 ,因此系统也绝不会将其回收。而现在又没人来调用 invalidate 方法,所以计时器将一直处于有效状态。

当指向 EOCClass 实例的最后一个外部引用被移除后,该实例仍然存活,因为计时器持有对它的强引用。同时,计时器对象也无法被系统释放,因为它被 EOCClass 实例强引用。这导致了实例和计时器对象互相持有对方的强引用,形成了保留环,导致内存泄漏。

更糟糕的是,除了计时器之外,已经没有其他引用指向 EOCClass 实例了,因此该实例会永远被保留,无法被释放。

这个问题可通过"块"来解决。虽然计时器当前并不直接支持块,但是可以用下面这段代码为其添加此功能:

objectivec 复制代码
#import <Foundation/Foundation.h>

@interface NSTimer (EOCBlockSupport)

+ (NSTimer *)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval block:(void(^)())block repeats:(BOOL)repeats;

@end

@implementation NSTimer (EOCBlocksSupport)

+ (NSTimer *)eoc_scheduledTimerWithTimeInterval:(NSTimeInterval)interval block:(void(^)())block repeats:(BOOL)repeats {
    return [self scheduledTimerWithTimeInterval:interval target:self selector:@selector(eoc_blockInvoke:) userInfo:[block copy] repeats:repeats];
}

+ (void)eoc_blockInvoke:(NSTimer *)timer {
    void (^block)() = timer.userInfo;
    if (block) {
        block();
    }
}

@end

这段代码为 NSTimer 添加了支持块(blocks)的功能。通过类别(category),扩展了 NSTimer 类,添加了一个类方法 eoc_scheduledTimerWithTimeInterval:block:repeats:,用于创建带有块回调的定时器。

在 eoc_scheduledTimerWithTimeInterval:block:repeats: 方法中,调用了 scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: 方法创建了一个定时器,并传入了一个 selector,但是这个 selector 实际上是 eoc_blockInvoke: 方法。同时,将传入的块对象通过 copy 方法复制,并作为 userInfo 参数传递给定时器。

eoc_blockInvoke: 方法是一个类方法,用于实际执行定时器触发时的回调操作。它从定时器的 userInfo 中获取了保存的块对象,并执行该块对象。

这样,通过调用 eoc_scheduledTimerWithTimeInterval:block:repeats: 方法创建的定时器,当触发定时器时,就会执行传入的块代码块。

我们修改刚才那段有问题的范例代码,使用新分类中的 eoc_scheduledTimerWithTimeInterval 方法来创建计时器并改用 weak 引用,即可打破保留环:

objectivec 复制代码
  - (void)startPolling {
    __weak EOCClass *weakSelf = self;
    _pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval: 5.0 block:^{
          EOCClass *strongSelf = weakSelf;
          [strongSelf p_doPoll];
          } repeats:YES];
  }

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

采用这种写法之后,如果外界指向 EOCClass 实例的最后一个引用将其释放,则该实例就可为系统所回收了。

  • NSTimer 对象会保留其目标,直到计时器本身失效为止,调用 invalidate 方法可令计时器失效,另外,一次性的计时器在触发完任务之后也会失效。
  • 反复执行任务的计时器(repeating timer),很容易引入保留环,如果这种计时器的目标对象又保留了计时器本身,那肯定会导致保留环。这种环状保留关系,可能是直接发生的,也可能是通过对象图里的其他对象间接发生的。
  • 可以扩充 NSTimer 的功能,用 "块"来打破保留环。不过,除非 NSTimer 将来在公共接口里提供此功能,否则必须创建分类,将相关实现代码加入其中。
相关推荐
张铁铁是个小胖子26 分钟前
微服务学习
java·学习·微服务
AITIME论道1 小时前
论文解读 | EMNLP2024 一种用于大语言模型版本更新的学习率路径切换训练范式
人工智能·深度学习·学习·机器学习·语言模型
青春男大4 小时前
java栈--数据结构
java·开发语言·数据结构·学习·eclipse
mashagua5 小时前
RPA系列-uipath 学习笔记3
笔记·学习·rpa
ii_best5 小时前
ios按键精灵自动化的脚本教程:自动点赞功能的实现
运维·ios·自动化
沐泽Mu5 小时前
嵌入式学习-QT-Day05
开发语言·c++·qt·学习
锦亦之22336 小时前
cesium入门学习二
学习·html
m0_748256146 小时前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
IT古董6 小时前
【机器学习】机器学习的基本分类-半监督学习(Semi-supervised Learning)
学习·机器学习·分类·半监督学习
jbjhzstsl7 小时前
lv_ffmpeg学习及播放rtsp
学习·ffmpeg