《支付宝客户端架构解析》系列将从支付宝客户端的架构设计方案入手,细分拆解客户端在“容器化框架设计”、“网络优化”、“性能启动优化”、“自动化日志收集”、“RPC 组件设计”、“移动应用监控、诊断、定位”等具体实现,带领你们进一步了解支付宝在客户端架构上的迭代与优化历程。ios
启动应用是用户使用任何一款应用最必不可少的操做,从点击 App 图标到首页展现,整个启动过程的性能,严重影响着用户的体验。支付宝客户端做为一个超级 App,启动的性能固然是咱们关注的重要指标之一,下文将从三方面来介绍支付宝在 iOS 端启动性能优化的具体设计思路。安全
分析启动时间以前,先看一下 App 启动的两种方式。性能优化
相比而言,冷启动比较重要,一般咱们分析启动时间,都是指的冷启动。bash
要想分析启动时间,还须要了解启动的过程,iOS应用的启动大概分如下几个阶段:服务器
pre-main()
:整个 pre-main()
阶段的耗时能够经过添加环境变量 DYLD_PRINT_STATISTICS=1
来获取,以下图所示。网络
这些阶段都是系统进行管控,具体在这些阶段内如何进行优化,能够参照 WWDC2013 Session(文章尾部附地址)中提供的方案进行,这里不详细说明。架构
post-main()
:这部分主要是启动的框架初始化,首页数据获取,首页渲染等业务逻辑,这一部分咱们只把必要的初始化操做保留,尽可能把逻辑后置或者放在 background 线程执行。 这里的优化方案须要结合实际的业务场景和应用的架构来进行分析,采起对应的策略。app
除了这些通用的优化方案以外,咱们也探索了一些创新的方式。 在介绍 Background Fetch 以前,咱们先看这样一个案例:框架
操做:async
首先,启动支付宝,按 Home 键切入后台。而后,从新启动手机,进入桌面。放置 10-30 秒。
现象:
此时,点击桌面的支付宝(以及淘宝等几乎全部 App)都与平时的冷启动同样,整个启动过程至少 1 秒以上。
虽然对冷启动的时间已经进行了优化,可是能不能每次启动都作到“秒起”呢?(秒起定义为:启动时显示 LaunchScreen 约 500ms 后立刻进入首页) 咱们发现系统提供了这样一个 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,系统就会学习而且适应这个习惯,在用户使用以前,后台进行调度来启动应用并执行数据更新。下图比较清晰的说明了系统是如何学习用户的使用模式的。
针对这样的策略,你们可能会有疑虑,这种频繁的后台启动会不会增长耗电量? 固然不会,系统会根据设备的电量和数据使用状况来调用频率控制,避免在非活跃时间频繁的获取数据。并且,进程启动后后存活的时间很短,多数状况下会当即 suspend,对电量影响不多(相比压后台后不少 app 还要存活接近3分钟的状况不多)。
按照官方资料,Background Fetch 的用法很简单,总体流程以下图所示。
[[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
复制代码
这一步配置的minimum interval,单位是秒,只是给系统的建议,系统并不会按照给定的时间间隔按规律的唤醒进程。
- (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);
});
}
复制代码
苹果推出这种特性的动机在于,后台触发获取数据并更新页面,确保用户使用时看到的永远是最新的内容。然而,支付宝只是为了实现“秒起”,因此看似简单的实现,却隐藏着巨大的风险。 在测试过程当中就发现了这些问题:
灰度期间,开发同窗发现同步服务 Sync 成功率降低不少,找来找去发现缘由:因为进程唤醒后,网络长链接线程被激活并立刻创建长链接,而1秒后调用completionHandler,进程又被挂起。服务器端的sync消息则发送超时。
系统预测用户使用 App 的时间,并在用户实现 App 前唤醒 App,给予 App 后台准备数据的机会。再加上预测的准确性问题,这样进程被唤醒的次数远大于用户使用的次数。进程唤醒后,网络长链接会当即创建。所以致使网络建连次数大增,甚至翻倍。
例如,一个间隔间隔时间为 60 秒的定时器,因为进程挂起时间超过 60 秒,则下次进程唤醒时会马上触发到时。(延迟调用 dispatch_after 等相似)。对于进程自身来讲,可能定时器有点不正常,须要排查全部的定时器逻辑,是否会由于挂起致使“业务层面的异常”。
因为进程挂起,致使先后获取的时间戳间隔很大。
为解决以上遇到的、以及预测到的问题,通过讨论,决定在 Background Fetch 后台唤醒的时候,不创建长链接。
后台唤醒存在两种状况:进程从无到有,进程从挂起到恢复。前者须要有充足的时间完成 App 的后台冷启动过程,所以定义了 10 秒的时间。
”后台 Background Fetch 的时间“定义为:performFetchWithCompletionHandler 被回调并一直到 completionHandler 调用的时间内。
咱们维护了一个全局变量 underBackgroundFetch 用于标识这段时间。处于这段时间的全部网络请求都被阻塞,并增长重试判断。App 进入前台(willEnterForeground)时主动从新创建长链接。在一些其余后台须要创建长链接的状况下(例如 WatchApp 的链接、PUSH 快速回复),也主动修改标记,并通知网络层创建长链接。underBackgroundFetch 的修改是在主线程执行,但网络长链接的创建是在子线程,且进程被唤醒后早于 underBackgroundFetch 的修改。目前首次回调 performFetchWithCompletionHandler 时,仍然会存在这个“间隙”致使网络长链接创建,但后续的 Background Fetch 时状态是准确的。(这个间隙如何更加准确,必要性及方案在讨论中,目前尚未带来没法解决的问题)
为获取全部在后台 Background Fetch 时间内被拦截的 RPC,拦截操做增长了埋点。灰度期间收集出全部的 RPC,并逐个找到 Owner,让你们评估影响、以及避免产生 Toast 等弹窗提示。确保全部 RPC 异常的最外层异常捕获处,不因 RPC 拦截的异常而 Toast。
因为进程挂起致使的定时器、延迟调用的超时判断,须要修改业务逻辑。不能过分依赖假想的时序,进程运行在操做系统上,不能受进程的挂起与恢复影响。
虽然使用这么多的方案来保证应用的稳定性,可是实际上线也避免不了一些奇怪的问题:
灰度期间发现少许用户存在 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; // 复制给成员变量
//...
复制代码
这个闪退 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....
}
}
复制代码
在优化以后,也伴随而来一些不稳定的问题:
根据分析,咱们决定把这段代码移到 AppDelegate 的 didFinishLaunching 中,而且增长开关。
在 iPhone7 设备出来后,咱们发现 iPhone7 的启动性能反而不如 iPhone6S。分析后发现,在性能更好的 iPhone7 上,因为启动很快,致使子线程的 imageNamed 与 主线程的 imageNamed 相互穿插调用,而 imageNamed 内部的线程安全锁的粒度很小,致使锁的消耗过大。以下图:
所以,在性能更好的 iPhone7 上再也不启用预加载。
经过 Background Fetch 和图片预加载这两种方式对启动性能进行优化,给咱们提供了另一种思路,对于优化不要仅限制在条框内,须要适当的创新。可是,对于这种有点“创新”的代码,必定要有“开关”,加强风险意识。固然,性能优化不是一蹴而就的,它是一个持续的课题,值得咱们时刻来关注。
因为篇幅限制,不少技术要点咱们没法一一展开。而相应的技术内核,咱们一样应用在了 mPaaS 并对外输出,欢迎你们上手体验:
关于 iOS 端启动性能优化的设计思路和具体实践,一样期待大家的反馈,欢迎一块儿探讨交流。
附注:WWDC2013 Session developer.apple.com/videos/play…
往期阅读