安卓最佳实践(二)
四、敏捷
作为一名开发人员,您希望在 Android 开发方面做得更好,有更少的错误,做出更好的产品,或者只是让客户满意。无论您是为 web、移动甚至桌面开发,采用敏捷方法进行开发和测试都是值得的。
利益
我将谈论敏捷方法对于 Android 以及移动开发的真正好处。到本章结束时,应该会清楚敏捷对于 Android 开发者来说到底有多好。
让我们从敏捷开发最明显的好处开始:
- 你会有更少的错误。
- 你会得到更快的反馈。
- 它是可重复和可靠的。
- 您将需要更少的手动测试。
- 比较便宜。
- 它提供了内置的回归测试。
使用测试驱动开发(TDD)将导致更少的缺陷,并消除许多人进行手动测试的需要,这使得应用的开发更加便宜。持续集成(CI)将为客户提供更快的反馈,本质上使 APKs 创建过程可重复且可靠,并为您引入应用的任何新功能提供内置回归测试。
对企业的好处
在你的 Android 项目中加入敏捷实践的目的是什么?如果我们使用持续集成,我们将从业务中获得更快的反馈,通过可重复和可靠的开发,并始终确保您正在生产对人们试图执行其基本需求有用的东西。简而言之,如果你在移动开发中使用敏捷,这会让企业感到高兴,因为缺陷更少了,团队成员可以看到应用每天的进展情况。
对我来说,敏捷是关于经验教训和如何整合最佳实践,以确保我们提高每个人的标准,而不仅仅是一个应用。这是为了确保人们以正确的方式使用敏捷,让客户满意,不管他们是谁。
开发商的利益
当我第一次开始开发移动应用时,令人惊讶的是,当人们开始移动开发时,他们变得如此兴奋,他们只是开始编码,似乎忘记了我们在过去 10 年中学到的一切。
我喜欢采用敏捷实践的主要原因之一是确保每个人都遵循相同的标准,这样我们就可以在许多不同的应用上拥有可重复的质量。我看到当人们不使用敏捷时,当他们在移动中使用时,我们没有理由在我们的开发中不使用 TDD 和行为驱动开发(BDD)。没有它们,质量就会荡然无存。使用敏捷也是为了确保当新的开发人员进来时,他们都以他们应该的方式工作。
我们将在下一节探讨敏捷的要素。我确信你的列表可能不同,但是对我来说,最基本的敏捷开发包括 TDD、BDD 和 CI。对于开发人员来说,BDD 从来都不是一个真正需要采用的问题。这可能是因为启动和运行 BDD 主要是业务分析师和 QA 人员的工作。CI 也容易被采用,因为它允许任何和所有的代码部署和集成问题在过程的更早阶段被修复。CI 消除了那些困扰软件开发几十年的最后部署混乱或问题。
另一方面,对于许多开发人员来说,TDD 可能很难掌握和正确采用。顾名思义,TDD 意味着首先编写一个单元测试,然后编写最简单的代码使测试通过。一旦通过测试,代码就会被清理或重构。然后冲洗并重复添加任何新的所需功能。TDD 并不意味着在代码完成后添加单元测试。对于许多开发人员来说,这是编码实践的巨大逆转;对许多人来说,这是软件世界的量子力学,因为它没有直观的意义。
然而,TDD 为开发人员提供了两大好处。首先,它将开发倾斜成一种 YAGNI——你不需要它——的心态。为你的新应用开发一个真正美妙的新框架或架构的诱惑经常存在,但是相信我,你不会需要它。TDD 消除了这种诱惑,并确保您只为必要的功能编写代码,仅此而已。其次,TDD 还为开发人员提供了缺陷保险。对于任何一个新特性,你编写一个新的测试,然后编写相应的代码使测试通过;然后,如果单元测试全部通过,您可以相对确定新代码没有引入任何不想要的副作用。
最佳地点
敏捷非常适合移动开发,原因有很多。我在前面的章节中已经提到了它的一些好处,但是敏捷过程和移动开发是非常好的伙伴关系,因为做移动敏捷比做大型机敏捷甚至 web 敏捷要容易得多。
第一个原因是,与其他开发工作相比,移动项目的团队更小,开发生命周期也更短。根据为移动开发者运行云后端服务的 Kinvey 的说法,创建一个移动应用的平均时间是 18 周(见http://www.kinvey.com/blog/2086/how-long-does-it-take-to-build-a-mobile-app)。
这是因为移动应用往往不像网站那样复杂,从头到尾开发的功能也更少。通常,工作可以在几个 sprint 中完成,其中 s sprint 是一个敏捷术语,意思是定期(通常是 2 周)完成一些工作,并为评审做好准备。
Kinvey 报告进一步指出,前端工作大约需要 8 周,后端工作大约需要 10 周。
因为移动应用和团队往往更小,采用敏捷实践进行移动应用开发不需要大规模的公司重组。所需要的是团队对尝试敏捷方法的兴趣,并且前面部分列出的好处可以很快实现。
第二,尽管我们稍后会看到 Google TDD 和 BDD 并不是最好的起点,但是有开源的替代方案,使得采用敏捷的 Android 开发实践变得非常容易。
因此,即使你开始时只使用 TDD,甚至是像使用 Monkey Runner 记录脚本这样简单的事情,从长远来看,敏捷也会帮助你让你的客户满意,不管他是谁。
敏捷的要素
让我们看看在我们的敏捷 Android 项目中,我们需要什么样的基本形式。曾经有一段时间,你可以只做单元测试,并声称你在做敏捷开发。但是我们最终要做的是确保客户满意,而仅仅依靠单元测试可能无法让我们满意。理想情况下,我们会寻求更多,至少是单元测试和功能测试的持续集成过程。
敏捷 Android 的要素如下:
- 持续集成服务器
- 单元测试
- BDD 或功能测试
- 部署;也就是说,通过电子邮件发送 APK
本章的其余部分将展示如何使用这些元素创建 Android 项目。
目标
在我们研究建立一个敏捷 Android 项目的细节之前,让我们先谈一谈我们的目标。
测试应该自动化;我们不想一遍又一遍地手动运行它们,因为那样效率不高。我们还希望尽早构建并经常构建,这样我们就不会有任何部署问题。我们希望在将 APK 发送给任何人之前运行单元和功能测试。你多久运行一次整个过程取决于你自己。有些人每天晚上运行它,有些人选择在每次新代码被检入时运行测试。
因此,理想情况下,我们需要一个自动化的构建过程,这个过程从代码被检入我们的源代码库(比如 GitHub)开始。然后 CI 服务器检查它并构建代码,单元测试自动运行,接着是功能测试(以可执行需求的形式)。如果没有任何故障,也就是说,如果一切都是绿色的,那么 APK 将通过电子邮件发送给客户,以便可以安装在设备上。
如果我说我们彻底结束了,那我是在撒谎。代码覆盖率是一个问题,但这里的目标是传递信息,以便您也可以开始。我会试着指出你需要的主要元素,以及你能做什么和目前不能做什么。
遵循敏捷方法,我们可以从基本元素开始,然后从那里开始构建。以后你可能想添加更多的元素,比如负载测试、性能测试或安全测试,但是现在如果我们做 TDD、BDD 和 CI,那么我们就包括了敏捷 Android 的主要元素。
点名
现在我想向你介绍敏捷 Android 的这些元素背后的名字,因为本章的其余部分将是实用的而不是理论的。表 4-1 显示了我们将为敏捷模型的每个元素使用的 Android 开发工具。
表 4-1 。Agile Android 元素名称
|
敏捷元素
|
Android 工具名称
| | --- | --- | | TimeDivisionDuplex 时分双工 | 机器人电器 | | BDD | 葫芦 | | 海峡群岛 | 詹金斯 | | 源代码管理 | 开源代码库 |
从表 4-1 我们可以看到我们的元素现在变成了如下:
- robolectric(robolectric.org):虽然 jUnit 是 Android 的开箱即用的单元测试系统,但它有一些缺点,我不推荐它作为一个好的起点。您应该能够快速高效地运行单元测试,但是 Android 设备模拟器上的 jUnit 并不适合高效的单元测试。相反,我们将使用 Robolectric,它没有 jUnit 的缺点,也是 jUnit4 而不是 jUnit3。
- 葫芦(calaba.sh) :我们将使用葫芦来满足我们的 BDD 或可执行需求。Calabash 允许我们以一种给定的时间格式强加可执行的需求。Calabash 是为您的 Android 项目启动和运行 BDD 的最简单的系统。
- Jenkins(jenkins-ci.org):事实上的行业标准持续集成服务器,以前称为 Hudson。
- GitHub(github.org):迅速成为事实上的行业标准源代码版本控制或源代码库。虽然人们经常把它们当作一回事,但我应该指出,Git 是版本控制系统,GitHub 是一个可以发布 Git 项目的网站。
TDD
测试驱动开发(TDD)已经存在一段时间了;它来自于 90 年代末 XP 的测试优先编程。概念很简单:为每个新特性编写一个测试,运行测试使其失败,编写代码满足测试,最后整理或重构代码;图 4-1 说明了该过程。每个测试通常被称为单元测试。
图 4-1 。测试驱动开发
尽管这个想法并不算新,但它还没有被编程社区大量采用,因为它对许多开发人员来说似乎是违反直觉的。然而,在较小的应用中,比如移动开发项目,TDD 可以显示出显著的改进。它可以减少缺陷的数量,同时提高开发速度。
TDD 的一个巨大好处是内置的回归测试。如果在重构阶段或添加新特性时做了一个小的改变,TDD 测试都通过了,那么你可以确定你的应用运行正常。单元测试是防止因重构或添加新功能而引入问题的最佳保障。
TDD 的另一个主要好处是它给开发过程带来了焦点。对代码进行伟大的架构添加或发明新的框架已经一去不复返了,没有人会再使用它们。开发人员的工作变成了编写一个或多个单元测试来满足下一个特性,然后编写最简单的代码来通过单元测试。这也叫做 YAGNI,或者你不需要它,另一个 XP 原则。因此,决定是否使用 ORM 而不仅仅是使用 SQLite 就变得简单多了;问题变成了“我需要 ORM 来通过单元测试吗?”答案必然是否定的,或者是 YAGNI。
如前所述,我们将使用 Roboelectric 来编写我们的单元测试,因为 Android 的内置解决方案使用旧版本的 jUnit,并要求我们使用非常慢的 Android 模拟器来运行测试,这使得 TDD 成为一项非常痛苦的工作。虽然 Roboelectric 简化了这个过程,但是代码覆盖率报告(单元测试覆盖了多少代码)仍然是一个问题。
BDD
行为驱动开发(BDD),在这种情况下以可执行需求的形式,通过增加另一层来扩展 TDD,如图 4-2 所示。这意味着我们正在添加可执行的需求作为我们的元素之一。这些是用例或用户故事类型的需求,以小黄瓜格式编写,也称为给你我/何时/然后。
图 4-2 。行为驱动开发
图 4-3 显示了一个可执行需求的简单例子。不管这是为了一个 Android 游戏还是网页上的什么东西;需求的描述仍然是一样的。不难看出如何将旧式的用户故事转换成这种小黄瓜格式。
图 4-3 。示例特征文件
可执行的需求写在特征文件中,特征文件由一个或多个场景组成,通常有一个小的数据表来驱动场景。特性文件总是与步骤定义文件密切相关,步骤定义文件通常包含一些 Ruby 代码来驱动 web 或移动应用。步骤定义文件中的简单正则表达式将两者结合在一起,使您的需求可执行。
有时候,整个给定/何时/然后的想法(正如我们在本章后面讨论的黄瓜方法所使用的)需要几分钟才能被理解。我希望图 4-4 也能让你明白。给定一组前提条件,当你做 X 时,你期望下面这个可测试的结果。
图 4-4 。给定/何时/然后开发模式
当我开始尝试在 Android 工作中采用敏捷实践时,BDD 工具根本不可用,但现在有足够的空间让你使用 given/when/then 开发。
我们将使用葫芦作为我们的 BDD 工具,因为它非常容易使用。Calabash 如此简单的主要原因之一是它的步骤定义函数库允许您测试 Android 应用,通常不需要编写任何自己的步骤定义。
那么,如何决定进行多少单元测试和功能测试呢?图 4-5 所示的敏捷金字塔给了我们一个好主意。这个图如何适用于 Android?GUI 测试和验收测试层是使用 BDD 实现的,单元测试/组件测试层显然是使用单元测试完成的。
图 4-5 。敏捷测试三角形
单元测试和 GUI 测试的区别是什么?单元测试作用于一个方法,通常是一个公共方法;相比之下,GUI 测试或 BDD 功能测试是通常针对仿真器运行的测试。
Android 应用通常是客户端-服务器应用;它们是具有相应后端数据库的前端。所以他们通常有我们将使用 BDD 测试的 API。我们还将测试异常和错误路径以及“理想路径”
持续集成
持续集成(CI)采用构建服务器的形式,其中每个开发人员的代码定期合并在一起,通常是每天或每当任何代码被签入项目的源代码库中时。最初创建 CI 是为了阻止在应用发布前合并多个开发人员的代码时出现的集成混乱;各种各样的新缺陷、不可预见的依赖性和性能问题可能会共同推迟项目的启动。CI 使得代码合并更频繁地发生,所以理论上集成应该不那么痛苦,因为您最多只合并一天的代码。
CI 服务器自动化了构建过程,简化了部署,并使在项目早期发现任何依赖关系变得更加容易。CI 服务器还允许我们做其他事情,例如运行我们的单元测试(TDD)和可执行需求(BDD)以及性能测试、设备测试和各种报告。如果任何测试失败,他们甚至会停止部署,阻止应用在尚未准备好的时候获得业务用户。
在本章中,我们将使用 Jenkins 作为 CI 服务器。Jenkins 和 CI 通常非常适合移动项目。如果您可以从命令行手动运行命令,那么您可以在 Jenkins 中自动运行它。还有许多插件可以使构建、测试和部署阶段易于设置和维护。
我们还将研究在多部手机和平板电脑上使用 CI 进行自动化测试,这对我来说一直是 Android 开发的圣杯。
把这一切放在一起
我们通过使用 Jenkins 作为我们的 CI 服务器,开始在我们的移动开发流程中采用敏捷。从http://jenkins-ci.org/下载詹金斯。您也可以下载并安装一个 windows 或 Mac OS 本机二进制文件,但是下载 war 文件并从命令行运行java -jar jenkins.war也同样容易。接下来,将你的浏览器指向http://localhost:8080,加载 Jenkins 您应该会看到类似于图 4-6 中的仪表板页面。
图 4-6 。詹金斯打开仪表板上的显示器
提示也有一些网站,比如 cloudbees.com 的 Cloudbees ,会为你托管 Jenkins。有了 Cloudbees,您可以让 it 人员简单地编译您的应用或设置从属客户端来编译代码,并让 Cloudbees 来协调一切。
与 Jenkins 合作时,我们通常会去两个地方,如图 4-7 中的管理 Jenkins 屏幕所示。第一个是管理插件,在这里我们可以引入 Ant、GitHub 和 Android 模拟器插件。我们还需要转到 Configure System,为 JDK、Ant 位置等添加默认的项目设置。
图 4-7 。管理 Jenkins 屏幕
让 Jenkins 强大的是数以千计插件的可用性。图 4-8 显示了管理插件➤安装页签;如果你没有的话,一定要抓住前面列出的插件。
图 4-8 。在 Jenkins 中管理插件
接下来转到 Configure System,在 CI 服务器上添加 Android SDK、JDK、Git 和 Ant 位置;图 4-9 显示了 Mac 上的显示。
图 4-9 。使用配置系统页面在 Jenkins 中添加插件
詹金斯就其本身而言,它相当无用;我们需要让它做一些事情,我们希望它做的第一件事是从 GitHub 构建代码。
- 创建一个名为 ToDoList 的新工作,并使其成为一个自由风格的软件项目。
- 点击 Configure 并输入一个 GitHub 项目,例如
https://github.com/godfreynolan/ToDoList。 - 在源代码管理下,输入存储库 URL,如
git@github.com:godfreynolan/ToDoList.git。 - 在“构建触发器”下,选择“将更改推送到 GitHub 时构建”
接下来我们需要告诉 Jenkins 如何构建项目。在这种情况下,我们需要如下两个命令。我们正在创建一个调试版本,使用配置项目设置 ,如图图 4-10 所示。
android update project –name "ToDoList"
ant –Dadb.device.arg='-s $ANDROID_AVD_DEVICE' debug
图 4-10 。Jenkins 为 ToDoList 配置项目设置
点击保存并通过点击新项目运行构建,该项目现在应该在 Jenkins 仪表板中,如图 4-11 所示。
图 4-11 。Jenkins 仪表盘显示了添加的新项目
现在我们将使用一个版本的 jUnit 向流程添加一些 TDD。如前所述,测试驱动开发是在编写任何代码之前编写测试,然后编写满足该测试的代码的过程,重复该过程直到特性完成。
通常,当我们运行单元测试时,第一次测试会失败——因为你没有代码——然后当你编写满足测试的代码时,它们应该会变绿。此外,所有的 TDD 类都有一个安装和拆卸以及单元测试。
谷歌推荐的单元测试有很多问题。首先,它是 jUnit3,使用起来很麻烦,而不是 jUnit4。它也没有好的单元测试代码覆盖工具。我通常不会问客户:“你能告诉我你的代码覆盖率是多少吗?”或者争论如何将代码覆盖率从 83%提高到 90%。当你想出一个适合你的数字时,代码覆盖率就足够好了。通常当我听到有人在同一个句子中说“反射”和“代码覆盖”时,我知道他们已经走得太远了。然而,也有一点,你在另一个方向走得太远,根本没有足够的单元测试。Android 的 jUnit3 更容易落入这个陷阱。
隐式采用 Robolectric 允许您使用 jUnit4,包括它的代码覆盖智能。Robolectric 还具有其他一些不错的功能,另外,您的整个 TDD、BDD 和 CI 工具链都使用了最高质量的现代组件。
在下面的例子中,我们为 ToDoList 应用创建了五个简单的测试:
- 如果未创建活动,则
should_create_activity失败。这是最基本的机器人电力测试,可以在任何 Android 应用中使用。 should_find_tasks添加三个任务,如果没有找到新创建的任务,则添加失败。should_add_new_task通过 ToDoProvider 方法添加任务,如果找不到任务则失败。should_add_task_using_ux通过 GUI 添加任务,如果找不到任务,则失败。should_remove_tasks添加任务和删除任务,如果找到新创建的任务则失败。
清单 4-1 显示了 ToDoActvityTest 类,它包括这五个测试,以及 Robolectric/jUnit4 装饰器@RunWith和@Test,它们是编写这类测试的标志。
清单 4-1 。ToDoActivityTest.java
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import android.app.Activity;
@RunWith(RobolectricTestRunner.class)
public class TodoActivityTest
{
@Test
public void should_add_new_task() throws Exception
{
final TodoActivity activity = Robolectric.buildActivity(TodoActivity.class).create().get();
activity.getProvider().addTask("Some task");
final List<String> tasks = activity.getProvider().findAll();
Assert.assertEquals(tasks.size(), 1);
}
@Test
public void should_add_task_using_ux() throws Exception
{
final TodoActivity activity = Robolectric.buildActivity(TodoActivity.class).create().get();
activity.getEditableTextbox().setText("My task");
activity.getSaveTaskButton().performClick();
final int tasks = activity.getTaskListView().getCount();
Assert.assertEquals(tasks, 1);
}
@Test
public void should_create_activity() throws Exception
{
final Activity activity = Robolectric.buildActivity(TodoActivity.class).create().get();
Assert.assertTrue(activity != null);
}
@Test
public void should_find_tasks() throws Exception
{
final TodoActivity activity = Robolectric.buildActivity(TodoActivity.class).create().get();
activity.getProvider().addTask("Some task 1");
activity.getProvider().addTask("Some task 2");
activity.getProvider().addTask("Some task 3");
final List<String> tasks = activity.getProvider().findAll();
Assert.assertEquals(tasks.size(), 3);
}
@Test
public void should_remove_task() throws Exception
{
final TodoActivity activity = Robolectric.buildActivity(TodoActivity.class).create().get();
activity.getProvider().addTask("Some task");
activity.getProvider().deleteTask("Some task");
final List<String> tasks = activity.getProvider().findAll();
Assert.assertEquals(tasks.size(), 0);
}
}
Robolectric 最好与 Maven 构建工具一起使用,而不是 Ant。要优化你的项目,采取清单 4-2 中的步骤。
清单 4-2 。Mavenizing ToDoList
git clone https://github.com/mosabua/maven-android-sdk-deployer.git
cd maven-android-sdk-deployer
mvn install -P 4.3
cd ToDoList
mvn clean test
第一次运行 Maven 或mvn时,它会安装所有丢失的 jar,这需要一些时间。如果项目已经被正确地 been 化,测试输出应该类似于清单 4-3 中的。
清单 4-3 。试验结果
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.example.TodoActivityTest
WARNING: no system properties value for ro.build.date.utc
DEBUG: Loading resources for android from jar:/Users/godfrey/.m2/repository/org/ToDoList/android-res/4.1.2_r1_rc/android-res-4.1.2_r1_rc-real.jar!/res...
DEBUG: Loading resources for com.example from ./res...
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 12.168 sec
Results :
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 19.018s
[INFO] Finished at: Tue Nov 19 20:03:48 EST 2013
[INFO] Final Memory: 18M/81M
[INFO] ------------------------------------------------------------------------
将它作为单元测试添加到 CI 服务器需要能够从命令行运行 Robolectric 代码,这仅仅意味着运行 Maven 测试:
mvn clean test
在第二章第一节中,我们用 Dagger 展示了我们第一次见到的依赖注入 ?? 框架的例子。Dagger 允许您创建模拟框架,以便我们测试我们的代码,而不是到 web 服务的任何网络连接,或者在给定的示例中是 SQLite 数据库。在这个例子中,我们模拟了 ToDoList 应用中的数据提供者。清单 2-1 展示了如何连接两个数据库供应器;第一个是真正的数据库,第二个是存根函数。
倒数第二步是使用 Calabash 添加可执行的需求代码。我们使用 Calabash 来提供 BDD 或行为驱动设计,在这种情况下是以可执行需求的形式。向葫芦人道歉,因为他们没有真正的图标,所以我必须做一个。
当我们开始尝试在我们的 Android 工作中采用敏捷实践时,使用可执行需求的选项根本不存在,但是现在有足够的空间让你使用 cumber 风格的 given/when/then 编程和其他工具。
BDD 通过增加另一层来扩展 TDD,这里称之为验收测试。因此,现在您编写您的可执行文件需求以及您的单元测试,然后在将可执行文件发布给业务涉众之前,通过编写满足测试的代码来使它们都通过。
清单 4-4 给出了一个可执行需求的简单例子。不管这是为了一个 Android 游戏还是网页上的什么东西;需求的描述仍然是一样的。不难看出如何将旧式的用户故事转换成这种格式。
清单 4-4 。实现给定/何时/然后开发
Feature:
As a user I want to see my To Do List and individual reminders
Scenario: Display an individual reminder
Given I wait for the "ToDoListActivity" screen to appear
When I touch the "Get The Milk" text
Then I wait up to 3 seconds for the "ReminderActivity" screen to appear
Then I see the text "Remember to Get The Milk"
如前所述,可执行的需求是在特征文件中编写的,特征文件由一个或多个场景组成,通常有一个小的数据表来驱动场景,然后这些场景与步骤定义文件相匹配。
在 Cucumber 中,特征文件定义需求,步骤定义执行代码。在 Calabash 中,您可以通过特性文件来完成大部分工作,因为优秀的 Calabash 人员已经编写了一个步骤定义库,它将涵盖您正在尝试测试的大多数场景,或者换句话说,他们已经为您完成了艰苦的工作。
要运行葫芦,你首先需要安装葫芦宝石。然后在测试 APK 的calabash文件夹中创建您的特征文件,并调用calabash-android命令。使用以下语法从命令行调用 Calabash 它可以作为 Jenkins 中的另一个执行 shell 来添加。
calabash-android run ToDoListApplication.apk
Calabash 的工作原理是分解您的测试 APK,注入calabash服务器,然后重新组装您的 APK,这样您就可以运行您的测试了。
最后,一旦单元测试和可执行需求全部通过,您需要将 APK 通过电子邮件发送给您的业务涉众。还有其他选项你可能要考虑,比如使用 TestFlightApp 之类的空中部署模型;然而,在 Android 平台上,这可能是多余的,所以我们将只通过电子邮件发送 APK。谢天谢地,Jenkins 有一个电子邮件插件,可以让你简单地添加一个电子邮件收件人列表来发送 APK。
摘要
在你的 Android 项目中加入敏捷实践的目的是什么?通过可重复且可靠的开发,您将更快地从客户那里获得反馈,并始终确保您正在生产一些有用的东西来帮助他们满足基本的业务需求。简而言之,如果你在移动开发中使用敏捷,客户会很高兴,因为他们看到更少的缺陷,因为你在做单元测试和实现可执行的需求。
你会发现移动应用也有很大的可见性。虽然完成一个应用的总费用可能比网络工作要少,但可见性非常高,通常意味着要见到 C 级高管并与之交谈。留下积极的印象通常会带来更多的工作。因此,即使你开始时只使用 TDD,甚至是像使用 Monkey Runner 记录脚本这样简单的事情,从长远来看,它也会帮助你让客户满意。
敏捷宣言(http://www.agilemanifesto.org/principles.html )陈述了它的首要原则。
我们最优先考虑的是通过尽早和持续交付有价值的软件来满足客户。
应用敏捷 Android 的原则让我们到达那里。
最后,你可以随意添加更多的插件到你的 Jenkins 服务器上,比如代码覆盖、性能测试和安全漏洞测试。这不是完整的任务列表;它旨在让您开始走上敏捷之路。寻找需要改进的地方,并逐步应用它们——任何对你和你的团队有助于创建更好的软件的方法都是最好的敏捷过程。
五、原生开发
尽管 Android 框架完全是为基于 Java 的应用而设计的,但 Android 原生开发工具包(NDK)也由 Google 提供,作为 Android SDK 的官方配套工具集,使开发人员能够使用原生机器码生成编程语言(如 C、C++ 和汇编)来实现和嵌入其应用的性能关键部分。
通过 Java 原生接口(JNI) 技术,可以像普通 Java 方法一样无缝访问原生组件。应用的 Java 和本机代码部分运行在同一个进程中。尽管 JNI 技术允许 Java 和本地代码在同一个应用中共存,但它没有扩展 Dalvik 虚拟机(VM)的边界。Java 代码仍然由 Dalvik VM 管理和执行,所有本机代码都应该在应用的整个生命周期中自我管理。这给开发人员增加了额外的责任。
为了有效地与虚拟机并行执行,本机组件应该是好邻居,并与它们的 Java 对应物交互,保持微妙的界限。如果这种交互管理不当,本机组件可能会在应用中导致难以跟踪的错误;这种错误甚至会导致虚拟机崩溃,从而导致整个应用停止运行。
在这一章中,你将学到一些在 Android 平台上开发良好运行的本地组件的最佳实践。
决定在哪里使用本机代码
在本章中,你将学到的第一个最佳实践是正确识别你的应用中可以从使用本机代码支持中获益的组件。
不使用本机代码的地方
关于本机代码,最大也是最常见的错误假设是期望通过简单地用本机代码而不是 Java 编写应用模块来自动提高性能。
使用本机代码并不总是会自动提高性能。尽管众所周知早期版本的 Java 比本地代码慢得多,但最新的 Java 技术经过了高度优化,在许多情况下,速度差异可以忽略不计。Java 虚拟机的 JIT 编译特性,特别是 Android 情况下的 Dalvik VM,允许在应用启动期间将解释的字节码翻译成机器码。然后,在应用的整个执行过程中使用翻译后的机器码,使 Java 应用的运行速度与本地应用一样快。
注意使用本机代码并不总能自动提高性能。
请注意,在您的应用中过度使用本机代码支持很容易导致更大的稳定性问题。因为本机代码不是由 Dalvik VM 管理的,所以大部分内存管理代码必须由您来编写;这本身增加了整个应用的复杂性和代码量。
在哪里使用本机代码
在 Android 应用中使用原生代码绝对不是一个坏习惯。在某些情况下,它变得非常有益,因为它可以提供代码重用并提高一些复杂应用的性能。下面列出了一些可以从本机代码支持中受益的常见领域:
- **使用现有的第三方库:**假设您将在 Android 平台上开发一个视频编辑应用。为了让您的应用运行,它需要能够读写各种视频格式,如 Theora 视频编解码器。Java 框架没有提供任何 API 来处理 Theora。开发处理这种视频格式所需的代码不是一种有效的时间利用,因此您的最佳选择是利用已经可用的第三方库,它可以理解 Theora 视频编解码器。尽管 Java 编程语言很流行,但代码库生态系统仍然高度依赖于基于 C/C++ 的本地代码库。您很有可能会发现 Theora 视频编解码器的各种实现都是 C/C++ 库。本机代码支持在这里变得非常方便,因为它可以让您将本机 C/C++ 库无缝地融合到您的 Android 应用中。使用本机代码支持来促进代码重用是一个很好的实践,因为这有助于开发过程。
- **性能关键代码的硬件特定优化:**作为一种独立于平台的编程语言,Java 不提供任何使用 CPU 特定特性来优化 Android 应用的性能关键部分的机制。与桌面平台相比,移动设备资源非常稀缺。对于具有高性能要求的复杂应用,如 3D 游戏和多媒体应用,有效地利用每一个可能的 CPU 功能至关重要。ARM 处理器,如 ARM NEON 和 ARM VFPv3-D32,提供了额外的指令集,允许移动应用对许多性能关键型操作进行硬件加速。使用本机代码支持来受益于这些 CPU 特定的特性是一个很好的实践。
Java 本地接口
如本章前面所述,JNI 是一种机制和一组 API,由 Java 虚拟机公开,使开发人员能够使用本机编程语言编写 Java 应用的各个部分。这些本地组件可以像普通的 Java 方法一样从 Java 代码中透明地访问。JNI 还提供了一组 API 函数,使本地代码能够访问 Java 对象。本地组件可以创建新的 Java 对象或使用由 Java 应用创建的对象,Java 应用可以检查、修改和调用这些对象上的方法来执行任务。
使用 JNI 编写本地代码的困难
通过 JNI 将本机代码集成到 Java 应用中需要使用符合 JNI 规范的特制名称来声明本机函数。除了函数名,本机函数的每个参数也应该使用 JNI 数据类型。因为 Java 和本机代码是在不同的筒仓中编译的,所以这部分代码中的任何问题在编译时都是不可见的。
从本机代码回到 Java 空间也需要一系列 API 调用。由于本机编程语言不了解代码的 Java 部分,因此如果您使用了错误的 API 调用,它不会提供任何编译时错误。此外,代码的 Java 部分的更改也可能破坏代码的本机部分,而且在编译时也不会通知您这一点。
即使您采取了非常措施来防止 bug 的发生,保持本地方法和它们在 Java 空间中的声明保持一致也是一项麻烦和多余的任务。在本节中,您将学习如何利用可用的工具来自动生成必要的代码,而不是手动键入代码。
使用工具生成代码
几乎每一种编程语言都有一个共同的良好实践,那就是作为一名优秀的开发人员,您应该尽可能减少手动生成的代码行数。您生成的任何代码行都必须在应用的整个生命周期中进行维护。作为一种良好的实践,您应该始终利用 SDK 和 ide 提供的代码生成器来实现这一点。
提示从 SDK 提供的代码生成器中获益,最大限度地减少您需要编写的代码量。
使用 javah 生成 C/C++ 头文件
工具是 Java JDK 发行版的一部分。它使用本机方法声明对 Java 类文件进行操作,并基于 JNI 规范生成相应的 C/C++ 头文件,这些头文件带有适当的签名。因为生成的头文件不会被开发人员修改,所以您可以任意多次调用javah来保持本地方法声明的同步。
javah工具是一个独立的应用,位于您机器上的<JDK_HOME>/bin目录 中。在没有任何命令行参数的情况下调用它将显示可用参数的列表。根据您的项目结构和独特的需求,您可以决定在构建过程中的什么地方使用javah工具。
下面是一个简单的例子,演示了javah是如何工作的。为了简单起见,并且尽可能独立于平台,在这个例子中,您将通过扩展 Android ANT 构建框架的 ANT 构建脚本来使用javah。这里只突出显示源代码的相关部分。你可以从这本书的网站下载完整的源代码 。
-
As shown in Listing 5-1, define a new ANT task called
headersin thecustom_rules.xmlfile in order to extend the Android build system with the ability to generate C/C++ header files for native methods. List your classes with native modules accordingly. Thejavahtool will process only the classes that are explicitly mentioned.清单 5-1 。
custom_rules.xml文件的内容<?xml version="1.0" encoding="UTF-8"?> <project name="custom_rules"> <target name="headers" depends="debug"> <path id="headers.classpath"> <path refid="project.all.jars.path" /> <path path="${out.classes.absolute.dir}" /> </path> <property name="headers.bootclasspath.value" refid="project.target.class.path" /> <property name="headers.classpath.value" refid="headers.classpath" /> <property name="headers.destdir" value="jni" /> <echo message="Generating C/C++ header files..." /> <mkdir dir="${headers.destdir}" /> <javah destdir="${headers.destdir}" classpath="${headers.classpath.value}" bootclasspath="${headers.bootclasspath.value}" verbose="true"> <!-- List of classes with native methods. --> <class name="com.apress.example.MainActivity" /> </javah> </target> </project> -
Assume that your Android application contains a native method, called
nativeMethod, within theMainActivityclass as shown in Listing 5-2.清单 5-2 。MainActivity.java 文件的内容带有原生方法
public class MainActivity extends Activity { ... /** * Native method that is implemented using C/C++. * * @param index integer value. * @param activity activity instance. * @return string value. * @throws IOException */ private static native String nativeMethod(int index, Activity activity) throws IOException; } -
现在,您可以通过在命令行上调用以下命令来使用 ANT 脚本:
ant headers -
This will first trigger a full compile of your application, for the class files to be generated. Then it will invoke the
javahtool on the specified class files to parse the method signatures of your native methods. While thejavahtool is working, it will print a status message as shown in Listing 5-3.清单 5-3 。生成头文件的 javah 工具
headers: [echo] Generating C/C++ header files... [mkdir] Created dir: C:\src\JavahTest\jni [javah] [Creating file ... [com_apress_example_MainActivity.h]] -
The
javahtool will generate a set of header files in thejnisubdirectory of your project. The header files will be named according to the name of the Java class that encapsulates the native method. In this example, the header filecom_apress_example_MainActivity.hheader fill will be generated. As shown in Listing 5-4, the content of this header file will include the native function signature for each native method that you need to implement.清单 5-4 。生成的 C/C++ 头文件
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_apress_example_MainActivity */ #ifndef _Included_com_apress_example_MainActivity #define _Included_com_apress_example_MainActivity #ifdef __cplusplus extern "C" { #endif ... /* * Class: com_apress_example_MainActivity * Method: nativeMethod * Signature: (ILandroid/app/Activity;)Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_com_apress_example_MainActivity_nativeMethod (JNIEnv *, jclass, jint, jobject); #ifdef __cplusplus } #endif #endif -
正如头文件顶部所建议的,您不应该直接修改这个头文件,因为每次您执行
javah工具时,它都会被覆盖。相反,您应该在一个单独的 C/C++ 源文件中提供在这个头文件中声明的所有本机方法的实现。
因为代码的 Java 和本机部分在两个独立的筒仓中,Android 构建系统在构建应用时不执行任何验证。一旦在运行时 被调用,任何丢失的本地函数仅仅触发一个java.lang.UnsatisfiedLinkError。javah工具通过自动生成签名来帮助您防止这些错误。
提示使用javah工具有助于在你的 Android 应用中防止java.lang.UnsatisfiedLinkError运行时异常。
由于每个本机方法都是在头文件中声明的,因此这些函数的任何缺失实现都会触发一个编译时错误,从而阻止您发布包含缺失实现的 Android 应用。
使用 SWIG 生成 JNI 码
在上一节中,您学习了如何使用javah工具。虽然javah通过生成本机函数签名并使它们与 Java 代码保持同步来帮助您,但是您仍然必须提供包装器代码来将这些本机函数的本机实现粘合到 Java 层。这将需要您使用大量的 JNI API 调用,这是一项繁琐而耗时的开发任务。
在本节中,您将了解另一个强大的工具,称为简化包装器和接口生成器(SWIG) 。它通过生成必要的 JNI 包装器代码 ,简化了开发本地函数的过程。SWIG 是一个接口编译器,仅仅是一个代码生成器;它没有定义新的协议,也不是一个组件框架或专门的运行时库。SWIG 将一个接口文件作为其输入,并生成必要的代码来在 Java 中公开该接口。SWIG 不是存根生成器;它生成可以编译和运行的代码。可以在www.swig.org从其官网下载 SWIG。一个简单的示例应用将帮助您更好地理解 SWIG 是如何提供帮助的。
在这个例子中,假设您需要在运行时获得 Android 应用的 Unix 用户名。这些信息可以通过 POSIX getlogin函数获得,该函数只能从本机 C/C++ 代码中访问,而不能从 Java 中访问。尽管平台已经提供了这个函数的实现,但是你仍然需要编写 JNI API 调用来将这个函数的结果暴露给 Java 空间,如清单 5-5 所示。
清单 5-5 。通过 JNI 暴露的 Getlogin 函数
JNIEXPORT jstring JNICALL Java_com_apress_example_Unix_getlogin(JNIEnv* env, jclass clazz) {
jstring loginString = 0;
const char* login = getlogin();
if (0 != login) {
loginString = env->NewStringUTF(login);
}
return loginString;
}
SWIG 可以通过自动生成这段代码来帮助你。为了让 SWIG 知道包装哪个函数,您需要在 SWIG 接口文件中指定它。如前所述,SWIG 是一个接口编译器;它根据提供的接口生成代码。用于公开getlogin函数的 SWIG 接口文件如清单 5-6 所示。
清单 5-6 。Unix.i SWIG 接口文件
/* Module name is Unix. */
%module Unix
%{
/* Include the POSIX operating system APIs. */
#include <unistd.h>
%}
/* Ask SWG to wrap getlogin function. */
extern char* getlogin(void);
假设您已经在工作站上安装了 SWIG 工具,并且 SWIG 二进制目录已经添加到 PATH 环境变量中,请在命令提示符下调用以下命令:
swig -java
-package com.apress.example
-outdir src/com/apress/example
jni/Unix.i
SWIG 工具处理Unix.i接口文件并生成jni目录中的Unix_wrap.c C/C++ JNI 包装代码,如清单 5-7 所示,以及com.apress.example Java 包中的UnixJNI.java和Unix.java Java 代理类。
清单 5-7 。SWIG 生成的 Unix_wrap.c 原生源文件
/* ----------------------------------------------------------------------------
* This file was automatically generated by SWIG (http://www.swig.org).
* Version 2.0.11
*
* This file is not intended to be easily readable and contains a number of
* coding conventions designed to improve portability and efficiency. Do not make
* changes to this file unless you know what you are doing--modify the SWIG
* interface file instead.
* ----------------------------------------------------------------------------- */
#define SWIGJAVA
...
/* Include the POSIX operating system APIs. */
#include <unistd.h>
#ifdef __cplusplus
extern "C" {
#endif
SWIGEXPORT jstring JNICALL Java_com_apress_example_UnixJNI_getlogin(JNIEnv *jenv, jclass jcls) {
jstring jresult = 0 ;
char *result = 0 ;
(void)jenv;
(void)jcls;
result = (char *)getlogin();
if (result) jresult = (*jenv)->NewStringUTF(jenv, (const char *)result);
return jresult;
}
...
要使用本机函数,现在只需在应用中使用来自com.apress.example.Unix类的getlogin Java 方法。无需编写任何 JNI 包装器代码,SWIG 就能让您在 Android 应用中使用本机功能。
最小化 JNI API 调用的数量
尽管 SWIG 工具很有前途,但不用说,仍然会有自动代码生成不可行的情况。在这些情况下,您将需要编写必要的 JNI API 调用来提供该功能。即使无法阻止手动 JNI API 调用,最小化此类调用的数量仍然有助于优化整个应用并减少代码占用。在本节中,您将了解一些最佳实践,以最大限度地减少应用中所需的 JNI API 调用次数。
使用原始数据类型作为本机方法参数
Java 编程语言中有两种数据类型:原始数据类型,如byte、short、int和float,以及复杂数据类型,如Object、Integer和String。JNI 可以自动将大多数原始数据类型映射到 C/C++ 原始数据类型。本机函数可以直接使用作为原始类型传递的数据,而无需进行任何特定的 JNI API 调用,如表 5-1 所示。
表 5-1 。原始数据类型映射
但是,复杂数据类型作为对本机函数的不透明引用传递。为了使用这些数据,本地函数必须进行各种 JNI API 调用来提取原始数据格式的数据片段,以便在本地代码中使用。在定义本机方法时,作为一种最佳实践,应尽可能地消除参数列表和返回值中的复杂数据类型。这将帮助您最大限度地减少本机代码中 JNI API 调用的数量,并且还将显著提高本机函数的性能。
最小化从本机代码到 Java 空间的回溯
本机函数不受通过其参数传递给它的数据的限制。JNI 提供了必要的 API,使本地代码能够与 Java 空间进行交互。这种灵活性是有代价的。使用 JNI API 调用从本机代码返回到 Java 空间会消耗 CPU 周期并影响应用性能;同时,由于必需的 JNI API 调用的数量,它增加了本机代码的复杂性。
作为一个最佳实践,请确保通过参数将所有需要的数据传递给本机函数,而不是让本机函数返回 Java 空间来获取它们。
看看下面的代码示例。如清单 5-8 所示,本地代码通过多次 JNI API 调用来访问它需要的数据。
清单 5-8 。从对象实例访问两个字段的本机方法
JNIEXPORT void JNICALL Java_com_apress_example_Unix_method(JNIEnv* env, jobject obj) {
jclass clazz = env->GetObjectClass(obj);
jfieldID field1Id = env->GetFieldID(clazz, "field1", "Ljava/lang/String;");
jstring field1Value = env->GetObjectField(obj, field1Id);
jfieldID field2Id = env->GetFieldID(clazz, "field2", "Ljava/lang/Integer;");
jobject field2Value = env->GetObjectField(obj, field2Id);
...
}
如清单 5-9 所示,本地方法声明可以修改为包含field1和field2作为本地方法参数的一部分,以消除那些 JNI API 调用。
清单 5-9 。字段 1 和字段 2 都直接传递给了本机方法
JNIEXPORT jstring JNICALL Java_com_apress_example_Unix_method(JNIEnv* env, jobject obj,
jstring field1, jobject field2) {
...
}
为了避免 Java 空间中的冗余编码,通常的做法是利用助手方法在调用本地方法之前聚集这些额外的数据项,而不是要求开发人员每次都传递它们,如清单 5-10 所示。
清单 5-10 。聚集必要参数的帮助器方法
public void method() {
jniMethod(field1, field2);
}
public native void jniMethod(String field1, Integer field2);
内存使用
与基于桌面的平台相比,内存是移动设备上的稀缺资源。Java 被称为托管编程语言,这意味着 Java 虚拟机(JVM) 代表开发人员管理应用内存。在应用的执行过程中,JVM 会留意对分配的内存区域的可用引用。当 JVM 检测到应用代码无法再到达分配的内存区域时,它会通过一种称为垃圾收集 的机制自动释放内存。这将开发人员从直接管理应用内存中解放出来,并极大地降低了代码的复杂性。
JVM 垃圾收集器的边界仅限于 Java 空间。因为本机代码不在托管环境中运行,所以 JVM 垃圾收集器无法监视或释放应用在本机空间中分配的内存。开发人员有责任正确管理本机空间中的应用内存。否则,应用很容易导致设备内存不足。这可能会危及应用和设备的稳定性。
在本节中,您将了解在本机空间中有效使用内存的一些最佳实践。
本地参考
正如在 Java 领域一样,引用在本机领域也继续扮演着重要的角色。JNI 支持三种引用:局部引用、全局引用和弱全局引用。因为 JVM 垃圾收集器不适用于本机空间,所以 JNI 提供了一组 API 调用,使开发人员能够管理每个引用类型的生命周期。
传递给本机函数的所有参数都是局部引用。此外,大多数 JNI API 调用也返回本地引用。
从不缓存本地引用
局部引用的生命周期受限于本机方法本身的生命周期。一旦本地方法返回,JVM 就释放所有本地引用,无论这些引用是传入的还是在本地方法中分配的。因此,您不能在后续调用中缓存和重用这些本地引用。要重用一个引用,你必须使用NewGlobalRef JNI API 调用,基于本地引用显式创建一个全局引用,如清单 5-11 所示。
清单 5-11 。从局部引用获取全局引用
jobject globalObject = env->NewGlobalRef(localObject);
if (0 != globalObject) {
// You can now cache and reuse globalObject
}
当不再需要全局引用时,可以使用DeleteGlobalRef JNI API 调用来释放它:
env->DeleteLocalRef(globalObject);
与往常一样,通过将必要的数据作为参数直接传递给本机方法,可以避免本机空间中的全局引用。否则,管理本地代码中全局引用的生命周期是开发人员的责任,因为它们不是由 JVM 管理的。
在复杂的本地方法中释放本地引用
尽管 JVM 仍然管理本地引用的生命周期,但它只能在原生方法返回后才能这样做。因为 JVM 不了解本机方法的内部,所以当本机方法执行时,它不能接触本地引用。因此,开发人员有责任在本地方法执行期间管理本地引用。
注意请注意,本地引用的内存占用并不是您需要管理它们的唯一原因;在本地方法执行期间,JVM 本地引用表最多只能保存 512 个本地引用。如果本地引用表溢出,JVM 将终止您的应用。
为了更好地理解这个问题,请看一下清单 5-12 中的代码。
清单 5-12 。本机代码分配本地引用
jsize len = env->GetArrayLength(nameArray); // len = 600
for (jsize i=0; i < len; i++) {
jstring name = env->GetObjectArrayElement(nameArray, i);
...
}
如您所见,如果stockQuotes数组中的元素数量大于 512,您的应用将会崩溃。要解决这个问题,请看一下 for 循环的主体。每次循环迭代,变量quote的值只使用一次,前一个值变得不可达;但是,它仍然留在本地引用表中,因为 JVM 不知道本地方法的内部情况。
为了解决这个问题,一旦知道本地引用不会在本机方法中使用,就应该使用DeleteLocalRef JNI API 调用来释放本地引用。进行必要的修改后,代码看起来如清单 5-13 所示。
清单 5-13 。本机代码释放本地引用
jsize len = env->GetArrayLength(nameArray);
for (jsize i=0; i < len; i++) {
jstring name = env->GetObjectArrayElement(nameArray, i);
...
env->DeleteLocalRef(name);
}
这段代码可以处理大量的元素,而不会导致应用崩溃,因为本地引用表不会溢出。
处理字符串
Java 字符串由 JNI 作为引用类型来处理。这些引用类型不能直接用作本机 C 字符串。JNI 提供了必要的函数将这些 Java 字符串引用转换成 C 字符串,如清单 5-14 所示。
清单 5-14 。将 Java 字符串转换成 C 字符串
const jbyte* str;
jboolean isCopy;
str = env->GetStringUTFChars(javaString, &isCopy);
if (0 != str) {
/* You can use the string as an ordinary C string. */
}
一旦 Java 字符串被转换成 C 字符串,它就是一个指向字符数组的指针。因为 JNI 不能再自动管理内存分配,开发者有责任使用ReleaseString 或ReleaseStringUTF函数显式释放这些字符数组,如清单 5-15 所示。否则会发生内存泄漏。
清单 5-15 。释放 C 弦
const jbyte* str;
jboolean isCopy;
str = env->GetStringUTFChars(javaString, &isCopy);
if (0 != str) {
/* You can use the string as an ordinary C string. */
env->ReleaseStringUTFChars(javaString, str);
str = 0;
}
使用适当的内存管理功能
尽管 Java 编程语言没有内存管理功能,但 C/C++ 空间有多种管理内存的方法。此外,JNI 还引入了一系列功能来管理证明资料的生命周期:
malloc和free函数是 C 代码中管理内存的方法。new和delete函数由 C++ 引入,是 C++ 应用中管理内存的正确方法。- JNI 提供了
DeleteLocalRef、DeleteGlobalRef和其他函数,使应用能够管理本地空间中 JNI 对象的内存。JNI 获得的任何参考资料都应使用这些方法发布。
在复杂的应用中,由于没有明确的方法来检测用于为数据变量分配内存的方法,开发人员很容易通过使用错误的内存管理函数对在代码中引入问题。至少,在 C++ 代码中用 new 和delete替换malloc和free是一个很好的做法。
对数组进行操作
如本章前面所述,虽然基本数据类型直接映射到本机数据类型,但复杂数据类型作为不透明引用传递,本机代码可以通过一组 JNI API 调用来利用它们。因为数组也是复杂数据类型的一部分,所以 JNI 也提供 API 调用来在本机空间操作 Java 数组。使用多种 API 方法的主要原因是它们中的每一种都是为不同的用例专门设计的。针对应用的独特需求使用正确的 API 调用是一个很好的实践,可以提高应用的性能。同样,使用错误的 API,或者使用正确的 API 而不小心设置参数,都会严重影响应用的整体性能。
不要请求不必要的数组元素
为了保持 Java 代码和本地代码在不同的筒仓中运行而不相互影响,JNI 不提供对实际数据的直接访问。通过它提供的不透明引用,JNI 允许本地代码通过指定的 JNI API 函数与实际数据进行交互。这确保了通信只通过 JNI API,而不通过其他媒体。在某些场景中,比如在数组上操作,为每个数据片段从本机空间返回到 Java 空间会带来难以承受的性能开销。JNI 通过复制实际数据并让本机代码作为普通的本机数据集在其上交互来解决这个问题。调用Get<Type>ArrayElements JNI API 会在本机代码中生成实际数组的完整副本。虽然这听起来像是一种操作数组的便捷方式,但这是有代价的。在大型数组上操作时,需要复制整个数组,以便本机代码开始处理它。一旦本机代码完成了对数组数据的操作,它就可以调用Release<Type>ArrayElements JNI API 调用,将更改应用回 Java 数组,并释放其副本。如前所述,本机方法的内部对于 JNI 是完全不透明的,它不知道数组的哪些元素在本机代码中被修改了。因此,它只是将每个元素复制回原始的 Java 数组。为了更好地理解结果,请看一下清单 5-16 中的示例代码。
清单 5-16 。用本机代码修改整个 Java 数组
jsize len = env->GetArrayLength(stockQuotesArray); // len = 1000
jint* stockQuotes = env->GetIntArrayElements(stockQuotesArray, 0);
stockQuotes[0] = 1;
stockQuotes[1] = 2;
env->ReleaseIntArrayElements(stockQuotesArray, stockQuotes, 0);
这段代码有两个主要问题:
- 虽然整个 1000 个元素被
GetIntArrayElements复制,但是只有前两个元素被本机代码访问。本例中剩余的 998 个元素只是浪费 CPU 周期和运行时内存。 - 在调用
ReleaseIntArrayElements时,JNI 开始将所有 1000 个元素从本机数组复制回 Java 数组,因为 JNI 不知道只有前两个元素被本机代码修改了。
作为一种良好的做法,请确保您只从 JNI 请求相关的数据。如果您的应用只需要更大数组的一个子集,那么用Get<Type>ArrayRegion替换对Get<Type>ArrayElements API 函数的 API 调用。Get<Type>ArrayRegion JNI API 允许您定义数据区域,并且它只复制特定的区域。这确保了只有重要的数据才会被处理,如清单 5-17 所示。
清单 5-17 。在本机代码中修改 Java 数组的一部分
jint stockQuotes[2];
env->GetIntArrayRegion(stockQuotesArray, 0, 2, stockQuotes);
stockQuotes[0] = 1;
stockQuotes[1] = 2;
env->SetIntArrayRegion(stockQuotesArray, 0, 2, stockQuotes);
防止更新未更改的数组
在某些情况下,您只需要访问 Java 数组来读取它的值。尽管 JNI 不支持只读数据的概念,但是您可以明确地通知 JNI 不要将值写回 Java 数组。为此,使用Release<Type>ArrayElements函数的最后一个参数mode;
void Release<Type>ArrayElements(JNIEnv* env, ArrayType array,
NativeType* elements, jint mode );
mode参数可以取以下值:
- 0: 复制回内容,释放原生数组。
- **JNI 提交:**复制回内容,但不释放本机数组。
- **JNI _ 中止:**释放本机数组而不复制其内容。
大多数开发人员只是通过将0传递给它来触发默认的操作模式,从而忽略了这个参数。相反,根据独特的用例将适当的模式传递给 JNI API 调用是一个好的实践。如果开发人员知道数据不会在原生方法中被修改,代码应该通过JNI_ABORT通知 JNI 它可以释放原生数组而不用复制回它的内容。
本机输入/输出
尽管通过将数组拷贝限制在较大数据的一个小的子集来最小化其影响可以使许多用例受益,但是仍然会有无法应用这种最佳实践的情况。例如,开发一个多媒体应用将需要您操作包含高分辨率视频帧或多通道音频数据等数据的大型数组。在这种情况下,您将无法将数据的边界限制在一个很小的集合中,因为所有这些都需要由本机代码来消耗。
在这种情况下,您可以依赖 JNI 本地 I/O (NIO) API 调用。NIO 在缓冲区管理、可伸缩的网络和文件 I/O 以及字符集支持方面提供了改进的性能。JNI 提供了从本机代码使用 NIO 缓冲区的函数。与数组操作相比,NIO 缓冲区提供了更好的性能。NIO 不会复制数据;它只是提供对它的直接内存访问。因此,NIO 缓冲区非常适合在本机代码和 Java 应用之间传递大量数据。
假设 NIO 缓冲区作为java.nio.ByteBuffer类的一个实例被分配在 Java 空间上,你可以通过调用GetDirectBufferAddress JNI API 来获得一个指向其内存的直接指针,如清单 5-18 所示。
清单 5-18 。获取指向字节缓冲存储器的直接指针
unsigned char* buffer;
buffer = (unsigned char*) env->GetDirectBufferAddress(directBuffer);
对于希望从本机代码支持中获益的数据密集型 Android 应用来说,使用 NIO 缓冲区进行操作是最佳实践。
缓存类、方法和字段 id
JNI 不直接在本机代码中公开 Java 类的字段和方法。相反,它提供了一组 API 来间接访问它们。例如,要获取类的字段值,将采取以下步骤:
- 通过
FindClass函数获得对类对象的引用。 - 通过
GetFieldID函数获取将要访问的字段的 ID。 - 通过向
Get<Type>Field函数提供类实例和字段 ID 来获得字段的实际值。
虽然它们在 JNI 应用中使用非常频繁,但是GetFieldID和GetMethodID函数本质上都是非常繁重的函数调用。正如您所想象的,这些函数必须遍历整个继承链,以便类识别要返回的正确 ID。因为在应用执行期间,类对象、类继承和字段 ID 都不能改变,所以这些值实际上可以缓存在本机层中,以便用较少的 API 调用进行后续访问。
FindClass函数的返回类型是局部引用。为了缓存它,你需要首先通过NewGlobalRef函数创建一个全局引用。另一方面,GetFieldID的返回值是jfieldID,简单来说就是一个整数,可以按原样缓存。
提示虽然您可以提高 JNI 函数从本机空间访问 Java 字段和方法的性能,但是 Java 和本机代码之间的转换是一个代价很高的操作。强烈建议您在决定在哪里拆分 Java 和本机代码时考虑到这一点。最小化 Java 和本机代码之间的可及性可以提高应用的性能。
作为一个良好的实践,您应该关注缓存在应用执行期间被多次访问的片段的字段和方法 id。
穿线
JNI 不对本地代码的执行模型施加任何限制。Java 代码和本机代码都可以通过使用线程来实现并行处理。这些线程可以是 Java 线程,也可以是平台线程,比如 POSIX 线程。这种灵活性使得通过 JNI 重用现有本机模块作为 Java 应用的一部分变得更加容易,因为线程模型保持兼容。
尽管这两种线程机制可以并行同时运行,但是如果您希望本机的非 Java 线程访问任何 JNI 函数,就要记住 JNI 的某些限制。
从不缓存 JNI 环境接口指针
如本章前面所述,通过方法参数或 JNI API 调用获得的本地引用不能在本地方法调用的执行范围之外缓存和重用。
此外,为了执行任何 JNI API 函数,指向 JNI 环境接口(JNIEnv)的指针需要对本地代码可用。与本地引用一样,JNIEnv接口指针也只在本地方法调用的执行范围内有效,并且不能被缓存和重用。
为了获得当前线程正确的JNIEnv接口指针,需要将它附加到 Java VM。
永远不要从分离的本机线程访问 Java 空间
你可以通过JavaVM接口的AttachCurrentThread函数将你的非 Java 线程附加到 Java 虚拟机上。JavaVM 接口指针可以通过GetJavaVM函数调用从有效的JNIEnv接口获得,如清单 5-19 所示。
清单 5-19 。获取 JavaVM 的 GetJavaVM 函数
static JavaVM* vm = 0;
JNIEXPORT jstring JNICALL Java_com_apress_example_Unix_init(JNIEnv* env, jclass clazz) {
if (0 != env->GetJavaVM( &vm)) {
/* Error occured. */
} else {
/* JavaVM obtained. */
}
}
获得的JavaVM指针可以被缓存并在本地线程中使用。在从非 Java 线程使用JavaVM接口调用AttachCurrentThread函数时,本地线程将被添加到 Java VM 的已知线程列表中,并且将返回当前线程的唯一JNIEnv接口指针,如清单 5-20 所示。
清单 5-20 。将当前本机线程附加到 Java 虚拟机
void threadWorker() {
JNIEnv* env = 0;
if (0 = (*vm)->AttachCurrentThread(vm, &env, NULL)) {
/* Error occurred. */
} else {
/* JNI API can be accessed using the JNIEnv. */
}
}
注意如果非 Java 线程已经连接到 Java VM,后续调用不会有任何副作用。
现在,使用适当的JNIEnv接口指针,您可以从非 Java 线程访问 JNI API 函数。线程的JNIEnv接口指针保持有效,直到使用DetachCurrentThread函数分离线程,如清单 5-21 所示。
清单 5-21 。从 Java 虚拟机分离当前本机线程
(*vm)->DetachCurrentThread();
env = 0;
解决纷争
尽管 Java 代码很简单,但调试本机代码可能非常复杂。当你面对意想不到的事情时,掌握故障排除技能就成了救命稻草。了解正确的工具和技术可以让您快速解决问题。在这一节中,您将简要探索一些在本机代码中排除问题的最佳实践。
扩展 JNI 检查
为了在运行时提供高性能,JNI 函数很少进行错误检查。错误通常会导致难以排除的崩溃。Dalvik VM 为 JNI 调用提供了一种扩展的检查模式,称为 CheckJNI 。当它被启用时,JavaVM 和 JNIEnv 接口指针被切换到函数表,这些函数表在调用实际实现之前执行扩展级别的错误检查。CheckJNI 可以检测以下问题:
- 尝试分配负大小的数组
- 传递类名时,传递给 JNI 函数语法错误的错误或
NULL指针 - 在危急关头打 JNI 电话
- 传递给
NewDirectByeBuffer的参数不正确 - 异常待定时进行 JNI 调用
JNIEnv接口指针用在了错误的线程中- 字段类型和
Set<Type>Field功能不匹配 - 方法类型和
Call<Type>Method函数不匹配,例如用错误的引用类型调用了DeleteGlobalRef/DeleteLocalRef - 传递给
Release<Type>ArrayElement功能的错误释放模式 - 从本机方法返回的类型不兼容
- 传递给 JNI 调用的 UTF-8 序列无效
默认情况下,CheckJNI 模式只在模拟器中启用,而不在常规的 Android 设备上启用,因为它会影响系统的整体性能。在常规设备上,可以通过在命令提示符下发出以下命令来启用 CheckJNI 模式:
adb shell setprop debug.checkjni 1
这不会影响正在运行的应用,但是之后启动的任何应用都将启用 CheckJNI。观察运行在 CheckJNI 模式下的应用是一个很好的实践,这样可以在本地代码中的任何问题导致应用出现更复杂的问题之前发现它们。
总是检查 Java 异常
异常处理是 Java 编程语言的一个重要方面。异常在 JNI 中的行为与在 Java 中不同。在 Java 中,当抛出异常时,虚拟机停止代码块的执行,并以相反的顺序遍历调用堆栈,以找到可以处理特定异常类型的异常处理程序代码块。这也称为捕获异常。虚拟机清除异常并将控制转移到异常处理程序块。相反,JNI 要求开发人员在异常发生后显式实现异常处理流程。
您可以使用 JNI API 调用ExceptionOccurred捕获本机代码中的 Java 异常。该函数查询 Java VM 是否有任何挂起的异常,并返回异常 Java 对象的本地引用,如清单 5-22 所示。
清单 5-22 。捕捉和处理本机代码中的异常
jthrowable ex;
...
env->CallVoidMethod(instance, throwingMethodId);
ex = env->ExceptionOccurred(env);
if (0 != ex) {
env->ExceptionClear(env);
/* Exception handler. */
}
不这样做不会阻止您的本机函数的执行;但是,对 JNI API 的任何后续调用都将无声无息地失败。这可能变得很难排除故障,因为实际的异常不会留下任何痕迹。
一个好的做法是,在调用任何可能抛出异常的 Java 方法之后,应该总是检查是否抛出了 Java 异常。
在处理异常时,您还应该使用ExceptionClear函数来清除它,以通知 Java VM 异常已被处理,JNI 可以继续为 Java 空间的请求提供服务。
始终检查 JNI 返回值
异常是编程语言的扩展,用于开发人员报告和处理需要在应用实际流程之外进行特殊处理的异常事件。尽管异常从一开始就是 Java 编程语言的一部分,但异常支持并没有在所有平台上广泛用于 C/C++ 编程语言。因为 JNI 被设计成一个通用的解决方案,便于将本机模块集成到 Java 应用中,所以它不使用异常。相反,JNI API 函数依靠它们的返回值来指示 API 调用执行过程中的任何错误,如清单 5-23 所示。
清单 5-23 。检查 JNI API 调用的返回值
jclass clazz;
...
clazz = env->FindClass("java/lang/String");
if (0 == clazz) {
/* Class could not be found. */
} else {
/* Class is found, you can use the return value. */
}
因此,作为一个好的实践,永远不要假设按原样使用 JNI API 调用的返回值是安全的。总是检查返回值,以确保 JNI API 调用被成功执行,并且正确的可用值被返回到您的本机函数。
开发时总是添加日志行
日志记录是故障排除最重要的部分,但是很难实现,尤其是在移动平台上,应用的开发和执行发生在两台不同的机器上。作为一个良好的实践,您应该在开发应用时,而不是在尝试解决问题时,包含日志消息,因为到那时已经太晚了。在应用中加入适当的日志记录功能,可以帮助您通过查看应用的日志输出更容易地解决问题。不用说,读取和共享日志消息比使用复杂的调试器应用来检查应用的执行要容易得多。
虽然在应用中添加日志记录是一个很有吸引力的解决方案,但是拥有大量的日志记录会影响应用的性能,而且还会向外部公开太多的应用内部流程。尽管在开发和故障排除阶段进行广泛的日志记录是有好处的,但是您应该在发布应用之前将这些组件从应用中剥离出来。尽管 Java 空间中有大量的日志框架可用,但对于 C/C++ 代码来说,选择相当有限。在本节中,您将通过为 C/C++ 代码构建一个小型日志框架来填补这个空白。
为了实现高级日志框架所提供的相同功能,本节中介绍的解决方案将在很大程度上依赖于本机 C/C++ 编译器提供的预处理器支持。清单 5-24 中的my_log.h头文件通过一组预处理器指令包装了 Android 本地日志 API,以提供对日志强度的编译时控制。
清单 5-24 。my_log.h日志头文件
#pragma once
/**
* Basic logging framework for NDK.
*
* @author Onur Cinar
*/
#include <android/log.h>
#define MY_LOG_LEVEL_VERBOSE 1
#define MY_LOG_LEVEL_DEBUG 2
#define MY_LOG_LEVEL_INFO 3
#define MY_LOG_LEVEL_WARNING 4
#define MY_LOG_LEVEL_ERROR 5
#define MY_LOG_LEVEL_FATAL 6
#define MY_LOG_LEVEL_SILENT 7
#ifndef MY_LOG_TAG
# define MY_LOG_TAG __FILE__
#endif
#ifndef MY_LOG_LEVEL
# define MY_LOG_LEVEL MY_LOG_LEVEL_VERBOSE
#endif
#define MY_LOG_NOOP (void) 0
#define MY_LOG_PRINT(level,fmt,...) \
__android_log_print(level, MY_LOG_TAG, "(%s:%u) %s: " fmt, \
__FILE__, __LINE__, __PRETTY_FUNCTION__, ##__VA_ARGS__)
#if MY_LOG_LEVEL_VERBOSE >= MY_LOG_LEVEL
# define MY_LOG_VERBOSE(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_VERBOSE, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_VERBOSE(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_DEBUG >= MY_LOG_LEVEL
# define MY_LOG_DEBUG(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_DEBUG, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_DEBUG(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_INFO >= MY_LOG_LEVEL
# define MY_LOG_INFO(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_INFO, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_INFO(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_WARNING >= MY_LOG_LEVEL
# define MY_LOG_WARNING(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_WARN, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_WARNING(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_ERROR >= MY_LOG_LEVEL
# define MY_LOG_ERROR(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_ERROR, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_ERROR(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_FATAL >= MY_LOG_LEVEL
# define MY_LOG_FATAL(fmt,...) \
MY_LOG_PRINT(ANDROID_LOG_FATAL, fmt, ##__VA_ARGS__)
#else
# define MY_LOG_FATAL(...) MY_LOG_NOOP
#endif
#if MY_LOG_LEVEL_FATAL >= MY_LOG_LEVEL
# define MY_LOG_ASSERT(expression, fmt, ...) \
if (!(expression)) \
{ \
__android_log_assert(#expression, MY_LOG_TAG, \
fmt, ##__VA_ARGS__); \
}
#else
# define MY_LOG_ASSERT(...) MY_LOG_NOOP
#endif
为了使用这个小小的日志框架,只需包含my_log.h头文件:
#include "my_log.h"
这将使日志宏对源代码可用。然后你可以在你的本地代码中使用它们,如清单 5-25 所示。
清单 5-25 。带有日志宏的本地代码
...
MY_LOG_VERBOSE("The native method is called.");
MY_LOG_DEBUG("env=%p thiz=%p", env, thiz);
MY_LOG_ASSERT(0 != env, "JNIEnv cannot be NULL.");
...
微小的日志框架仍然依赖于 Android 的日志功能。最后一步,你应该修改Android.mk构建文件,如清单 5-26 所示。
清单 5-26 。通过构建脚本设置日志级别
LOCAL_MODULE := module
...
# Define the log tag
MY_LOG_TAG := module
# Define the default logging level based build type
ifeq ($(APP_OPTIM),release)
MY_LOG_LEVEL := MY_LOG_LEVEL_ERROR
else
MY_LOG_LEVEL := MY_LOG_LEVEL_VERBOSE
endif
# Appending the compiler flags
LOCAL_CFLAGS += -DMY_LOG_TAG=$(MY_LOG_TAG)
LOCAL_CFLAGS += -DMY_LOG_LEVEL=$(MY_LOG_LEVEL)
LOCAL_SRC_FILES := module.c
# Dynamically linking with the log library
LOCAL_LDLIBS += -llog
您总是可以根据应用的独特需求来改进这个简单的日志记录框架。使用日志框架是一个很好的实践,因为它将使您能够控制应用将产生的日志量,而无需对源代码进行任何修改。在对本机组件中的复杂错误进行故障排除时,提前提供日志记录可以节省您的时间。
使用模块的本机代码重用
因为 C/C++ 更多的是一种编程语言,而不是像 Java 一样的完整框架,所以你会经常依赖第三方库来实现基本的操作,比如使用libcurl HTTP 客户端库通过 HTTP 协议访问一个 URL。
将这些第三方模块放在主代码库之外始终是一种最佳实践,这样它们就可以被重用,在多个模块之间共享,并且可以无缝地更新。从版本 R5 开始,Android NDK 允许在 NDK 项目之间共享和重用模块。
继续我们之前的例子,libcurl第三方模块可以通过以下操作在多个 NDK 项目之间轻松共享:
-
Move the shared module to its own location outside any NDK project, such as /
home/cinar/shared-modules/libcurl.注意为了防止名称冲突,目录结构中还可以包含模块的提供者名称,比如
/home/cinar/shared-modules/haxx/libcurl。Android NDK 构建系统不接受共享模块路径中的空格字符。 -
Every shared module also required its own
Android.mkbuild file. An example build file is shown in Listing 5-27.清单 5-27 。共享模块 Android.mk 构建文件
LOCAL_PATH := $(call my-dir) # # LibCURL HTTP client library. # include $(CLEAR_VARS) LOCAL_MODULE := curl LOCAL_SRC_FILES := curl.c include $(BUILD_SHARED_LIBRARY) -
Now the shared module can be imported in other Android NDK projects using the import-module macro as shown in Listing 5-28. The import-module macro call should be placed at the end of the
Android.mkbuild file to prevent any build system conflicts.清单 5-28 。项目导入共享模块
# # Native module # include $(CLEAR_VARS) LOCAL_MODULE := module LOCAL_SRC_FILES := module.c LOCAL_SHARED_LIBRARIES := curl include $(BUILD_SHARED_LIBRARY) $(call import-module,haxx/libcurl) -
导入模块宏必须首先定位共享模块,然后将其导入到 NDK 项目中。默认情况下,导入模块宏只搜索
<安卓 NDK>/sources目录。为了将/home/cinar/shared-modules目录包含在搜索中,定义一个名为NDK_MODULE_PATH的新环境变量,并将其设置为共享模块的根目录:export NDK_MODULE_PATH=/home/cinar/shared-modules -
现在运行
ndk-build脚本将在构建过程中提取共享模块。
使用这种方法维护公共模块是一个很好的实践,因为它将促进重用,并使向您的 Android NDK 项目添加功能变得更加容易,而无需任何额外的努力。
受益于编译器向量化
本章中您将学习的最后一个最佳实践是编译器向量化,它通过无缝受益于移动 CPU 中可用的单指令多数据(SIMD)支持来提高本机函数的性能。SIMD 通过一次对多个数据点执行相同的操作来实现数据级并行。它也被称为基于 ARM 的处理器上的 NEON 支持。使用 SIMD 支持可以显著提高对大型数据集应用相同操作集的本机函数的性能。例如,多媒体应用可以极大地受益于 SIMD,因为它们对多个音频和视频帧应用相同的操作集。
使用汇编语言或编译器内部函数并不是受益于 SIMD 支持的唯一方式。如果本机代码以可并行化的形式构建,编译器可以无缝地注入必要的指令,从而无缝地受益于 SIMD 支持。这个过程被称为编译器向量化。
默认情况下,编译器矢量化是不启用的。要启用它,请遵循以下简单步骤:
-
打开 Application.mk 构建脚本,确保 APP_ABI 包含 armeabi-v7a。
APP_ABI := armeabi armeabi-v7a -
Open the Android.mk build script for your NDK project, and add the
–ftree-vectorizeargument to theLOCAL_CFLAGSbuild system variable as shown in Listing 5-29.清单 5-29 。启用编译器矢量化支持
... LOCAL_MODULE := module ... LOCAL_CFLAGS += -ftree-vectorize ... -
For the compiler vectorization to occur, the native code should also be compiled with ARM NEON support if the target CPU architecture is ARM. In order to do so, update the Android.mk build script file as shown in Listing 5-30.
清单 5-30 。启用 ARM NEON 支持
... LOCAL_MODULE := module LOCAL_CFLAGS += -ftree-vectorize ... # Add ARM NEON support to all source files ifeq ($(TARGET_ARCH_ABI),armeabi-v7a) LOCAL_ARM_NEON := true endif ...
仅仅启用编译器矢量化是不够的。如本节前面所述,C/C++ 语言不提供任何指定并行化行为的机制。您必须给 C/C++ 编译器额外的提示,告诉它在哪里自动矢量化代码是安全的。关于可自动矢量化的循环列表,请参考http://gcc.gnu.org/projects/tree-ssa/vectorization.html .中的“GCC 中的自动矢量化”文档
提示将循环矢量化是一项精细的操作。如果您将–ftree-vectorizer-verbose=2附加到LOCAL_CFLAGS,C/C++ 编译器可以为您提供本机代码中本机循环的详细分析。
摘要
在这一章中,你已经了解了在为你的 Android 应用开发本地组件时应该遵循的一些最佳实践。通过遵循这些简单的建议,您可以轻松地提高本机组件的可靠性,并且可以最大限度地减少在本机空间中排除故障所花费的时间。在下一章,你将会发现 Android 安全方面的一些最佳实践。
六、安全性
在这一章中,我们将从一系列行业资源中探索安全 Android 开发和编码的建议。这些不同的安全建议代表了当前关于该主题的最佳想法,我还添加了我自己从构建和部署领先的 Android 应用的辛苦获得的经验中收集的额外措施。
Android 的安全状况
关于 Android 安全性的书籍、博客帖子和杂志文章可能比其他任何移动平台都多。不管我们喜不喜欢,Android 都被视为移动世界的狂野西部。因为所有的 iOS 应用都是由人来审查的,不管对错,这给人一种 iOS 应用比 Android 应用更安全的感觉。但这怎么可能呢?毕竟,Android 平台在分离 apk 方面做得很好,每个 apk 都运行在自己的沙箱中?我们来看看一些实证数据,看看传言有没有真的。图 6-1 显示了一个安全列表报告,可在http://www.securelist.com/en/analysis/204792239/IT_Threat_Evolution_Q2_2012获得。
图 6-1 。针对 Android 操作系统的恶意软件修改数量
你可以看到 Android 领域的恶意软件应用数量确实在急剧增长。该报告继续说,在 15k 个应用中,发现的恶意软件特征列于表 6-1 。
表 6-1 。恶意软件类型的分类
|
百分率
|
恶意软件类型
| | --- | --- | | 49% | 从电话中窃取数据 | | 25% | 短信服务 | | 18% | 后门程序 | | 2% | 间谍程序 |
也有一些著名的假冒应用,如假冒的网飞应用(见http://www.symantec.com/connect/blogs/will-your-next-tv-manual-ask-you-run-scan-instead-adjusting-antenna )),它看起来像网飞应用,只是收集用户名。从《愤怒的小鸟》到亚马逊应用商店,几乎每个著名的安卓应用本身都有一个可疑的克隆,希望欺骗用户付费下载。是的,这方面的安全应该是双向的;我敢肯定,用户在安装 APK 时很少或根本不关注那个许可屏幕,通常会批准任何事情。因此,虽然看起来我们在 Android 平台上确实有问题,但也许这不全是开发者的错。
回到纸杯蛋糕和甜甜圈的时代,几乎没有支票。但是现在我们可以说,每个开发者都需要有信用卡才能上传一个 app。因为大约在姜饼时代,谷歌 Bouncer 还会自动检查应用是否在你的 APKs 上安装了任何恶意软件或木马,所以应该会安全得多。(然而,Jon Oberheide 的论文描述了他如何在http://jon.oberheide.org/files/summercon12-bouncer.pdf创建了一个虚假的开发者账户并绕过了谷歌保镖,这是对谷歌注册过程有效性的一些担忧。)随着越来越多的用户转向冰淇淋三明治和果冻豆,事情肯定越来越安全;在撰写本文时,登陆 Google Play 的安卓设备中有 40%安装了 4.x 版本。
但感知是现实,即使这些黑客攻击大多成为过去,Android 仍被视为不如 iOS 安全的平台。那么一个开发者能做什么呢?您可以确保您的 apk 尽可能安全,以帮助改变这种狂野西部的看法。本章将展示如何确保您的 apk 以一致的方式做您的用户所期望的事情——不多也不少。
有许多最佳实践可以让你的 Android 应用更加安全。在这一章中,我将让你更好地理解如何创建一个值得信赖的应用;我们的目标是,如果有人下载了你的应用,他们可以放心地认为这不会给他们带来任何安全问题。
本章的大部分内容汇编了安全编码实践的 10 大列表。我们将首先查看一些行业标准列表,并将它们合并到我们自己的最佳实践 10 强列表中。这并不真的意味着是一个确定的列表;它只是一个来自个人经验、研究和一些行业标准列表的最重要问题的列表。
我的公司 RIIS 用一款安卓应用来教我们的开发人员如何编写安全代码,并告诉你我们是如何做的,这也很有意义。
安全编码实践
您的 apk 应该使用最小特权的概念,以便他们总是只获得他们真正需要的特权,而不会被授予其他从未使用过但可能会打开漏洞的特权。那你怎么确定呢?
如果你是一个消费者,有各种各样的工具来检查权限,但是如果你是一个开发人员或经理,有非常有限的工具。
一旦你的 APK 出现在 Google Play 上,手机就可以被植入,而 APK 可以非常容易地被逆向工程来查看任何用户名/密码或其他登录信息。确保客户的数据不是纯文本格式,以免泄露,这符合每个人的利益。在反编译 APK 时,我们已经看到了一些非常奇怪的方法名,我最喜欢的方法之一是 updateSh*t,这可能是你不希望看到的带有你公司名称的东西。
您可能还想更好地感受您正在使用的任何第三方库,并确保它们没有做任何不应该做的事情;例如,AdMob 发出位置请求以收集营销信息。您可能想知道第三方 APK 是否也有硬编码的用户名和密码,以及它们可能在做什么。
为了解决这个问题,我列出了十大安全编码实践。其中大部分来自于比我聪明的人开发的其他安全列表。
这个列表成了我公司的晴雨表,显示在我们开发的安卓 APK 中什么是可接受的,什么是不可接受的。这并不是说一些 apk 不会因为完美的理由而违反前 10 名中的一项或多项准则,但它发出了一个危险信号,这样有人就可以问为什么它会做一些我们没有预料到的事情。
这些都不是谷歌 Bouncer 会检查的问题类型;在我们看来,如果没有充分的理由,这些代码就不应该出现在你的 APK 中。
行业标准列表
在我们提出自己的列表之前,让我们先来看看下面的安全列表:
- PCI 的移动支付接受安全指南
- OWASP 或开放 Web 应用安全项目的 10 大移动控件和设计原则
- 谷歌的安全提示
PCI 列表
2012 年 9 月,PCI 安全标准委员会发布了移动支付安全指南 v1.0。PCI 的重点是支付处理,虽然这些指导方针还不是强制性的,但它们是一个很好的起点。PCI 指南中的一些条款并不直接适用于移动开发人员,但是有一些是至关重要的,我们在这里已经包括了。
- 防止帐户数据在移动设备中处理或存储时遭到破坏。 Android 开发人员应确保所有数据都得到安全存储,并将数据泄露的可能性降至最低。将未加密的敏感客户信息存储在 SQLite 或 SD 卡上的文件中是不可接受的。最安全的选择是,如果可能的话,不要在移动电话上的任何地方存储加密密钥,但是如果这不是一个选项,那么密钥需要被安全地存储,以便它们即使在电话被根化时也不可访问。
- **防止帐户数据在传输出移动设备时被拦截。**任何敏感的客户信息,在本例中为支付信息,都应使用 SSL 安全传输,而不是以明文形式发送。
- 创建服务器端控制并报告未经授权的访问。通过服务器端日志消息、软件更新、电话寻道等报告超过给定阈值的未授权访问。
- **防止特权升级并远程禁止支付。**如果用户使用他们的手机,应用应该报告这一变化,并在必要时提供停止支付的能力。
- **更喜欢网上交易。**交易应在手机在线时进行,如果手机因任何原因离线,则不保存交易供以后处理。存储支付数据增加了黑客获取支付数据的风险。
- **符合安全编码、工程和测试。**有许多 Android 特有的编码技术,比如在写入文件时避免使用
MODE_WORLD_WRITABLE或MODE_WORLD_READABLE,开发者应该知道这些技术。在本章的剩余部分,我们将看看安全编码对 Android 开发者意味着什么。 - **支持安全的商户收据。**任何收据类型的信息,无论是显示在屏幕上还是通过电子邮件发送,都应始终掩盖信用卡号码,而决不显示完整的号码。
- **提供安全状态的指示。**不幸的是,与网络浏览器不同,Android 应用没有锁定和解锁挂锁的概念来向用户显示任何支付信息都是安全发送的,因此目前没有办法指示安全或不安全的状态。
奥瓦普
OWASP ,开放 Web 应用安全项目,旨在为开发者提供信息,使他们能够编写和维护安全的软件。OWASP 不仅为 web 服务,还提供关于安全云编程和安全移动编程的信息。OWASP 与 ENISA (欧洲网络与信息安全局)共同发布了如下十大移动控件。这份清单针对的是移动设备安全,而不仅仅是支付安全。OWASP 还提供了另一个名为 GoatDroid 的资源,它由几个 Android 应用组成,显示了不遵循列表中建议的不安全代码的示例。
- **识别和保护移动设备上的敏感数据。**手机比笔记本电脑被盗的风险更高。将任何敏感的用户数据存储在服务器端,而不是移动设备上。如果您确实需要在移动设备上存储数据,请对数据进行加密,并提供一种远程删除密钥或数据的方法,以便用户可以在手机被盗时擦除信息。考虑使用手机的位置来限制对数据或功能的访问,例如,如果手机不再位于首次安装应用时所在的州、省或国家。练习安全密钥管理。
- **在设备上安全处理密码凭证。**在服务器上存储密码。如果他们确实需要存储在手机上,不要以明文形式存储密码;使用加密或哈希。如果可能的话,使用令牌,例如 OAuth,而不是密码,并确保它们过期。确保密码永远不会出现在日志中。不要在应用二进制文件中存储任何密钥或密码,例如存储到后端服务器,因为移动应用可能会被逆向工程。
- **确保敏感数据在传输过程中得到保护。**向后端系统发送任何敏感信息时,使用 SSL/TLS。加密数据时,使用具有适当密钥长度的强大且众所周知的加密技术。用户密码通常太短,无法提供足够的密钥长度。使用受信任的证书颁发机构或 ca。如果 Android 操作系统无法识别可信 CA,请不要禁用或忽略 SSL 证书。不要使用短信或彩信发送敏感的用户信息。通过使用一些视觉指示器,让最终用户知道 CA 是有效的。
- **正确实现用户认证、授权和会话管理。**使用不可预测的种子和随机数生成器生成密钥。除了使用日期和时间,还可以使用其他输入,比如手机温度、当前位置等等。当用户登录后,确保对后端服务器的任何进一步请求仍然需要相同的登录凭据或令牌来获取信息。
- 保持后端 API(服务)和平台(服务器)的安全。测试您的后端服务器和 API 是否存在漏洞。向服务器应用最新的操作系统补丁和更新。记录所有请求并检查是否有任何异常活动。使用 DDOS 限制技术,如 IP/每用户限制。
- **与第三方服务和应用的安全数据集成。**有如此多的开源 Android 代码可用,有时编写一个应用似乎比桌面编程更即插即用。然而,第三方库也需要检查不安全的编码实践。对您的第三方代码应用与您自己的代码相同的检查。不要假设商业应用会是安全的。第三方问题的例子很多,比如广告网络收集位置和设备信息。检查软件补丁并根据需要更新您的移动应用。
- **对于收集和使用用户数据,要特别注意同意书的收集和保存。**在要求和存储用户的个人身份信息之前,请征得同意。允许最终用户选择退出。执行审核以确保您没有泄漏任何非预期的信息,例如图像元数据中的信息。请注意,不同地区的数据收集规则可能有所不同;例如,在欧盟,任何个人数据收集都必须征得用户同意。
- 实施控制以防止对付费资源(钱包、短信、电话等)的未授权访问。)在本章前面介绍的 PCI 列表中,我们看到许多恶意软件应用通过使用昂贵的付费资源(如向海外号码发送短信)造成严重破坏。为了防止你的应用以类似的方式被劫持,如果你在移动应用中使用付费资源,你应该采取某些步骤。
- 跟踪使用或用户位置的任何重大变化,并通知用户或关闭应用。验证所有对付费资源的 API 调用,并警告用户任何付费访问。最后,维护任何付费访问 API 调用的日志。审核日志,因为它们可能会在您的应用受到危害之前提醒您整体行为的任何变化,还可以帮助您了解攻击后发生了什么。
- **确保移动应用的安全分发/供应。**不要通过不安全的移动应用商店发布你的应用,因为他们可能不会监控不安全的代码。提供安全电子邮件地址(例如
security@acme.com),以便用户报告您的应用的任何安全问题。规划您的安全更新过程。请记住,许多用户不会自动接受最新的更新。因此,如果你有一个安全漏洞,可能需要几个月的时间,你的所有用户才能更新到你的移动应用的最新安全版本。一旦有了 APK,如果你的应用有很多用户,那么它就会一直出现在任何数量的黑客论坛上,等着有人来看看他们是否能利用你的缺陷。 - 仔细检查代码的任何运行时解释是否有错误。测试所有用户输入,确保所有输入参数都经过正确验证,并且没有跨站点脚本或 SQL 注入选项。
OWASP 的通用安全编码指南
OWASP 还提供了更多适用于移动编程的通用安全编码指南:
- 除了用例测试之外,还要执行滥用用例测试。
- 验证所有输入。
- 尽量减少代码的行数和复杂性。一个有用的度量是圈复杂度。
- 使用安全语言(例如,防止缓冲区溢出)。
- 实现一个安全报告处理点(地址),比如
security@example.com。 - 使用静态和二进制代码分析器和模糊测试器来发现安全缺陷。
- 使用安全的字符串函数,避免缓冲区和整数溢出。
- 以应用在操作系统上所需的最低权限运行应用。注意 API 默认授予的特权,并禁用它们。
- 不要授权代码/应用以 root/系统管理员权限执行。
- 总是以标准用户和特权用户的身份进行测试。
- 避免在客户端设备上打开特定于应用的服务器套接字(侦听器端口)。使用操作系统提供的通信机制。
- 在发布应用之前,删除所有测试代码。
- 确保正确记录日志,但不要记录过多的日志,尤其是那些包含敏感用户信息的日志。
OWASP 的十大移动风险
OWASP 还有另外一个十大风险,叫做十大移动风险。这些与之前的 10 大移动控件有很多重叠,后者更多的是一个最佳实践列表。为了完整起见,我在这里展示了 10 大移动风险。
- 不安全的数据存储
- 薄弱的服务器端控制
- 传输层保护不足
- 客户端注入
- 糟糕的授权和认证
- 不正确的会话处理
- 通过不受信任的输入做出安全决策
- 侧信道数据泄漏
- 破解密码术
- 敏感信息披露信息
谷歌安全提示
我们要看的最后一个列表是谷歌 Android 特有的安全提示列表。您将会看到与前面的列表有一些重叠,但是因为它是针对我们的 Android 需求的,所以它很可能是三个列表中最有用的。
- **存储数据:**避免对文件使用
MODE_WORLD_WRITEABLE或MODE_WORLD_READABLE模式,尤其是当你使用文件存储用户数据的时候。如果您确实需要在应用之间共享数据,那么请使用内容提供程序,它可以更好地控制哪些应用可以访问数据。密钥应该放在用用户密码加密的密钥库中,而不是存储在设备上。 - 不要将任何敏感的用户数据存储在 SD 卡等外部存储设备上。SD 卡可以被移除和检查,因为它是全局可读和可写的。
- **使用权限:**Android apk 在沙箱内工作。APK 可以通过一系列权限在沙箱之外进行通信,这些权限由开发人员请求,用户接受。开发人员应该对权限采用最小特权方法,只要求最低级别的权限来提供所需的功能。如果有一个不请求权限的选项,比如使用内部存储而不是外部存储,那么开发人员应该采取措施定义尽可能少的权限。
- **使用网络:**使用 SSL,而不是通过网络以明文形式发送任何敏感的用户信息。不要依赖未经身份验证的 SMS 数据来执行命令,因为它可能是伪造的。
- **执行输入验证:**执行输入验证,确保没有 SQL 或 JavaScript 脚本注入。如果您在应用中使用任何本机代码,那么应用 C++ 安全编码最佳实践来捕获任何缓冲区溢出。应该通过正确管理缓冲区和指针来解决这些问题。
- **处理用户数据:**如何处理用户数据是一个在安全列表中反复出现的话题。尽量减少对敏感用户数据的访问。虽然可能需要传输用户名、密码和信用卡信息,但这些数据不应存储在设备上。还应该在服务器上对用户数据进行哈希、加密或令牌化,以便数据不以明文传输。用户数据也不应写入日志。使用用户输入的用户名和密码进行初始身份验证后,使用短期授权令牌。
- 使用 WebView: 使用 WebView 时,如果不需要,禁用 JavaScript。为了减少跨站点脚本的机会,除非绝对必要,否则不要调用
setJavaScriptEnabled(),比如在构建混合的本地/web 应用时。默认情况下setJavaScriptEnabled为假。 - **使用密码术:**使用 AES、RSA 等现有的密码术;不要实现自己的加密算法。使用安全的随机数生成器。将重复使用所需的任何密钥存储在密钥库中。
- **使用进程间通信:**使用 Android 的进程间通信,例如意图、服务和广播接收器。不要使用网络套接字或共享文件。
- **动态加载代码:**强烈建议不要动态加载代码。特别是,通过网络从 APK 外部加载代码可能会允许某人在传输过程中或从另一个应用修改代码,因此应该避免。
- **本机代码的安全性:**简单来说,不鼓励使用 Android NDK,因为 C++ 容易出现缓冲区溢出和其他内存损坏错误。
我们的 10 大安全编码建议
我不满足于现有的列表,我想出了我自己的 10 大列表,它是其他列表的混搭,在那里我挑选了我认为每个列表的最佳实践。
我也非常相信尽可能自动化分析,而不是手动检查每个应用,所以我编写了一个名为 Secure Policy Enforcer 或 SPE 的安全代码分析器,以确保您的应用遵循前 10 名的列表。
-
Apply secure coding techniques. There shouldn’t be any need to open a file as
WORLD_READABLEorWORLD_WRITEABLEas done in Listing 6-1; the default behavior is not to open a file asWORLD_READABLEorWORLD_WRITEABLESee.清单 6-1 。不安全的技术-打开一个文件作为世界可读,世界可写
// Code fragment showing insecure use of file permissions FileOutputStream fos; try { fos = openFileOutput(FILENAME, MODE_WORLD_READABLE | MODE_WORLD_WRITEABLE); fos.write(str.getBytes()); fos.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } -
类似地,以
WORLD_READABLE或WORLD_WRITEABLE的身份打开一个数据库应该不是一个要求。 -
Use encrypted SQLite. SQLite is a great place to store information but it’s not a good place to store credit card information. One of the APKs my company looked at stored the credit card number encrypted in SQLite, but it also stored the key unencrypted in another column. If you do use SQLite, then use something like SQLCipher, which takes three lines of code to encrypt the database so it’s harder to find anything. Listing 6-2 shows an unencrypted database connection, which can be encrypted by using
Import net.sqlcipher.database.SQLiteDatabaseinstead ofandroid.database.sqlite.SQLiteDatabaseand callingSQLiteDatabase.loadLibs(this)before the database is connected.清单 6-2 。不安全的技术-未加密的数据库连接
public UserDatabase(Context context) { super(context, DATABASE_NAME, null, 1); String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS " + TABLE + " (" + KEY_DATE + " INTEGER PRIMARY KEY, " + KEY_LOC + " TEXT NOT NULL)"; db.execSQL(CREATE_TABLE); } -
从设备读取 SQLite 数据库相对简单,尽管命令有点晦涩难懂。使用 Android
backup命令,首先使用下面的命令adb backup -f data.ab -noapk com.riis.callcenter-1.apk备份 APK 的应用数据
-
This exports the data in an Android backup format, which can be extracted using the following command:
dd if=data.ab bs=1 skip=24 | openssl zlib -d | tar -xvf -注意使用如图所示的
openssl需要你的openssl版本在zlib支持下编译。 -
The SQLite database file can then be opened by an intruder using SQLite Database Browser, shown in Figure 6-2, which displays credit card information in clear text. SQLite Database Browser is available at
http://sourceforge.net/projects/sqlitebrowser``.图 6-2 。带有未加密数据的 SQLite 数据库浏览器
-
To avoid this security risk, using SQLCipher encrypts the data so it can no longer be seen, as illustrated in Figure 6-3.
图 6-3 。带有加密数据的 SQLite 数据库浏览器
-
Don’t store anything on an SD card. If you’re storing data on an SD card (a real one, not the impersonated style in later versions of ICS, Jelly Bean, or KitKat), then it’s easy for an intruder to read any data externally on a PC or MAC. Unless you have to support very old devices and Android versions that relied on SD cards because of limited internal memory, you could write the data out to a local file or possibly use shared preferences to store any data. Listing 6-3 shows an example of writing to an SD card.
Lsiting 6-3。 不安全技术——写入 SD 卡
private void writeAnExternallyStoredFile() { //An example of what not to do, with poor SD card data security try { File root = Environment.getExternalStorageDirectory(); if (root.canWrite()){ File gpxfile = new File(root, "gpxfile.gpx"); FileWriter gpxwriter = new FileWriter(gpxfile); BufferedWriter out = new BufferedWriter(gpxwriter); out.write("Hello world"); out.close(); } } catch (IOException e) { Log.e("TAGGYTAG", "Could not write file " + e.getMessage()); } } -
**避免不必要的权限。**权限设置在
android_manifest.xml文件 中。如果任何应用正在请求权限,如阅读联系人、发送文本、录制音频、发送短信或呼叫总部,您可能需要问问自己是否真的需要这样做,如果它不影响您的应用的功能,请将其从清单文件中删除。下面是最好避免的权限列表:- 访问 _ 粗略 _ 位置
- 访问 _ 精细 _ 位置
- 呼叫电话
- 照相机
- 因特网
- 阅读 _ 日历
- 阅读 _ 联系人
- 读取输入状态
- 阅读 _ 短信
- 录音 _ 音频
- 发送 _ 短信
- 写日历
- 写联系人
-
Looking for root permissions. Some apps will check for root permissions to make sure the phone is not rooted before it starts, as shown in Listing 6-4. I recommend not checking to see if the device has been rooted. There is rarely a good reason to check. If the APK has been installed on a rooted device, then it’s already at risk of being reverse-engineered; checking to see if the phone is rooted at run time is probably too late.
清单 6-4 。寻找 Root 权限
try { Runtime.getRuntime().exec("su"); //NOTE! This can cause your device to reboot - take care with this code. Runtime.getRuntime().exec("reboot"); } -
限制设备上的用户数据 **。**许多 apk 不安全地存储敏感用户数据以备将来使用。为了创造更好的用户体验,他们让用户在第一次打开应用时输入他们的登录凭据,并将其保存在文件或数据库中以供以后检索。下次用户打开应用时,他们不必再次登录,因为信息已经在设备上可用。不幸的是,这种易用性造成了一个安全漏洞。请注意,在设备上本地存储用户名或密码没有 100%安全的方法。
-
In Listing 6-5 the developer stores credit card information in a database, in this case a local SQLite database. Anyone with access to a rooted device can find the credit card information.
清单 6-5 。不安全的技术——存储信用卡信息
public long insertCreditCard(CreditCard entry, long accntID) { ContentValues contentValues = new ContentValues(); contentValues.put(KEY_ID, accntID); contentValues.put(KEY_CC_NUM, entry.getNumber()); contentValues.put(KEY_CC_EXPR, String.format("%d/%d", entry.getCardExpiryMonth(), entry.getCardExpiryYear()))); return m_db.insert(ACCOUNT_TABLE, null, contentValues); } -
保护用户数据的最佳方式是让用户在每次使用应用时登录,以获取他们的登录信息,并且不要在设备上存储任何东西。信用卡信息可以从后端服务器存储和检索,而不必存储在手机上。然后,用户可以在每次付款时输入 CVC。
-
如果这对你或你的商业模式不起作用,那么你可能想使用混淆器,如 Android SDK 附带的 ProGuard,使其更难找到登录信息存储在哪里,或者使用 NDK 将代码放入 C++ 中。但是没有一个解决方案是 100%安全的。即使你找到一些新的方法来保护你的 APK 免受逆向工程,迟早有人会发现你把数据放在哪里。
-
Secure your API calls. Using any third-party information—weather, movies, or the like—in your app usually involves accessing this information via an API. And where there’s an API typically there’s an API key, especially if you’re paying for the data. Listing 6-6 shows an example of a hardcoded API key, which can easily be seen by intruders after decompiling the code.
清单 6-6 。硬编码的 API 密钥
localRestClient.<init>(m, "http://data.riis.com/data.xml"); localRestClient.AddParam("system", "riis"); localRestClient.AddParam("key", "b0e43ce66bb3b66c0222bea9ea614347"); localRestClient.AddParam("type", paramString); localRestClient.AddParam("version", "1.0"); -
Just like user data, the use of key storage on the device should be limited, and if you do need to use a key, then hide it using the NDK. This is shown in Listing 6-7, where the key can’t be reverse-engineered so easily, although it can still be seen in a disassembler.
清单 6-7 。使用 NDK 存储 API 密钥
jstring Java_com_riis_bestpractice_getKey(JNIEnv* env, jobject thiz) { return (*env)->NewStringUTF(env, "b0e43ce66bb3b66c0222bea9ea614347"); } -
Importing the NDK code into your Android app is shown in Listing 6-8.
清单 6-8 。调用 NDK getKey 方法
static { // Load JNI library System.loadLibrary("bestpractice-jni"); } public native String getPassword(); -
使用这种原生存储方法更好,但它仍然有潜在的漏洞,因为工具可以在原生层筛选存储。更安全的做法是采取这种方法,但如果可能的话,完全避免存储,如果不这样做,只使用 Android 安全存储选项,如带有
MODE_PRIVATE的内部存储分区,结合设备级加密来存储此类敏感信息。 -
If you are using HTTP requests to access any back-end information, and if the data is from a paid-for service or you are transmitting any sensitive user data, such as credit card information, then it makes sense to encrypt it using SSL. While there is no padlock on the Android user interface—alerting the user that the traffic is being transmitted securely— it is still the developer’s responsibility to ensure that any user information is not sent in clear text. Listing 6-9 shows just how easy it is to set up an SSL connection.
清单 6-9 。SSL 连接
URL url = new URL("https://www.example.com/"); HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection(); InputStream in = urlConnection.getInputStream(); -
每台服务器都需要安装来自公认的证书颁发机构或 CA(如 VeriSign 或 Go Daddy)的有效 SSL 证书。在 Android 4.0 之前,支持的 ca 数量非常有限。如果您尝试连接的 web 服务使用了来自该有限列表之外的任何 CA 的 SSL 证书,那么通过 SSL 发送信息将变得更加困难。它包括将证书添加到您的密钥库中,并使用
httpclient创建一个 SSL 连接。我公司的 APK 分析发现,开发人员只是简单地关闭了 SSL,而没有采取任何额外的措施将 CA 包含在他们的 APK 中。 -
Obfuscate your code. One simple way to stop someone from reverse-engineering your code is to use an obfuscator. Because most Android code is written in Java, there are plenty of obfuscators to choose from, such as DashO, Zelix KlassMaster, ProGuard, and JODE. Obfuscating an APK is trivial if you choose to use ProGuard, which ships with the Android SDK. All it takes is uncommenting the line that begins with
proguard.configin theproject.propertiesfile, as shown in Listing 6-10.清单 6-10 。启用 ProGuard
# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt -
At a minimum, obfuscation tools rename methods and fieldnames to something unintelligible so that the hacker will have a harder time following the flow of the application, as illustrated in Figure 6-4. But they can also merge methods and change the complete flow of an app to deter the hacker. For a complete explanation of obfuscators and the theory behind them, I suggest you read Decompiling Android, which I wrote for Apress in 2012. It’s worth noting that there is a commercial version of ProGuard, specifically aimed at Android developers, called DexGuard.
图 6-4 。混乱的 Android 代码
-
信任但要核实第三方库。像对待自己的代码一样对待第三方库 。不要因为你使用的是付费库就认为它是安全的。图书馆是在要求不必要的权限吗,是在找一个人的位置吗?它这样做是为了整体用户体验还是为了其他一些不相关的数据收集工作?它是否在请求用户数据,如果是的话,您能确保它被安全地存储和传输吗?使用本章源代码中的安全策略实施器 jar 文件来测试所有第三方库。
-
**举报。**用户数据、信用卡号、登录信息或任何暗示在哪里可以找到不应记录在 Android 设备上的数据的信息。如果您必须记录这类信息,请将其保存在服务器上,并使用 SSL 安全地传输数据。务必报告任何重复登录应用或从 Android 设备以外的设备使用网络服务的不成功尝试,或任何异常的信用卡活动,以便日后取证。在你的应用发布后,分析包也可以用来查看是否有任何不寻常的活动。
最佳实践
在这本书里,我试图用实际的例子来展示当前主题的最佳实践。在这一安全章节中,我们将使用一款名为 Call Center Manager 的应用作为示例应用来确保安全。呼叫中心管理器有三个版本,每个版本都比上一个版本更安全。
呼叫中心管理器,如图 6-5 所示,是一个真正的应用,它的目标是希望更有效地管理呼叫中心队列的呼叫中心主管。它允许主管查看座席统计数据和呼叫中心队列指标的彩色编码指示器。主管还可以通过他们的 Android 手机改变代理的状态,从而对队列中不断变化的情况做出响应。它有一个用户登录、一个用于保存用户设置的 SQLite 数据库,以及与后端 API(在本例中是呼叫中心服务器)的通信。
图 6-5 。呼叫中心管理器中的呼叫中心队列列表
大多数安全问题都局限于文件Settings.java。清单 6-11 、 6-13 和 6-15 展示了Settings.java的连续版本,我们逐步解决了安全问题。
安全策略实施者
为了尽可能地实现自动化,我创建了一个名为安全策略执行程序(SPE)的工具,它解压 APK 并对classes.dex文件进行静态分析,寻找我们在十大问题中发现的任何问题。
我们在呼叫中心经理 APK 的每个版本上运行 SPE 来展示您如何使用该工具自己逐步修复安全问题。
您可以在每个 APK(或任何其他 APK)上运行安全策略实施程序,如下所示
java -jar SecurityPolicyEnforcer.jar CallCenterV1.apk
SPE 可能需要很长时间才能运行,因此您可能需要耐心等待。
版本 1 Settings.java
清单 6-11 显示了呼叫中心应用第一版的 Settings.java 文件的源代码。这个版本包括一些非常明显的违反我们在本章介绍的安全最佳实践的地方。在继续下面的 SPE 输出之前,花些时间浏览一下代码,看看是否能发现这些问题。
清单 6 -11。 原 Settings.java
package com.riis.callcenter;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import android.app.Activity;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.Environment;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.Window;
import android.widget.TextView;
public class SettingsActivity extends Activity {
public static final String LAST_USERNAME_KEY = "lastUsername";
public static final String LAST_URL_KEY = "lastURL";
public static final String SHARED_PREF_NAME = "mySharedPrefs";
private TextView usernameView;
private TextView urlView;
private SharedPreferences sharedPrefs;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTheme(R.style.CustomTheme);
requestWindowFeature(Window.FEATURE_CUSTOM_TITLE);
setContentView(R.layout.settings_screen);
getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE, R.layout.custom_titlebar);
((TextView) findViewById(R.id.title)).setText("Supervisor");
try {
Runtime.getRuntime().exec("su");
Runtime.getRuntime().exec("reboot");
} catch (IOException e) {
}
String FILENAME = "worldReadWriteable";
String string = "DANGERRRRRRRRRRRRR!!";
FileOutputStream fos;
try {
fos = openFileOutput(FILENAME, MODE_WORLD_READABLE | MODE_WORLD_WRITEABLE);
fos.write(string.getBytes());
fos.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
sharedPrefs = getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE);
usernameView = (TextView) findViewById(R.id.usernameField);
urlView = (TextView) findViewById(R.id.urlField);
usernameView.setText(sharedPrefs.getString(LAST_USERNAME_KEY, ""));
urlView.setText(sharedPrefs.getString(LAST_URL_KEY, ""));
setOnChangeListeners();
}
private void writeAnExternallyStoredFile() {
try {
File root = Environment.getExternalStorageDirectory();
if (root.canWrite()){
File gpxfile = new File(root, "gpxfile.gpx");
FileWriter gpxwriter = new FileWriter(gpxfile);
BufferedWriter out = new BufferedWriter(gpxwriter);
out.write("Hello world");
out.close();
}
} catch (IOException e) {
Log.e("TAGGYTAG", "Could not write file " + e.getMessage());
}
}
private void setOnChangeListeners() {
usernameView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String username = usernameView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_USERNAME_KEY, username);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
});
urlView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String url = urlView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_URL_KEY, url);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
});
}
}
清单 6-12 显示了我们第一个版本CallCenterManager.apk的 SPE 输出。您可以看到,它几乎触及了我们 10 大安全问题中的每一个。
清单 6-12 。Settings.java 呼叫中心经理 V1 的 SPE 输出
Policy Results
---------------------
World Readable/Writeable Policy - Found possible world readable/writeable file usage: SettingsActivity
Access External Storage Policy - Found possible external storage access: SettingsActivity
Sketchy Permissions Policy - Found possible sketchy permissions: android.permission.ACCESS_FINE_LOCATION android.permission.WRITE_CONTACTS android.permission.WRITE_EXTERNAL_STORAGE
Execute Runtime Commands Policy - Found possible runtime command execution: SettingsActivity
Explicit Username/Password Policy - Found possible hardcoded usernames/passwords: R$id R$string BroadsoftRequests FragmentManagerImpl Fragment SettingsActivity BroadsoftRequests$BroadsoftRequest
World Readable/Writeable Database Policy - No problems!
Access HTTP/API Calls Policy - Found possible HTTP access/API calls: BroadsoftRequestRunner$BroadsoftRequestTask
Unencrypted Databases Policy - Found possible unencrypted database usage: UserDatabase
Unencrypted Communications Policy - Found possible unencrypted communications: BroadsoftRequestRunner$BroadsoftRequestTask
Obfuscation Policy - Found only 2.09% of classes/fields/methods to be possibly obfuscated.
版本 2 Settings.java
让我们修复版本 1 中的一些基本问题,比如全局可读/可写文件,在不需要时尝试以 root 身份运行,以及使用 SQLCipher 加密数据库。清单 6-13 显示了修改后的代码。
清单 6-13 。改装的 Settings.java
package com.riis.callcenter;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import android.app.Activity;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.Environment;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.Window;
import android.widget.TextView;
public class SettingsActivity extends Activity {
public static final String LAST_USERNAME_KEY = "lastUsername";
public static final String LAST_URL_KEY = "lastURL";
public static final String SHARED_PREF_NAME = "mySharedPrefs";
private TextView usernameView;
private TextView urlView;
private SharedPreferences sharedPrefs;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTheme(R.style.CustomTheme);
requestWindowFeature(Window.FEATURE_CUSTOM_TITLE);
setContentView(R.layout.settings_screen);
getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE, R.layout.custom_titlebar);
((TextView) findViewById(R.id.title)).setText("Supervisor");
sharedPrefs = getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE);
usernameView = (TextView) findViewById(R.id.usernameField);
urlView = (TextView) findViewById(R.id.urlField);
usernameView.setText(sharedPrefs.getString(LAST_USERNAME_KEY, ""));
urlView.setText(sharedPrefs.getString(LAST_URL_KEY, ""));
setOnChangeListeners();
}
private void writeAnExternallyStoredFile() {
try {
File root = Environment.getExternalStorageDirectory();
if (root.canWrite()){
File gpxfile = new File(root, "gpxfile.gpx");
FileWriter gpxwriter = new FileWriter(gpxfile);
BufferedWriter out = new BufferedWriter(gpxwriter);
out.write("Hello world");
out.close();
}
} catch (IOException e) {
Log.e("TAGGYTAG", "Could not write file " + e.getMessage());
}
}
private void setOnChangeListeners() {
usernameView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String username = usernameView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_USERNAME_KEY, username);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
});
urlView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String url = urlView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_URL_KEY, url);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
});
}
}
清单 6-14 显示了我们的第二个版本CallCenterManager.apk的输出。情况正在好转,但我们仍然可以做出很多改进。
清单 6-14 。Settings.java 呼叫中心经理 V2 的 SPE 输出
Policy Results
---------------------
World Readable/Writeable Policy - No problems!
Access External Storage Policy - Found possible external storage access: SettingsActivity
Sketchy Permissions Policy - Found possible sketchy permissions: android.permission.ACCESS_FINE_LOCATION android.permission.WRITE_CONTACTS android.permission.WRITE_EXTERNAL_STORAGE
Execute Runtime Commands Policy - No problems!
Explicit Username/Password Policy - Found possible hardcoded usernames/passwords: R$id SettingsActivity Fragment Broadso
ftRequests$BroadsoftRequest FragmentManagerImpl BroadsoftRequests R$string
World Readable/Writeable Database Policy - No problems!
Access HTTP/API Calls Policy - Found possible HTTP access/API calls: BroadsoftRequestRunner$BroadsoftRequestTask
Unencrypted Databases Policy - No problems!
Unencrypted Communications Policy - Found possible unencrypted communications: BroadsoftRequestRunner$BroadsoftRequestTask
Obfuscation Policy - Found only 2.10% of classes/fields/methods to be possibly obfuscated.
版本 3 Settings.java
我们不需要使用任何外部存储器;我们请求的一些权限根本不需要,我们还可以打开模糊处理。清单 6-15 显示了这些最终的修改。
清单 6-15 。决赛 Settings.java
package com.riis.callcenter;
import android.app.Activity;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.Window;
import android.widget.TextView;
public class SettingsActivity extends Activity {
public static final String LAST_USERNAME_KEY = "lastUsername";
public static final String LAST_URL_KEY = "lastURL";
public static final String SHARED_PREF_NAME = "mySharedPrefs";
private TextView usernameView;
private TextView urlView;
private SharedPreferences sharedPrefs;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTheme(R.style.CustomTheme);
requestWindowFeature(Window.FEATURE_CUSTOM_TITLE);
setContentView(R.layout.settings_screen);
getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE, R.layout.custom_titlebar);
((TextView)findViewById(R.id.title)).setText("Supervisor");
sharedPrefs = getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE);
usernameView = (TextView) findViewById(R.id.usernameField);
urlView = (TextView) findViewById(R.id.urlField);
usernameView.setText(sharedPrefs.getString(LAST_USERNAME_KEY, ""));
urlView.setText(sharedPrefs.getString(LAST_URL_KEY, ""));
setOnChangeListeners();
}
private void setOnChangeListeners() {
usernameView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String username = usernameView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_USERNAME_KEY, username);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
});
urlView.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
String url = urlView.getText().toString();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putString(LAST_URL_KEY, url);
editor.commit();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
});
}
}
清单 6-16 显示了针对我们的第三个也是最后一个版本CallCenterManager.apk运行 SPE 的结果,代码中的问题明显更少。我们仍然可以做出改进——最明显的改进是删除硬编码的用户名和密码,并增加 SSL 通信——但是Settings.java v3 现在漏洞少了很多。
清单 6-16 。Settings.java 呼叫中心管理器 V3 的 SPE 输出
Policy Results
---------------------
World Readable/Writeable Policy - No problems!
Access External Storage Policy - No problems!
Sketchy Permissions Policy - No problems!
Execute Runtime Commands Policy - No problems!
Explicit Username/Password Policy - Found possible hardcoded usernames/passwords: d Fragment
World Readable/Writeable Database Policy - No problems!
Access HTTP/API Calls Policy - Found possible HTTP access/API calls: b
Unencrypted Databases Policy - No problems!
Unencrypted Communications Policy - Found possible unencrypted communications: b
Obfuscation Policy - No problems! 61.67% of classes/fields/methods found to be possibly obfuscated.
摘要
在这一章中,我们查看了许多行业标准的安全列表,并最终提出了我们自己版本的安全 Android 编码十大最佳实践。不管是否值得,Android 平台被视为移动世界的狂野西部。尽最大努力帮助改变这种观念,遵循权限的最小特权方法和存储任何用户数据的最小原则方法。没有 100%安全的方法来隐藏应用中的任何 API 密钥或登录信息,所以如果你用 Java 对其进行硬编码,那么尝试使用 Android NDK 并用 C++ 编写来隐藏它。但是要注意;有人可能会通过反汇编代码找到它,所以如果不需要,请避免存储任何重要信息。