收藏iosiOS 技巧优化iOS

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

2020-05-07  本文已影响0人  Q以梦为马

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

1. 内存管理

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

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

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

1.1 内存消耗

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

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

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

1.1.1 栈大小

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

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 方法。这意味着与跟踪内存使用(如确保对象被及时回收了)有关的最大难题被解决了。

1.5 引用类型

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

1.5.1 变量限定符

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

1.5.2 属性限定符

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

1.6 僵尸对象

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

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

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

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

1.7 循环引用

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

1.7.1 避免循环引用的规则

1.7.2 循环引用的常见场景

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

1. 委托

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

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

2. block

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

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

3. 线程与计时器

不正确地使用 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 最佳实践

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

2. 能耗

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

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

2.1 CPU

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

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

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

2.2 网络

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

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

因此,我们需要:

2.3 定位管理器和GPS

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

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

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

2.3.1 最佳的初始化

CLLocationManager的常用操作和属性

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

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

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

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

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。

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 提供的功能列表。

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 的一个快速比较。

3.5 线程安全的代码

3.5.1 原子属性

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

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

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

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

3.5.2 锁

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

以下是三种可用的锁。

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

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

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

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

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

3.5.4 使用不可变实体

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

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

3.5.5 异步优于同步

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


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

上一篇 下一篇

猜你喜欢

热点阅读