稳定性与内存优化
随着Android技术的发展各种开源库层出不穷,开发一个Android应用已经变得容易了很多。然而开发一个商业应用并不是单纯是实现业务需求那么简单,开发完成只是基础,后续还需要经过QA同学的严格测试。然而对于小型创业公司来说,我们并没有BAT等大厂里的测试平台、方案研究员,我们QA资源比较有限,如果将一切发现问题的重担都交给测试部门,不但耗费的测试周期长,而且有一些问题将难以发现。例如某个crash只会在某个场景下复现,某个内存泄漏只有在用户执行了某个操作才会出现,而QA同学在测试时并不一定能够执行到那条crash的测试路径。对于内存泄漏来说,即使测试到了那条路径,但可能他们并不是在测试内存问题,因此即使出现了内存泄漏也难以发现。然而由于内存泄漏导致的OOM、空指针正是导致应用崩溃的两大原因,因此尽早的发现并且解决掉这类问题对于应用质量来说至关重要。
也许有同学会说通过LeakCanary
可以很方便的为我们检测内存泄漏,但是问题是我们并不能保证我的研发、QA同学在每个版本都会通过LeakCanaey
检测各个页面的内存问题,因为人不是机器,你不能保证每一次都会进行手动回归。而如果在开发中直接引入LeakCanary会拖慢你的开发速度。因此,找到一种低成本、高收益的自动化测试方案来保证应用的稳定性对于创业小团队来说还是非常有价值的。
这篇文章我就来分享一下我们是如何保证应用的稳定性、避免内存泄漏的。首先我列一下几个要点:
- Jenkins 持续集成
- 单元测试
- Monkey 压力测试 以及 log收集
- 定制 LeakCanary 实现配合Monkey测试的内存检测
一、Jenkins 持续集成平台
在敏捷方法中,持续集成是其基石,持续集成的核心是自动化测试。Jenkins是一个可扩展的持续集成平台,它提供了丰富的插件能够让开发人员完成各种任务。它主要作用有如下两个方面:
- 持续、自动地构建或者测试软件项目;
- 定时地执行任务。
对于Android项目来说,你可以理解为它可以定期的拉取代码,然后打包你的应用,并且执行一些特定的任务,例如打包之后运行单元测试、压力测试、UI自动化测试、上传到fir.im 上等。Jenkins的执行流程大致如图 1-1 所示 :
图 1-1
通过定时触发Jenkins构建任务,它能够自动从github拉取代码、打包apk、运行我们的测试任务,最后我们可以将结果通过邮件发送给相关人员。例如我们的Jenkins每隔两个小时就会执行一次单元测试(如果代码有改动),然后将结果发送给相关人员。假如有一位同事进行了代码重构,但是引入了错误,那么单元测试将会快速的发现问题,并且最后通过邮件将报告发送给相关人员。相关人员通过报告发现错误之后就会尽快修复bug, 而不需要等到测试阶段经过各种测试路径之后才能发现问题。如果这个问题在QA测试阶段没有被覆盖到,那么就会导致有问题apk交付给用户。
关于如何搭建Jenkins平台我就不做过多介绍,这方面的资源比较多,大家可以参考下面两篇文章。
二、单元测试
说到自动化测试,成本最低的应该是单元测试。单元测试成本最低,但是收益却非常高。因为它是最基础的测试,正所谓“九层之台,起于垒土;千里之行,始于足下”,只有基础牢固了才能保证更高层次的正确性。但是由于国内开发人员对于单元测试认识不多,所以能够写单元测试的开发人员并不是很多,也正因为如此在2015年我才在《Android开发进阶:从小工到专家》的第九章详细讲述了单元测试,也是希望将这些知识尽早的推荐给早期接触Android开发的同学,因此本文不会再次介绍如何写单元测试。言归正传,这些测试策略其实很早就有总结过,最著名的就是Martin Fowler的测试金字塔,如图 2-1所示。
Martin Fowler是世界著名的面向对象分析设计、UML、模式等方面的专家,敏捷开发方法的创始人之一,现为ThoughtWorks公司的首席科学家,出版过《重构:改善既有代码的设计》、《企业应用架构模式》等名著。
图 2-1 中将自动测试分为了三个层次,从下到上依次为单元测试、业务逻辑测试、UI测试,越往上测试成本越高、测试的效率越低,也就是说单元测试是整个测试金字塔中投入最少、收益最高、测试效率最高的测试类型。
举个具体的例子,假如我们的应用中有数据库缓存功能,那么我们如何快速验证数据库存储模块是否正确?通常的流程我们是运行应用得到UI上的数据,然后记录当前的数据,数据存储之后,然后再重新进入应用,再与之前记录的数据做对比,反复执行这个过程来来确保数据的正确性。每次发布新版本之前测试人员都得执行上述测试流程,枯燥无味不说,还容易出错、浪费时间,而如果我们有单元测试,那么我们只需要运行一次单元测试,如果测试通过我们就认为数据库缓存模块基本没有问题,再简单配合我们的人工测试就可以通过测试,这样一来效率就提高了很多。
这三个层次的自动化测试的分配比例从下到上通常为 70% 、20%、10%,可见单元测试在整个自动化测试中占据了非常大的比例。通过单元测试,我们能够获得如下收益:
- 便于后期重构。用单元测试尽量覆盖程序中的每一项功能的正确性,这样就算是开发后期,也可以有保障地增加功能或者更改程序结构,而不用担心这个过程中会破坏原来的功能,因为单元测试为代码的重构提供了保障。只要重构代码之后单元测试全部运行通过,那么,在很大程度上表示这次重构没有引入新的Bug,当然这是建立在完整、有效的单元测试覆盖率的基础上;
- 优化设计。编写单元测试将使用户从调用者的角度观察、思考,特别是使用测试驱动开发的开发方式,迫使设计者把程序设计成易于调用和可测试,并且解除软件中的耦合。
- 具有回归性。自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地地快速运行测试。而不是将代码部署到设备上,然后再手动地覆盖各种执行路径,这样的行为效率低下、浪费时间。
- 提高你对代码的信心。通过单元测试,相当于我们从另一个角度审视了我们的代码,并且验证了它们的正确性,这样一来使得我们对于代码更有信心,而不是在上线之后还担心基础代码会出现问题。
当我们有单元测试之后,我们就可以在Jenkins上执行Gradle任务(需要安装Gradle插件),以此来执行我们的单元测试。首先需要添加构建步骤,然后选择”Invoke Gradle Scripts”, 然后在Gradle任务下如图 2-2 所示的任务:
图 2-2
配置好之后我们就将Android设备(或者使用模拟器插件)连接到jenkins主机上,然后触发Jenkins任务启动单元测试的任务,Jenkins就会执行我们配置的Gradle脚本 assembleDebug connectedDebugAndroidTest --continue
任务,这个任务会打包一个debug版的apk包,然后安装被测项目、测试项目,最后执行工程中的单元测试。如果我们配置了邮件插件,那么我们也可以将测试报告(测试报告存放在 build/reports/androidTests/connected/flavors/测试的flavor/index.html
)通过邮件发送给相关人员。如表 2-1 所示:
邮件通知 | 测试成功 | 测试失败 |
---|---|---|
表 2-1
假如测试失败,那么我们通过测试报告就知道是哪个测试运行失败,以及为什么失败,然后相关人员就可以快速的修复bug,将基础bug扼杀在摇篮之中。
还是回到前文提到的,写单元测试需要一定的知识,怎么编写单元测试不是难点,难点是怎么让你的代码可以测试,这些涉及到解耦、依赖注入等知识,虽然说很浅显,但是很多工程师并没有真正领会到这些,因此能够写单元测试的工程师是少之又少。也正是因为这样,在小公司执行单元测试才会显得困难。
三、Monkey压力测试与内存泄漏检测
将基础的bug扼杀于单元测试后我们还要面临高层次的测试问题,例如在某些页面的某些情况下应用会发生崩溃,但是测试的时候我们没有测试到该场景,因此就上线之后发现某个页面崩溃直线上升。由于测试资源、测试时间有限,这种情况难以避免,为了尽量避免这种情况我们可以通过Monkey进行压力测试。
Monkey是一款压力测试工具,它能够根据用户指定的事件比例向指定的应用发送事件,比如触摸事件、点击事件、屏幕旋转等,通过Monkey测试能够让应用处在一个未知的测试环境下(通俗点讲就是有规律的在应用内乱点),这个时候我们往往能够发现QA同学没有测出来的bug,从另一个层面保证应用的质量。
在执行Monkey的过程中,如果应用产生了崩溃、ANR等,它都会输出日志,测试结束之后如果测试失败我们只需要查看错误日志就可以发现问题所在。通过这种自动化的测试、日志收集,我们就能够边开发、边测试,尽早的发现、修复bug。
要在Jenkins中实现压力自动化测试,我们需要如下几步:
- 通过gradle命令生成apk,并且安装
- 执行 monkey 脚本进行测试
- 获取并且发送测试报告
生成apk我们可以通过添加gradle 脚本命令实现,方式与图2-2中一样,只需要我们将Switches的值修改为”assembleDebug”。然后在Jenkins中我们可以为一个项目添加构建任务,任务类型为 “Execute Shell”, 如图 3-1 所示:
图 3-1
Execute Shell中的内容就是我们要执行的脚本,作用分别为:
- unlock.sh - 设备解锁,然后才可以让Monkey运行下一步的压力测试。
- 启动真正的压力测试, 即执行 start_monkey.sh 脚本;
- 分析测试日志,判定测试的成功与失败;
其中start_monkey.sh最为重要,核心脚本如下所示:
#! /bin/bash
project=你的jenkins项目名称
app_package=你的应用包名
# 卸载旧应用
adb uninstall $app_package
# 重新安装被测试的apk
adb install -r $project/你的app模块名/build/outputs/apk/生成的debug.apk
# 执行monkey脚本,将错误输出到monkey_error.txt中
adb shell monkey -p $app_package --ignore-crashes --ignore-timeouts --ignore-native-crashes --ignore-security-exceptions --pct-touch 40 --pct-motion 25 --pct-appswitch 10 --pct-rotation 5 -s 12358 -v -v -v --throttle 500 100000 2>$project/test_logs/monkey_error.txt 1>$project/test_logs/monkey_log.txt
上述脚本(需要根据情况替换掉部分内容)的含义为执行 100000次 事件,每次事件相隔 500毫秒,忽略崩溃、忽略ANR,--pct-touch 40 --pct-motion 25 --pct-appswitch 10 --pct-rotation 5
为设定各种事件的百分比,Monkey的具体参数这里不再赘述,大家可以查看其他文章。
在执行这100000次事件的过程中,如果出现ANR、crash,那么相关的日志会输出到 $project/test_logs/monkey_error.txt
路径中,当测试结束之后我们可以判定monkey_error.txt
文件的大小,如果monkey_error.txt
文件中有内容那我们则认为本次测试失败,然后通过邮件将 monkey_error.txt
作为附件发送给相关人员,相关人员就可以通过 monkey_error.txt
以及测试设备中的 /data/anr/traces.txt
文件来定位、修复问题! 重要的是这些操作我们都可以让Jenkins在夜间自动的为我们来完成,定期执行任务、分析报告与log、发送邮件,例如我们的Jenkins任务会在每天夜里 10点之后执行压力测试,每次测试跑8个小时,那么在第二天早上我们就可以得到测试报告,如果发现问题我们就可以在早上将问题解决掉,而不会拖到提交测试之后!!!
如果你的应用能够经受8个小时压力测试蹂躏之后没有崩溃、没有内存泄漏、没有OOM,那么在一定的程度上来说你的应用已经具备一定的稳定性。然后问题显然没有那么简单,在执行压力测试的早期,你很可能在一个连续的时间段内都面临测试失败的问题。崩溃问题比较好查找愿意,那如果在压力测试过程中如果出现了内存泄漏我们怎么知道呢?我们有没有办法能够自动化的发现问题?
我们的解决方案是通过定制 LeakCanary 来实现在自动化测试的过程中自动检测内存泄漏,因为 LeakCanary 默认是在发现内存泄漏是在通知栏显示,这样不便于实现自动化。我们通过修改 LeakCanary 发现内存泄漏的策略来实现我们的目标,即发现内存泄漏之后将相关信息写入到一个具体的文件,然后测试完成之后分析这个文件,如果这个文件里面有内容,那么认为产生了内存泄漏,最后将这个log文件通过邮件发送给相关人员。我们的修改如下:
public class LeakDumpService extends AbstractAnalysisResultService {
@Override
protected final void onHeapAnalyzed(HeapDump heapDump, AnalysisResult result) {
if ( !result.leakFound || result.excludedLeak ) {
return;
}
Log.e("", "### *** onHeapAnalyzed in onHeapAnalyzed , dump dir : " + heapDump.heapDumpFile.getParentFile().getAbsolutePath());
String leakInfo = LeakCanary.leakInfo(this, heapDump, result, true);
CanaryLog.d(leakInfo);
// 将内存泄漏日志
StorageUtils.saveResult(leakInfo);
}
}
LeakCanary 检测到内存泄漏之后就会执行 LeakDumpService 中的 onHeapAnalyzed 函数,在这个函数中我们将泄漏的信息保存到一个文件中,每次运行产生的log会叠加写入到同一个文件,因此如果一次测试产生了多个泄漏我们就从一个文件中得到。要使用LeakDumpService作为LeakCanary发现泄漏后的处理服务需要进行如下配置:
public final class LeakCanaryForTest {
private static String sAppPackageName = "";
private static RefWatcher sWatcher ;
public static void install(Application application) {
if (LeakCanary.isInAnalyzerProcess(application)) {
return;
}
sAppPackageName = application.getPackageName();
// 设置定制的 LeakDumpService , 将 leak 信息输出到指定的目录
sWatcher = LeakCanary.refWatcher(application)
.listenerServiceClass(LeakDumpService.class)
.excludedRefs(AndroidExcludedRefs.createAppDefaults().build())
.buildAndInstall();
// disable DisplayLeakActivity
LeakCanaryInternals.setEnabled(application, DisplayLeakActivity.class, false);
}
/**
* 手动监控一个对象, 比如在 Fragment 的 onDestroy 函数中 调用 watch 监控Fragment是否被回收.
* @param target
*/
public static void watch(Object target) {
if ( sWatcher != null ) {
sWatcher.watch(target);
}
}
}
通过调用LeakCanaryForTest的install函数,我们就可以将LeakDumpService作为LeakCanary发现泄漏后的处理服务。这样一来,我们就可以在执行压力测试时通过 LeakCanary 检测内存泄漏,并且将内存泄漏输出到一个日志文件中,最后通过邮件得到这个日志,然后根据日志修复内存泄漏问题。因为压力测试的事件是随机性的,因此它能够发现一些比较隐蔽的问题,这些测试路径可能我们的QA同学不会测试到,因此Monkey 结合 LeakCanary 往往能够得到意想不到的效果.
内存泄漏检测效果如图 3-2 所示:
图3-2
2017-03-27_leak.txt就是内存泄漏的日志文件, 部分日志如下所示:
In com.mynews:2.2.2:101.
* com.包名路径.NewsDetailActivity has leaked:
* GC ROOT static android.os.AsyncTask.SERIAL_EXECUTOR
* references android.os.AsyncTask$SerialExecutor.mTasks
* references java.util.ArrayDeque.elements
* references array java.lang.Object[].[0]
* references android.os.AsyncTask$SerialExecutor$1.val$r (anonymous implementation of java.lang.Runnable)
* references android.widget.TextView$3.this$0 (anonymous implementation of java.lang.Runnable)
* references android.support.v7.widget.AppCompatEditText.mContext
* references android.support.v7.widget.TintContextWrapper.mBase
* references android.view.ContextThemeWrapper.mBase
* leaks com.包名路径.NewsDetailActivity instance
如果你一大早来到公司就收到了内存泄漏测试结果的报告,那么恭喜你,你又即将解决了一个隐蔽的内存问题! 当然,没有人愿意在一大早打开邮件就看到这类的测试报告。但这又何尝不是一件好事,通过自动化的手段尽早的发现问题,解决问题,降低了成本、提升了应用质量。经过一段时间之后,我们相信应用内的内存泄漏问题会基本上被消灭掉!
四、开发与测试隔离
然而,我们并不是在开发的时候将 LeakCanary 引入到我们的工程中,因为它会拖慢我们的编译速度,在开发测试过程中 LeakCanary 的内存检测也会导致应用运行卡顿。比如我们只希望在运行压力测试时引入 LeakCanary 进行内存检测,那么我们可以新建一个 module (这里我们暂且叫做 leakfortest ), 该模块引用了 LeakCanary, 然后将 LeakCanaryForTest、LeakDumpService等类封装到这个模块中,并且在压力测试的时候引用它。这样我们的应用模块build.gradle就需要做类似如下的修改:
android {
// 其他配置
productFlavors {
// 原包
prod {
}
// 用于压力测试
monkey {
}
}
}
dependencies {
// 其他配置
// 用于在自动化测试中引入leakcanary监控内存泄露.
monkeyCompile project(':leakfortest')
}
然后我们我的应用代码中添加如下函数,代码如下:
public static void setupLeakCanary(Application application) {
if ( BuildConfig.FLAVOR.equals("monkey") ) {
try {
Class canaryClz = Class.forName("com.simple.leakfortest.LeakCanaryForTest") ;
Method method = canaryClz.getDeclaredMethod("install", Application.class) ;
method.setAccessible(true);
method.invoke(null, application) ;
} catch (Exception e) {
Log.e("", "### leak canary error : " + e.getMessage()) ;
e.printStackTrace();
}
}
}
然后我们在 Application 类中调用 setupLeakCanary 函数,在该函数中会判定如果这个应用是monkey flavor, 那么就会集成 leakfortest 模块,并且在通过反射调用了LeakCanaryForTest类的install函数来集成我们定制过的 LeakCanary, 从而达到将内存泄漏的日志输出到特定文件的效果. 为了实现这个效果,我们只需要将gradle任务中生成apk的命令改为 assembleMonkeyDebug
, 然后将生成的apk安装到设备中,最后执行测试即可进行后续的流程。这样一来,我们就将开发与自动化测试隔离开来了!
其他测试
通过上述的方案,我们就有了一套简单、投入低、收益高的自动化测试方案,它们能够快速的发现基础模块的问题、内存泄漏问题,能够保证应用的稳定性。但是这只能保证应用逻辑在单个设备的稳定性,不同的设备可能会产生一些兼容性的问题。因此,另一个重要的测试就是兼容性测试,确保我们的应用在各种设备上能够正确的运行。如果条件许可,我们可以借助市场上云测试平台运行一些monkey测试来验证应用的兼容性,从而避免兼容性引发的问题。
如果说通过jenkins、monkey、单元测试能够在一个点的角度保证应用的稳定性,那么兼容性测试就是从一个面的角度保证了应用的兼容性。通过这两个维度的测试,我们的应用肯定会越来越稳定,我们也能从中领悟更多软件设计、测试的方法与思想。
然而,这一切只是开始,如果团队有精力和时间,我们还可以在Jenkins中添加更多的方案进行测试。例如:
- 通过 TinyDancer 、BlockCanary等性能检测框架来查找性能问题;
- 在测试过程中定期的输出内存、CPU占用,测试结束得到一个报表,最终可以与其他报告一块来分析问题;
- 通过 Espresso、Robotium实现UI自动化测试。
通过不断的完善自动化平台,以机器替代部分的人工测试,我们的应用质量将会得到很大程度的保障。即使只有单元测试、压力测试、LeakCanary内存检测、云平台的兼容性测试,我们的应用也能够经受住创业公司快速迭代带来的质量考验。但并不是有更多的测试就会更好,有的时候也会适得其反,因此运用哪些测试方案、做到什么程度都需要根据各自的情况进行决策。我们的目标是提高应用的质量,而不是增加测试的数量。
好吧,以上就是我这阵子的实践与总结,也希望更多的人将自己的实践、所思所得分享出来,让我们在开发过程中少走弯路!