支付宝客户端架构解析:iOS 客户端启动性能优化初探

avatar
mPaaS 官方专栏号 @蚂蚁集团

前言

《支付宝客户端架构解析》系列将从支付宝客户端的架构设计方案入手,细分拆解客户端在“容器化框架设计”、“网络优化”、“性能启动优化”、“自动化日志收集”、“RPC 组件设计”、“移动应用监控、诊断、定位”等具体实现,带领大家进一步了解支付宝在客户端架构上的迭代与优化历程。

启动应用是用户使用任何一款应用最必不可少的操作,从点击 App 图标到首页展示,整个启动过程的性能,严重影响着用户的体验。支付宝客户端作为一个超级 App,启动的性能当然是我们关注的重要指标之一,下文将从三方面来介绍支付宝在 iOS 端启动性能优化的具体设计思路。

启动时间优化

分析启动时间之前,先看一下 App 启动的两种方式。

  • 热启动:启动应用时,应用的进程和数据已经存在于系统内存中,系统只是将应用的状态从后台切换到前台。
  • 冷启动:启动应用时,应用不存在于系统内核的 buffer cache 中,比如应用首次启动或者重启设备之后的启动。

相比而言,冷启动比较重要,通常我们分析启动时间,都是指的冷启动。

要想分析启动时间,还需要了解启动的过程,iOS应用的启动大概分以下几个阶段:

phase

  • 针对 pre-main() :

整个 pre-main() 阶段的耗时可以通过添加环境变量 DYLD_PRINT_STATISTICS=1 来获取,如下图所示。

env
premain

这些阶段都是系统进行管控,具体在这些阶段内如何进行优化,可以参照 WWDC2013 Session(文章尾部附地址)中提供的方案进行,这里不详细说明。

  • 针对 post-main() :

这部分主要是启动的框架初始化,首页数据获取,首页渲染等业务逻辑,这一部分我们只把必要的初始化操作保留,尽量把逻辑后置或者放在 background 线程执行。 这里的优化方案需要结合实际的业务场景和应用的架构来进行分析,采取对应的策略。

Background Fetch

除了这些通用的优化方案之外,我们也探索了一些创新的方式。 在介绍 Background Fetch 之前,我们先看这样一个案例:

操作:

首先,启动支付宝,按 Home 键切入后台。然后,重新启动手机,进入桌面。放置 10-30 秒。

现象:

此时,点击桌面的支付宝(以及淘宝等几乎所有 App)都与平时的冷启动一样,整个启动过程至少 1 秒以上。

虽然对冷启动的时间已经进行了优化,但是能不能每次启动都做到“秒起”呢?(秒起定义为:启动时显示 LaunchScreen 约 500ms 后马上进入首页) 我们发现系统提供了这样一个 Background Fetch 特性,决定在这个上面做一些尝试。

Background Fetch 简介

Background Fetch 类似一种智能的轮询机制,系统会根据用户的使用习惯进行适应,在用户真正启动应用之前,触发后台更新,来获取数据并且更新页面。

摘自苹果官方文档

Background Fetch lets your app run periodically in the background so that it can update its content. Apps that update their content frequently, such as news apps or social media apps, can use this feature to ensure that their content is always up to date. Downloading data in the background before it is needed minimizes the lag time in displaying that data when the user launches the app.

Background Fetch 具有下面几个特性:

  • 系统调度
  • 适应设备上各应用的实际使用模式
  • 对电量和数据的使用敏感
  • 与应用实际的运行状态无关

举个例子,比如用户习惯在下午1点使用某新闻类app,系统就会学习并且适应这个习惯,在用户使用之前,后台进行调度来启动应用并执行数据更新。下图比较清晰的说明了系统是如何学习用户的使用模式的。

pattern

针对这样的策略,大家可能会有疑虑,这种频繁的后台启动会不会增加耗电量? 当然不会,系统会根据设备的电量和数据使用情况来调用频率控制,避免在非活跃时间频繁的获取数据。而且,进程启动后后存活的时间很短,多数情况下会立即 suspend,对电量影响很少(相比压后台后很多 app 还要存活接近3分钟的情况很少)。

Background Fetch 使用

按照官方资料,Background Fetch 的用法很简单,整体流程如下图所示。

fetch

  1. Info.plist 中 UIBackgroundModes 节点配置 fetch 数值
  2. didFinishLaunching 时配置
[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];

这一步配置的minimum interval,单位是秒,只是给系统的建议,系统并不会按照给定的时间间隔按规律的唤醒进程。

  1. 实现下面的回调,并调用 completionHandler
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler

由于 Background Fetch 机制是为了让App在后台拉取准备数据,但支付宝只是为了实现”秒起“。调用 completionHandler 后系统将把 App 进程挂起。且系统必须在30秒内调用 completionHandler,否则进程将被杀死。此外根据文档,系统会根据后台调用 completionHandler 的时间来决定后台唤起App的频率。因此,认为可以“伪造“1秒的延迟时间,即1秒后调用 completionHandler。类似下面的代码:

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        completionHandler(UIBackgroundFetchResultNewData);
    });
}

Background Fetch 实践

苹果推出这种特性的动机在于,后台触发获取数据并更新页面,确保用户使用时看到的永远是最新的内容。然而,支付宝只是为了实现“秒起”,所以看似简单的实现,却隐藏着巨大的风险。 在测试过程中就发现了这些问题:

  1. 进程快速挂起导致 Sync 成功率下降

灰度期间,开发同学发现同步服务 Sync 成功率下降很多,找来找去发现原因:由于进程唤醒后,网络长连接线程被激活并马上建立长连接,而1秒后调用completionHandler,进程又被挂起。服务器端的sync消息则发送超时。

  1. 进程频繁挂起、唤醒导致网络建连次数增加

系统预测用户使用 App 的时间,并在用户实现 App 前唤醒 App,给予 App 后台准备数据的机会。再加上预测的准确性问题,这样进程被唤醒的次数远大于用户使用的次数。进程唤醒后,网络长连接会立即建立。因此导致网络建连次数大增,甚至翻倍。

  1. 由于进程挂起,导致定时器、延迟调用等时间“与预想的时间不同”

例如,一个间隔间隔时间为 60 秒的定时器,由于进程挂起时间超过 60 秒,则下次进程唤醒时会立刻触发到时。(延迟调用 dispatch_after 等类似)。对于进程自身来说,可能定时器有点不正常,需要排查所有的定时器逻辑,是否会因为挂起导致“业务层面的异常”。

  1. 获取时间戳

由于进程挂起,导致前后获取的时间戳间隔很大。

为解决以上遇到的、以及预测到的问题,经过讨论,决定在 Background Fetch 后台唤醒的时候,不建立长连接。

  • 延后 10 秒调用 completionHandler。

后台唤醒存在两种情况:进程从无到有,进程从挂起到恢复。前者需要有充足的时间完成 App 的后台冷启动过程,因此定义了 10 秒的时间。

  • 后台 Background Fetch 的时间内不建立长连接。

”后台 Background Fetch 的时间“定义为:performFetchWithCompletionHandler 被回调并一直到 completionHandler 调用的时间内。

我们维护了一个全局变量 underBackgroundFetch 用于标识这段时间。处于这段时间的所有网络请求都被阻塞,并增加重试判断。App 进入前台(willEnterForeground)时主动重新建立长连接。在一些其他后台需要建立长连接的情况下(例如 WatchApp 的连接、PUSH 快速回复),也主动修改标记,并通知网络层建立长连接。underBackgroundFetch 的修改是在主线程执行,但网络长连接的建立是在子线程,且进程被唤醒后早于 underBackgroundFetch 的修改。目前首次回调 performFetchWithCompletionHandler 时,仍然会存在这个“间隙”导致网络长连接建立,但后续的 Background Fetch 时状态是准确的。(这个间隙如何更加准确,必要性及方案在讨论中,目前还没有带来无法解决的问题)

  • 后台不建连导致的网络请求阻塞异常,避免产生 Toast 等弹窗。

为获取所有在后台 Background Fetch 时间内被拦截的 RPC,拦截操作增加了埋点。灰度期间收集出所有的 RPC,并逐个找到 Owner,让大家评估影响、以及避免产生 Toast 等弹窗提示。确保所有 RPC 异常的最外层异常捕获处,不因 RPC 拦截的异常而 Toast。

  • 超时判断

由于进程挂起导致的定时器、延迟调用的超时判断,需要修改业务逻辑。不能过度依赖假想的时序,进程运行在操作系统上,不能受进程的挂起与恢复影响。

虽然使用这么多的方案来保证应用的稳定性,但是实际上线也避免不了一些奇怪的问题:

  1. completionHandler 调用两次

灰度期间发现少量用户存在 completionHandler 调用两次导致闪退。捞取用户日志发现 performFetchWithCompletionHandler 在1秒内连续被系统回调了两次。而 completionHandler 被存储为 AppDelegate 的成员变量,在10秒超时到期后,同一个 completionHandler 被调用了两次。

为避免此问题,可以避免采用成员变量存储 completionHandler ,而采用 dispatch_after 来直接让 block 捕获 completionHandler,但这样又会带来另一个 libdispatch 中 block 为空的极小概率的闪退。

因此采用成员变量存储 completionHandler,而在 performFetchWithCompletionHandler 的首行判断存储的 completionHandler 与传入的 completionHandler 是否相同。大致代码如下:

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    if(_backgroundFetchCompletionHandler && _backgroundFetchCompletionHandler != completionHandler){
        // 避免performFetch被快速重复调用,如果completionHandler不同,则先完成上一个completionHandler;如果相同,则避免调用两次。
        [self callBackgroundFetchCompletionHandler]; // 内部调用completionHandler
    }
    _backgroundFetchCompletionHandler = completionHandler; // 复制给成员变量
    //...
  1. iOS7 闪退

这个闪退 StackOverflow 上有人遇到,但点赞最多的答案实际上也没解决问题。

这个闪退仅在 iOS7 上产生,经过各方资料认为是 iOS7 系统的 bug。那么在 iOS7 设备上则不再启用 BackgroundFetch。

if ios 7 : 
[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalNever];
else ...

Background Fetch 机制让 iOS App 也能做到“热启动”,但带来的进程挂起、唤醒次数大量增加,给已经稳定运行很久的代码带来一种”不稳定“的运行方式,必须要认真考虑每一个细节。

图片预加载

[UIImage imageNamed:@"xxx"] 是 iOS 中加载图片的 API,它的使用频率是比较高的,那么它的性能如何呢。我们在分析启动性能的过程中,发现这个方法的耗时很多,iPhone5S 下每个耗时都在 20ms 到 50ms 之间,首页加载过程中有10多张这种方式加载的图片。针对整个现象,在支付宝中,我们使用了一种图片预加载的方式来进行优化。

设计思想

在看 [UIImage imageNamed:] 文档时发现一句话

In iOS 9 and later, this method is thread safe.

看到它之后立刻想到,能否在进程启动早期通过子线程预先加载首页图片。为什么在早期呢?通过 Instruments 分析可看到在支付宝启动早期,CPU 占用是不那么满的,为了让启动过程中充分利用 CPU,就尽量在早期启动子线程。

首先通过 hook 方式,获取首页的所有 imageNamed 加载的图片,然后,大致代码如下:

int main(){
    @autoreleasepool{
        //if >= iOS9
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSArray<NSString*> *images = @[
                                           // 10.0
                                           @"Launcher.bundle/TabBar_BG",
                                           @"Launcher.bundle/TabBar_HomeBar",
                                           //.... 省略10多个图片
                                           ];
            for (NSString *name in images) {
                [UIImage imageNamed:name];
            }
        }

        // AppDelegate....
    }
}

问题与解决

在优化之后,也伴随而来一些不稳定的问题:

  • App 启动会有小概率的 Crash。

根据分析,我们决定把这段代码移到 AppDelegate 的 didFinishLaunching 中,并且增加开关。

  • iPhone7 不需要预加载

在 iPhone7 设备出来后,我们发现 iPhone7 的启动性能反而不如 iPhone6S。分析后发现,在性能更好的 iPhone7 上,由于启动很快,导致子线程的 imageNamed 与 主线程的 imageNamed 相互穿插调用,而 imageNamed 内部的线程安全锁的粒度很小,导致锁的消耗过大。如下图:

imagenamed

因此,在性能更好的 iPhone7 上不再启用预加载。

总结

通过 Background Fetch 和图片预加载这两种方式对启动性能进行优化,给我们提供了另外一种思路,对于优化不要仅限制在条框内,需要适当的创新。但是,对于这种有点“创新”的代码,一定要有“开关”,增强风险意识。当然,性能优化不是一蹴而就的,它是一个持续的课题,值得我们时刻来关注。

由于篇幅限制,很多技术要点我们无法一一展开。而相应的技术内核,我们同样应用在了 mPaaS 并对外输出,欢迎大家上手体验:

tech.antfin.com/docs/2/4954…

关于 iOS 端启动性能优化的设计思路和具体实践,同样期待你们的反馈,欢迎一起探讨交流。

附注:WWDC2013 Session developer.apple.com/videos/play…

往期阅读

《开篇 | 模块化与解耦式开发在蚂蚁金服 mPaaS 深度实践探讨》

《支付宝移动端动态化方案实践》

《支付宝客户端架构解析:iOS 容器化框架初探》

《支付宝客户端架构解析:Android 容器化框架初探》

《支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」》

关注我们公众号,获得第一手 mPaaS 技术实践干货

QRCode