iOS 性能技巧让您的应用感觉更高效

已发表: 2022-03-10
快速总结 ↬良好的性能对于提供良好的用户体验至关重要,iOS 用户通常对他们的应用程序抱有很高的期望。 缓慢且无响应的应用程序可能会让用户放弃使用您的应用程序,或者更糟糕的是,留下差评。

尽管现代 iOS 硬件足够强大,可以处理许多密集和复杂的任务,但如果您不注意应用程序的执行方式,设备仍然可能会感到无响应。 在本文中,我们将研究五种优化技巧,让您的应用程序感觉更灵敏。

1.出列可重用单元格

您之前可能在tableView(_:cellForRowAt:)中使用过tableView.dequeueReusableCell(withIdentifier:for:) 。 有没有想过为什么你必须遵循这个尴尬的 API,而不是仅仅传入一个单元格数组? 让我们来看看这个推理。

假设您有一个包含一千行的表格视图。 如果不使用可重复使用的单元格,我们将不得不为每一行创建一个新单元格,如下所示:

 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // Create a new cell whenever cellForRowAt is called. let cell = UITableViewCell() cell.textLabel?.text = "Cell \(indexPath.row)" return cell }

正如您可能已经想到的那样,当您滚动到底部时,这将在设备的内存中添加一千个单元格。 想象一下,如果每个单元格都包含一个UIImageView和大量文本会发生什么:一次加载它们可能会导致应用程序内存不足! 除此之外,每个单元格都需要在滚动期间分配新的内存。 如果你快速滚动一个表格视图,大量的小块内存将被动态分配,这个过程会使 UI 卡顿!

为了解决这个问题,Apple 为我们提供了dequeueReusableCell(withIdentifier:for:)方法。 单元格重用通过将屏幕上不再可见的单元格放入队列中来工作,当一个新单元格即将在屏幕上可见时(例如,当用户向下滚动时,下面的后续单元格),表格视图将从此队列中检索一个单元格并在cellForRowAt indexPath:方法中对其进行修改。

信元重用队列机制
单元重用队列在 iOS 中的工作原理(大预览)

通过使用队列来存储单元格,表格视图不需要创建一千个单元格。 相反,它只需要足够的单元格来覆盖表格视图的区域。

通过使用dequeueReusableCell ,我们可以减少应用程序使用的内存,并使其不太容易耗尽内存!

跳跃后更多! 继续往下看↓

2.使用看起来像初始屏幕的启动屏幕

正如 Apple 的人机界面指南 (HIG) 中所述,启动屏幕可用于增强对应用响应能力的感知:

“它的唯一目的是增强您的应用程序快速启动和立即可用的感觉。 每个应用程序都必须提供一个启动屏幕。”

使用启动屏幕作为启动屏幕来显示品牌或添加加载动画是一个常见的错误。 正如 Apple 所说,将启动屏幕设计为与应用程序的第一个屏幕相同:

“设计一个与应用程序的第一个屏幕几乎相同的启动屏幕。 如果您包含在应用程序完成启动时看起来不同的元素,人们可能会在启动屏幕和应用程序的第一个屏幕之间体验到令人不快的闪光。

“启动屏幕不是品牌推广机会。 不要设计看起来像闪屏或“关于”窗口的输入体验。 不要包含徽标或其他品牌元素,除非它们是您应用首屏的静态部分。”

使用启动屏幕进行加载或品牌推广可能会减慢首次使用的时间,并使用户感觉应用程序运行缓慢。

当您开始一个新的 iOS 项目时,将创建一个空白的LaunchScreen.storyboard 。 当应用程序加载视图控制器和布局时,该屏幕将显示给用户。

为了让您的应用程序感觉更快,您可以将启动屏幕设计为类似于将显示给用户的第一个屏幕(视图控制器)。

例如,Safari 应用程序的启动屏幕类似于它的第一个视图:

启动屏幕和第一个视图看起来相似
启动屏幕和 Safari 应用程序的第一个视图的比较(大预览)

启动屏幕故事板与任何其他故事板文件一样,只是您只能使用标准的 UIKit 类,如 UIViewController、UITabBarController 和 UINavigationController。 如果您尝试使用任何其他自定义子类(例如 UserViewController),Xcode 会通知您禁止使用自定义类名。

使用自定义类时 Xcode 显示错误
启动屏幕故事板不能包含非 UIKit 标准类。 (大预览)

另一个需要注意的是UIActivityIndicatorView放在启动屏幕上时不会动画,因为 iOS 会从启动屏幕故事板生成静态图像并将其显示给用户。 (这在 WWDC 2014 演讲“Platforms State of the Union”中简要提及,大约01:21:56 。)

Apple 的 HIG 还建议我们不要在启动屏幕上包含文本,因为启动屏幕是静态的,您无法本地化文本以适应不同的语言。

推荐阅读具有面部识别功能的移动应用程序:如何使其成为现实

3. 视图控制器的状态恢复

状态保存和恢复允许用户从刚刚离开应用程序之前返回到完全相同的 UI 状态。 有时,由于内存不足,操作系统可能需要在应用处于后台时从内存中删除您的应用,如果不保留,应用可能会丢失其最后的 UI 状态,从而可能导致用户丢失工作进行中!

在多任务屏幕中,我们可以看到已置于后台的应用程序列表。 我们可能会假设这些应用程序仍在后台运行; 实际上,由于内存需求,其中一些应用程序可能会被系统杀死并重新启动。 我们在多任务视图中看到的应用程序快照实际上是系统在我们退出应用程序时(即进入主屏幕或多任务屏幕)从右侧截取的屏幕截图。

iOS 通过截取最新视图的屏幕截图来制造应用程序在后台运行的错觉
iOS 用户退出应用时的应用截图(大预览)

iOS 使用这些屏幕截图给人一种应用程序仍在运行或仍在显示此特定视图的错觉,而应用程序可能已经在后台终止或重新启动,但仍显示相同的屏幕截图。

在从多任务屏幕恢复应用程序时,您是否体验过该应用程序显示的用户界面与多任务视图中显示的快照不同? 这是因为应用程序没有实现状态恢复机制,在后台杀死应用程序时显示的数据丢失了。 这可能会导致糟糕的体验,因为用户希望您的应用处于与离开时相同的状态。

来自苹果的文章:

“他们希望您的应用程序与他们离开时的状态相同。 状态保存和恢复可确保您的应用在再次启动时恢复到之前的状态。”

UIKit 为我们简化状态保存和恢复做了很多工作:它在适当的时候自动处理应用程序状态的保存和加载。 我们需要做的就是添加一些配置来告诉应用程序支持状态保存和恢复,并告诉应用程序需要保留哪些数据。

要启用状态保存和恢复,我们可以在AppDelegate.swift中实现这两个方法:

 func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool { return true }
 func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { return true }

这将告诉应用程序自动保存和恢复应用程序的状态。

接下来,我们将告诉应用程序需要保留哪些视图控制器。 我们通过在情节提要中指定“恢复 ID”来做到这一点:

在情节提要中设置恢复 ID
在情节提要中设置恢复 ID(大预览)

您还可以选中“使用 Storyboard ID”以使用 Storyboard ID 作为恢复 ID。

要在代码中设置恢复 ID,我们可以使用视图控制器的restorationIdentifier属性。

 // ViewController.swift self.restorationIdentifier = "MainVC"

在状态保存期间,任何已分配恢复标识符的视图控制器或视图都将其状态保存到磁盘。

恢复标识符可以组合在一起以形成恢复路径。 标识符使用视图层次结构进行分组,从根视图控制器到当前活动视图控制器。 假设 MyViewController 嵌入在导航控制器中,该控制器嵌入在另一个选项卡栏控制器中。 假设他们使用自己的类名作为恢复标识符,恢复路径将如下所示:

 TabBarController/NavigationController/MyViewController

当用户以 MyViewController 作为活动视图控制器离开应用程序时,此路径将由应用程序保存; 那么应用程序将记住之前显示的视图层次结构( Tab Bar ControllerNavigation ControllerMy View Controller )。

分配恢复标识符后,我们需要为每个保留的视图控制器实现 encodeRestorableState(with coder:) 和 decodeRestorableState(with coder:) 方法。 这两种方法让我们指定需要保存或加载哪些数据以及如何对它们进行编码或解码。

让我们看看视图控制器:

 // MyViewController.swift​ // MARK: State restoration // UIViewController already conforms to UIStateRestoring protocol by default extension MyViewController { // will be called during state preservation override func encodeRestorableState(with coder: NSCoder) { // encode the data you want to save during state preservation coder.encode(self.username, forKey: "username") super.encodeRestorableState(with: coder) } // will be called during state restoration override func decodeRestorableState(with coder: NSCoder) { // decode the data saved and load it during state restoration if let restoredUsername = coder.decodeObject(forKey: "username") as? String { self.username = restoredUsername } super.decodeRestorableState(with: coder) } } // MyViewController.swift​ // MARK: State restoration // UIViewController already conforms to UIStateRestoring protocol by default extension MyViewController { // will be called during state preservation override func encodeRestorableState(with coder: NSCoder) { // encode the data you want to save during state preservation coder.encode(self.username, forKey: "username") super.encodeRestorableState(with: coder) } // will be called during state restoration override func decodeRestorableState(with coder: NSCoder) { // decode the data saved and load it during state restoration if let restoredUsername = coder.decodeObject(forKey: "username") as? String { self.username = restoredUsername } super.decodeRestorableState(with: coder) } }

请记住在您自己的方法的底部调用超类实现。 这确保了父类有机会保存和恢复状态。

一旦对象完成解码,将调用applicationFinishedRestoringState()来告诉视图控制器状态已恢复。 我们可以在这个方法中更新视图控制器的 UI。

 // MyViewController.swift​ // MARK: State restoration // UIViewController already conforms to UIStateRestoring protocol by default extension MyViewController { ... override func applicationFinishedRestoringState() { // update the UI here self.usernameLabel.text = self.username } } // MyViewController.swift​ // MARK: State restoration // UIViewController already conforms to UIStateRestoring protocol by default extension MyViewController { ... override func applicationFinishedRestoringState() { // update the UI here self.usernameLabel.text = self.username } }

你有它! 这些是为您的应用程序实现状态保存和恢复的基本方法。 请记住,当应用程序被用户强制关闭时,操作系统会删除保存的状态,以免在状态保存和恢复出现问题时卡在损坏状态。

此外,不要将任何模型数据(即应该保存到 UserDefaults 或 Core Data 的数据)存储到状态中,即使这样做看起来很方便。 当用户强制退出您的应用程序时,状态数据将被删除,您当然不希望以这种方式丢失模型数据。

要测试状态保存和恢复是否正常工作,请按照以下步骤操作:

  1. 使用 Xcode 构建和启动应用程序。
  2. 导航到您要测试的状态保存和恢复屏幕。
  3. 返回主屏幕(通过向上滑动或双击主页按钮,或在模拟器中按Shift ⇧ + Cmd ⌘ + H )将应用程序发送到后台。
  4. 通过按下按钮停止 Xcode 中的应用程序。
  5. 再次启动应用,查看状态是否恢复成功。

因为本节只涉及状态保存和恢复的基础知识,所以我推荐苹果公司的以下文章,以获得更深入的状态恢复知识:

  1. 保存和恢复状态
  2. UI 保存过程
  3. UI 恢复过程

4. 尽可能减少非透明视图的使用

不透明视图是没有透明度的视图,这意味着放置在其后面的任何 UI 元素根本不可见。 我们可以在 Interface Builder 中将视图设置为不透明:

这将通知绘图系统跳过绘制此视图后面的任何内容
在情节提要中将 UIView 设置为不透明(大预览)

或者我们可以使用 UIView 的isOpaque属性以编程方式完成:

 view.isOpaque = true

将视图设置为不透明将使绘图系统在渲染屏幕时优化一些绘图性能。

如果视图具有透明度(即 alpha 低于 1.0),那么 iOS 将不得不通过在视图层次结构中混合不同的视图层来计算应显示的内容。 另一方面,如果一个视图被设置为不透明,那么绘图系统只会把这个视图放在前面,避免混合多个视图层在它后面的额外工作。

您可以通过检查DebugColor Blended Layers来检查 iOS 模拟器中哪些图层正在混合(非透明)。

绿色是非颜色混合,红色是混合层
在模拟器中显示颜色混合层

在检查了颜色混合图层选项后,您可以看到一些视图是红色的,一些是绿色的。 红色表示视图不是不透明的,并且其输出显示是在其后面混合图层的结果。 绿色表示视图是不透明的并且没有进行混合。

使用不透明的颜色背景,该图层不需要与另一图层混合
尽可能为 UILabel 分配不透明的背景颜色,以减少颜色混合层。 (大预览)

上面显示的标签(“查看朋友”等)以红色突出显示,因为当标签被拖到情节提要时,其背景颜色默认设置为透明。 当绘图系统在标签区域附近合成显示时,它会询问标签后面的图层并进行一些计算。

优化应用程序性能的一种方法是尽可能减少以红色突出显示的视图数量。

通过将label.backgroundColor = UIColor.clear更改为label.backgroundColor = UIColor.white ,我们可以减少标签与其后面的视图层之间的层混合。

使用透明背景颜色会导致图层混合
许多标签以红色突出显示,因为它们的背景颜色是透明的,导致 iOS 通过混合其后面的视图来计算背景颜色。 (大预览)

您可能已经注意到,即使您将 UIImageView 设置为不透明并为其分配了背景色,模拟器仍会在图像视图中显示红色。 这可能是因为您用于图像视图的图像具有 Alpha 通道。

要删除图像的 Alpha 通道,您可以使用 Preview 应用程序复制图像( Shift ⇧ + Cmd ⌘ + S ),并在保存时取消选中“Alpha”复选框。

保存图像时取消选中“Alpha”复选框以丢弃 Alpha 通道。
保存图像时取消选中“Alpha”复选框以丢弃 Alpha 通道。 (大预览)

5. 将繁重的处理函数传递给后台线程 (GCD)

因为 UIKit 只在主线程上工作,所以在主线程上执行繁重的处理会减慢 UI。 UIKit 使用主线程不仅处理和响应用户输入,还绘制屏幕。

使应用程序响应的关键是将尽可能多的繁重处理任务转移到后台线程。 避免在主线程上进行复杂的计算、联网和繁重的 IO 操作(例如读写磁盘)。

您可能曾经使用过突然对您的触摸输入无响应的应用程序,并且感觉该应用程序已挂起。 这很可能是由于应用程序在主线程上运行繁重的计算任务造成的。

主线程通常会在 UIKit 任务(例如处理用户输入)和一些轻量级任务之间以较小的间隔交替进行。 如果一个繁重的任务正在主线程上运行,那么 UIKit 将需要等到繁重的任务完成后才能处理触摸输入。

避免在主线程上运行性能密集或耗时的任务
以下是主线程如何处理 UI 任务以及为什么在执行繁重的任务时它会导致 UI 挂起。 (大预览)

默认情况下,视图控制器生命周期方法(例如 viewDidLoad)和 IBOutlet 函数中的代码在主线程上执行。 要将繁重的处理任务转移到后台线程,我们可以使用 Apple 提供的 Grand Central Dispatch 队列。

这是切换队列的模板:

 // Switch to background thread to perform heavy task. DispatchQueue.global(qos: .default).async { // Perform heavy task here. // Switch back to main thread to perform UI-related task. DispatchQueue.main.async { // Update UI. } }

qos代表“服务质量”。 不同的服务质量值表示指定任务的不同优先级。 操作系统将为分配在具有较高 QoS 值的队列中的任务分配更多的 CPU 时间和 CPU 功率 I/O 吞吐量,这意味着任务将在具有较高 QoS 值的队列中更快地完成。 较高的 QoS 值也将消耗更多的能量,因为它使用更多的资源。

以下是从最高优先级到最低优先级的 QoS 值列表:

按性能和能效排序的队列服务质量值
按性能和能效排序的队列的服务质量值(大预览)

Apple 提供了一个方便的表格,其中包含用于不同任务的 QoS 值的示例。

要记住的一件事是所有 UIKit 代码都应该始终在主线程上执行。 在后台线程上修改 UIKit 对象(例如UILabelUIImageView )可能会产生意想不到的后果,例如 UI 没有实际更新、发生崩溃等。

来自苹果的文章:

“在主线程以外的线程上更新 UI 是一个常见错误,可能导致错过 UI 更新、视觉缺陷、数据损坏和崩溃。”

我建议观看 Apple 关于 UI 并发的 WWDC 2012 视频,以更好地了解如何构建响应式应用程序。

笔记

性能优化的权衡是您必须在应用程序的功能之上编写更多代码或配置其他设置。 这可能会使您的应用程序交付晚于预期,并且您将来将有更多代码需要维护,而更多代码意味着潜在的更多错误。

在花时间优化你的应用程序之前,问问自己应用程序是否已经很流畅,或者它是否有一些反应迟钝的部分确实需要优化。 花大量时间优化已经流畅的应用程序以减少 0.01 秒可能不值得,因为时间可以更好地用于开发更好的功能或其他优先事项。

更多资源

  • “A Suite of Delicious iOS Eye Candy”,Tim Oliver,2018 年东京 iOS 聚会(视频)
  • “在 iOS 上构建并发用户界面”,Andy Matuschak,WWDC 2012(视频)
  • “在启动过程中保留应用程序的 UI”,Apple
  • “并发编程指南:调度队列”,文档存档,Apple
  • “主线程检查器”,Apple