出行iOS用户端卡顿治理实践

11,723 阅读11分钟

一、前言

我们使用APP有时会遇到点击响应迟钝、页面跳转缓慢、滑动列表不流畅、卡死无响应,这些就是卡顿问题,它会影响用户体验,严重时会导致用户的流失,因此卡顿治理是非常重要的。

但是要将卡顿治理好并不容易。很多卡顿是随着时间各种机型、系统、运行环境的出现慢慢浮现的,并且卡顿总数量往往比较多,这就意味着不可能一次性解决,从卡顿预防、监控上报、上报处理都是长期的事情。那么该如何去做好卡顿治理呢?接下来我将对App的实践做个总结说明,有兴趣的同学可以在评论区一起探讨。

二、治理效果

我们使用的是Bugly进行卡顿的监控,设置的卡顿阈值是3000ms。经过多期的卡顿优化,iOS乘客端设备卡顿率从6.51%降到了0.21%。

版本号v1.2.10(优化前)v1.3.30(优化后)
设备卡顿率6.51%0.21%

截止目前最新版本v1.3.30的设备卡顿率是0.21%,见下图。

三、治理策略

卡顿治理是长期的事情,为了使治理工作有序且能看到效果,需要有策略得进行。

首先,把卡顿分为四个阶段,A阶段是处理大头且卡顿原因明显的;B阶段处理大头的疑难卡顿;C阶段处理小头的卡顿;D阶段是达到目标后的阶段,主要维持卡顿不突然大增。

其次,对于每个阶段,动态分期处理。分期是以一个发版窗口为一期,动态是每期围绕最近两个版本进行。另外针对比较疑难的卡顿,可以采取尝试性地解决,尽可能减少它的发生次数。

再其次,各期卡顿处理方案和效果形成文档,卡顿防劣化基于此文档进行组内分享,另外这样也方便跟踪各期处理的效果。

最后,在这个策略之下再进行具体卡顿问题的处理。

四、治理方式

卡顿治理能起到直接效果的方式就是解决上报的卡顿问题。把上报的卡顿分为两类,一类是开发人员写的方法耗时导致的;另一类是比较隐晦的,通常涉及到系统方法内部的执行。因为第一类卡顿在实际开发中大家都有意识避免,也容易解决,所以接下来重点讲述第二类卡顿的解决。

  1. 复现卡顿的思路

复现堆栈是解决问题的关键,复现了堆栈就确定了执行链路,接下来就好定位问题了。

流程图 (2).jpg

结合实际的经验,复现的具体操作如下:

先从Bugly上报堆栈中找到关键方法或函数,在Xcode中添加符号断点,运行APP到对应的使用场景中,然后在断点停住时可以使用lldb调试指令bt打印出调用栈跟Bugly中的比对。 如果是一样的,那么执行的堆栈就复现了。接下来从Debug Navgator栏中可以很方便的查看到这期间的方法执行情况,结合代码查看这个调用链路中存在的耗时操作,定位问题进行处理。

在实际的堆栈对比时,如果发现Bugly堆栈中有的方法在Xcode堆栈中没有,但该方法前后都能对上,不用慌!这可能是Xcode在打包时将一些方法优化成内联函数了,这种情况我们运行在release模式下查看就可以了,接下来通过两个示例来说明。

  1. 示例

  1. 系统库函数带下划线

根据上述思路,首先分析多个上报记录,查看到堆栈都是下面这样:

0 libsystem_kernel.dylib _mach_msg_trap + 8
1 libsystem_kernel.dylib _mach_msg + 76
2 libdispatch.dylib __dispatch_mach_send_and_wait_for_reply + 540
3 libdispatch.dylib _dispatch_mach_send_with_result_and_wait_for_reply + 60
4 libxpc.dylib _xpc_connection_send_message_with_reply_sync + 240
5 Foundation ___NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ + 16
6 Foundation -[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:] + 2540
7 Foundation -[NSXPCConnection _sendSelector:withProxy:arg1:arg2:arg3:] + 152
8 Foundation __NSXPCDistantObjectSimpleMessageSend3 + 84
9 CoreLocation _CLCopyTechnologiesInUse + 32832
10 CoreLocation _CLCopyTechnologiesInUse + 26408
11 CoreLocation _CLClientStopVehicleHeadingUpdates + 94460
12 XLUser  + [ HLLMKLocationRecorder locationAuthorised] ( HLLMKLocationRecorder .m: 299 )
13 ... // 以下略

接下来找到关键方法、函数。从上面的堆栈中,我们可以看到是主线程运行到[LocationRecorder locationAuthorised]方法中第269行发生,该行代码是[CLLocationManager authorizationStatus],是获取系统用户当前定位权限的方法。对堆栈中第0-12行中的方法做一番了解,初步发现xpc_connection_send_message_with_reply_sync函数可能会阻塞当前线程,点击查看官方说明

接下来添加符号断点xpc_connection_send_message_with_reply_sync, 注意如果是系统库中的带下划线的函数,我们添加符号断点的时候一般需要少一个下划线_,又比如上述的__dispatch_mach_send_and_wait_for_reply函数,我们添加符号断点时也要少一个下划线,即_dispatch_mach_send_and_wait_for_reply

然后在断点停住时,在lldb调试台敲bt后回车就可以打印出当前的调用栈。

20221213-181927.jpeg

然后经过比对是一致的,这样确认了场景。

然后定位问题。在上述卡顿堆栈调用链路中,我发现自己项目的方法调用链路中不存在耗时多的操作,那么接下来可以分析系统函数是否存在耗时多的操作,一般是涉及进程间通信的,接着我们通过xpc_connection_send_message_with_reply_sync方法查网上资料,发现这个方法涉及到了进程间通信。

最后出解决方案。我们通过新增一个单例类,单例设置为CLLocationManager代理并根据代理方法更新单例的定位权限属性,项目中全局获取定位权限的方式改为访问单例类中的属性,上线后就解决了该问题。

  1. 系统库中方法调用,不带下划线

下面是在iPhone6、6plus之类的较老机型上发生的卡顿,是在push页面后键盘弹起时发生。

0 libsystem_kernel.dylib ___psynch_cvwait + 8
1 libsystem_pthread.dylib __pthread_cond_wait$VARIANT$mp + 688
2 Foundation -[NSCondition waitUntilDate:] + 128
3 Foundation -[NSConditionLock lockWhenCondition:beforeDate:] + 100
4 UIKitCore -[UIKeyboardTaskQueue lockWhenReadyForMainThread] + 420
5 UIKitCore -[UIKeyboardTaskQueue waitUntilAllTasksAreFinished] + 84
6 UIKitCore -[UIKeyboardImpl generateAutofillCandidate] + 136
7 UIKitCore -[UIKeyboardImpl setDelegate:force:] + 4884
8 UIKitCore -[UIPeripheralHost(UIKitInternal) _reloadInputViewsForResponder:] + 1544
9 UIKitCore -[UIResponder(UIResponderInputViewAdditions) reloadInputViews] + 80
10 UIKitCore -[UIResponder becomeFirstResponder] + 804
11 UIKitCore -[UIView(Hierarchy) becomeFirstResponder] + 156
12 UIKitCore -[UITextField becomeFirstResponder] + 244
13 ... // 以下略

从堆栈中看到,系统库中的方法是一些OC方法。对于这个情况我们添加符号断点时可以直接使用方法的名字,比如上述的waitUntilDate:lockWhenCondition:beforeDate:。断点停住后,在lldb调试台bt就可以打印出当前的调用栈,跟Bugly卡顿堆栈是对得上的。

通过方法调用链路分析认为是这个锁产生的卡顿,在生成键盘上候选词时会调用到。在我们设置输入框的属性autocorrectionTypeUITextAutocorrectionTypeNo后,就不会出现了,从而这个卡顿就解决了。

if (低版本机型) {
    textField.autocorrectionType = UITextAutocorrectionTypeNo;
}

五、常见卡顿

  1. 多个小耗时任务累积成了卡顿

在一次runloop循环中调用方法多、执行链路较长的情况下,如果该链路中存在多个小耗时的操作,就大概率会发生卡顿。这种情况在测试时期不会暴露,但线上运行的环境更复杂,APP可能处于后台、低电量等设备CPU资源紧张的情况。这种卡顿的特点是,在卡顿列表中,有该执行链路中的多个方法的卡顿问题。

一个例子,在某个网络请求回调到主线程后,Json解析成Model,之后有创建和更新UI、多个地方调用layoutIfNeed立即布局视图、磁盘IO存取数据等多个小段耗时操作。对于这样的卡顿,解决办法是,首先排查出链路中涉及到耗时操作的地方,进行优化;然后将某些方法放到异步主队列中执行,这样链路就缩短了。

  1. Jetsam 机制下收到内存警告

iOS 系统在内存紧张时,会压缩一些内存内容,并在需要时解压,但副作用是会造成较高的 CPU 占用甚至卡顿,手机耗电量也会随之增加。为了解决上面的问题,苹果设计了 Jetsam 机制。 其工作方式是当内存不足时,系统会通知前台应用去释放内存(通过 applicationDidReceiveMemoryWarning 方法和 UIApplicationDidReceiveMemoryWarningNotification 通知),如果内存压力依然存在,将会终止一些后台APP, 最终内存还不够的话,就会终止当前APP(FOOM),并且上报日志。

我们在卡顿上报中可以看到有一些卡顿是因为收到内存警告时发生的,这就需要我们根据自己页面的情况在收到内存警告时适当的清理内存,并且最好是在收到内存警告通知的处理放到异步主队列中,避免过多的内存警告处理都集中在一次runloop中。

  1. 主线程执行了耗时任务

比如在聊天页面选择高清大图后,先执行压缩后保存到本地一份再发送,这个可能会发生卡顿。我们可以将此操作放到异步子队列中处理。

  1. 调用涉及进程间通信的系统API

如果方法执行中调用了涉及到进程间同步通信的API,是可能发生卡顿的,特点是堆栈中会有_xpc_connection_send_message_with_reply_sync这个函数,发现的几种情况是:

  • NSUserDefault调用写操作
  • CLLocationManager当前定位权限状态的获取
  • 给通用剪贴板UIPasteboard设置值、获取值。
  • UIApplication通过openURL打开其他APP
  • CNCopyCurrentNetworkInfo获取WiFi信息
  • 给系统钥匙串keychain中设置值。

解决办法是尽量不频繁调用,或者寻找其他的实现方式,比如NSUserDefault可以换为使用MMKV;跳转到其它APP进行分享或者第三方支付时,可以在跳转前将这个操作放到异步主队列中进行,也能避免极端情况下多次调用出现卡顿。

  1. 处于后台时递归调用自身方法

有个具体的例子是:有一个2S的Lotties动画,在动画结束后3秒之后再次执行该动画,如果我们的实现方式是如下,那么处于后台时就可能发生卡顿。

// 这个是有问题的写法,在后台时也会一直递归调用自身
private func beginCallCarAnimation() {
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        // 让 callCarBtnAnimation开始一个2s的lotties动画
        self.callCarBtnAnimation.play()
    }
    // 开启延时等待5s后调用自身方法再次开启动画
    DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
        self?.beginCallCarAnimation()
    }
}

解决办法为, 将这个5S的延时放到动画结束后,因为动画模式设置为了pauseAndRestore, 在进入后台时会保存状态,进入前台时恢复动画,执行完动画才调用closure

private func beginCallCarAnimation() {
    // 注意📢:如果进入后台动画会停止,下次回来扫光结束后才开始等3秒
    self.callCarBtnAnimation.play { finished in         guard finished else { return }
        // 动画结束后,3秒后再次执行动画
        // 因为动画设置的后台模式为pauseAndRestore,这样就能保证在后台时不会递归执行
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in             self?.beginCallCarAnimation()
        }
    }
}
  1. 初始化比较大的Lotties动画

在Lotties动画文件较大时,在一些情况下也是有必要将动画文件的解析放到异步子线程中,完成后再回调主线程初始化视图。如下是一个例子:

// 串行队列去执行动画文件加载和解析,串行队列也写在全局引用的地方
DispatchQueue(label: "com.xxx.caton").async {
    // 在子线程加载解析动画文件
    let animation = Animation.named("lotties文件名")
    let provider = BundleImageProvider(bundle: Bundle.main, searchPath: nil)
    DispatchQueue.main.async { 
        // 回调主线程初始化动画视图
        let animationView = AnimationView.init(animation: animation, imageProvider: provider)
        animationView.loopMode = .playOnce
        animationView.backgroundBehavior = .pauseAndRestore
        
        self.addSubview(animationView)
        animationView.snp.makeConstraints { ... }
    }
}
  1. 监听系统通知后执行太拥挤

APP中监听进入前台、后台、内存警告通知的地方很多,导致通知一来,CPU就上升,我们可以适当得将一些处理放到一个串行子队列中完成,如果是耗时的操作,在进入后台时,可以使用开启后台任务的API来完成。

  1. 打印函数

项目中有大量的调用NSLogprintdebugPrint,在线上是会有卡顿问题上报的。实际上,抛开卡顿,release环境下也是不需要控制台打印的,我们应该屏蔽。

对于OC中可以使用宏定义NSLog来解决。对于Swift来说,print和debugPrint都会在release下打印,应该封装使用自己的打印方法,并且宏定义在release下不生效。

六、总结

对于APP,卡顿治理是一件长期的事情,需要定好一个策略分期有序进行,这样下来也能看到每期的效果和价值。对于个人,在解决卡顿问题时,会遇到一些疑难卡顿,可能需要花费很多时间查阅API文档、网上资料甚至是查源码才能解决,但这个过程也让我们获得成长。

参考:

iOS 保持界面流畅的技巧

iOS-runloop-ibrime

iOS内存abort(Jetsam) 原理探究

进程间同步通信官方API文档

从底层分析一下存在跨进程通信问题的 NSUserDefaults