高性能iOS应用开发 - 核心优化

620 阅读34分钟

此篇博客是《高性能iOS应用开发》一书第二部分“核心优化”的读书笔记,主要包括“内存管理”、“能耗(电量消耗)”、“并发编程”这三方面。

1. 内存管理

iPhone 和 iPad 设备的内存资源非常有限。如果某个应用的内存使用量超过了单个进程的上限,那么它就会被操作系统终止使用。正是由于这个原因,成功的内存管理在 iOS 应用的实现过程中扮演着核心的角色。

与(基于垃圾回收的)Java 运行时不同,Objective-C 和 Swift 的 iOS 运行时使用引用计数。使用引用计数的负面影响在于,如果开发人员不够小心,那么可能会出现重复的内存释放和循环引用的情况。

因此,理解 iOS 的内存管理是十分重要的。

1.1 内存消耗

内存消耗指的是应用消耗的 RAM。

iOS 的虚拟内存模型并不包含交换内存,与桌面应用不同,这意味着磁盘不会被用来分页内存。最终的结果是应用只能使用有限的 RAM。这些 RAM 的使用者不仅包括在前台运行的应用,还包括操作系统服务,甚至还包括其他应用所执行的后台任务。

应用中的内存消耗分为两部分:栈大小和堆大小。

1.1.1 栈大小

应用中新创建的每个线程都有专用的栈空间,该空间由保留的内存和初始提交的内存组成。栈可以在线程存在期间自由使用。线程的最大栈空间很小,这就决定了以下的限制。

  • 可被递归调用的最大方法数。每个方法都有其自己的栈帧,并会消耗整体的栈空间。
  • 一个方法中最多可以使用的变量个数。所有的变量都会载入方法的栈帧中,并消耗一定的栈空间。
  • 视图层级中可以嵌入的最大视图深度。渲染复合视图将在整个视图层级树中递归地调用 layoutSubViews 和 drawRect 方法。如果层级过深,可能会导致栈溢出。

1.1.2 堆大小

每个进程的所有线程共享同一个堆。一个应用可以使用的堆大小通常远远小于设备的 RAM 值。

应用并不能控制分配给它的堆。只有操作系统才能管理堆。

使用 NSString、载入图片、创建或使用 JSON/XML 数据、使用视图等都会消耗大量的堆内存。如果你的应用大量使用图片(与 Flickr 和 Instagram 应用类似),那么你需要格外关注平均值和峰值内存使用的最小化。

保持应用的内存需求总是处于 RAM 的较低占比是一个非常好的主意。虽然没有强制规定,但强烈建议使用量不要超过 80%~85%,要给操作系统的核心服务留下足够多的内存。不要忽视 didReceiveMemoryWarning 信号。

1.2 内存管理模型

内存管理模型基于持有关系的概念。当一个对象创建于某个方法的内部时,那该方法就持有这个对象了。如果一个对象正处于被持有状态,那它占用的内存就不能被回收。

一旦与某个对象相关的任务全部完成,那么就是放弃了持有关系。这一过程没有转移持有关系,而是分别增加或减少了持有者的数量。当持有者的数量降为零时,对象会被释放。

这种持有关系计数通常被正式称为引用计数。

1.3 自动释放池块

自动释放池块是允许你放弃对一个对象的持有关系、但可避免它立即被回收的一个工具。当从方法返回对象时,这种功能非常有用。

它还能确保在块内创建的对象会在块完成时被回收。这在创建了多个对象的场景中非常有用。本地的块可以用来尽早地释放其中的对象,从而使内存用量保持在较低的水平。

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

以上代码是 main.m 中的 @autoreleasepool 块,块中收到过 autorelease 消息的所有对象都会在 autoreleasepool 块结束时收到 release 消息。更加重要的是,每个 autorelease 调用都会发送一个 release 消息。这意味着如果一个对象收到了不止一次的 autorelease 消息,那它也会多次收到 release 消息。这一点很棒,因为这能保证对象的引用计数下降到使用 autoreleasepool 块之前的值。如果计数为 0,则对象将被回收,从而保持较低的内存使用率。

看了 main 方法的代码后,你会发现整个应用都在一个 autoreleasepool 块中,这意味着所有的 autorelease 对象最后都会被回收,不会导致内存泄漏。

1.4 自动引用计数

ARC 是一种编译器特性。它评估了对象在代码中的生命周期,并在编译时自动注入适合的内存管理调用。编译器还会生成适合的 dealloc 方法。这意味着与跟踪内存使用(如确保对象被及时回收了)有关的最大难题被解决了。

  • ARC的规则
    • 不能实现或调用 retain、release、autorelease 或 retainCount 方法。这一限制不仅针对对象,对选择器同样有效。因此,[obj release]或@selector(retain)是编译时的错误。
    • 可以实现 dealloc 方法,但不能调用它们。不仅不能调用其他对象的 dealloc 方法,也不能调用超类。[super dealloc] 是编译时的错误。但你仍然可以对 Core Foundation 类型的对象调用 CFRetain、CFRelease 等相关方法。
    • 不能调用 NSAllocateObject 和 NSDeallocateObject 方法。应使用 alloc 方法创建对象, 运行时负责回收对象。
    • 不能在 C 语言的结构体内使用对象指针。
    • 不能在 id 类型和 void * 类型之间自动转换。如果需要,那么你必须做显示转换。
    • 不能使用 NSAutoreleasePool,要替换使用 autoreleasepool 块。
    • 不能使用 NSZone 内存区域。
    • 属性的访问器名称不能以 new 开头,以确保与 MRC 的互操作性。

1.5 引用类型

ARC 带来了新的引用类型:弱引用。深入理解这些引用类型对内存管理非常重要。支持的类型包括以下两种。

  • 强引用。强引用是默认的引用类型。被强引用指向的内存不会被释放。强引用会对引用计数加 1,从而扩展对象的生命周期。
  • 弱引用。弱引用是一种特殊的引用类型。它不会增加引用计数,因而不会扩展对象的生命周期。在启用了 ARC 的 Objective-C 编程中,弱引用格外重要。

1.5.1 变量限定符

ARC 为变量供了四种生命周期限定符。

  • __strong 这是默认的限定符,无需显示引入。只要有强引用指向,对象就会长时间驻留在内存中。可以将 __strong 理解为 retain 调用的 ARC 版本。
  • __weak 这表明引用不会保持被引用对象的存活。当没有强引用指向对象时,弱引用会被置为 nil。可将 __weak 看作是 assign 操作符的 ARC 版本,只是对象被回收时,__weak 具有安全性——指针将自动被设置为 nil。
  • __unsafe_unretained__weak 类似,只是当没有强引用指向对象时,__unsafe_unretained 不会被置为 nil。可将其看作 assign 操作符的 ARC 版本。
  • __autoreleasing__autoreleasing 用于由引用使用id *传递的消息参数。它预期了autorelease方法会在传递参数的方法中被调用。

1.5.2 属性限定符

属性声明有两个新的持有关系限定符:strong 和 weak。此外,assign 限定符的语义也被更新了。一言以蔽之,现在共有六个限定符。

  • strong。默认符,指定了 __strong 关系。
  • weak。指定了 __weak 关系。
  • assign。这不是新的限定符,但其含义发生了改变。在 ARC 之前,assign 是默认的持有关系限 定符。在启用 ARC 之后,assign 表示了 __unsafe_unretained 关系。
  • copy。暗指了 __strong 关系。此外,它还暗示了 setter 中的复制语义的常规行为。
  • retain。指定了 __strong 关系。
  • unsafe_unretained。指定了 __unsafe_unretained 关系。

1.6 僵尸对象

僵尸对象是用于捕捉内存错误的调试功能。

通常情况下,当引用计数降为 0 时对象会立即被释放,但这使得调试变得困难。如果开启了僵尸对象,那么对象就不会立即释放内存,而是被标记为僵尸。任何试图对其进行访问的行为都会被日志记录,因而你可以在对象的生命周期中跟踪对象在代码中被使用的位置。

NSZombieEnabled 是一个环境变量,可以控制 Core Foundation 的运行时是否将使用僵尸对象。不应长期保留 NSZombieEnabled,因为默认情况下不会有对象被真正析构,这会导致应用使用大量的内存。特别说明一点,在发布的构建包中一定要禁用 NSZombieEnabled。

要想设置 NSZombieEnabled 环境变量,需要进入 Product → Scheme → Edit Scheme。选择 左侧的 Run,然后在右侧选取 Diagnostics 标签页。选中 Zombie Objects 选项,如下图:

1.7 循环引用

引用计数的最大陷阱在于,它不能处理环状的引用关系,即 Objective-C 的循环引用。

1.7.1 避免循环引用的规则

  • 对象不应该持有它的父对象,应该用 weak 引用指向它的父对象。
  • 作为必然的结果,一个层级体系中的子对象应该保留祖先对象。
  • 连接对象不应持有它们的目标对象。目标对象的角色是持有者。连接对象包括以下几种。
    • 使用委托的对象。委托应该被当作目标对象,即持有者。
    • 包含目标和 action 的对象,这是由上一条规则推理得到的。例如,UIButton 会调用它的目标对象上的 action 方法。按钮不应该保留它的目标。
    • 观察者模式中被观察的对象。观察者就是持有者,并会观察发生在被观察对象上的变化。
  • 使用专用的销毁方法中断循环引用。双向链表中存在循环引用,环形链表中也存在循环引用。在这类情况下,一旦明确对象不会再被使用时(当链表的表头超出作用范围),你要编写代码以打破链表的链接。创建一个方法切断其自身与链表中下一个节点的链接。通过访问者模式递归地执行这一过程,从而避免无限递归。

1.7.2 循环引用的常见场景

大把的常见场景会导致循环引用。例如,使用线程、计时器、简单的块方法或委托都可能会导致循环引用。接下来我们将逐步探索这些场景,并给出避免循环引用的步骤。

1. 委托

委托很可能是引入循环引用的最常见的地方。在应用启动时,从服务器获取最新的数据并更新 UI 是常见的事情。当用户点击刷新按钮时也会触发类似的刷新逻辑。

解决方案是在委托中建立对操作的强引用,并在操作中建立对委托的弱引用。

2. block

与不正确地使用委托对象导致的问题类似,在使用 block 时,捕获外部变量也是导致循环引用的原因。

解决方案是通过弱引用获得强引用,类似于 __weak typeof(self) weakSelf = self;

3. 线程与计时器

不正确地使用 NSThread 和 NSTimer 对象也可能会导致循环引用。运行异步操作的典型步骤如下。

  • 如果没有编写更高级的代码来管理自定义的队列,则在全局队列上使用 dispatch_async 方法。
  • 在需要的时间和地点用 NSThread 开启异步执行。
  • 使用 NSTimer 周期性地执行一段代码。

解决方案:NSTimer 在主线程中不会造成循环引用,但是子线程会造成循环引用,问题应该是出在子线程问题上。在定时器释放时必须要调用 invalidate 方法,这个方法会做一些释放 self、block、RunLoop 等释放资源的工作,而且释放 RunLoop 只能释放和定时器同一个线程的 RunLoop。

1.7.3 观察者

1. 键-值观察

Objective-C 允许用 addObserver:forKeyPath:options:context: 方法在任何 NSObject 子类的 对象上添加观察者。观察者会通过 observeValueForKeyPath:ofObject:change:context: 方法得到通知。removeObserver:forKeyPath:context: 方法用于解除注册或移除观察者。这就是众所周知的键 - 值观察。

这是一个极为有用的特性,尤其是在以调试为目的跟踪某些共享于应用多个部分(如用户接口、业务逻辑、持久化以及网络)的对象时。

键 - 值观察在双向数据绑定中也非常有用。视图可以关联委托来响应那些会导致模型更新的用户交互。键 - 值观察可以用于反向的绑定,以便在模型发生变化时更新 UI。

这意味着观察者需要有足够长的生命周期才能够持续地监控变化。你需要额外关注观察者的生命周期,而且要持续到所观察的内存被废弃之后。

当你为目标对象添加键 - 值观察者时,目标对象的生命周期至少应该和观察者一样长,因为只有这样才有可能从目标对象移除观察者。这可能会导致目标对象的生命周期比预期要长,也是你需要额外小心的地方。

2. 通知中心

一个对象可以注册为通知中心(NSNotificationCenter 对象)的观察者,并接收 NSNotification 对象。与键 - 值观察者相似,通知中心不会对观察者持有强引用。这意味着开发人员得到了解放,无需为观察者的析构过早或过晚而操心。

1.8 对象寿命与泄漏

对象在内存中活动的时间越长,内存不能被清理的可能性就越大。所以应当尽可能地避免出现长寿命的对象。当然,你需要保留代码中关键操作对象的引用,为的是不必每次都浪费时间来创建它们。尽量在使用这些对象时完成对它们的引用。

长寿命对象的常见形式是单例。日志器是典型的例子——只创建一次,从不销毁。

另一个方案是使用全局变量。全局变量在程序开发中是可怕的东西。

要想合理地使用全局变量,必须满足以下条件:

  • 没有被其他对象所持有;
  • 不是常量;
  • 整个应用中只有一个,而不是每个组件一个。

如果某个变量不符合这些要求,那么它不应该被用作全局变量。

复杂的对象图使得回收内存的机会变得更少,同时增加了应用因内存耗尽而崩溃的风险。如果主线程总是被迫等待子线程的操作(如网络或数据库存取),那么应用的响应性能会变得很差。

1.9 单例

单例模式是限制一个类只初始化一个对象的一种设计模式。在实践中,初始化常常在应用启动不久后执行,而且这些对象不会被销毁。

让一个对象有着与应用一样长的生命周期可不是什么好主意。如果这个对象是其他对象的源头(如一个服务定位器),若定位器的实现不正确则有可能造成内存风险。

毫无疑问,单例是必要的。但单例的实现对其使用方式有重要影响。

在充分讨论单例引入的问题之前,我们不妨先更好地理解单例,了解一下为什么确实需要使用单例。

单例极为有用,尤其是在某个系统确定只需要一个对象实例时。应该在以下情形中使用单例:

  • 队列操作(如日志和埋点)
  • 访问共享资源(如缓存)
  • 资源池(如线程池或连接池)

一旦创建,单例会一直存活到应用关闭。日志器、埋点服务以及缓存都是使用单例的合理场景。

更重要的是,单例通常会在应用启动时进行初始化,打算使用单例的组件需要等它们准备得当。这会增加应用的启动时间。

你可以使用以下的指导原则。

  • 尽可能地避免使用单例。
  • 识别需要内存的部分,如用于埋点的内存缓冲区(在尚未将数据同步到服务器前使用)。寻求减少内存的方法。注意,你需要将减少内存与其他事情做权衡。减小缓冲区意味着更多的服务器通信。
  • 尽量避免对象级的属性,因为它们会与对象共存亡。尽量使用本地变量。

1.10 最佳实践

通过遵循这些最佳实践,你将很大程度上避免许多麻烦,如内存泄漏、循环引用和较大内 存消耗。

  • 避免大量的单例。具体来说,不要出现上帝对象(如职责特别多或状态信息特别多的对象)。这是一个反模式,指代一种常见解决方案的设计模式,但很快产生了不良效果。日志器、埋点服务和任务队列这样的辅助单例都是很不错的,但全局状态对象不可取。
  • 对子对象使用 __strong。
  • 对父对象使用 __weak。
  • 对使引用图闭合的对象(如委托)使用 __weak。
  • 对数值属性(NSInteger、SEL、CGFloat 等)而言,使用 assign 限定符。
  • 对于块属性,使用 copy 限定符。
  • 当声明使用NSError ** 参数的方法时,需要使用 __autoreleasing,并要注意用正确的 语法: NSError * __autoreleasing *
  • 避免在块内直接引用外部的变量。在块外面将它们 weakify,并在块内再将它们 strongify。 参见 libextobjc 库 来了解 @weakify 和 @strongify。
  • 进行必要清理时遵循以下准则:
    • 销毁计时器
    • 移除观察者(具体来说,移除对通知的注册)
    • 解除回调(具体来说,将强引用的委托设置为 nil)

2. 能耗

设备中的每个硬件模块都会消耗电量。电量的最大消费者是 CPU,但这只是系统的一个方面。一个编写良好的应用需要谨慎地使用电能。用户往往会删除耗电量大的应用。

除 CPU 外,耗电量高、值得关注的硬件模块还包括:网络硬件、蓝牙、GPS、麦克风、加 速计、摄像头、扬声器和屏幕。

2.1 CPU

不论用户是否正在直接使用,CPU 都是应用所使用的主要硬件。在后台操作和处理推送通知时,应用仍会消耗 CPU 资源。

应用计算得越多,消耗的电量就越多。在完成相同的基本操作时,老一代的设备会消耗更多的电量。计算量的消耗取决于不同的因素。

  • 对数据的处理(例如,对文本进行格式化)。
  • 待处理的数据大小——更大的显示屏允许软件在单个视图中展示更多的信息,但这也意味着要处理更多的数据。
  • 处理数据的算法和数据结构。
  • 执行更新的次数,尤其是在数据更新后,触发应用的状态或 UI 进行更新(应用收到的推送通知也会导致数据更新,如果此时用户正在使用应用,你还需要更新 UI)。

没有单一规则可以减少设备中的执行次数。很多规则都取决于操作的本质。以下是一些可以在应用中投入使用的最佳实践。

  • 针对不同的情况选择优化的算法。例如,当你在排序时,如果列表少于 43 个实例,则插入排序优于归并排序,但实例多于 286 个时,应当使用快速排序。要优先使用双枢轴快速排序而不是传统的单枢轴快速排序。
  • 如果应用从服务器接收数据,尽量减少需要在客户端进行的处理例如,如果一段文字需要在客户端进行渲染,尽可能在服务器将数据清理干净。
  • 优化静态编译(ahead-of-time,AOT)处理。动态编译(just-in-time,JIT)处理的缺点在于它会强制用户等待操作完成。但是激进的 AOT 处理则会导致计算资源的浪费。需要根据应用和设备选择精确定量的 AOT 处理。
  • 分析电量消耗。测量目标用户的所有设备上的电量消耗。找到高能耗的区域并想办法降低能耗。

2.2 网络

智能的网络访问管理可以让应用响应得更快,并有助于延长电池寿命。在无法访问网络时,应当推迟后续的网络请求,直到网络连接恢复为止。

此外,应避免在没有连接 WiFi 的情况下进行高带宽消耗的操作,比如视频流。众所周知,蜂窝无线系统(LTE、4G、3G 等)对电量的消耗远大于 WiFi 信号。根源在于 LTE 设备基于多输入、多输出技术,使用多个并发信号以维护两端的 LTE 链接。类似地,所有的蜂窝数据连接都会定期扫描以寻找更强的信号。

因此,我们需要:

  • 在进行任何网络操作之前,先检查合适的网络连接是否可用;
  • 持续监视网络的可用性,并在连接状态发生变化时给予适当的反馈。

2.3 定位管理器和GPS

了解定位服务包括 GPS(或 GLONASS)和 WiFi 硬件这一点很重要,同时要知道定位服务需要大量的电量。

使用 GPS 计算坐标需要确定两点信息。

  • 时间锁
    • 每个 GPS 卫星每毫秒广播唯一一个 1023 位随机数,因而数据传播速率是 1.024Mbit/s。 GPS 的接收芯片必须正确地与卫星的时间锁槽对齐。
  • 频率锁
    • GPS 接收器必须计算由接收器与卫星的相对运动导致的多普勒偏移带来的信号误差。

计算坐标会不断地使用 CPU 和 GPS 的硬件资源,因此它们会迅速地消耗电池电量。

2.3.1 最佳的初始化

CLLocationManager的常用操作和属性

// 开始用户定位
- (void)startUpdatingLocation;
// 停止用户定位
- (void) stopUpdatingLocation;

说明:当调用了 startUpdatingLocation 方法后,就开始不断地定位用户的位置,中途会频繁地调用代理的下面方法

- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations;

在调用 startUpdatingLocation 方法时,两个参数起着非常重要的作用。

  • distanceFilter
    • 只要设备的移动超过了最小距离,距离过滤器就会导致管理器对委托对象的 locationManager:didUpdateLocations: 事件通知发生变化。该距离使用公制单位(米)。这并不会有助于减少 GPS 接收器的使用,但会影响应用的处理速度,从而直接减少 CPU 的使用。
  • desiredAccuracy
    • 精度参数的使用直接影响了使用天线的个数,进而影响了对电池的消耗。精度级别的选取取决于应用的具体用途。按照降序排列,精度由以下常量定义。
      • kCLLocationAccuracyBestForNavigation 用于导航的最佳精度级别。
      • kCLLocationAccuracyBest 设备可能达到的最佳精度级别。
      • kCLLocationAccuracyNearestTenMeters 精度接近 10 米。如果对用户所走的每一米并不感兴趣,不妨使用这个值(例如,可 在测量大块距离时使用)。
      • kCLLocationAccuracyHundredMeters 精度接近 100 米。
      • kCLLocationAccuracyKilometer 精度在千米范围。这在粗略测量两个距离数百千米的兴趣点时非常有用。
      • kCLLocationAccuracyThreeKilometers 精度在 3 千米范围。在距离真的很远时使用这个值。

2.3.2 关闭无关紧要的特性

判断何时需要跟踪位置的变化。在需要跟踪时调用 startUpdatingLocation 方法,无需跟踪时调用 stopUpdatingLocation 方法。

假设用户需要用一个消息类的应用与朋友分享位置。如果该应用只是发送城市的名称,则只需要一次性地获取位置信息,然后就可以通过调用 stopUpdatingLocation 关闭位置跟踪。

2.3.3 只在必要时使用网络

为了提高电量的使用效率,iOS 总是尽可能地保持无线网络关闭。当应用需要建立网络连接时,iOS 会利用这个机会向后台应用分享网络会话,以便一些低优先级的事件能够被处理,如推送通知、收取电子邮件等。

关键在于每当应用建立网络连接时,网络硬件都会在连接完成后多维持几秒的活动时间。每次集中的网络通信都会消耗大量的电量。

要想减轻这个问题带来的危害,你的软件需要有所保留地使用网络。应该定期集中短暂地使用网络,而不是持续地保持着活动的数据流。只有这样,网络硬件才有机会被关闭。

2.3.4 后台定位服务

CLLocationManager 提供了一个替代的方法来监听位置的更新。startMonitoringSigni-ficantLocationChanges 可以帮助你在更远的距离跟踪运动。精确的值由内部决定,且与 distanceFilter 无关。

使用这一模式可以在应用进入后台后继续跟踪运动。(除非应用是导航类应用,且你想在锁屏期间也获得很好的细节。)典型的做法是在应用进入后台时执行 startMonitoringSigni-ficantLocationChanges 方法,而当应用回到前台时执行 startUpdatingLocation。

2.3.5 NSTimer、NSThread和定位服务

当应用位于后台时,任何定时器或线程都会挂起。但如果你在应用位于后台状态时申请了定位,那么应用会在每次收到更新后被短暂唤醒。在此期间,线程和计时器都会被唤醒。

可怕之处在于,如果你在这段时间做了任何网络操作,则会启动所有相关的天线(如 WiFi 和 LTE/4G/3G)。

想要控制这种状况往往非常棘手。最佳的选择是使用 NSURLSession 类。

2.4 屏幕

屏幕非常耗电。屏幕越大就越费电。当然,如果你的应用在前台运行且与用户进行交互,则势必会使用屏幕并消耗电量。

然而,仍然有一些方案可以优化屏幕的使用。

2.4.1 动画

你可以遵守一个简单的规则:当应用在前台时使用动画,一旦应用进入后台则立即暂停动 画。通常来说,你可以通过监听 UIApplicationWillResignActiveNotification 或 UIApplic ationDidEnterBackgroundNotification 的通知事件来暂停或停止动画,也可以通过监听 UI ApplicationDidBecomeActiveNotification 的通知事件来恢复动画。

2.4.2 视频播放

在视频播放期间,最好强制保持屏幕常亮。可以使用 UIApplication 对象的 idleTimerDisabled 属性来实现这个目的。一旦设置为 YES,它会阻止屏幕休眠,从而实现常亮。与动画类似,你可以通过响应应用的通知来释放和获取锁。

2.5 其他硬件

当应用进入后台时,应该释放对这些硬件的锁定:

  • 蓝牙
  • 相机
  • 扬声器,除非应用是音乐类的
  • 麦克风

我们并不会在这里讨论这些硬件的特性,但是基本规则是一致的——只有当应用处于前台时才与这些硬件进行交互,应用处于后台时应停止交互。

扬声器和无线蓝牙可能是例外。如果你正在开发音乐、收音机或其他的音频类应用,则需要在应用进入后台后继续使用扬声器。不要让屏幕仅仅为音频播放的目的而保持常亮。类似地,若应用还有未完成的数据传输,则需要在应用进入后台后持续使用无线蓝牙,例如,与其他设备传输文件。

2.6 电池电量与代码感知

一个智能的应用会考虑到电池的电量和自身的状态,从而决定是否要真正执行资源密集消耗型的操作。另外一个有价值的点是对充电的判断,确定设备是否处于充电状态。

使用 UIDevice 实例可以获取 batteryLevel 和 batteryState(充电状态)。

当剩余电量较低时提示用户,并请求用户授权执行电源密集型的操作——当然,只在用户同意的前提下执行。总是用一个指示符显示长时间任务的进度,包括设备上即将完成的计算或者只是下载一些内容。向用户提供完成进度的估算,以帮助他们决定是否需要为设备充电。

2.7 分析电量使用

利用 Xcode Instruments 的 Energy Log。

  • 打开手机设置,点击 "开发者",选中 Logging。
  • iOS 设置中的 Instruments 勾选 Energy,并点击startRecording。然后打开你的 APP 跑起来。操作五分钟左右 (具体看你的需要) ,再进入手机设置点击 stopRecording。
  • 接着,把 iOS 设备连接 Xcode,并打开 Instruments 中的 Energy Log(Xcode --> Open Developer Tool --> Instruments --> Energy Log),点击工具栏中 Import Logged Data from Device。导入我们 iOS 性能优化中能耗的数据。
  • Instruments 中可以看到你的 APP 的功耗。

2.8 最佳实践

以下的最佳实践可以确保对电量的谨慎使用。遵循以下要点,应用可以实现对电量的高效使用。

  • 最小化硬件使用。换句话说,尽可能晚地与硬件打交道,并且一旦完成任务立即结束使用。
  • 在进行密集型任务前,检查电池电量和充电状态。
  • 在电量低时,提示用户是否确定要执行任务,并在用户同意后再执行。
  • 或提供设置的选项,允许用户定义电量的阈值,以便在执行密集型操作前提示用户。

3. 并发编程

3.1 线程

线程是运行时执行的一组指令序列。

每个进程至少应包含一个线程。在 iOS 中,进程启动时的主要线程通常被称作主线程。所有的 UI 元素都需要在主线程中创建和管理。与用户交互相关的所有中断最终都会分发到 UI 线程,处理代码会在这些地方执行——IBAction 方法的代码都会在主线程中执行。

Cocoa 编程不允许其他线程更新 UI 元素。这意味着,无论何时应用在后台线程执行了耗时操作,比如网络或其他处理,代码都必须将上下文切换到主线程再更新 UI——例如,进度条指示任务进度或标签展示处理结果。

3.2 线程开销

虽然应用有多个线程看起来非常赞,但每个线程都有一定的开销,从而影响到应用的性能。线程不仅仅有创建时的时间开销,还会消耗内核的内存,即应用的内存空间。

3.2.1 内核数据结构

每个线程大约消耗 1KB 的内核内存空间。这块内存用于存储与线程有关的数据结构和属性。这块内存是联动内存(wired memory),无法被分页。

3.2.2 栈空间

主线程的栈空间大小为 1M,而且无法修改。所有的二级线程默认分配 512KB 的栈空间。注意,完整的栈并不会立即被创建出来。实际的栈空间大小会随着使用而增长。因此,即使主线程有 1MB 的栈空间,某个时间点的实际栈空间很可能要小很多。

在线程启动前,栈空间的大小可以被改变。栈空间的最小值是 16KB,而且其数值必须是 4KB 的倍数。

3.2.3 创建耗时

创建线程后启动线程的耗时区间为 5~100 毫秒,平均大约在 29 毫秒。这是很大的时间开销,若在应用启动时开启多个线程,则尤为明显。

线程的启动时间之所以如此之长,是因为多次的上下文切换所带来的开销。

3.3 GCD

GCD 提供的功能列表。

  • 任务或分发队列,允许主线程中的执行、并行执行和串行执行。
  • 分发组,实现对一组任务执行情况的跟踪,而与这些任务所基于的队列无关。
  • 信号量。
  • 屏障,允许在并行分发队列中创建同步的点。
  • 分发对象和管理源,实现更为底层的管理和监控。
  • 异步 I/O,使用文件描述符或管道。

GCD 同样解决了线程的创建与管理。它帮助我们跟踪应用中线程的总数,且不会造成任何的泄漏。

大多数情况下,应用单独使用 GCD 就可以很好地工作,但仍有特定的情况需要考虑使用 NSThread 或 NSOperationQueue。当应用中有多个长耗时的任务需要并行执行时,最好对线程的创建过程加以控制。如果代码执行的时间过长,很有可能达到线程的限制 64 个,即 GCD 的线程池上限。 应该避免浪费地使用 dispatch_async 和 dispatch_sync,因为那会导致应用 崩溃 4。虽然 64 个线程对移动应用来说是个很高的合理值,但不加控制的应 用迟早会超出这个限制。

关于 GCD 线程池上限,可以参考这个文档:stackoverflow.com:number-of-threads-created-by-gcd

3.4 操作与队列

操作和操作队列是 iOS 编程中和任务管理有关的又一个重要概念。

NSOperation 封装了一个任务以及和任务相关的数据和代码,而 NSOperationQueue 以先入先出的顺序控制了一个或多个这类任务的执行。

NSOperation 和 NSOperationQueue 都提供控制线程个数的能力。可用 maxConcurrentOpera-tionCount 属性控制队列的个数,也可以控制每个队列的线程个数。

以下是对 NSThread、NSOperationQueue 和 GCD API 的一个快速比较。

  • GCD

    • 抽象程度最高。
    • 两种队列开箱即用:main 和 global。
    • 可以创建更多的队列(使用 dispatch_queue_create)。
    • 可以请求独占访问(使用 dispatch_barrier_sync 和 dispatch_barrier_async)。
    • 基于线程管理。
    • 硬性限制创建 64 个线程。
  • NSOperationQueue

    • 无默认队列。
    • 应用管理自己创建的队列。
    • 队列是优先级队列。
    • 操作可以有不同的优先级(使用 queuePriority 属性)。
    • 使用 cancel 消息可以取消操作。注意,cancel 仅仅是个标记。如果操作已经开始执行,则可能会继续执行下去。
    • 可以等待某个操作执行完毕(使用 waitUntilFinished 消息)。
  • NSThread

    • 低级别构造,最大化控制。
    • 应用创建并管理线程。
    • 应用创建并管理线程池。
    • 应用启动线程。
    • 线程可以拥有优先级,操作系统会根据优先级调度它们的执行。
    • 无直接 API 用于等待线程完成。需要使用互斥量(如 NSLock)和自定义代码。

3.5 线程安全的代码

3.5.1 原子属性

原子属性是实现应用状态线程安全的一个良好开始。如果一个属性是 atomic,则修改和读取肯定都是原子的。

这一点很重要,因为这样可以阻止两个线程同时更新一个值,反之则有可能导致错误的状态。正在修改属性的线程必须处理完毕后,其他线程才能开始处理。

所有的属性默认都是原子性的。作为最佳实践,在需要时应该显式地使用 atomic。否则使 用 nonatomic 标记属性。

因为原子属性存在开销,所以过度使用它们并不明智。例如,如果能够保证某个属性在任何时刻都不会被多个线程访问,那最好还是将其标记为 nonatomic。

3.5.2 锁

锁是进入临界区的基础构件。atomic 属性和 @synchronized 块是为了实现便捷实用的高级 别抽象。

以下是三种可用的锁。

  • NSLock

    • 这是一种低级别的锁。一旦获取了锁,执行则进入临界区,且不会允许超过一个线程并行执行。释放锁则标记着临界区的结束。
    • NSLock 必须在锁定的线程中进行解锁。
  • NSRecursiveLock

    • NSRecursiveLock 允许在被解锁前锁定多次。如果解锁的次数与锁定的次数相匹配,则 认为锁被释放,其他线程可以获取锁。
  • NSCondition

    • 有些情况需要协调线程之间的执行。例如,一个线程可能需要等待其他线程返回结果。NSCondition 可以原子性地释放锁,从而使得其他等待的线程可以获取锁,而初始的线程继续等待。一个线程会等待释放锁的条件变量。另一个线程会通知条件变量释放该锁,并唤醒等待中的线程。

3.5.3 将读写锁应用于并发读写

有这么一个情况:如果有多个线程试图读取一个属性,同步的代码在同一时刻只允许单个线程进行访问。使用上文提到的 atomic 属性会拖慢应用的性能。

读写锁允许并行访问只读操作,而写操作需要互斥访问。这意味着多个线程可以并行地读取数据,但是修改数据时需要一个互斥锁。

GCD 屏障允许在并行分发队列上创建一个同步的点。当遇到屏障时,GCD 会延迟执行提交的代码块,直到队列中所有在屏障之前提交的代码块都执行完毕。随后,通过屏障提交的代码块会单独地执行。我们将这个代码块称为屏障块。待其完成后,队列会按照原有行为继续执行。

要想实现这一行为,我们需要遵循以下步骤。

  • 创建一个并行队列。
  • 在这个队列上使用 dispatch_sync 执行所有的读操作。
  • 在相同的队列上使用 dispatch_barrier_sync 执行所有的写操作。

3.5.4 使用不可变实体

如果需要访问一个正在修改的状态,那将会怎么样呢?例如,如果缓存被清空,但因为用户执行了一个交互,其中部分状态要求立即被使用,情况将会是怎样的呢?是否存在更有效的机制以管理状态,而不是多个组件试图同时更新状态?

你的团队应该遵循以下的最佳实践。

  • 使用不可变实体。
  • 通过更新子系统提供支持。
  • 允许观察者接收有关数据变化的通知。

3.5.5 异步优于同步

要想实现线程安全、不死锁且易于维护的代码,强烈建议使用异步风格。能放到异步处理的,就放到异步。


博客首发于GitHub:高性能iOS应用开发 - 核心优化

相关文章:高性能iOS应用开发 - iOS性能