作者介绍:李运锋,美团点评iOS工程师,5年iOS开发经验,现在是美团点评点餐团队的一员。
前言
本文较长(5000字左右),建议阅读时间: 20min+
一个iOS App的稳定性,主要决定于整体的系统架构设计,同时也不可忽略编程的细节,正所谓“千里之堤,溃于蚁穴”,一旦考虑不周,看似无关紧要的代码片段可能会带来整体软件系统的崩溃。尤其因为苹果限制了热更新机制,App本身的稳定性及容错性就显的更加重要,之前可以通过发布热补丁的方式解决线上代码问题,现在就需要在提交之前对App开发周期内的各个指标进行实时监测,尽量让问题暴漏在开发阶段,然后及时修复,减少线上出问题的几率。针对一个App的开发周期,它的稳定性指标主要有以下几个环节构成,用一个脑图表示如下:
1 开发过程
开发过程中,主要是通过监控内存使用及泄露,CPU使用率,FPS,启动时间等指标,以及常见的UI的主线程监测,NSAssert断言等,最好能在Debug模式下,实时显示在界面上,针对出现的问题及早解决。
内存问题
内存问题主要包括两个部分,一个是iOS中常见循环引用导致的内存泄露 ,另外就是大量数据加载及使用导致的内存警告。
mmap
虽然苹果并没有明确每个App在运行期间可以使用的内存最大值,但是有开发者进行了实验和统计,一般在占用系统内存超过20%的时候会有内存警告,而超过50%的时候,就很容易Crash了,所以内存使用率还是尽量要少,对于数据量比较大的应用,可以采用分步加载数据的方式,或者采用mmap方式。mmap 是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。 操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。之前在开发输入法的时候 ,词库的加载也是使用mmap方式,可以有效降低App的内存占用率,具体使用可以参考链接第一篇文章。
循环引用
循环引用是iOS开发中经常遇到的问题,尤其对于新手来说是个头疼的问题。循环引用对App有潜在的危害,会使内存消耗过高,性能变差和Crash等,iOS常见的内存主要以下三种情况:
Delegate
代理协议是一个最典型的场景,需要你使用弱引用来避免循环引用。ARC时代,需要将代理声明为weak是一个即好又安全的做法:
@property (nonatomic, weak) id <MyCustomDelegate> delegate;
复制代码
NSTimer
NSTimer我们开发中会用到很多,比如下面一段代码
- (void)viewDidLoad {
[super viewDidLoad];
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self
selector:@selector(doSomeThing)
userInfo:nil
repeats:YES];
}
- (void)doSomeThing {
}
- (void)dealloc {
[self.timer invalidate];
self.timer = nil;
}
复制代码
这是典型的循环引用,因为timer会强引用self,而self又持有了timer,所有就造成了循环引用。那有人可能会说,我使用一个weak指针,比如
__weak typeof(self) weakSelf = self;
self.mytimer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(doSomeThing) userInfo:nil repeats:YES];
复制代码
但是其实并没有用,因为不管是weakSelf还是strongSelf,最终在NSTimer内部都会重新生成一个新的指针指向self,这是一个强引用的指针,结果就会导致循环引用。那怎么解决呢?主要有如下三种方式:
- 使用类方法
- 使用weakProxy
- 使用GCD timer
具体如何使用,我就不做具体的介绍,网上有很多可以参考。
Block
Block的循环引用,主要是发生在ViewController中持有了block,比如:
@property (nonatomic, copy) LFCallbackBlock callbackBlock;
复制代码
同时在对callbackBlock进行赋值的时候又调用了ViewController的方法,比如:
self.callbackBlock = ^{
[self doSomething];
}];
复制代码
就会发生循环引用,因为:ViewController->强引用了callback->强引用了ViewController,解决方法也很简单:
__weak __typeof(self) weakSelf = self;
self.callbackBlock = ^{
[weakSelf doSomething];
}];
复制代码
原因是使用MRC管理内存时,Block的内存管理需要区分是Global(全局)、Stack(栈)还是Heap(堆),而在使用了ARC之后,苹果自动会将所有原本应该放在栈中的Block全部放到堆中。全局的Block比较简单,凡是没有引用到Block作用域外面的参数的Block都会放到全局内存块中,在全局内存块的Block不用考虑内存管理问题。(放在全局内存块是为了在之后再次调用该Block时能快速反应,当然没有调用外部参数的Block根本不会出现内存管理问题)。
所以Block的内存管理出现问题的,绝大部分都是在堆内存中的Block出现了问题。默认情况下,Block初始化都是在栈上的,但可能随时被收回,通过将Block类型声明为copy类型,这样对Block赋值的时候,会进行copy操作,copy到堆上,如果里面有对self的引用,则会有一个强引用的指针指向self,就会发生循环引用,如果采用weakSelf,内部不会有强类型的指针,所以可以解决循环引用问题。
那是不是所有的block都会发生循环引用呢?其实不然,比如UIView的类方法Block动画,NSArray等的类的遍历方法,也都不会发生循环引用,因为当前控制器一般不会强引用一个类。
其他内存问题
1 NSNotification addObserver之后,记得在dealloc里面添加remove;
2 动画的repeat count无限大,而且也不主动停止动画,基本就等于无限循环了;
3 forwardingTargetForSelector返回了self。
内存解决思路:
1 通过Instruments来查看leaks
2 集成Facebook开源的FBRetainCycleDetector
3 集成MLeaksFinder
具体原理及使用,可以参考链接。
CPU使用率
CPU的使用也可以通过两种方式来查看,一种是在调试的时候Xcode会有展示,具体详细信息可以进入Instruments内查看,通过查看Instruments的time profile来定位并解决问题。另一种常见的方法是通过代码读取CPU使用率,然后显示在App的调试面板上,可以在Debug环境下显示信息,具体代码如下:
int result;
mib[0] = CTL_HW;
mib[1] = HW_CPU_FREQ;
length = sizeof(result);
if (sysctl(mib, 2, &result, &length, NULL, 0) < 0)
{
perror("getting cpu frequency");
}
printf("CPU Frequency = %u hz\n", result);
复制代码
FPS监控
目前主要使用CADisplayLink来监控FPS,CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。我们在应用中创建一个新的 CADisplayLink 对象,把它添加到一个runloop中,并给它提供一个 target 和selector 在屏幕刷新的时候调用,需要注意的是添加到runloop的common mode里面,代码如下:
- (void)setupDisplayLink {
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTicks:)];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)linkTicks:(CADisplayLink *)link
{
//执行次数
_scheduleTimes ++;
//当前时间戳
if(_timestamp == 0){
_timestamp = link.timestamp;
}
CFTimeInterval timePassed = link.timestamp - _timestamp;
if(timePassed >= 1.f)
//fps
CGFloat fps = _scheduleTimes/timePassed;
printf("fps:%.1f, timePassed:%f\n", fps, timePassed);
}
}
复制代码
启动时间
点评App里面本身就包含了很多复杂的业务,比如外卖、团购、到综和酒店等,同时还引入了很多第三方SDK比如微信、QQ、微博等,在App初始化的时候,很多SDK及业务也开始初始化,这就会拖慢应用的启动时间。 如下主要参考了今日头条iOS客户端启动速度优化
App的启动时间t(App总启动时间) = t1(main()之前的加载时间) + t2(main()之后的加载时间)。
t1 = 系统dylib(动态链接库)和自身App可执行文件的加载;
t2 = main方法执行之后到AppDelegate类中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示。
复制代码
针对t1的优化,优化主要有如下:
- 减少不必要的framework,因为动态链接比较耗时;
- 检查framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查;
- 合并或者删减一些OC类,这些我会在后续的静态检查中进行详解;
针对t2的时间优化,可以采用:
- 异步初始化部分操作,比如网络,数据读取;
- 采用延迟加载或者懒加载某些视图,图片等的初始化操作;
- 对与图片展示类的App,可以将解码的图片保存到本地,下次启动时直接加载解码后的图片;
- 对实现了+load()方法的类进行分析,尽量将load里的代码延后调用。
UI的主线程监测
我们都知道iOS的UI的操作一定是在主线程进行,该监测可以通过hook UIView的如下三个方法
-setNeedsLayout,
-setNeedsDisplay,
-setNeedsDisplayInRect
复制代码
确保它们都是在主线程执行。子线程操作UI可能会引起什么问题,苹果说得并不清楚,但是在实际开发中,我们经常会遇到整个App的动画丢失,很大原因就是UI操作不是在主线程导致。
2 静态分析过程
静态分析在这里,我主要介绍两方面,一个是正常的code review机制,另外一个就是代码静态检查工具
code review
组内的code review机制,可以参考团队之前的OpenDoc - 前端团队CodeReview制度,iOS客户端开发,会在此基础上进行一些常见手误及Crash情况的重点标记,比如:
1 我们开发中首先都是在测试环境开发,开发时可以将测试环境的url写死到代码中,但是在提交代码的时候一定要将他改为线上环境的url,这个就可以通过gitlab中的重点比较部分字符串,给提交者一个强力的提示;
2 其他常见Crash的重点检查,比如NSMutableString/NSMutableArray/NSMutableDictionary/NSMutableSet 等类下标越界判断保护,或者 append/insert/add nil对象的保护;
3 ARC下的release操作,UITableViewCell返回nil,以及前面介绍的常见的循环引用等。
code review机制,一方面是依赖写代码者的代码习惯及质量,另一名依赖审查者的经验和细心程度,即使让多人revew,也可能会漏过一些错误,所以我们又添加了代码的静态检查。
代码静态检查
代码静态分析(Static Program Analysis)是指在不运行程序的条件下,由代码静态分析工具自动对程序进行分析的方法. iOS常见的静态扫描工具有Clang Static Analyzer、OCLint、Infer,这些主要是用来检查可能存在的问题,还有Deploymate用来检查api的兼容性。
Clang Static Analyzer
Clang Static Analyzer是一款静态代码扫描工具,专门用于针对C,C++和Objective-C的程序进行分析。已经被Xcode集成,可以直接使用Xcode进行静态代码扫描分析,Clang默认的配置主要是空指针检测,类型转换检测,空判断检测,内存泄漏检测这种等问题。如果需要更多的配置,可以使用开源的Clang项目,然后集成到自己的CI上。
OCLint
OCLint是一个强大的静态代码分析工具,可以用来提高代码质量,查找潜在的bug,主要针对 C、C++和Objective-C的静态分析。功能非常强大,而且是出自国人之手。OCLint基于 Clang 输出的抽象语法树对代码进行静态分析,支持与现有的CI集成,部署之后基本不需要维护,简单方便。
OCLint可以发现这些问题
- 可能的bug - 空的 if / else / try / catch / finally 语句
- 未使用的代码 - 未使用的局部变量和参数
- 复杂的代码 - 高圈复杂度, NPath复杂, 高NCSS
- 冗余代码 - 多余的if语句和无用的括号
- 坏味道的代码 - 过长的方法和过长的参数列表
- 不好的使用 - 倒逻辑和入参重新赋值
对于OCLint的与原理和部署方法,这里不做细讲解,主要是每次提交代码后,可以在打包的过程中进行代码检查,及早发现有问题的代码。当然也可以在合并代码之前执行对应的检查,如果检查不通过,不能合并代码,这样检查的力度更大。
Infer
Infer facebook开源的静态分析工具,Infer可以分析 Objective-C, Java 或者 C 代码,报告潜在的问题。Infer效率高,规模大,几分钟能扫描数千行代码; C/OC中捕捉的bug类型主要有:
1:Resource leak
2:Memory leak
3:Null dereference
4:Premature nil termination argument
复制代码
只在 OC中捕捉的bug类型
1:Retain cycle
2:Parameter not null checked
3:Ivar not null checked
复制代码
结论
Clang Static Analyzer和Xcode集成度更高、更好用,支持命令行形式,并且能够用于持续集成。OCLint有更多的检查规则和定制,和很多工具集成,也同样可用于持续集成。Infer效率高,规模大,几分钟能扫描数千行代码;支持增量及非增量分析;分解分析,整合输出结果。infer能将代码分解,小范围分析后再将结果整合在一起,兼顾分析的深度和速度,所以根据自己的项目特点,选择合适的检查工具对代码进行检查,减少人力review成本,保证代码质量,最大限度的避免运行错误。
3 测试过程
前面介绍了很多指标的监测,代码静态检查,这些都是性能相关的,真正决定一个App功能稳定是否的是测试环节。测试是发布之前的最后一道卡,如果bug不能在测试中发现,那么最终就会触达用户,所以一个App的稳定性,很大程度决定它的测试过程。iOS App的测试包括以下几个层次:单元测试,UI测试,功能测试,异常测试。
单元测试
XCTest是苹果官方提供的单元测试框架,与Xcode集成在一起,由此苹果提供了很详细的文档XCTest。
Xcode单元测试包含在一个XCTestCase的子类中。依据约束,每一个 XCTestCase 子类封装一个特殊的有关联的集合,例如一个功能、用例或者一个程序流。同时还提供了XCTestExpectation来处理异步任务的测试,以及性能测试measureBlock(),还包括很多第三方测试框架比如:KiWi,Quick,Specta等,以及常用的mock框架OCMock。
单元测试的目的是将程序中所有的源代码,隔离成最小的可测试单元,以确保每个单元的正确性,如果每个单元都能保证正确,就能保证应用程序整体相当程度的正确性。但是在实际的操作过程中,很多公司都很难彻底执行单元测试,主要就是单元测试代码量甚至大于功能开发,比较难于维护。
对于测试用例覆盖度多少合适这个话题,也是仁者见仁智者见智,其实一个软件覆盖度在50%以上就可以称为一个健壮的软件了,要达到70,80这些已经是非常难了,不过我们常见的一些第三方开源框架的测试用例覆盖率还是非常高的,让人咋舌。例如,AFNNetWorking的覆盖率高达87%,SDWebImage的覆盖率高达77%。
UI测试
Xcode7中新增了UI Test测试,UI测试是模拟用户操作,进而从业务处层面测试,常用第三方库有KIF,appium。关于XCTest的UI测试,建议看看WWDC 2015的视频UI Testing in Xcode。 UI测试还有一个核心功能是UI Recording。选中一个UI测试用例,然后点击图中的小红点既可以开始UI Recoding。你会发现:随着点击模拟器,自动合成了测试代码。(通常自动合成代码后,还需要手动的去调整)
功能测试
功能测试跟上述的UT和UI测试有一些相通的地方,首先针对各个模块设计的功能,测试是否达到产品的目的,通常功能测试主要是测试及产品人员,然后还需要进行专项测试,比如我们公司的云测平台,会对整个App的性能,稳定性,UI等都进行整体评测,看是否达到标准,对于大规模的活动,还需要进行服务端的压力测试,确保整个功能无异常。测试通过后,可以进行estFlight测试,到最后正式发布。
功能测试还包括如下场景:系统兼容性测试,屏幕分辨率兼容性测试,覆盖安装测试,UI是否符合设计,消息推送等,以及前面开发过程中需要监控的内存、cpu、电量、网络流量、冷启动时间、热启动时间、存储、安装包的大小等测试。
异常测试
异常测试主要是针对一些不常规的操作
- 使用过程中的来电时及结束后,界面显示是否正常;
- 状态栏为两倍高度时,界面是否显示正常;
- 意外断电后,数据是否保存,数据是否有损害等;
- 设备充电时,不同电量时的App响应速度及操作流畅度等;
- 其他App的相互切换,前后台转换时,是否正常;
- 网络变化时的提示,弱网环境下的网络请求成功率等;
- 各种monkey的随机点击,多点触摸测试等是否正常;
- 更改系统时间,字体大小,语言等显示是否正常;
- 设备存储不够时,是否能正常操作;
- ...
异常测试有很多,App针对自身的特点,可以选择性的进行边界和异常测试,也是保证App稳定行的一个重要方面。
4 发布及监控
因为移动App的特点,即使我们通过了各种测试,产品最终发布后,还是会遇到很多问题,比如Crash,网络失败,数据损坏,账号异常等等。针对已经发布的App,主要有一下方式保证稳定性:
热修复
目前比较流行的热修复方案都是基于JSPatch、React Native、Weex、lua+wax。
JSPatch能做到通过js调用和改写OC方法。最根本的原因是 Objective-C 是动态语言,OC上所有方法的调用/类的生成都通过 objective-c Runtime 在运行时进行,我们可以通过类名和方法名反射得到相应的类和方法,也可以替换某个类的方法为新的实现,还可以新注册一个类,为类添加方法。JSPatch 的原理就是:JS传递字符串给OC,OC通过 Runtime 接口调用和替换OC方法。
React Native 是从 Web 前端开发框架 React 延伸出来的解决方案,主要解决的问题是 Web 页面在移动端性能低的问题,React Native 让开发者可以像开发 Web 页面那样用 React 的方式开发功能,同时框架会通过 JavaScript 与 Objective-C 的通信让界面使用原生组件渲染,让开发出来的功能拥有原生App的性能和体验。
Weex阿里开源的,基于Vue+Native的开发模式,跟RN的主要区别就在React和Vue的区别,同时在RN的基础上进行了部分性能优化,总体开发思路跟RN是比较像的。
但是在今年上半年,苹果以安全为理由,开始拒绝有热修复功能的应用,但其实苹果拒的不是热更新,拒的是从网络下载代码并修改应用行为,苹果禁止的是“基于反射的热更新“,而不是 “基于沙盒接口的热更新”。而大部分框架(如 React Native、weex)和游戏引擎(比如 Unity、Cocos2d-x等)都属于后者,所以不在被警告范围内。而JSPatch因为在国内大部分应用来做热更新修复bug的行为,所以才回被苹果禁止。
降级
用户使用App一段时间后,可能会遇到这样的情况:每次打开App时闪退,或者正常操作到某个界面时闪退,无法正常使用App。这样的用户体验十分糟糕,如果没有一个好的解决方案,很容易被用户删除App,导致用户量的流失。因为热更新基本不能使用,那就只能是App自身修复能力。目前常用的修复能力有:
- 启动Crash的监控及修复
1 在应用起来的时候,记录flag并保存本地,启动一个定时器,比如5秒钟内,如果没有发生Crash,则认为用户操作正常,清空本地flag。
2 下次启动,发现有flag,则表明上次启动Crash,如果flag数组越大,则说明Crash的次数越多,这样就需要对整个App进行降级处理,比如登出账号,清空Documents/Library/Caches目录下的文件。
- 具体业务下的Crash及修复
针对某些具体业务Crash场景,如果是上线的前端页面引起的,可以先对前端功能进行回滚,或者隐藏入口,等修复完毕后再上线,如果是客户端的某些异常,比如数据库升迁问题,主要是进行业务数据库修复,缓存文件的删除,账号退出等操作,尽量只修复此业务的相关的数据。
- 网络降级
比如点评App,本身有CIP(公司内部自己研发的)长连接,接入腾讯云的WNS长连接,UDP连接,HTTP短连接,如果CIP服务器发生问题,可以及时切换到WNS连接,或者降级到Http连接,保证网络连接的成功率。
线上监控
Crash监控
Crash是对用户来说是最糟糕的体验,Crash日志能够记录用户闪退的崩溃日志及堆栈,进程线程信息,版本号,系统版本号,系统机型等有用信息,收集的信息越详细,越能够帮助解决崩溃,所以各大App都有自己崩溃日志收集系统,或者也可以使用开源或者付费的第三方Crash收集平台。
端到端成功率监控
端到端监控是从客户端App发出请求时计时,到App收到数据数据的成功率,统计对象是:网络接口请求(包括H5页面加载)的成败和端到端延时情况。端到端监控SDK提供了监控上传接口,调用SDK提供的监控API可以将数据上报到监控服务器中。
整个端到端监控的可以在多个维度上做查询端到端成功率、响应时间、访问量的查询,维度包括:返回码、网络、版本、平台、地区、运营商等。
用户行为日志
用户行为日志,主要记录用户在使用App过程中,点击元素的时间点,浏览时长,跳转流程等,然后基于此进行用户行为分析,大部分应用的推荐算法都是基于用户行为日志来统计的。某些情况下,Crash分析需要查询用户的行为日志,获取用户使用App的流程,帮助解决Crash等其他问题。
代码级日志
代码级别的日志,主要用来记录一个App的性能相关的数据,比如页面打开速度,内存使用率,CPU占用率,页面的帧率,网络流量,请求错误统计等,通过收集相关的上下文信息,优化App性能。
总结
虽然现在市面上第三方平台已经很成熟,但是各大互联公司都会自己开发线上监控系统,这样保证数据安全,同时更加灵活。因为移动用户的特点,在开发测试过程中,很难完全覆盖所有用户的全部场景,有些问题也只会在特定环境下才发生,所以通过线上监控平台,通过日志回捞等机制,及时获取特定场景的上下文环境,结合数据分析,能够及时发现问题,并后续修复,提高App的稳定性。
全文总结
本文主要从开发测试发布等流程来介绍了一个App稳定性指标及监测方法,开发阶段主要针对一些比较具体的指标,静态检查主要是扫描代码潜在问题,然后通过测试保证App功能的稳定性,线上降级主要是在尽量不发版的情况下,进行自修复,配合线上监控,信息收集,用户行为记录,方便后续问题修复及优化。本文观点是作者从事iOS开发的一些经验,希望能对你有所帮助,观点不同欢迎讨论。
参考:
微信mars 的高性能日志模块 xlog 基于 CADisplayLink 的 FPS 指示器详解 今日头条iOS客户端启动速度优化 微信读书 iOS 性能优化总结 移动端监控体系之技术原理剖析 美团点评移动网络优化实践 iOS 启动连续闪退保护方案 微信 SQLite 数据库修复实践