高性能iOS应用开发 - iOS性能

869 阅读44分钟

《高性能iOS应用开发》是一本质量很高的 iOS 书籍,我从此书中系统的学到了很多东西。这篇博客是《高性能iOS应用开发》一书第三部分“iOS性能”的读书笔记,因为我对 APP 性能比较感兴趣,所以就先从第三部分“iOS性能”开始了。

1. 应用启动

iOS 应用在启动时会调用 UIApplicationMain 方法,并传入 UIApplicationDelegate 类的引用。委托接收应用范围的事件,并且有明确的生命周期,application:didFinishLaunchingWithOptions: 方法表明应用已经启动。

应用的窗口有一个 rootViewController,对应的 UIViewController 对象同样具有明确的生命周期。UIViewController 的方法viewDidAppear:执行时,说明启动已完成。

APP 启动过程中,应尽量减少不必要的操作,从而缩短应用的启动时长,实现更好的用户体验。应用有四种启动类型。

1.1 首次启动

安装应用后的首次启动。此时没有之前的状态,也没有本地缓存。这意味着将会出现以下两种情况中的一种:没有需要加载的内容(因此加载时间会缩短),或者需要从服务器上下载初始数据(可能需要很长的加载时间)。在应用首次启动时,你可以选择提供引导图来总结应用的功能和用法。

首次启动时,应用通常会执行多个任务:

  • 加载应用的默认项(NSUserDefaults、捆绑的配置等)
  • 检查私有 / 测试版本
  • 初始化应用标识符,包括但不限于对匿名用户使用的供应商标识符(Identifier for Vendor,IDFV)、广告标识符(Identifier for Advertiser,IDFA)等
  • 初始化崩溃报告系统
  • 建立 A/B 测试
  • 建立分析方法
  • 使用操作或 GCD 建立网络
  • 建立 UI 基础设施(导航、主题、初始 UI)
  • 显示登录提示或从服务器加载最新内容及其他更新
  • 建立内存缓存(如图片缓存)

上述列举的内容只是应用在首次启动时可能执行的任务。其中一些还会在后续启动中执行。问题是,任务数量的快速增加必然会导致应用的启动速度变慢。

那么怎么避免这样的问题呢?可以遵循下述具体步骤,拆解任务列表,从而获得更高的性能。

  • 确定在展示 UI 前必须执行的任务。如果应用是第一次启动,那么没有必要加载任何用户偏好,如主题、刷新间隔、缓存大小等。此时是没有任何自定义值的。初始缓存肆意增长也是没问题的,因为它的增长不会超过最终的限制值。崩溃报告系统应该第一个被初始化。
  • 按顺序执行任务。排序是非常重要的,因为任务之间可能具有相互依赖性,同时,排序还可以节省用户的宝贵时间。
  • 将任务拆分为两类:一类是必须在主线程中执行的任务,另一类是可以在其他线程中执行的任务 ,然后分别执行。还可以进一步将在非主线程中执行的任务分为可以并发执行的和不能并发执行的。
  • 其他任务可以在加载 UI 后执行或异步执行。延迟其他子系统(如记录仪和分析方法)的初始化。在应用的后续阶段将一些操作(例如,写日志消息或跟踪事件)放入队列中,直到子系统完全完成初始化。

1.2 冷启动

应用后续的启动。在启动期间,可能需要恢复原来的状态,例如,游戏中达到的最高等级、消息应用中的聊天记录、新闻应用中上一次同步的文章、已登录用户的证书,或者 仅仅是用户已经使用过的引导图标记符。

冷启动中一个较为重要的任务是,载入之前的状态。在应用中,显示给用户(登录后)的第一个画面是 feed 流。如果用户在以前的启动中登录过,并且数据已经同步,那我们就会考虑加载之前已经缓存的 feed 流。

为了实现向用户展示 feed 流的任务,必须向服务器请求最近的更新,同时还要从本地缓存加载数据。这些行为是不用思考就知道的。但是,以下几点却是不容忽视的。

  • 展示有用且有意义的 UI 所需要的最少信息数目(min)。
  • 记录从本地缓存加载 M 条信息花费的时间(记作 tl)。
  • 记录从服务器获取最新的 M 条信息花费的时间(记作 tr)。
  • 为了获得更快的速度,任何时刻在内存中存储的最大信息数目(max),特别是在快速滑动和滚动时。

1.3 热(重)启动

这是指当应用处于后台,但并未被挂起或关闭时,用户切换至应用而触发的启动。在这种情况下,当用户通过点击应用图标或深层链接返回应用时,不会触发启动时的回调,而是直接用 applicationDidBecomeActive:(或 application:openURL:source:annotation:)回调。

通常来说,这种情况和继续执行没什么区别,只是视图控制器可能需要处理一些额外的事件。

热启动是指切换到一个已经运行了的应用。两个原因可能会使应用变成非激活状态:一是用户向下拉拽状态栏,二是用户点击 home 键或切换至其他应用。

热启动有两种情境:

  • 用户点击图标
  • 应用接收到深层链接

1.3.1 应用重启

当用户点击应用图标时,一般不需要执行其他特殊的操作。

应用处于安全状态,或者运行很多动画时,可以监测背景和前景通知。在第一种情况下, 应用每次进入前景状态时,都会展示登录界面;在后一种情况下,动画或者游戏状态会被暂停,需要恢复。

1.3.2 深层链接

当应用接收到 application:openURL:sourceApplication:annotation: 回调时,期望能跳转 到应用的特定页面,实现用户想要完成的操作。但此时的目标应用可能已经发生变化,处于某一特定状态了。

如果深层链接需要从服务器获取数据,那么可以先展示与深层链接相关的原始页面,或者先展示一个进度条,等从服务器获取到了最新数据,再执行刷新操作。

1.4 升级后的启动

应用升级以后的启动。通常而言,升级后的启动与冷启动没有差别。但是,不同的启动叫法表明了本地存储发生变化的时刻是不同的,这些变化包括模式、内容、之前版本挂起的同步操作,以及内部的 API/ 默认依赖。

应用升级后的首次启动将遵循下列情形之一:

  • 无本地缓存或应用完全放弃缓存;
  • 本地缓存可用,可以直接使用或需要切换至升级版本。

如果无本地缓存或应用决定放弃缓存(例如,数据不可用或从服务器同步获取更快),则不需要进行特殊处理。本地数据发生改变时通知用户。以下的最佳实践可以让用户有更好的体验。

  • 如果本地缓存可用,通知用户该情况。如果没有迁移到本地缓存的必要,则无需通知用户,因为本地缓存的使用是隐式的。
  • 如果必须花几分钟对数据进行迁移,那么向用户展示一个可以推迟该操作的选项。
  • 如果从服务器检索数据更快、更容易,因而必须放弃本地缓存的使用,那么这种情况下需要通知用户。

2. 用户界面

当与 UI 进行交互时,大部分用户才注意到性能问题。如果某个应用在数据同步和刷新上耗时较长,或用户交互不够稳定,那么应用会被认为是迟钝的。

功耗、网络使用率、本地存储等因素对用户来说是不可见的。因此,虽然这些因素是解决性能问题的要素,但 UI 却是应用的门面,如果 UI 反应迟钝,则必然会直接影响用户的反馈。

还有一些无法控制的外部因素,如下。

  • 网络
    • 弱网环境会增加同步所需的时间。
  • 硬件
    • 硬件越好,其提供的性能越高。与旧型号的 iPhone 相比,搭载新系统的新 iPhone 执行 速度更快。
  • 存储
    • 应用可以在存储容量不同的设备上运行,存储容量小至 16GB,大到 128GB,它们限制了应用在本地离线缓存数据的规模。

2.1 视图控制器

在应用开发的最初阶段,视图控制器都较为精简,状态较好。随着时间的推移,这些视图控制器慢慢变成了所有业务逻辑的垃圾场,代码量也增长至几千行。虽然逻辑的“总量”是不可避免的,但将代码重构成短小、可复用的方法是很好的主意。这样不仅能解除耦合,还可以发现无用的、重复的代码。

下面列举了创建视图控制器时需要遵循的一些较为基本的最佳实践。

  • 保持视图控制器轻量。在 MVC 结构的应用中,控制器只是纽带,而不是存放所有业务逻辑的地方。它甚至不属于模型。业务逻辑应该属于服务层或业务逻辑组件。将它放在那里。
  • 不要在视图控制器中编写动画逻辑。动画可以在独立的动画类中实现,该类接受视图作为参数传入,这些视图就是用来运行动画的视图。然后,视图控制器会将动画添加至视图或转场效果上。
  • 使用数据源和委托协议,将代码按照数据检索、数据更新和其他的业务逻辑进行分离。 视图控制器只能用来选择正确的视图,并将它们连接到供应源。
  • 视图控制器响应来自视图的事件,如按钮点击事件或列表单元格的选择事件,然后将它们连接至数据接收器。
  • 视图控制器响应来自操作系统的 UI 相关事件,如方向变化或低内存警告。这可能会触发视图的重新布局。
  • 不要在视图控制器中使用代码手工布局 UI,也不要在视图控制器中实现全部的 UI、视图创建和视图布局逻辑等操作。
  • 比较好的方式是,创建一个实现了公共设置的基类视图控制器,其他视图控制器从这里继承就好。
  • 在各视图控制器之间,使用 category 创建可复用的代码。如果父视图控制器不能满足使用(例如,在应用中需要不同种类的视图控制器),那就创建 category,并在 category 中加上自定义的方法或属性。

2.1.1 视图加载

视图初始化时会涉及两个方法——loadView 和 viewDidLoad。

如果通过覆写 loadView 方法创建了自定义 UI,你需要牢记以下几点。

  • 将 view 属性设置到视图层级的根上。
  • 确保视图正被其他的视图控制器所共享。
  • 不要调用 [super loadView]。

在执行过程中,应该尽量缩短在 viewDidLoad 方法上花费的时间。具体来讲,将要被渲染的数据应该是已经可用的,或是在其他线程进行加载的。在 viewDidLoad 的完成中发生的任何延迟,都将导致与视图控制器相关的 UI 展示发生延迟。用户会卡在应用启动或前一个视图控制器中。

2.1.2 视图层级

展示出来的 UI 是由嵌套在树形结构中的各层次视图组成的,它们的位置受自动布局或其他编排方式的约束。视图结构和渲染包括以下步骤。

  • (1) 构造子视图。
  • (2) 计算并提供约束。
  • (3) 为子视图递归地执行步骤 1 和步骤 2。
  • (4) 递归渲染。

视图层次越复杂,构建和渲染视图消耗的时间也就越长,因此要尽量减少视图层级。

2.1.3 视图可见性

视图控制器提供了四个生命周期方法,以接收有关视图可视性的通知。

  • viewWillAppear: 当视图层级已经准备好,且视图即将被放入视图窗口时,此方法会被调用。在即将展示视图控制器或之前入栈(modal 或者其他)的视图控制器弹出时,这种情况就会发生。在这个时刻,过渡动画还未开始,视图对终端用户也是不可见的。不要启动任何视图动画,因为没有任何作用。
  • viewDidAppear: 当视图在视图窗口展示出来,且过渡动画完成后,此方法会被调用。因为动画会耗费约 300 毫秒,所以,对比 viewWillAppear: 和 viewDidLoad:,viewDidAppear: 和 viewWillAppear: 之间的时间差可能会比较大。启动或恢复任何想要呈现给用户的视图动画。
  • viewWillDisappear: 该方法表示视图将要从屏幕上隐藏起来。这可能是因为其他视图控制器想要接管屏幕, 或该视图控制器将要出栈。
  • viewDidDisappear: 当上一个 / 下一个视图控制器的过渡动画完成时,此方法会被调用。正如 viewDidAppear:,viewWillDisappear: 事件也会有约 300 毫秒的差值。

以下列举了一些高效使用生命周期事件的最佳实践。

  • 无需多说,不要重写 loadView。
  • 将 viewDidLoad 作为最后的检查点,查看来自数据源的数据是否可用。如果可用,则更新 UI 元素。
  • 如果每次都需要展示最新的信息,那么就使用 viewWillAppear: 更新 UI 元素。
  • 在 viewDidAppear: 中开始动画。如果有视频等流式内容,那么就可以开始播放了。订 阅应用事件来检测动画 / 视频或其他持续更新视频的处理是应该继续还是停止。不推荐在该方法中用最新的数据更新 UI。如果你这样做了,最终的效果是,在过渡动 画完成之后,用户会过渡至旧的 UI,然后产生更新。这个体验不是很友好。
  • 使用 viewWillDisappear: 来暂停或停止动画。同样,不要做其他多余的操作。
  • 使用 viewDidDisappear:销毁内存中的复杂数据结构。也可以在这里注销与视图控制器绑定的数据源通知,以及与动画、数据源、UI 更新有 关的应用事件通知中心。

2.2 视图

优化视图方面最具挑战性的部分是,很少有普适于所有视图的技术。每个视图都有其独特 的用途,且大部分的优化技术都与特定的视图和暴露出的 API 有关。

  • 基本准则:
    • 尽量减少在主线程中所做的工作。任何额外代码的执行都意味着更高的丢帧概率。过多的丢帧会导致不流畅。
    • 避免较大的 nibs 或故事板。故事板很强大,但整个 XML 在真正使用之前必须被加载(I/O) 和解析(XML 处理)。应该最小化故事板中的单元数目。
    • 避免在视图层次结构中多层嵌套。尽量保持扁平化。
    • 尽可能延迟加载视图并进行重用。更多的视图不仅会导致加载时间变长,还会使渲染时间变长,这些会影响内存和 CPU 的使用。
    • 对于复杂的 UI 而言,最好使用自定义绘图。这样只会触发一个视图进行绘制,而不是多个子视图,同时也避免了调用代价较高的 layoutSubviews 和 drawRect: 方法。此外,要避免使用具有通用目的及功能丰富的组件而带来的消耗,你可以使用那些直接实现了绘制方法的视图来代替。

2.2.1 UILabel

这可能是 iOS 上最常用的视图了。它虽然看起来简单,但是渲染代价却不容小觑。下列是涉及的一些复杂步骤。

  • 使用字体、字体类型以及要被渲染的文本时,计算需要的像素数目。这是一个消耗较大的过程,应尽可能少地去做。
  • 检查要被渲染的宽度。
  • 检查 numberOfLines,计算将要展示的行数。
  • sizeToFit 是否被调用?如果是,则计算高度。
  • 如果 sizeToFit 没有被调用,检查当前的内容能否在给定的高度下展示出来。
  • 如果 frame 不够,使用 lineBreakMode 确定隐藏或截断的位置。
  • 最后,使用字体、类型及颜色来渲染最终展示的文本。

具体说明每个 UILabel 是一件工作量很大的事情。使用较少的标签,更容易管理效果,使用较多的标签,你就需要多留意这些标签的创建、配置和重用。

2.2.2 UIButton

渲染按钮的方式有以下四种:

  • 使用自定义文本的默认渲染
  • 全尺寸资源的按钮
  • 可变大小的资源
  • 使用 CALayer 和贝塞尔路径自定义绘制

2.2.3 UIImageView

在渲染代价较大的各种 UI 元素中,图像首屈一指。在使用 UIImage 和 UIImageView 时,遵循以下的最佳实践可以提升性能。

  • 对于已知的图像,使用 imageNamed: 方法加载图像。它可以确保内容只被加载至内存一次, 还可以确保在多个 UIImage 对象间改变用途。
  • 在使用 imageNamed: 方法加载包图片时,使用资源包。如果应用有一堆图标,且每个图 标都较小时,这种方式极其有用。可以随意地创建相关图像(即通常被一起使用的图片) 的多个目录。
  • 对于其他图像,使用高性能的图像缓存库。AFNetworking 和 SDWebImage 都是可选的强大库。当使用内存中的图片时,确保正确配置了内存的使用参数。不要使用硬编码。让它能够自适应——使用合理的 RAM 百分比可以较好地进行配置。
  • 载入的图像与即将渲染的 UIImageView 大小相同。如果被解析的图像尺寸与 UIImageView 相同,那么你会得到极高的性能,因为调整图像大小是一个耗费较大的操作,如果该图像被包含在 UIScrollView 中,则耗费会更大。如果图像来自网络下载,那么尽量下载和视图大小匹配的图像。如果行不通,适当地对图片进行预处理,调整其大小。
  • 如果需要使用一些类似于模糊或色调的效果,那么可以创建一份图像内容的副本,在副本上施加效果,然后使用最终的位图创建所需的 UIImage。如此一来,这些附加的效果只会被使用一次,如果有需要,原始图像还可以用于其他显示。
  • 无论使用何种技术加载图像,在非主线程中执行,最好在一个专用的队列中执行。尤其要在非主线程中解压 JPG/PNG 图像。
  • 最后同样重要的是,确定是否真的需要图像。如果要展示一个评分栏,最好使用直接绘制的自定义视图,而不是使用多个图像,通过调整透明或覆盖来实现。

2.2.4 UITableView

无论是在新闻应用、邮件应用、照片流,还是其他的应用中,UITableView 都是最常用于显示数据的视图。UITableView 提供了一个展示信息条的极好选择,这些信息条既可以是同一类别,也可以是不同类别。

UITableView 绑定了两个协议。

  • UITableViewDataSource

    • 必须将 dataSource 属性设置到数据源上。顾名思义,数据源是指将要填充至列表单元格中的数据源。
  • UITableViewDelegate

    • 必须将 delegate 属性设置到委托上,当用户与列表或单元格交互时,此处的委托必须能接收到回调。

下列是使用 UITableView 时需要牢记的一些最佳实践。

  • 在数据源的 tableView:cellForRowAtIndexPath: 方法中,使用 tableView:dequeueReusa bleCellWithIdentifier: 或 tableView:dequeueReusableCellWithIdentifier:forIndexPa th: 进行单元格的重用,而不是每次都创建新的单元格。
  • 尽可能避免动态高度的单元格。诚然,已经确定的高度代表着只需很少的计算量。如果内容是动态配置的,那么不仅需要计算高度,而且每次视图要被渲染时,单元格的内容也需要刷新和重新布局。这是一个很大的性能损失。
  • 如果你真的需要动态高度的单元格,那么定义一个规则来标记单元格是脏的。如果某个单元格是脏的,计算它的高度并缓存。在委托的 tableView:heightForRowAtIndexPath: 回调中继续返回缓存的高度,直到单元格不再被标记为脏。如果要被渲染的模型是不可变的,一个可用的简单规则是,检查当前被渲染的模型是否 和相应的 indexPath 的值一样。如果一样,则使用同样的值渲染,无需进一步的处理。如果不一样,则重新计算值,并将新的对象(模型)附加至该单元格。
  • 当用自定义视图重用单元格时,要避免通过调用 layoutIfNeeded 每次都对其进行布局。即使一个单元格的高度是固定的,也有可能出现这样的情况:在单元格中的独立元素可能会被设置成不同的高度,例如,UILabel 支持多行内容,UIImageView 可以装入不同大小的图像。
  • 避免透明的单元格子视图。创建 UITableViewCell 时,尽量引入不透明元素。半透明或透明元素(alpha 低于 1.0 的视图)很好看,但会有性能损失。
  • 在快速滚动时考虑使用界面外壳(可以参考这个Skeleton)。当用户快速滚动列表视图时,虽然使用了 所有的优化,但视图的重用和渲染仍然需要超过 16 毫秒,还有可能出现偶发的丢帧现 象,从而导致不流畅的体验。
  • 避免渐变、图像缩放以及任何屏幕外的绘制。这些效果对 CPU 以及图形处理单元(GPU) 来说都是消耗。

2.2.5 UIWebView

UIWebView 是用于渲染未知或动态内容的最常见视图。

虽然有些应用可能全部都是原生的,但还是有需要使用 UIWebView 的场景,以下是一些常见场景。

  • 任何应用中的用户登录。Spotify、Mint 和 LinkedIn 这样的应用使用原生 UI 渲染登录表单。但这有一定的限制。
  • 在任何应用中显示隐私政策或使用条款。因为这些会随着时间变化,并且需要大量的格式化(文本样式、编号列表、其他内容的交叉引用),使用原生视图不是较好的选择。
  • 新闻或文章阅读器,因为大部分的文章都是为 Web 创建的,几乎都是 HTML。
  • 邮件应用。例如,初始邮件是 HTML 形式,当呈现消息或跟帖,以及撰写回复时。

使用 UIWebView 时,请将以下几个最佳实践牢记在心。(需要注意的是,关于 UIWebView 能做的事情非常少,并非都是关注性能的;相反,此处的重点是以最恰当的方式展示 HTML 内容。)

  • UIWebView 可能比较笨重且迟钝,所以尽可能复用 web view。同时,UIWebView 也因内存泄漏而知名。因此,每个应用的实例都应该足够好。
  • 附加一个自定义的 UIWebViewDelegate。实现 webView:shouldStartLoadWithRequest: navigationType: 方法。要留意 URL scheme。如果是 http 或 https 以外的东西,需要注意: 应用应该知道如何处理这种情况,或警告用户该网站正试图脱离应用。
  • 你可以通过 stringByEvaluatingJavaScriptFromString: 方法创建一个桥来连接应用和 JavaScript,从而在当前已经加载的 web 页面执行 JavaScript。如果想要调用原生应用的方法,你可以参考之前的处理方法,使用自定义的 URL scheme。
  • 实现委托的 webView:didFailLoadWithError: 方法,以保持对所有可能出现的错误的紧密追踪。
  • 实现 webView:didFailLoadWithError: 方法来处理特定的错误。

2.3 自动布局

通过 Auto Layout,可以描述一个元素距另一元素的距离(水平或垂直)、其大小(宽度或高度),或其与另一元素的对齐方式(水平或垂直)。

关于 Auto Layout 的性能问题,书中只介绍了 Auto Layout 在视图数量很多时,消耗会比 Frame 布局大很多,介绍的比较笼统,具体还可以参考下这篇博客:从 Auto Layout 的布局算法谈性能

3. 网络

在应用中使用网络是必不可少的,但减少网络延迟的方法却是有限的,因此,你应该着手对网络条件进行最大程度的优化,并预先对不同的场景进行规划。

3.1 指标和测量

在网络中完成的大多数工作是无法控制的,因此确定衡量的标准非常重要。接下来会列出在性能优化相关的测量中更为重要的一些指标。

3.1.1 DNS查找时间

发起连接的第一步是 DNS 查找。如果你的应用严重依赖网络操作,DNS 的查找时间会使应用变慢。

为了最大限度地减少 DNS 查询时间所产生的延迟,你应该遵循以下的最佳实践。

  • 最小化应用使用的专有域名的数量。按照路由的一般工作方式,多个域名是不可避免的。最好是能做到以下几点:
    • 身份管理(登录、注销、配置文件)
    • 数据服务(API 端点)
    • CDN(图片和其他静态人工产品)
  • 在应用启动时不需要连接所有的域名,可能只需要身份管理和初始画面所需的数据。对于后续的子域名,尝试更早地进行 DNS 解析,也被称为 DNS 预先下载。为实现此操作, 你可以参考以下两点。
    • 如果子域名和主机在控制范围内,可以配置一个预设的 URL,不返回任何数据,只返回 HTTP 204 的状态码,然后提前对该 URL 发起连接。
    • 第二个方法是使用 gethostbyname 执行一个明确的 DNS 查找。然而,针对不同的协议, 主机可能会解析至不同的 IP,例如,HTTP 请求可能会解析至一个地址,而 HTTPS 会解析至另一个地址。虽然不是很常见,但第 7 层的路由可以根据实际的请求解析 IP 地址,例如,图像是一个地址,视频是另外一个地址。鉴于这些因素,在连接之前解析 DNS 经常是无用的,对主机进行伪连接会更有效。

3.1.2 SSL握手时间

为了安全起见,可以假设应用中所有的连接均是通过 TLS/SSL 的(使用 HTTPS)。HTTPS 在连接开始时,先进行 SSL 握手,SSL 握手主要是验证服务器证书,同时共享用于通信的随机密钥。这一操作听起来简单,但是却有很多步骤,还会耗费较多时间。

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

  • 最大程度地减少应用发起的连接数。因此,也需要减少应用连接的独有域名的数量。
  • 请求结束后不要关闭 HTTP/S 连接。为所有的HTTPS请求添加头Connection: keep-alive。这确保了同样的连接在下一次 请求时可以复用。
  • 使用域分片。如此一来,虽然连接的是不同的主机名,你也可以使用同一个 socket,只 要它们解析为相同的 IP,可以使用相同的证书(例如,在通配符域)就行了。

3.1.3 网络类型

一般情况下,iPhone 和 iPad 可以使用 WiFi、4G、3G 等网络连接到互联网。

遵循以下的最佳实践。

  • 设计时考虑不同的网络可用性。在移动网络中,唯一不变的是,网络可用性是多变的。 对于流媒体,最好选择 HTTP 实时流或任何可用的自适应比特率流媒体技术,这些技术可以在某一时刻针对可用带宽进行动态切换,切换至当前带宽的最佳流质量,从而提供流畅的视频播放。对于非流媒体内容,你需要实现一些策略,确定在单次拉取时应该下载多少数据,并且数据量必须是自适应的。例如,你可能不希望在最新一次更新时,一次拉取所有的 200 封新邮件。你可以先下载前 50 封邮件,再逐步下载更多邮件。同样,在低速网络时,不要打开视频自动播放功能,这可能会花费用户很多钱。
  • 出现失败时,在随机的、以指数增长的延迟后进行重试。例如,第一次失败后,应用可能会在 1 秒后重试。第二次失败时,应用在 2 秒后重试, 接着是 4 秒的延迟。不要忘记对每个会话设置最多的自动重试次数。
  • 设立强制刷新之间的最短时间。当用户明确要求刷新时,不要立即发出请求。相反,检查是否已经存在一个请求,或当前请求与上次请求的时间间隔是否小于阈值。如果满足上述条件,则不要发送此次请求。
  • 使用可到达性库发现网络状态的变化。
  • 不要缓存网络状态。不论是通过触发请求时的回调来获取状态,还是在发送请求之前显式地检查状态,要始终使用网络敏感度高的任务的最新值。
  • 基于网络类型下载内容。如果想要展示一个图像,不用总是下载原始的、高质量的图像。应该始终下载和设备适配的图像——iPhone 4S 所需的图像尺寸和第三代 iPad 所需的差别很大。
  • 乐观地预先下载。在 WiFi 网络中预先下载用户在后续时刻需要的内容。随后就可以使用缓存内容了。最好分次下载内容,在使用之后关掉网络连接,这有助于节省电量。
  • 如果适用,当网络可用时,支持同步的离线存储。通常情况下,网络缓存就足够了。但如果需要更多的结构化数据,使用本地文件或 Core Data 会是一个较好的选择。对游戏来说,缓存最近一级的详细信息。对邮件应用来说,存储一些带有附件的最新电子邮件是一个不错的选择。

3.1.4 延迟

延迟是指从服务器请求资源时,在网络传输上花费的额外时间。设置用于测量网络延迟的系统是很重要的。

网络延迟可以通过使用请求过程中花费的总时间减去服务器上花费的时间(计算和服务响 应)来测量:

Round-Trip Time = (Timestamp of Response - Timestamp of Request)
Network Latency = Round-Trip Time - Time Spent on Server

花费在服务器上的时间可以由服务器来计算。对客户端而言,往返的时间是准确可用的。服务器可以将花费的时间放在响应的自定义头部,然后客户端就可以用来计算延迟了。

如果你有数据来分析任何模式下的延迟,还需跟踪下列数据。

  • 连接超时

    • 跟踪连接超时的次数是非常重要的。根据网络质量(较薄弱的基础设施或较低的容量),该指标会提供详细的地理区域分类,网络质量将反过来帮助规划同步时间的传输。例如,同步会在短时间间隔传输,比如几分钟,而不用在某一个特定时间跨时区同步。
  • 响应超时

    • 捕捉连接成功但响应超时的数量。这有助于根据地理位置和日期、年份的时间来规划数据中心的容量。
  • 载荷大小

    • 请求以及响应的大小完全可以在服务器端进行测量。使用此数据可以识别任何可能降 低网络操作速度的峰值,并确定一些可用选项:通过选择合适的序列化格式(JSON、 CSV、Protobuf 等)减少数据占位,或者分割数据并使用增量同步(例如,通过使用小的批量大小或在多个块中发送部分数据)。

3.2 应用部署

随着对这些指标的统计,你可以更好地规划应用的部署。这不仅包括服务器、服务器的位置和容量,还包括客户端,以及如何在给定的场景下获得最好的。

3.2.1 服务器

在查看网络延迟的地域分布时,我们可以使用这个信息为数据中心选择适当的位置。如果使用托管的数据中心提供商,不妨选择有多个地理位置的,如 Amazon AWS 或 Rackspace Cloud。如果你有自己的数据中心,那么应该确保它们在地理上是分散的。

无需多想,服务器应该安装在多个位置,这样你可以更好地服务本地内容。

以下是一些应该遵循的最佳实践。

  • 使用多个数据中心,让服务器在地理上分散开来,更贴近用户。
  • 使用 CDN 提供静态内容,如图像、JavaScript、CSS、字体等。
  • 使用接近的边缘服务器来提供动态内容。
  • 避免使用多个域名(DNS 查询时间可能会很长,这会降低用户体验)。

3.2.2 请求

为了恰当地设置网络,正确地配置 HTTP/S 请求很重要。你应该遵循以下的最佳实践。

  • 不要为每一个操作单元都进行一次请求,使用批量请求。即使必须实现多个后端子系统来完成,但是合并批量请求会带来较大的性能提升,所以还是值得的。
  • 使用持续的 HTTP 连接,该连接也被称为 HTTP 长连接。它们有助于最大限度地减少 TCP 和 SSL 握手的消耗,同时也减少了网络拥塞。
  • 在任何可以的情况下都使用 HTTP/2。通过单一的连接, HTTP/2 支持 HTTP 请求的真正复用;如果请求解析为一个 IP 地址,那么 HTTP/2 会将跨越了多个子域的请求聚集到一起;HTTP/2 还支持报头压缩等。使用 HTTP/2 的好处是巨大的。最好的是,就消息结构而言,该协议仍旧保持不变,依然包括头部和主体。
  • 使用 HTTP 缓存头设置正确的缓存级别。对于想要下载的标准图像(如主题背景或表情), 内容的有效期可以设置为较长的时间。这不仅保证了网络库在本地缓存它们,还保证了其他设备可以从在本地进行了缓存的中介服务器(ISP 服务器或代理)中受益。影响 HTTP 缓存的响应头是 Last-Modified、Expires、ETag 和 Cache-Control。

3.2.3 数据格式

选择正确的数据格式和选择网络参数一样重要。一些选择可能会使应用的性能产生很大的不同,比如对无损图像压缩使用 PNG 还是 WEBP。

如果你的应用是以数据为导向的,那么选择适合其传输的正确格式很关键。其他协议支持的功能也可以提供帮助。

在选择数据格式时,你应该遵循以下的最佳实践。

  • 使用数据压缩。当传送 JSON 或 XML 这样的文本内容时,这一点尤为重要。 NSURLRequest 会自动给头部添加 Accept-Encoding:gzip、deflate,这样你就无需自己动手了。但这也意味着服务器应该承认头部,并使用适当的传输编码发送数据。
  • 选择正确的数据格式。不用多想,JSON 和 XML 这样冗长、人类可读的格式是资源密集型的——序列化、传输、反序列化会比使用自定义制作的、二进制的、机器友好的格式更耗费时间。此处不讨论媒体压缩(即图像压缩和视频编解码器),而是着眼于文本数据格式。

3.3 工具

Charles 是一个非常强大的网络调试代理。使用比较简单,这里不做过多介绍,如有需要可参考网上博客。

推荐一些之前收集的性能优化的博客:

4. 数据共享

有时你会需要与其他应用共享数据,或访问设备上其他应用的共享数据。共享数据的场景包括以下几个。

  • 与其他应用集成(例如,让用户使用微信的登录信息登录你的应用)。
  • 发布一系列互补的应用。
  • 将用户数据从统一的应用移动到有多个特定用途的应用,检测其是否存在,并在需要时传递控制。
  • 在可用的最佳查看器中打开文档。

4.1 深层链接

在移动应用的上下文中,深层链接包括使用统一的资源标识符(uniform resourceidentifier, URI),其链接到移动应用内的特定位置,而不是简单地启动应用。

深层链接为应用之间的共享数据提供了解耦的方案。与访问网站时的 HTTP 网址类似,iOS 中的深层链接通过所谓的自定义 URL scheme 来提供。你可以配置自己的应用,让它响应唯一的 scheme,操作系统会确保无论何时使用该 scheme,都由你的应用进行处理。应用可以响应任何数量的 scheme。

不论是访问共享数据,还是对外共享数据,深层链接可能是最常用的选项,同时,优化创建和解析的时间也很重要。以下列表涵盖了可以遵循的一些最佳实践,从而让应用实现最优性能。

  • 最好使用较短的 URL,因为它们的构建速度和解析速度都比较快。
  • 避免基于正则表达式的模式。
  • 优先选择基于查询的 URL 进行标准解析。用基于字符的分隔符解析比使用正则表达式解析更快。
  • 在你的 URL 中支持深层链接回调,以帮助用户完成意图。一个较好的方法是支持三个选项:success、failure 和 cancel。
  • URL 最好使用深层链接,以帮助用户定义一个需要多个应用协调的工作流。
  • 不要在 URL 中放置任何敏感数据。具体来说,不要使用任何身份验证令牌。这些令牌可能会被未知的应用劫持。
  • 不要信任任何传入的数据。始终验证 URL。作为附加的措施,可以让应用在传递 URL 前对数据进行签名,并在处理前验证签名,这可能会是个不错的主意。但是,为了安全地进行,私钥必须保存在服务器上,如此一来,就必须要有网络连接。
  • 使用 sourceApplication 来标识源。有一个应用白名单非常有用,你可以始终信任这些数 据。sourceApplication 的使用与签名验证不正交。这可以是 URL 开始处理前的第一步。

4.2 剪贴板

官方文档对剪贴板的描述如下。

剪贴板是用于在应用之内或之间交换数据的安全且标准化的机制。许多操作取决 于剪贴板,特别是复制—剪切—粘贴。......但你也可以在其他情况下使用剪贴板,例如,在应用之间共享数据时。

可通过 UIPasteBoard 类使用剪贴板,该类可以访问共享存储库,写对象和读对象在共享存储库中进行数据交换。写对象也被称为剪贴板所有者,将数据存储在剪贴板实例上。读对象访问剪贴板,将数据复制到其地址空间中。

与深层链接相比,剪贴板具有以下优点。

  • 它具有支持复杂数据(如图像)的能力。
  • 它支持在多种形式中表示数据,这些形式可以基于目标应用的功能来选择。例如,消息应用可以使用纯文本格式,邮件应用可以使用来自同一剪贴板项目的富文本格式。
  • 即使应用关闭后,剪贴板内容仍然会保留。

使用剪贴板时,你应该遵循以下的最佳实践。

  • 剪贴板本质上是由剪贴板服务进行调解的进程间通信。IPC 的所有安全规则都适用(例如,不发送任何安全数据、不信任任何传入数据)。
  • 因为不能控制哪个应用会访问剪贴板,所以使用时总是不安全的,除非数据被加密。
  • 不要在剪贴板中使用大量数据。虽然剪贴板支持交换图像以及多种格式,但请记住,每个条目不仅消耗内存,也需要额外的时间来读写。
  • 当应用将使用 UIApplicationDidEnterBackgroundNotification 通知或 UIApplicationWillResignActiveNotification 通知进入后台时,清除剪贴板。更好的做法是,你可以实现 UIApplicationDelegate 相应的回调方法。通过将 items 设置为 nil,你可以清除剪贴板,如下所示: myPasteboard.items = nil;
  • 为了防止任何类型的复制 / 粘贴,继承 UITextView,并在 canPerformAction 的 copy: 动作中返回 NO。

5. 安全

应用可能会在未知的执行环境中运行,并通过未知的传输网络交换数据,因此,应始终将安全性作为首要任务之一,以便保护用户及应用的敏感数据。

不论是通过代码的执行(例如,从 1024 位 DSA 密钥的加密密钥转为 2048 位 RSA 的加密密钥)还是通过用户干预(例如,引入双因素认证或应用 PIN),任何附加的安全层都会导致应用变慢。因此,在保证用户完成意图的前提下,你需要对添加的安全措施(会导致延迟)进行权衡。

5.1 应用访问

5.1.1 匿名访问

应用可能需要验证,也可能不需要验证。

有两个选项可用于识别设备:供应商的标识符(Identifier for Vendor,IDFV)和广告商的 标识符(Identifier for Advertiser,IDFA)。

IDFV 是设备上每个应用的持久唯一的标识符,用于向应用的供应商标识设备。应用包 ID 的一部分用于生成 IDFV,因此,即使应用来自同一家公司, IDFV 也可能不同。

IDFA 是可重置的标识符,在设备上的所有应用中是唯一的。正因为在众多应用中是唯一的,所以它才是真正唯一的 ID。但是,IDFA 可以被用户重置。此外,苹果公司对它的使用设置了限制,你必须保证在提交应用到 iTunes Connect 审核时使用它。此 ID 只应由广告投放系统使用。

5.1.2 认证访问

当需要识别用户时,你需要认证访问。这并不意味着认证必须在你的应用中完成。以下是一些可用的认证选项。

  • 应用密码

    • 它也被称为应用 PIN,无论是否存在登录至应用的一组凭据,应用 PIN 都是你想要添加到应用的本地凭据。实际上,它就是只存储在设备本地的密码。
  • 游戏中心

    • 此选项仅适用于游戏。用 GameKit 连接游戏中心,后者会负责使用凭据对用户进行验证。游戏中心可以访问用户资料、个人记录等,但仅共享唯一标识用户所需的内容(即用户 ID)。

5.2 网络安全

前面已经对网络进行了深入的讨论。这里将讨论在与远程设备通信中与安全有关的最佳实践,该远程设备可以是服务器,也可以是点对点设备。

5.2.1 使用HTTPS

假设你将 HTTP 作为底层消息传递协议(TCP 是传输层协议),那么你必须通过 TLS/SSL 使用它。这也就是说,你应该一直使用 HTTPS。但是,使用 HTTPS 有几个问题。如果这些潜在风险未得到解决,则 HTTPS 可能会受到影响。

1.CRIME攻击

不要使用 SSL/TLS 压缩。如果你现在在使用,请在继续之前立即关闭它。这会让你处于较大的风险当中。使用 TLS 压缩(gzip、deflate 或其他格式),任何请求都会受到 CRIME(Compression Ratio Info-leak Made Easy,压缩率使信息很容易泄露)攻击。要想缓解风险,可以关闭 TLS 压缩,并给每个响应发送反 CRIME cookie,较为简单的方式是发送一个唯 一的随机序列 cookie。

2.BREACH攻击

如果使用请求/响应正文压缩(Transfer-Encoding = gzip或deflate),你的通信会受到BREACH(Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext,通过自适应超文本压缩的浏览器侦听和渗透)攻击,这种攻击类型于 2012 年 9 月首次发现。当满足以下标准时,就会存在风险。

  • 应用使用 HTTP 压缩。
  • 响应反映了用户输入。
  • 响应反映了隐私。

没有单一的方法可以降低这种风险。The Breach Attack网站按有效性列出了以下方法。

  • 禁用 HTTP 压缩。这种方法增加了传输的数据量,可能不会作为实际的解决方案。
  • 从用户输入分离出隐私。将授权码放在远离请求正文的地方。
  • 对每个请求进行随机化加密。但是,由于每个请求的加密是随机的,因此,多个并行请求可能无法实现了。
  • 修饰隐私。不要以原始格式发送隐私。
  • 使用 CSRF 保护易受攻击的 HTML 页面。在移动原生应用上,除非使用移动 Web,否 则不需要 CSRF。
  • 隐藏长度。一个较好的方法是在 HTTP 响应中使用分块传输编码。
  • 对请求限速(这应该作为最后的方法)。

5.2.2 使用证书锁定

HTTPS 不是万灵药——采取 HTTPS 不会神奇地确保所有的通信都是安全的。HTTPS 的基础是对公钥的信任,该公钥用于加密初始消息(在 SSL 握手期间)。中间人(man-in-the- middle,MITM)攻击会捕获用于加密消息的密钥。

不让请求变成无效的唯一方法就是信任,该信任由网络库放置在接收到的证书之中。证书只是签名的公钥。因此,如果网络库信任签名者,那它也会信任主机提供的公钥。黑客提供的假的根证书成为了让所有安全措施崩溃的罪魁祸首。

这个问题的解决方案就是所谓的证书锁定。这种方案的工作原理是,通过只信任一个或几个能够作为应用根证书的证书,应用创建一个自定义的信任级别。这允许应用仅信任来自白名单的证书, 确保设备上永不安装那些允许网络监视的未知证书。

5.3 本地存储

与通过网络交换的数据类似,存储在设备上的数据是不能防止被篡改的,而且如果不小心处理的话,入侵者是可以读取或修改数据的。以下是需要注意的几个要点,以及为了保护本地存储空间需要遵循的最佳实践。

  • 本地存储不安全

    • 在越狱设备上非常容易访问本地存储。
  • 加密本地存储

    • 本地存储可以利用操作系统提供的数据保护能力进行加密。

5.4 数据共享

共享数据和处理传入数据时遵循的简单基本规则是:不要信任对方。

当接收数据时,总是进行验证。应用对数据的唯一假设应该是,它可能是无效且错误的。为了提高安全性,要求数据进行签名。

同样,因为不知道哪个应用会处理数据,所以永远不要发送敏感数据。如果你确实需要共享敏感数据,那么提供令牌,然后要求其他应用从你的应用(或服务器)请求数据。

5.5 安全和应用性能

额外添加的加密或安全措施会计入总内存的消耗之中,同时还会增加处理时间。你没有办法在所有维度上进行优化,只能做一些权衡。

有时,并非必须使用 2048 位的 RSA 密钥,1024 位的 DSA 密钥也许就已经足够了。其他时候,Rijndael 这样的对称加密算法就足以保护数据的安全了。

从钥匙串检索初始值可能会导致加载时间延长。你在使用时应该小心谨慎。

证书锁定有其自己的成本,有可能会减慢所有的网络操作。

创建和验证数据签名需要计算内容哈希,这意味着会产生额外的内容传递。根据内容的大小,这可能需要较多时间,更不用说计算和验证数字签名所需的额外时间了。

所有这些步骤会快速叠加起来。也许你有了世界上最保险和最安全的应用,但如果仅加载程序就需要 30 分钟,估计也没有人想使用它。对于这一点,即使 5 秒钟都可能对用户体验产生负面影响,甚者永远失去用户,尤其在其他应用可以满足同样需求的情况下。


博客首发于GitHub:高性能iOS应用开发 - iOS性能

相关文章:高性能iOS应用开发 - 核心优化