## 稳定性与内存优化――小型团队的 Android 应用质量保障之道
文/何红辉
>对于小型创业公司来说,没有 BAT 等大厂里的测试平台、方案研究员,QA 资源相对有限,如果将一切发现问题的重担都交给测试部门,不但耗费的测试周期长,而且有一些问题将难以发现。本文作者基于此分享了他们是如何保证应用的稳定性、避免内存泄漏的,希望能够帮助大家在开发过程中少走弯路。
随着 Android 技术的发展,各种开源库层出不穷,开发一个 Android 应用已经变得容易很多。然而开发一个商业应用并不单纯是实现业务需求那么简单,开发完成只是基础,后续还需要经过 QA 同学的严格测试。然而对于小型创业公司来说,我们并没有 BAT 等大厂里的测试平台、方案研究员,QA 资源较为有限,如果将一切发现问题的重担都交给测试部门,不但耗费的测试周期长,而且有一些问题还将难以发现。例如某个 Crash 只会在某个场景下复现,某个内存泄漏只有在用户执行了某个操作才会出现,而 QA 同学在测试时并不一定能够执行到那条 Crash 的测试路径。
对于内存泄漏来说,即使测试到了那条路径,但可能他们并不是在测试内存问题,因此即使出现了内存泄漏也难以发现。然而由内存泄漏导致的 OOM、空指针正是致使应用崩溃的两大原因,因此尽早地发现并解决掉这类问题对于应用质量来说至关重要。
也许有同学会说通过 LeakCanary 可以很方便地进行内存泄漏检测,但问题是我们并不能保证研发、QA 同学在每个版本都会通过 LeakCanary 检测各个页面的内存问题。因为人不是机器,你无法保证每一次都会进行手动回归,而如果在开发中直接引入 LeakCanary 会拖慢你的开发速度。因此,找到一种低成本、高收益的自动化测试方案来保证应用的稳定性,对于创业小团队来说还是非常有价值的。本文将分享我们是如何保证应用的稳定性、避免内存泄漏的,首先列一下几个要点:
- Jenkins 持续集成;
- 单元测试;
- Monkey 压力测试以及 Log 收集;
- 定制 LeakCanary 实现配合 Monkey 测试的内存检测。
### Jenkins 持续集成平台
在敏捷方法中,持续集成是其基石,持续集成的核心是自动化测试。Jenkins 是一个可扩展的持续集成平台,它提供了丰富的插件,能够让开发人员完成各种任务。其主要作用有如下两个方面:
- 持续、自动地构建或测试软件项目;
- 定时地执行任务。
对于 Android 项目来说,你可以理解为它可以定期地拉取代码,然后打包你的应用,并执行一些特定的任务,例如打包后运行单元测试、压力测试、UI 自动化测试、上传至 fir.im 等。Jenkins 的执行流程大致如图1所示。
图1 Jenkins 执行流程
通过定时触发 Jenkins 构建任务,它能够自动从 GitHub 拉取代码、打包 APK 、运行测试任务,最后我们可以将结果通过邮件发送给相关人员。比如我们的 Jenkins 每隔两个小时就会执行一次单元测试(如果代码有改动),然后将结果发送给相关人员。假如有一位同事进行了代码重构,但引入了错误,那么单元测试将会快速发现问题,并最后通过邮件将报告发送给相关人员。他们通过报告发现错误后就会尽快修复 Bug,而无需等到测试阶段经过各种测试路径之后才能发现问题。如果这个问题在 QA 阶段没有被覆盖到,那么就会导致有问题的 APK 被交付给用户。
关于如何搭建 Jenkins 平台,在此就不做过多介绍,这方面的资源比较丰富,大家可以参考下面两篇文章:
《Ubuntu下搭建 Android 开发环境》
《搭建 Jenkins持续测试平台》
### 单元测试
说到自动化测试,成本最低的应该是单元测试,虽然成本低但收益却非常高。因为它是最基础的测试,正所谓“九层之台,起于垒土;千里之行,始于足下”,只有基础牢固了才能保证更高层次的正确性。但由于国内开发人员对于单元测试认识不多,所以能够写单元测试的开发人员着实寥寥,也正因如此笔者在《Android 开发进阶:从小工到专家》的第九章中详细讲述了单元测试,也是希望将这些知识尽早地推荐给早期接触 Android 开发的同学,所以本文不会再次介绍如何写单元测试。
言归正传,这些测试策略其实很早就有总结过,最著名的当属 Martin Fowler 的测试金字塔,如图2所示。
图2 Martin Fowler 的测试金字塔
注:Martin Fowler 是世界著名的面向对象分析设计、UML、模式等方面的专家,敏捷开发方法的创始人之一,现为 ThoughtWorks 公司的首席科学家,出版过《重构:改善既有代码的设计》、《企业应用架构模式》等名著。
图2中将自动测试分为了三个层次,从下到上依次为单元测试、业务逻辑测试、UI 测试。越往上测试成本越高,同时测试效率越低,也就说明单元测试是整个金字塔中投入最少、收益最高、效率最高的测试类型。
举个具体的例子,假如我们的应用中有数据库缓存功能,那么该如何快速验证数据库存储模块是否正确?通常的流程是运行应用得到 UI 上的数据,然后记录当前的数据,数据存储之后再重新进入应用,并与之前记录的数据做对比,反复执行这个过程来确保数据的正确性。每次发布新版本前测试人员都得执行上述测试流程,枯燥无味不说,还容易出错、浪费时间。而如果我们有单元测试,那么只需运行一次单元测试,测试通过即可认为数据库缓存模块基本没有问题,再简单配合人工测试就可以通过测试,这样一来效率就提高了很多。
这三个层次的自动化测试分配比例从下到上通常为70%、20%、10%,可见单元测试在整个自动化测试中占据了非常大的比重。通过单元测试,我们能够获得如下收益:
- 便于后期重构。用单元测试尽量覆盖程序中的每一项功能的正确性,这样就算是开发后期,也可以有保障地增加功能或更改程序结构,而不用担心这个过程中是否会破坏原来的功能。因为单元测试为代码的重构提供了保障,只要重构代码后单元测试全部运行通过,那么在很大程度上就表示这次重构没有引入新的 Bug。当然,这是建立在完整、有效的单元测试覆盖率的基础上;
- 优化设计。编写单元测试将使用户从调用者的角度观察、思考,特别是使用测试驱动研发的开发方式,迫使设计者把程序设计成易于调用和可测试,并解除软件中的耦合;
- 具有回归性。自动化的单元测试避免了代码出现回归,编写完成后可以随时随地快速运行测试。而不是将代码部署到设备上,然后再手动覆盖各种执行路径,这样的行为效率低下、浪费时间;
- 提高你对代码的信心。通过单元测试,相当于从另一个角度审视了我们的代码,并验证了它们的正确性。这样一来,使得我们对于代码更有信心,而不是在上线之后还担心基础代码会出现问题。
当有了单元测试之后,就可以在 Jenkins 上执行 Gradle 任务(需要安装 Gradle 插件),以此来执行我们的单元测试。首先需要添加构建步骤,选择“Invoke Gradle Scripts”,然后在 Gradle 配置下如图3所示的任务。
配置好后,我们将 Android 设备(或使用模拟器插件)连接到 Jenkins 主机上,然后触发 Jenkins 任务以启动单元测试的任务,Jenkins 会执行我们配置的 Gradle 脚本 assembleDebugconnectedDebug Android Test --continue任务,它打包一个Debug版的 APK 包,然后安装被测项目、测试项目,最后执行工程中的单元测试。如果我们配置了邮件插件,那么也可以将测试报告(测试报告存放在 build/reports/ Android Tests/connected/flavors/测试的flavor/index.html)通过邮件发送给相关人员,如表1所示。
表1 邮件发送测试报告
假如测试失败,那么我们通过测试报告就能知道是哪个测试运行失败,以及为什么失败。然后相关人员就可以快速修复 Bug,以将基础 Bug 扼杀在摇篮之中。
还是回到前文提到的,写单元测试需要一定的知识。怎么编写单元测试不是难点,难就难在怎么让你的代码可以测试。这些涉及到解耦、依赖注入等知识,虽说很浅显,但许多工程师并没有真正领悟到这些,所以能够写单元测试的工程师是少之又少。也正因为这样,在小公司执行单元测试才会显得困难。
### Monkey 压力测试与内存泄漏检测
将基础的 Bug 扼杀于单元测试后,我们还要面临高层次的测试问题,例如在某些页面的某些情况下应用会发生崩溃,但测试时我们并没有测到该场景,因此就在上线之后发现某个页面崩溃直线上升。由于测试资源及时间有限,为了尽量避免这种情况,我们可以通过 Monkey 进行压力测试。
Monkey 是一款压力测试工具,它能够根据用户指定的事件比例向指定的应用发送事件,比如触摸事件、点击事件、屏幕旋转等。通过 Monkey 测试能够让应用处在一个未知的测试环境下(通俗点讲就是有规律地在应用内乱点),这个时候我们往往会发现 QA 同学没有测出来的 Bug,从另一个层面保证应用的质量。
在执行 Monkey 的过程中,如果应用产生了崩溃、ANR 等,它都会输出日志,测试结束后如果失败了,我们只需查看错误日志就能发现问题所在。通过这种自动化的测试、日志收集,我们就能够边开发、边测试,尽早地发现、修复 Bug。
要在 Jenkins 中实现压力自动化测试,我们需要如下几步:
- 通过 Gradle 命令生成 APK 并安装;
- 执行 Monkey 脚本进行测试;
- 获取并发送测试报告。
图3 Gradle 单元测试任务配置
生成 APK 可以通过添加 Gradle 脚本命令实现,方式与图3中一样,只需将 Switches 的值修改为“assembleDebug”。然后在 Jenkins 中,我们可以为一个项目添加构建任务,任务类型为“Execute Shell”,如图4所示。
图4 Execute Shell
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-touch40--pct-motion25--pct-appswitch10--pct-rotation5 为设定各种事件的百分比,Monkey 的具体参数这里不再赘述,大家可以查看其他文章。
在执行这100000次事件的过程中,如果出现 ANR、Crash,那么相关的日志会输出到 $project/test _ logs/ 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 StringsAppPackageName = "";
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);
}
}
}
```
通过调用 LeakCanary ForTest 的 install 函数,就可以将 LeakDumpService 作为 LeakCanary 发现泄漏后的处理服务。这样一来,就能在执行压力测试时通过 LeakCanary 检测内存泄漏,并将内存泄漏输出到一个日志文件中,最后通过邮件得到这个日志,然后根据日志修复内存泄漏问题。因为压力测试的事件是随机性的,所以它能够发现一些较为隐蔽的问题,或者这些测试路径可能我们的 QA 同学不会测到。所以说,Monkey 结合 LeakCanary 往往能得到意想不到的效果,如图5所示。
图5 压缩测试报告
在图5中,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,然后将 LeakCanary ForTest、 LeakDumpService 等类封装到这个模块中,并且在压力测试时引用它。这样我们的应用模块 build.gradle 就需要做类似如下的修改:
```
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 类中调用 setup LeakCanary 函数,在该函数中会判定——如果这个应用是 Monkey Flavor,那么就会集成 LeakForTest 模块,并且在通过反射调用了 LeakCanary ForTest 类的 install 函数来集成我们定制过的 LeakCanary,从而达到将内存泄漏的日志输出到特定文件的效果。为了实现这个效果,我们只需将 Gradle 任务中生成 APK 的命令改为 assembleMonkeyDebug,然后将生成的 APK 安装到设备中,最后执行测试即可进行后续的流程。这样一来,我们就将开发与自动化测试隔离开了。
### 其他测试
通过上述的方案,我们就有了一套简单、投入低、收益高的自动化测试方案,它能够快速地发现基础模块的问题、内存泄漏问题,能够保证应用的稳定性。但这只能保证应用逻辑在单个设备的稳定性,不同设备可能会产生一些兼容性的问题。因此,另一个重要的测试就是兼容性测试,确保我们的应用在各种设备上正确运行。如果条件许可,我们可以借助市场上的云测试平台运行一些 Monkey 测试来验证应用的兼容性,从而避免兼容性引发的问题。
如果说通过 Jenkins、Monkey、单元测试能够在一个点的角度保证应用的稳定性,那么兼容性测试就是从一个面的角度保证了应用的兼容性。通过这两个维度的测试,应用肯定会越来越稳定,同时我们也能从中领悟到更多软件设计、测试的方法与思想。
然而,这一切只是开始,如果团队有精力和时间,还可以在 Jenkins 中添加更多的方案进行测试。比如:
通过 TinyDancer、BlockCanary 等性能检测框架来查找性能问题;
在测试过程中定期输出内存、CPU 占用,测试结束得到一个报表,最终可以与其他报告一块来分析问题;
通过 Espresso、Robotium 实现 UI 自动化测试。
通过不断地完善自动化平台,以机器替代部分的人工测试,我们的应用质量将会得到很大程度的保障。即使只有单元测试、压力测试、LeakCanary 内存检测、云平台的兼容性测试,应用也能经受住创业公司快速迭代带来的质量考验。但并不是有更多的测试就会更好,有的时候也会适得其反,因此运用哪些测试方案、做到什么程度都需要根据各自的情况进行决策。我们的目标是提高应用的质量,而不是增加测试的数量。
以上就是本人最近的实践与总结,也希望更多的人将自己的实践、所思所得分享出来,让我们在开发过程中少走弯路。另外,我们团队有一个 Android 工程师的岗位,有兴趣的同学请移步阅览招聘信息。