安卓最佳实践-全-
安卓最佳实践(全)
一、开始之前
2011 年末,随着我对 Android 开发越来越感兴趣,我试图寻找一本书,希望它能让我的开发更上一层楼。我已经完成了几个应用,并想知道其他人在做什么,我可能已经错过了。当然,谷歌有大量的 Android 文档,但是 Android 文档有一些奇怪的建议;他们建议使用 jUnit3 进行我的单元测试,这感觉像是在倒退。我已经知道了现有的用于 Android 的 jUnit4 测试框架,比如 Roboelectric,所以可能还有其他一些我错过的、我根本不知道的很酷的东西,它们真的可以帮助我写出更好的代码。
这本书试图将开发者为 Android 平台创建的最佳实践的研究汇集在一起,希望你能在一个地方找到你需要的所有信息。
一旦你编写了一个应用,或者成为了 Android 开发团队的一员,你很快就会明白,如果你不考虑如何组织起来,Android 开发就像任何其他语言或环境一样,会变得混乱和低效。这本书将帮助你采取这些步骤,成为一个运转良好、富有成效的团队。
如果你想做以下一件或多件事,你可以考虑阅读这本书:
- 通过查看最佳实践示例代码,更好地进行 Android 开发。
- 编写更容易扩展和维护的应用。
- 编写更安全的应用。
- 学习如何不仅编写应用的客户端,还编写经常被忽略的服务器端。
Android 简介
Android 是基于 Linux 的智能手机开源操作系统。该公司成立于 2003 年 10 月,2005 年 8 月被谷歌收购。2008 年 10 月发布的 HTC Dream 是第一款运行 Android 的手机。
从开发者的角度来看,Android 应用通常是用 Java 编写的。Google 提供了一个 Android SDK,它提供了必要的库和应用来将 Java 代码转换成可以在 Android 手机上运行的格式。大多数人使用 Eclipse 或命令行来创建 Android 应用。Android Studio 最近作为 Eclipse 的替代产品出现,并有可能在未来一两年内成为首选的 IDE。
Android 是移动设备的首要操作系统,全球超过 75%的设备和 52%的美国市场都在运行它。
在我个人的经历中,曾经有一段时间 Android 开发是红发的继子。所有的开发首先在 iOS 上完成,然后在应用成功后在 Android 上开发。现在安卓手机有了这么大的市场份额,这种情况已经改变了。
谁应该读这本书?
这本书旨在让那些对 Android 有任何熟悉程度的开发人员都能接触到。然而,你的经验程度将决定你认为哪些部分最有用。如果你对 Android 开发完全陌生,或者只是在这里或那里修修补补,这本书应该可以帮助你为未来的 Android 工作培养良好的习惯和实践。如果你发现自己用 Android 做的工作越来越多,那就更是如此。测试、性能分析等等的方法和工具对于培养生产习惯和避免一些经典的陷阱和良好开发的反模式来说是非常好的。如果你最终没有说“我以后再写测试”,那么这本书很适合你。
对于中级或高级 Android 开发人员来说,这本书将带你了解 Android 工具链的当前技术状态的细节;您将看到如何最好地重构和改进现有的代码和应用,它将推动您拥抱一些您可能推迟到现在的高级主题。如果你从未考虑过基于 NDK 的开发,你将在第一时间学会如何正确地做。如果你从来没有必要的资金来做多平台、多手机测试和建模,你可以冒险一试,看看你一直以来都错过了什么。
开始前你需要什么
为了最大限度地利用这本书,预先整理一些日常事务将会消除以后的干扰,并且它将让你直接实现你将在每章中学到的工具和技术。
一个真正的 Android 应用
如果你已经编写了一两个 Android 应用,那么从这本书中获得最佳回报会有所帮助。他们甚至不需要一路走到 Google Play 但理想情况下,如果你经历了这个过程,有真实世界的用户对你的 Android 应用进行了测试,并且你根据他们的反馈或评论进行了修改,这将会有所帮助。
工作开发环境
你需要在你选择的 IDE 上安装 Android SDK:要么用 ADT 工具集安装 EclipseAndroid 开发者工作室;或者对于更喜欢冒险的人来说,像英特尔的 Beacon Mountain 这样的奇特的第三方开发环境。你需要一个实际的设备来跟随我们的一些例子,但是模拟器可以完成书中的大部分代码。
所有的花里胡哨
除了现有的 Android Developer Studio、带 ADT 的 Eclipse 或其他 IDE 之外,您还应该确保拥有 Android SDK 可用的可选库。这些工具包括 SDK 构建工具、与您的 SDK 发布级别相关的 Google APIs、Android 支持库以及适用于您的操作系统的 Web 驱动程序和 USB 驱动程序。
随着每一章的展开,还将向您介绍用于单元测试、手机多样性测试、性能分析等的特定附加工具。我们将在相关章节中逐一讨论这些工具。
示例应用的源代码
我们在每一章中使用的 Android 应用是一个简单的待办事项列表和任务提醒应用。你应该从www.apress.com/9781430258575/
下载代码,这样你就可以跟着做了。我们将使用待办事项应用来展示 Android 的最佳实践,在每一章中引导您了解设计模式、性能问题、安全问题等等。
这本书里有什么
这是你在本书过程中可以期待的逐章总结:
- 第二章:我们从第二章的模式开始。你可能已经对 Android 的用户界面(UI) 模式有些熟悉,这有助于在多种设备上创建一致的用户体验(UX) 。您还将了解如何使用 ActionBarSherlock 和 NineOldAndroids 等其他库来帮助您的老设备用户获得更及时的 Android 体验。
- 第三章:继 UI 和 UX 模式之后,第三章着眼于实现 MVC 和 MVVM 开发人员设计模式,作为标准 Android 设计的替代方案,然后我们深入研究 Android 注释,以及它如何帮助您创建干净易懂的 Android 代码。
- 第四章: 第四章详细介绍了测试驱动开发(TDD)、行为驱动设计(BDD)和持续集成(CI)的基本敏捷元素,您可以在开发过程中使用它们。我们着眼于 Android SDK 中可用的单元测试,着眼于 Roboelectric、Calabash 和 Jenkins 等工具的好处,以及如何使用它们来创建更高效的敏捷开发环境。
- 第五章: Android 允许你使用 Android NDK 直接合并 C++ 代码,但由于 Java 和 C++ 之间的上下文切换,性能会受到显著影响。然而,有时在 Android 中使用新的或现有的 C++ 代码更有意义,而不必将其移植到 Java。第五章探讨了 C++ 是正确答案的原因,以及在 Android 上使用 c++ 的最佳方式。
- 第六章: 第六章是对几个行业标准的十大安全列表的最新审视,这些列表让你对 Android 安全的注意事项有了更好的了解。这一章的结尾是一个新的列表,它结合了谷歌和 OWASP 的十大列表中的最佳元素。
- 第七章:设备测试可能是 Android 开发的克星。无论你想创建自己的测试平台还是使用众多在线服务中的一个第八章着眼于驯服设备碎片的实用方法。
- 第八章:对于商业世界中的大多数 Android 应用,应用的 Android 部分充当后端服务器的客户端。信息通常但不总是通过 REST API 以 JSON 的形式发送。第八章深入探讨了如何与 REST 和 SOAP APIs 对话。您将学习如何创建 REST API ,以及为什么 Richardson 成熟度模型对您的 API 的寿命很重要。您还将使用 Google App Engine 创建自己的 web 服务。
二、Android 模式
我们从第二章开始,看看安卓的设计模式。在我看来,这可能意味着两件事,用户界面设计和架构;我们将在这里同时讨论这两个问题。在“UI 设计模式”一节中,我们将看看 Google 在冰激凌三明治发布时发布的 Android UI 指南。
在编写 Android 应用时,你不必遵循开箱即用的编程结构;有 MVC、MVVM 和 DI 三种选择。在本章的后半部分,“架构设计模式”,我们将会看到一些传统 Android 编程设计的替代方案。
用户界面设计模式
在冰淇淋三明治之前,Android 设计不是很好定义的。许多早期的应用看起来与图 2-1 中的例子非常相似。这个应用有内置的后退按钮功能和类似 iOS 的标签,因为它更有可能是现有 iOS 应用的一个端口;该应用甚至有一个名字,iFarmers,属于 iTunes 应用商店。
图 2-1 。iFarmers 是一款典型的早期 Android 应用
我不想挑出 iFarmers 应用,因为在 Google Play 上有许多类似应用的例子。我敢肯定,应用开发人员推动了更多的 Android 设计,毫无疑问,在那个时候,他们不能指向一个设计资源,并说这是设计 Android 应用的行业标准方式;他们可能被告知继续做下去。
如今,Android 平台更多的是利用庞大的 Android 用户群,而不是 iOS 转换。谷歌还制作了一个设计指南,可在http://developer.android.com/design/get-started/principles.html
获得,这些原则就是本节将要解释的。
为了帮助演示不同的最佳实践,我们将在本书中使用一个简单的待办事项应用。因此,首先,让我们看看示例应用的代码;目前它有一个闪屏,如图图 2-2 所示,还有一个添加项目的待办列表屏幕,如图图 2-3 所示。
图 2-2 。TodDoList 应用闪屏
图 2-3 。应用的主要任务列表屏幕
这本书的可下载源代码中提供了该应用的完整代码,但出于我们的目的,我们将使用两个 Java 文件,TodoActivity.java
,如清单 2-1 所示,以及TodoProvider.java
,您将在清单 2-2 中看到。
清单 2-1 。TodoActivity.java
package com.logicdrop.todos;
import java.util.ArrayList;
import java.util.List;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import android.os.StrictMode;
public class TodoActivity extends Activity
{
public static final String APP_TAG = "com.logicdrop.todos";
private ListView taskView;
private Button btNewTask;
private EditText etNewTask;
private TodoProvider provider;
private OnClickListener handleNewTaskEvent = new OnClickListener()
{
@Override
public void onClick(final View view)
{
Log.d(APP_TAG, "add task click received");
TodoActivity.this.provider.addTask(TodoActivity.this
.getEditText()
.getText()
.toString());
TodoActivity.this.renderTodos();
}
};
@Override
protected void onStart()
{
super.onStart();
}
private void createPlaceholders()
{
this.getProvider().deleteAll();
if (this.getProvider().findAll().isEmpty())
{
List<String> beans = new ArrayList<String>();
for (int i = 0; i < 10; i++)
{
String title = "Placeholder " + i;
this.getProvider().addTask(title);
beans.add(title);
}
}
}
EditText getEditText()
{
return this.etNewTask;
}
private TodoProvider getProvider()
{
return this.provider;
}
private ListView getTaskView()
{
return this.taskView;
}
public void onCreate(final Bundle bundle)
{
super.onCreate(bundle);
this.setContentView(R.layout.main);
this.provider = new TodoProvider(this);
this.taskView = (ListView) this.findViewById(R.id.tasklist);
this.btNewTask = (Button) this.findViewById(R.id.btNewTask);
this.etNewTask = (EditText) this.findViewById(R.id.etNewTask);
this.btNewTask.setOnClickListener(this.handleNewTaskEvent);
this.showFloatVsIntegerDifference();
this.createPlaceholders();
this.renderTodos();
}
private void renderTodos()
{
List<String> beans = this.getProvider().findAll();
Log.d(APP_TAG, String.format("%d beans found", beans.size()));
this.getTaskView().setAdapter(
new ArrayAdapter<String>(this,
android.R.layout.simple_list_item_1, beans
.toArray(new String[]
{})));
this.getTaskView().setOnItemClickListener(new OnItemClickListener()
{
@Override
public void onItemClick(final AdapterView<?> parent,
final View view, final int position, final long id)
{
Log.d(APP_TAG, String.format(
"item with id: %d and position: %d", id, position));
TextView v = (TextView) view;
TodoActivity.this.getProvider().deleteTask(
v.getText().toString());
TodoActivity.this.renderTodos();
}
});
}
}
TodoActivity.java
控制应用的布局,TodoProvider.java
显示在列表 2-2 中,管理你添加到列表中的项目的数据。在应用中,我们已经用初始占位符项目的列表填充了它。
清单 2-2 。TodoProvider.java
package com.logicdrop.todos;
import java.util.ArrayList;
import java.util.List;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
import com.logicdrop.todos.TodoActivity;
public class TodoProvider
{
private static final String DB_NAME = "tasks";
private static final String TABLE_NAME = "tasks";
private static final int DB_VERSION = 1;
private static final String DB_CREATE_QUERY = "CREATE TABLE " + TABLE_NAME + " (id integer primary key autoincrement, title text not null);";
private SQLiteDatabase storage;
private SQLiteOpenHelper helper;
public TodoProvider(final Context ctx)
{
this.helper = new SQLiteOpenHelper(ctx, DB_NAME, null, DB_VERSION)
{
@Override
public void onCreate(final SQLiteDatabase db)
{
db.execSQL(DB_CREATE_QUERY);
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldVersion,
final int newVersion)
{
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
this.onCreate(db);
}
};
this.storage = this.helper.getWritableDatabase();
}
public synchronized void addTask(final String title)
{
ContentValues data = new ContentValues();
data.put("title", title);
this.storage.insert(TABLE_NAME, null, data);
}
public synchronized void deleteAll()
{
this.storage.delete(TABLE_NAME, null, null);
}
public synchronized void deleteTask(final long id)
{
this.storage.delete(TABLE_NAME, "id=" + id, null);
}
public synchronized void deleteTask(final String title)
{
this.storage.delete(TABLE_NAME, "title='" + title + "'", null);
}
public synchronized List<String> findAll()
{
Log.d(TodoActivity.APP_TAG, "findAll triggered");
List<String> tasks = new ArrayList<String>();
Cursor c = this.storage.query(TABLE_NAME, new String[] { "title" }, null, null, null, null, null);
if (c != null)
{
c.moveToFirst();
while (c.isAfterLast() == false)
{
tasks.add(c.getString(0));
c.moveToNext();
}
c.close();
}
return tasks;
}
}
这是一个非常基础的应用,设计和功能让人想起早期的 Android 2.x 应用,或者我们可以称为经典的 Android。
待办事项列表屏幕的布局在Layout.xml
文件中定义,该文件可以在本书的资源文件夹中找到,也显示在清单 2-3 中。
清单 2-3 。 Layout.xml
<?xml version="1.0" encoding="utf-8"?> (change to LinearLayout)
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget31"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
<TableRow
android:id="@+id/row"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_below="@+id/tasklist"
android:orientation="horizontal" >
<EditText
android:id="@+id/etNewTask"
android:layout_width="200px"
android:layout_height="wrap_content"
android:text=""
android:textSize="18sp" >
</EditText>
<Button
android:id="@+id/btNewTask"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@+string/add_button_name" >
</Button>
</TableRow>
<ListView
android:id="@+id/tasklist"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentTop="true" >
</ListView>
</RelativeLayout>
赫萝
有时很难想象我们刚刚看到的经典(2.x)设计风格和现代赫萝 Android 设计 (4.x)之间的对比,因为技术本身太年轻了。然而,在过去的几年里,手机用户界面的变化是显著的,所以我们确实需要区分这两者。
在我们研究新方法之前,请记住,我们的应用仍然需要占仍在使用经典手机的用户的相对较大比例,目前约占你的用户的四分之一(但这个数字一直在缩小;见http://developer.android.com/about/dashboards/index.html
)
。还有一种观点认为,我们应该进一步将 Android 3.x 从 Android 4.x 手机中分离出来,但基于你稍后将在第七章图 7-2 中看到的数字,蜂巢或 Android 3.x 已死。
那么赫萝安卓设计到底是什么意思呢?
以下是最基本的 Android 元素列表:
- 操作栏
- 导航抽屉
- 多窗格
在这一章中,我们将把重点放在动作栏上,因为它的变化无处不在,并且与您构建的每个应用都相关。从 Android 4.x 中的硬件动作栏转移到了软件动作栏,如图 2-4 所示。这种设计模式在 Android 中越来越普遍,也是 Android 和 iOS 的一个区别。然而,很少使用的应用设置仍然可以通过硬件按钮找到。
图 2-4 。动作栏
图 2-5 显示了与标签结合使用的动作栏,这对于更复杂的菜单结构很有用。
图 2-5 。带标签的动作栏
图 2-6 显示了导航抽屉或滑动菜单,它们可以作为动作栏的替代模式。
图 2-6 。导航抽屉
图 2-7 显示了我们的 TodoList 应用,增加了一个动作栏。
图 2-7 。带动作栏的 TodoList
Android 的用户界面设计模式与 iOS 有很大的不同,这经常会给不熟悉 Android 的人带来麻烦,尽管有一些相似之处,比如导航抽屉。没有必要在屏幕上的后退按钮或把标签在底部栏。跨平台的 HTML5 应用经常会遇到这个问题,因为它们经常混合了 iOS 和 Android 的设计模式。
要实现动作栏,在strings.xml
中创建字符串,如清单 2-4 中的所示。
清单 2-4 。strings . XML
<?xml version=*"1.0"*encoding=*"utf-8"*?>
<resources>
<string name=*"app_name"*>ToDoList</string>
<string name=*"action_settings"*>Settings</string>
<string name=*"add_button_name"*>Add item</string>
<string-array name=*"action_bar_action_list"*>
<item>Select Filter</item>
<item>A-H</item>
<item>I-P</item>
<item>Q-Z</item>
</string-array>
</resources>
在清单 2-5 中,我们为动作栏设置了适配器代码,在本例中是一个动作栏微调器。
清单 2-5 。??【actionBarSpinnerAdapter】
this.actionBarSpinnerAdapter = ArrayAdapter.createFromResource(this, R.array.action_bar_action_list, android.R.layout.simple_spinner_dropdown_item);
final ActionBar myActionBar = getActionBar();
myActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
myActionBar.setListNavigationCallbacks(actionBarSpinnerAdapter, handleActionBarClick);
添加清单 2-6 中的所示的OnNavigationListener
方法,以便在微调列表中选择菜单项时进行处理。
清单 2-6 。 动作栏监听器
private OnNavigationListener handleActionBarClick = new OnNavigationListener() {
@Override
public boolean onNavigationItemSelected(int position, long itemId) {
switch (position) {
case 0:
Log.d(APP_TAG, "Action Clear Filter selected");
TodoActivity.this.provider.clearFilter();
TodoActivity.this.renderTodos();
break;
case 1:
Log.d(APP_TAG, "Action A-H selected");
TodoActivity.this.provider.setFilter('A', 'H');
TodoActivity.this.renderTodos();
break;
case 2:
Log.d(APP_TAG, "Action I-P selected");
TodoActivity.this.provider.setFilter('I', 'P');
TodoActivity.this.renderTodos();
break;
case 3:
Log.d(APP_TAG, "Action Q-Z selected");
TodoActivity.this.provider.setFilter('Q', 'Z');
TodoActivity.this.renderTodos();
break;
default:
break;
}
return true;
}
};
不需要对renderTodos
方法做任何修改,因为它已经被过滤了。
actionbar 夏洛克导航
现在 Action Bar 已经成为 Android 4.0 和更高版本的设计模式,那么早期版本的 Android,更具体地说是那些仍然运行 2.x 的人,又将何去何从呢?如果你发布的是消费者应用,你或你的商业利益相关者可能不想忽视这些客户。
一种选择是使用早期手机中的硬件按钮,这些按钮在很大程度上被基于 Android 版本或 API 级别的不同功能的动作栏模式和代码所取代。
更好的选择是使用 Jake Wharton 的名为 Action Bar 夏洛克的库,可以在http://actionbarsherlock.com/
获得。
用杰克的话说,ActionBar 夏洛克是一个“通过单个 API 和主题,使用 Android 4.0+上的原生动作栏和 4.0 之前的自定义实现来实现动作栏设计模式的库。”它允许你为所有版本的 Android 编写一次代码,硬件按钮在很大程度上可以被忽略。图 2-8 显示了使用 ActionBarSherlock 的 ToDoList 应用。
在 Eclipse 中下载并安装这个库,并将这些项目添加到资源文件中,如清单 2-7 所示。
清单 2-7 。main.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item
android:id="@+id/action_A_H"
android:title="A-H"
android:showAsAction="always"
android:orderInCategory="100">
</item>
<item
android:id="@+id/action_I_P"
android:title="I-P"
android:showAsAction="always">
</item>
<item
android:id="@+id/action_Q_Z"
android:title="Q-Z"
android:showAsAction="always">
</item>
</menu>
将onCreateOptionsMenu
和onOptionsItemSelected
代码添加到ToDoActivity
中,如清单 2-8 所示。
清单 2-8 。OnCreateOptionsMenu 和 onOptionsItemSelected
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getSupportMenuInflater();
inflater.inflate(R.menu.activity_itemlist, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle item selection
switch (item.getItemId()) {
case R.id.action_A_H:
// filter & render
return true;
case R.id.action_I_P:
// filter & render
return true;
case R.id.action_Q_Z:
// filter & render
return true;
default:
return super.onOptionsItemSelected(item);
}
}
现在实现了动作栏,不考虑 Android OS 版本;图 2-8 显示它运行在 Android 2.1 上。
图 2-8 。在 Android 2.1 上使用 ActionBarSherlock 实现的动作栏
为不同设备设计
Android 允许你为不同的普通屏幕尺寸和屏幕像素密度提供图像和布局。为了在多种设备上创造良好的用户体验,您需要了解几个关键变量。最常见的屏幕尺寸有小、正常、大和超大(适用于平板电脑)。截至 2013 年 9 月 4 日,市场上几乎 80%的设备都是正常尺寸;参见表 2-1 。
表 2-1 。屏幕像素密度和屏幕尺寸
同样在表 2-1 中显示的是我们的第二个变量,显示器每平方英寸的像素数或屏幕像素密度。最常见的屏幕像素密度有 mdpi(中)、hdpi(高)、xhdpi(超高)和 xxhdpi(超高)密度。根据设备屏幕的屏幕密度或像素数,图像或布局的大小会有所不同。
该表的最新版本可在http://developer.android.com/about/dashboards/index.html
找到。
图 2-9 显示了开源 Wordpress 应用的资源目录的布局。它包含了layout
文件夹中的所有默认正常布局,以及小、大和 xlarge。对于某些但不是所有的屏幕尺寸,还为纵向和横向定义了进一步的资源。
图 2-9 。
但是什么是布局-sw720dp?在 Android 3.2 中,包含了新的布局定义来处理平板电脑;在本例中, sw 代表最小宽度,布局目标是 10 英寸平板电脑最小宽度为 720 密度像素的平板电脑。这些新的限定词还允许您以特定的宽度(w)和高度(h)为目标。
碎片
谷歌在 Android 3.0 中引入了片段,作为一种创建更加模块化的用户界面设计的方式,以便相同的片段可以在 Android 手机和 Android 平板电脑上以模块化的方式使用。
一个活动现在被分割成多个片段,允许基于设备的更复杂的布局。图 2-10 显示了手机上的一个带有相应任务细节的任务项。
图 2-10 。手机上的任务项和任务明细
图 2-11 显示了这在平板电脑上的外观,这里有更多的空间,可以在单个屏幕上查看任务项目和细节。
图 2-11 。平板电脑上的任务项目和任务详细信息
清单 2-8 显示了新片段布局的更新和注释的ToDoActivity.java
代码。ToDoActivity
现在扩展了FragmentActivity
,我们创建一个TaskFragment
和NoteFragment
,根据设备布局换入换出。清单 2-9 中的代码检查布局中是否存在注释片段并显示出来。注释片段只存在于layout-large/main.xml
资源中,而不存在于layout/main.xml
文件中。
清单 2-8 。ToDoActivity.java 片段来源
public class TodoActivity extends FragmentActivity implements TaskFragment.OnTaskSelectedListener
{
@Override
public void onCreate(final Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
this.setContentView(R.layout.main);
// Check whether the activity is using the layout version with
// the fragment_container FrameLayout. If so, we must add the first
// fragment
if (this.findViewById(R.id.fragment_container) != null)
{
// However, if we're being restored from a previous state,
// then we don't need to do anything and should return or else
// we could end up with overlapping fragments.
if (savedInstanceState != null)
{
return;
}
final TaskFragment taskFrag = new TaskFragment();
// In case this activity was started with special instructions
// from an Intent,
// pass the Intent's extras to the fragment as arguments
taskFrag.setArguments(this.getIntent().getExtras());
// Add the fragment to the 'fragment_container' FrameLayout
this.getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, taskFrag).commit();
}
}
/**
* User selected a task
*/
@Override
public void onTaskSelected(final int position)
{
// Capture the title fragment from the activity layout
final NoteFragment noteFrag = (NoteFragment) this.getSupportFragmentManager()
.findFragmentById(R.id.note_fragment);
if (noteFrag != null)
{
// If note frag is available, we're in two-pane layout…
noteFrag.updateNoteView(position);
}
else
{
// If the frag is not available, we're in the one-pane layout
// Create fragment and give it an argument for the selected task
final NoteFragment swapFrag = new NoteFragment();
final Bundle args = new Bundle();
args.putInt(NoteFragment.ARG_POSITION, position);
swapFrag.setArguments(args);
final FragmentTransaction fragTx = this.getSupportFragmentManager().beginTransaction();
// Replace whatever is in the fragment_container view
// and add the transaction to the back stack so the user can
// navigate back
fragTx.replace(R.id.fragment_container, swapFrag);
fragTx.addToBackStack(null);
// Commit the transaction
fragTx.commit();
}
}
}
清单 2-9 。 布局-large/main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/tasks_fragment"
android:name="com.example.TaskFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<fragment
android:id="@+id/note_fragment"
android:name="com.example.NoteFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
</LinearLayout>
建筑设计模式
所有类型的软件的一个基本问题可以归结为熵的概念,这表明有序的代码随着时间的推移自然会变得无序。或者换句话说,无论你如何努力,你的代码都会逐渐从一个有组织的状态变成一个无组织的状态,这就是所谓的高度耦合,或者更坦率地说,意大利面条式代码。
对于有一两个细心开发人员的较小的 Android 应用,这起初似乎不是问题。但是,随着新版本的发布和新人的加入,正如鲍伯·马丁所说,代码开始变得有味道,如果你想保持代码干净,就需要定期重组或重构。
对于更大的企业 Android 应用,组织代码的方式从一开始就是一个问题。不幸的是,经典的 Android 设计并不适合长期保持整洁。
在这一节中,我们将看看一些框架或软件设计模式,当你考虑你的应用的架构时,你可能要考虑这些模式。
如果你想在你的 Android 应用中有更少的耦合和更大的分离,你需要把你的逻辑移到主Activity
类之外的类。我们从经典的 Android 设计开始,然后看看 MVC 和 MVVM,最后用依赖注入来帮助你了解如何使用这些框架来更好地组织你的代码。
经典安卓
在经典的 Android 设计中,用户界面是在 XML 布局文件中定义的。然后,活动使用这些 XML 文件来绘制屏幕,并为多种屏幕分辨率和硬件加载图像、大小信息和字符串。任何其他用户界面代码都是在主 UI 线程之外的其他类中编写的。
前面的清单 2-1 和 2-2 中显示的 TodoList 应用的代码适用于经典的 Android 设计。我们将在整本书中使用这个应用的许多不同版本。
MVC
MVC (Model-View-Controller) 是一种软件设计模式,它使用一个中介(Controller)将模型连接到视图,从而将用户界面(View)与业务规则和数据(Model)分离开来。
对我们来说,MVC 的主要好处是关注点的分离。MVC 的每个部分负责自己的工作,仅此而已:视图负责用户界面,模型负责数据,控制器在两者之间发送消息。
控制器为视图提供来自模型的数据,以绑定到 UI。对控制器的任何更改对视图都是透明的,UI 的更改不会影响业务逻辑,反之亦然。
设计模式有助于加强开发人员的结构,从而使代码变得更容易控制,更不容易损坏。MVC 的关注点分离使得如果我们想在以后的阶段添加单元测试变得更加容易。
有一种观点认为 Android 已经使用了 MVC 模式,XML 文件充当视图。然而,这并没有为我们提供任何分离关注点的实际可能性。
在下面的例子中,经典的 Android 代码被重构为 MVC 框架,如下所示。
模型
MVC 模型组件,如清单 2-10 所示,很大程度上取代了之前的ToDoProvider.java
代码。
清单 2-10 。MVC 模型代码
final class TodoModel
{
private static final String DB_NAME = "tasks";
private static final String TABLE_NAME = "tasks";
private static final int DB_VERSION = 1;
private static final String DB_CREATE_QUERY = "CREATE TABLE " + TodoModel.TABLE_NAME +
" (id integer primary key autoincrement, title text not null);";
private final SQLiteDatabase storage;
private final SQLiteOpenHelper helper;
public TodoModel(final Context ctx)
{
this.helper = new SQLiteOpenHelper(ctx, TodoModel.DB_NAME, null, TodoModel.DB_VERSION)
{
@Override
public void onCreate(final SQLiteDatabase db)
{
db.execSQL(TodoModel.DB_CREATE_QUERY);
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldVersion,
final int newVersion)
{
db.execSQL("DROP TABLE IF EXISTS " + TodoModel.TABLE_NAME);
this.onCreate(db);
}
};
this.storage = this.helper.getWritableDatabase();
}
public void addEntry(ContentValues data)
{
this.storage.insert(TodoModel.TABLE_NAME, null, data);
}
public void deleteEntry(final String field_params)
{
this.storage.delete(TodoModel.TABLE_NAME, field_params, null);
}
public Cursor findAll()
{
Log.d(TodoActivity.APP_TAG, "findAll triggered");
final Cursor c = this.storage.query(TodoModel.TABLE_NAME, new String[]
{ "title" }, null, null, null, null, null);
return c;
}
}
景色
MVC 中的视图代码,如清单 2-11 所示,是之前ToDoActivity.java
代码的修改版本。任何 UI 更改现在都发生在这里,控制代码现在被移动到ToDoController.java
文件中。
清单 2-11 。MVC 视图代码
public class TodoActivity extends Activity
{
public static final String APP_TAG = "com.example.mvc";
private ListView taskView;
private Button btNewTask;
private EditText etNewTask;
/*Controller changes are transparent to the View. UI changes won't
*affect logic, and vice-versa. See below: the TodoModel has
* been replaced with the TodoController, and the View persists
* without knowledge that the implementation has changed.
*/
private TodoController provider;
private final OnClickListener handleNewTaskEvent = new OnClickListener()
{
@Override
public void onClick(final View view)
{
Log.d(APP_TAG, "add task click received");
TodoActivity.this.provider.addTask(TodoActivity.this
.etNewTask
.getText()
.toString());
TodoActivity.this.renderTodos();
}
};
@Override
protected void onStop()
{
super.onStop();
}
@Override
protected void onStart()
{
super.onStart();
}
@Override
public void onCreate(final Bundle bundle)
{
super.onCreate(bundle);
this.setContentView(R.layout.main);
this.provider = new TodoController(this);
this.taskView = (ListView) this.findViewById(R.id.tasklist);
this.btNewTask = (Button) this.findViewById(R.id.btNewTask);
this.etNewTask = (EditText) this.findViewById(R.id.etNewTask);
this.btNewTask.setOnClickListener(this.handleNewTaskEvent);
this.renderTodos();
}
private void renderTodos()
{
final List<String> beans = this.provider.getTasks();
Log.d(TodoActivity.APP_TAG, String.format("%d beans found", beans.size()));
this.taskView.setAdapter(new ArrayAdapter<String>(this,
android.R.layout.simple_list_item_1,
beans.toArray(new String[]
{})));
this.taskView.setOnItemClickListener(new OnItemClickListener()
{
@Override
public void onItemClick(final AdapterView<?> parent, final View view, final int position, final long id)
{
Log.d(TodoActivity.APP_TAG, String.format("item with id: %d and position: %d", id, position));
final TextView v = (TextView) view;
TodoActivity.this.provider.deleteTask(v.getText().toString());
TodoActivity.this.renderTodos();
}
});
}
}
控制器
如清单 2-12 所示,控制器将 UI 绑定到数据,但也在上面的模型和视图代码之间创建了一个分离层。这两层之间的接口为代码的扩展提供了一个框架,也为新开发人员提供了一个框架,让他们能够遵循 MVC 模式来了解新代码的归属。
清单 2-12 。MVC 控制器代码
public class TodoController {
/*The Controller provides data from the Model for the View
*to bind to the UI.
*/
private TodoModel db_model;
private List<String> tasks;
public TodoController(Context app_context)
{
tasks = new ArrayList<String>();
db_model = new TodoModel(app_context);
}
public void addTask(final String title)
{
final ContentValues data = new ContentValues();
data.put("title", title);
db_model.addEntry(data);
}
//Overrides to handle View specifics and keep Model straightforward.
public void deleteTask(final String title)
{
db_model.deleteEntry("title='" + title + "'");
}
public void deleteTask(final long id)
{
db_model.deleteEntry("id='" + id + "'");
}
public void deleteAll()
{
db_model.deleteEntry(null);
}
public List<String> getTasks()
{
Cursor c = db_model.findAll();
tasks.clear();
if (c != null)
{
c.moveToFirst();
while (c.isAfterLast() == false)
{
tasks.add(c.getString(0));
c.moveToNext();
}
c.close();
}
return tasks;
}
}
MVVM
MVVM(模型-视图-视图模型)模式来自微软世界。它是 MVC 的一个特例,处理像 Silverlight 这样的 UI 开发平台。Net,它可能也适用于 Android。MVC 和 MVVM 的区别在于模型不应该包含特定于视图的逻辑——只包含为视图模型提供最小 API 所必需的逻辑。
模型只需要添加/删除,视图模型处理视图的具体需求。所有事件逻辑和委托都由视图模型处理,视图只处理 UI 设置。
在我们的例子中,模型组件基本保持不变,正如你在清单 2-13 中看到的。如清单 2-15 所示,视图模型充当 ToDoActivity(视图)和 ToDoProvider(模型)之间的代理。ViewModel 从视图接收引用,并使用它们来更新 UI。视图模型处理视图数据的渲染和更改,而视图,如清单 2-14 所示,只是提供了对其元素的引用。
模型
如清单 2-13 所示,该模型在 MVVM 很大程度上与 MVC 版本保持一致。
清单 2-13 。MVVM 模型代码
package com.example.mvvm;
import java.util.ArrayList;
import java.util.List;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
final class TodoModel
{
//The Model should contain no logic specific to the view - only
//logic necessary to provide a minimal API to the ViewModel.
private static final String DB_NAME = "tasks";
private static final String TABLE_NAME = "tasks";
private static final int DB_VERSION = 1;
private static final String DB_CREATE_QUERY = "CREATE TABLE " + TodoModel.TABLE_NAME + " (id integer primary key autoincrement, title text not null);";
private final SQLiteDatabase storage;
private final SQLiteOpenHelper helper;
public TodoModel(final Context ctx)
{
this.helper = new SQLiteOpenHelper(ctx, TodoModel.DB_NAME, null, TodoModel.DB_VERSION)
{
@Override
public void onCreate(final SQLiteDatabase db)
{
db.execSQL(TodoModel.DB_CREATE_QUERY);
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldVersion,
final int newVersion)
{
db.execSQL("DROP TABLE IF EXISTS " + TodoModel.TABLE_NAME);
this.onCreate(db);
}
};
this.storage = this.helper.getWritableDatabase();
}
/*Overrides are now done in the ViewModel. The Model only needs
*to add/delete, and the ViewModel can handle the specific needs of the View.
*/
public void addEntry(ContentValues data)
{
this.storage.insert(TodoModel.TABLE_NAME, null, data);
}
public void deleteEntry(final String field_params)
{
this.storage.delete(TodoModel.TABLE_NAME, field_params, null);
}
public Cursor findAll()
{
//Model only needs to return an accessor. The ViewModel will handle
//any logic accordingly.
return this.storage.query(TodoModel.TABLE_NAME, new String[]
{ "title" }, null, null, null, null, null);
}
}
景色
MVVM 的视图,如清单 2-14 所示,只是提供了对其元素的引用。
清单 2-14 。MVVM 视图代码
package com.example.mvvm;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
public class TodoActivity extends Activity
{
public static final String APP_TAG = "com.logicdrop.todos";
private ListView taskView;
private Button btNewTask;
private EditText etNewTask;
private TaskListManager delegate;
/*The View handles UI setup only. All event logic and delegation
*is handled by the ViewModel.
*/
public static interface TaskListManager
{
//Through this interface the event logic is
//passed off to the ViewModel.
void registerTaskList(ListView list);
void registerTaskAdder(View button, EditText input);
}
@Override
protected void onStop()
{
super.onStop();
}
@Override
protected void onStart()
{
super.onStart();
}
@Override
public void onCreate(final Bundle bundle)
{
super.onCreate(bundle);
this.setContentView(R.layout.main);
this.delegate = new TodoViewModel(this);
this.taskView = (ListView) this.findViewById(R.id.tasklist);
this.btNewTask = (Button) this.findViewById(R.id.btNewTask);
this.etNewTask = (EditText) this.findViewById(R.id.etNewTask);
this.delegate.registerTaskList(taskView);
this.delegate.registerTaskAdder(btNewTask, etNewTask);
}
}
视图模型
如清单 2-15 所示,ViewModel 组件充当ToDoActivity
(视图)和ToDoProvider
(模型)之间的代理。ViewModel 处理视图数据的呈现和更改;它从视图中接收引用,并使用它们来更新 UI。
清单 2-15 。MVVM 视图-模型代码
package com.example.mvvm;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
public class TodoViewModel implements TodoActivity.TaskListManager
{
/*The ViewModel acts as a delegate between the ToDoActivity (View)
*and the ToDoProvider (Model).
* The ViewModel receives references from the View and uses them
* to update the UI.
*/
private TodoModel db_model;
private List<String> tasks;
private Context main_activity;
private ListView taskView;
private EditText newTask;
public TodoViewModel(Context app_context)
{
tasks = new ArrayList<String>();
main_activity = app_context;
db_model = new TodoModel(app_context);
}
//Overrides to handle View specifics and keep Model straightforward.
private void deleteTask(View view)
{
db_model.deleteEntry("title='" + ((TextView)view).getText().toString() + "'");
}
private void addTask(View view)
{
final ContentValues data = new ContentValues();
data.put("title", ((TextView)view).getText().toString());
db_model.addEntry(data);
}
private void deleteAll()
{
db_model.deleteEntry(null);
}
private List<String> getTasks()
{
final Cursor c = db_model.findAll();
tasks.clear();
if (c != null)
{
c.moveToFirst();
while (c.isAfterLast() == false)
{
tasks.add(c.getString(0));
c.moveToNext();
}
c.close();
}
return tasks;
}
private void renderTodos()
{
//The ViewModel handles rendering and changes to the view's
//data. The View simply provides a reference to its
//elements.
taskView.setAdapter(new ArrayAdapter<String>(main_activity,
android.R.layout.simple_list_item_1,
getTasks().toArray(new String[]
{})));
}
public void registerTaskList(ListView list)
{
this.taskView = list; //Keep reference for rendering later
if (list.getAdapter() == null) //Show items at startup
{
renderTodos();
}
list.setOnItemClickListener(new AdapterView.OnItemClickListener()
{
@Override
public void onItemClick(final AdapterView<?> parent, final View view, final int position, final long id)
{ //Tapping on any item in the list will delete that item from the database and re-render the list
deleteTask(view);
renderTodos();
}
});
}
public void registerTaskAdder(View button, EditText input)
{
this.newTask = input;
button.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(final View view)
{ //Add task to database, re-render list, and clear the input
addTask(newTask);
renderTodos();
newTask.setText("");
}
});
}
}
依赖注入
如果我们的目标是远离高度耦合的代码,那么依赖注入模式可能比 MVC 或 MVVM 允许更大程度的跨应用的分离。它消除了类之间任何硬编码的依赖性,并允许您在编译时插入不同的类。这对于团队中的多个开发人员非常有用,因为它可以强制执行一个更严格的框架。
同样重要的是,依赖注入也促进了可测试代码的编写,我们将在敏捷 Android 的第四章中看到更多。
依赖注入(DI)在 Java 开发中已经存在很多年了。它通常有两种风格,编译时 DI(如 Guice)或运行时 DI(如 Spring)。在编译时 DI 中,注入在编译时是已知的,并且由映射文件控制。运行时 DI 更多地采用面向方面的编程方法,在应用运行时注入类。
Android 中有许多可用的 DI 框架,如 Roboelectric 和 Dagger,它们都是编译时 DI。
在下面的例子中,我们将看看如何使用 Dagger 来模拟一个数据库连接。通常你想测试应用而不是数据库。
在这个例子中,我们需要将四个部分连接在一起。ToDoModule.java
包含注入映射,它告诉应用是使用连接到数据库的ToDoProvider
存根文件还是ToDoProvider2
文件。ToDoProvider.java
包含返回假任务列表的存根文件,ToDoProvider2.java
包含真实的数据库连接,ToDoApplication.java
包含一个currentChoice
布尔标志,告诉应用是使用存根还是真实的连接。
ToDoModule
清单 2-16 显示了ToDoModule
如何连接两个数据库提供者;第一个是真正的数据库,第二个是存根函数。
清单 2-16 。匕首 ToDoModule.java
import dagger.Module;
import dagger.Provides;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
@Module(complete = true, injects = { TodoActivity.class })
public class TodoModule {
static final String DB_NAME = "tasks";
static final String TABLE_NAME = "tasks";
static final int DB_VERSION = 1;
static final String DB_CREATE_QUERY = "CREATE TABLE "
+ TodoModule.TABLE_NAME
+ " (id integer primary key autoincrement, title text not null);";
private final Context appContext;
public static boolean sourceToggle = false;
private TodoApplication parent;
/** Constructs this module with the application context. */
public TodoModule(TodoApplication app) {
this.parent = app;
this.appContext = app.getApplicationContext();
}
@Provides
public Context provideContext() {
return appContext;
}
/**
* Needed because we need to provide an implementation to an interface, not a
* class.
*
* @return
*/
@Provides
IDataProvider provideDataProvider(final SQLiteDatabase db) {
//Here we obtain the boolean value for which provider to use
boolean currentChoice = parent.getCurrentSource();
if(currentChoice == true){
//Here is a log message to know which provider has been chosen
Log.d(TodoActivity.APP_TAG, "Provider2");
return new TodoProvider2(db);
}else{
Log.d(TodoActivity.APP_TAG, "Provider");
return new TodoProvider(db);
}
}
/**
* Needed because we need to configure the helper before injecting it.
*
* @return
*/
@Provides
SQLiteOpenHelper provideSqlHelper() {
final SQLiteOpenHelper helper = new SQLiteOpenHelper(this.appContext,
TodoModule.DB_NAME, null, TodoModule.DB_VERSION) {
@Override
public void onCreate(final SQLiteDatabase db) {
db.execSQL(TodoModule.DB_CREATE_QUERY);
}
@Override
public void onUpgrade(final SQLiteDatabase db,
final int oldVersion, final int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + TodoModule.TABLE_NAME);
this.onCreate(db);
}
};
return helper;
}
@Provides
SQLiteDatabase provideDatabase(SQLiteOpenHelper helper) {
return helper.getWritableDatabase();
}
}
数据库供应器
布尔值currentChoice
告诉代码使用哪个数据库提供者;我们可以连接到真实的数据库ToDoProvider2
,如清单 2-17 中的所示,或者连接到存根ToDoProvider
,如清单 2-18 中的所示。
清单 2-17 。匕首 ToDoProvider2.java
package com.example.dagger;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
class TodoProvider2 implements IDataProvider {
private final SQLiteDatabase storage;
@Inject
public TodoProvider2(SQLiteDatabase db)
{
this.storage = db;
}
@Override
public void addTask(final String title) {
final ContentValues data = new ContentValues();
data.put("title", title);
this.storage.insert(TodoModule.TABLE_NAME, null, data);
}
@Override
public void deleteAll() {
this.storage.delete(TodoModule.TABLE_NAME, null, null);
}
@Override
public void deleteTask(final long id) {
this.storage.delete(TodoModule.TABLE_NAME, "id=" + id, null);
}
@Override
public void deleteTask(final String title) {
this.storage.delete(TodoModule.TABLE_NAME, "title='" + title + "'",
null);
}
@Override
public List<String> findAll() {
Log.d(TodoActivity.APP_TAG, "findAll triggered");
final List<String> tasks = new ArrayList<String>();
final Cursor c = this.storage.query(TodoModule.TABLE_NAME,
new String[] { "title" }, null, null, null, null, null);
if (c != null) {
c.moveToFirst();
while (c.isAfterLast() == false) {
tasks.add(c.getString(0));
c.moveToNext();
}
c.close();
}
return tasks;
}
}
存根提供者
清单 2-18 显示了伪造的或被删除的数据库;我们这样做是为了确保我们只测试我们的代码,而不是数据库连接。
清单 2-18 。ToDoProvider.java
package com.example.dagger;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
class TodoProvider implements IDataProvider {
private final SQLiteDatabase storage;
@Inject
public TodoProvider(SQLiteDatabase db)
{
this.storage = db;
}
@Override
public void addTask(final String title) {
final ContentValues data = new ContentValues();
data.put("title", title);
this.storage.insert(TodoModule.TABLE_NAME, null, data);
}
@Override
public void deleteAll() {
this.storage.delete(TodoModule.TABLE_NAME, null, null);
}
@Override
public void deleteTask(final long id) {
this.storage.delete(TodoModule.TABLE_NAME, "id=" + id, null);
}
@Override
public void deleteTask(final String title) {
this.storage.delete(TodoModule.TABLE_NAME, "title='" + title + "'",
null);
}
@Override
public List<String> findAll() {
Log.d(TodoActivity.APP_TAG, "findAll triggered");
final List<String> tasks = new ArrayList<String>();
final Cursor c = this.storage.query(TodoModule.TABLE_NAME,
new String[] { "title" }, null, null, null, null, null);
if (c != null) {
c.moveToFirst();
while (c.isAfterLast() == false) {
tasks.add(c.getString(0));
c.moveToNext();
}
c.close();
}
return tasks;
}
}
全部应用
最后,我们需要告诉代码要注入什么代码。我们在ToDoApplcation.java
的getCurrentSource
方法中这样做,如清单 2-19 所示。理想情况下,我们希望将它设置在一个配置文件中的某个位置,但是这里它是硬编码在一个文件中的。
清单 2-19 。所有 Application.java
package com.example.dagger;
import android.app.Application;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import dagger.ObjectGraph;
public class TodoApplication extends Application {
private ObjectGraph objectGraph;
SharedPreferences settings;
@Override
public void onCreate()
{
super.onCreate();
//Initializes the settings variable
this.settings = getSharedPreferences("Settings", MODE_PRIVATE);
Object[] modules = new Object[] {
new TodoModule(this)
};
objectGraph = ObjectGraph.create(modules);
}
public ObjectGraph getObjectGraph() {
return this.objectGraph;
}
//Method to update the settings
public void updateSetting(boolean newChoice){
Editor editor = this.settings.edit();
editor.putBoolean("CurrentChoice", TodoModule.sourceToggle);
editor.commit();
}
//Method to obtain the value of the provider setting
public boolean getCurrentSource(){
return this.settings.getBoolean("CurrentChoice", false);
}
}
摘要
在这一章中,我们研究了赫萝图形用户界面设计模式,以了解图形用户界面的最佳实践,以及使用 Dagger 的 MVC、MVVM 和 DI 架构设计模式,以了解如何最好地组织或分离您的代码,以便获得一些增长空间。我们将在第四章中回到 Dagger,关于敏捷 Android,展示我们如何使用 DI 进行模拟测试。如果你想进一步研究,本章和书中所有例子的代码都可以在 Apress 网站上找到。
三、性能
迈克尔·杰克逊提出了著名的第一和第二条程序优化规则:
- 规则 1 。别这么做。
- 规则二。(仅限专家!):先别做。
这可以有多种解释。对我来说,它真正的意思是“保持你的代码干净,不要担心优化它的流程。”避免使代码过于复杂。它还指出了一个事实,随着计算机和 JIT 编译器变得更加先进,你可能在六个月内不必担心它,因为硬件将超越你可以应用的任何最小优化。
这并不是说,如果你的移动应用需要 10 秒或更多的时间来加载一个活动,并且是一个糟糕的用户体验,你就什么都不做。请记住,无论你认为在网上可以接受的时间,在手机上肯定是不可接受的,无论是在 Android 还是 iOS 上。
而且情况变得更糟,因为如果你的应用花费太长时间,那么 Android 会显示可怕的 Android 无响应图像(见图 3-1 ),你的用户很可能会离开应用。这种情况更可能发生在内存和功耗更低的旧设备上;与程序优化第二定律相反,姜饼时代的许多旧 Android 设备仍在该领域徘徊,预计不会很快消失。
图 3-1 。Android“无响应”弹出窗口
为了便于比较,我们将讨论一下性能调优在 Web 上是如何工作的。优化安卓应用还是有点黑的艺术;虽然对于如何优化你的网络服务器有一个普遍的共识,但是在 Android 平台上却没有这么干净整洁。毕竟,市场上各种各样的 Android 设备使得优化你的应用成为一个移动的目标。
我们还会花一些时间在 Android 性能方面的技巧,这些技巧真的会对你的代码产生影响。最后,我们将看看 Android SDK 附带的一些工具,如 dal vik Debug Monitor Server(DDMS)和 Traceview,它们可以帮助识别瓶颈,并有望为创建更好的应用指明道路。
历史
早在 2000 年,性能优化都是关于如何优化通常位于 IIS 或 Apache web 服务器上的 web 应用,许多相同的点适用于我们在本章中尝试做的事情。不幸的是,测量 Android 的性能不像在 web 服务器上那么容易。
Web 服务器通常的目标是 95%的页面应该在一秒或更短的时间内返回。原始统计数据,例如页面点击次数和页面计时(使用时间标记,如图 3-2 所示),都可以在日志文件中看到。诀窍是优化速度最慢、访问量最大的页面,这给人一种速度更快的 web 服务器的感觉;说到表现,感知就是现实。在移动设备上也是如此。
图 3-2 。带有耗时令牌的 Web 服务器日志文件
通过在数据库上添加索引、修复 SELECT 语句以限制返回的数据量,或者修复编程控制流逻辑的问题,通常可以在性能最差的页面上大幅提高页面速度。使用这种“清洗、冲洗、重复”的方法,在一段时间内反复修复访问量最大、性能最差的页面可以改变 web 服务器的速度。
另一方面,Android 就没那么简单了。在 Android 中,没有类似“95%的页面应该在一秒或更短的时间内返回”这样的标准。对于一个应用需要有多快响应速度,人们还没有达成共识。并且度量也可能因设备而异。衡量每项活动需要多长时间也要困难得多,因为没有带有方便的耗时令牌的日志文件可供您轻松使用。
然而,这也不全是坏消息,因为 Android SDK 确实附带了许多工具,如 DDMS 和 Traceview,这些工具确实有助于调试性能问题,但它们可以衡量 Android 应用性能的不同方面。
理想情况下,您需要一个好的负载测试工具,带有某种可靠的时间度量。如果可能的话,它应该作为构建的一部分在持续集成服务器上运行,这样您就可以看到回归测试报告;通过查看应用运行过程中同样的动作花费了多长时间,你将能够确定某件事情是否突然比过去花费了更多时间。
当我们试图优化 Web 服务时,我们将需要查看 Web 服务器的统计数据,我们将在本书的后面回到这个问题。
性能提示
让我们来看看一些 Android、Java、Web 服务和 SQL 技巧,如果您的应用没有正确响应,您可能想尝试一下。
Android 性能
谷歌发布了一份优秀的性能提示列表(见http://developer.android.com/training/articles/perf-tips.html
),以下内容大部分摘自该列表并对其进行了扩展。其中一些优化采用非常宏观的方法,和一些非常微观的方法进行优化,将只从 APK 中生成的classes.dex
中删除一两行字节码。这些微优化可能会由未来的即时 DVM 优化来处理,或者提前由新的 ART 或 Android 运行时虚拟机来处理,这是 DVM 的替代品。然而,在撰写本文时,ART 仅在 Android KitKat 上可用,这些自动化优化变得司空见惯可能还需要一段时间。
-
避免创建不必要的对象或内存分配。编写高效代码有两个基本规则:
-
不要做你不需要做的工作。
-
能避免就不要分配内存。
-
移动开发目前相对简单;我们没有随着技术的成熟而出现的一层又一层的复杂性,比如 EJB。
-
但在 Android 上这是迟早要发生的事情,这是必然的。人们已经在他们的 Android 应用中加入 ORM,所以试着转移到更多的 TDD(测试驱动开发)模型,想想你正在引入什么。您真的需要重新发明某种缓存机制来满足您正在实现的特性吗?如果你仍然担心,那么应用 YAGNI 概念——你不需要它,因为你真的不需要它。
-
避免内部 getter/setter。虚方法调用比实例字段查找更昂贵。遵循常见的面向对象编程实践并在公共接口中使用 getters 和 setters 是合理的,但是在一个类中,您应该总是直接访问字段。这是一个微优化的例子,它从 APK 中生成的
classes.dex
中删除了一两行字节码。 -
在适当的地方使用静态/最终。由于代码被编译成 Davlik 字节码的方式,任何引用 intVal 的代码如果使用 static final 都将直接使用整数值 42,对 strVal 的访问将使用相对廉价的“字符串常量”指令,而不是字段查找。
-
明智地使用浮动。浮点计算非常昂贵,在 Android 设备上通常需要两倍于整数计算的时间。
-
NDK 称,要少花钱,多办事。使用 JNI 或 NDK 在 Java 和 C++ 之间进行上下文切换可能会很昂贵。也没有 JIT 优化。
-
但是,如果应用使用一些核心算法或功能,不需要以任何重要的方式绑定到用户界面,它可能应该在本机运行。即使使用 JIT 编译器,本机运行几乎总是比 Java 快。NDK 还带来了一些重要的安全优势,因为对 C++ 代码进行逆向工程要困难得多。
-
仅在需要时放大视图。基本上,这里的想法是,你只需要将视图放大最少的次数,或者更好的是延迟显示视图,因为放大视图是非常昂贵的。
-
使用标准库和增强功能。使用库,而不是滚动自己的代码。Android 有时也会用优化的手工编码汇编程序代替库方法。例如,使用
String.indexOf()
和System.arraycopy()
方法比手工编码的循环快 9 倍。 -
Use StrictMode. To limit the chance of an Android Not Responsive (ANR) error, it helps to not include any slow network or disk access in the applications main thread. Android provides a utility called
StrictMode
, which is typically used to detect if there are any unwanted disk or network accesses introduced into your application during the development process. AddStrictMode
to youronCreate()
method as shown in Listing 3-1.StrictMode
calls are also pretty expensive, so make sure the code isn’t shipped as production code.清单 3-1 。使用 Strictmode 实用程序
public void onCreate() { // remove from production code if (BuildConfig.DEBUG){ StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder(), .detectDiskReads() .detectDiskWrites() .detectNetwork() .penaltyLog() .build()); { super.onCreate(); }
-
优化 onSomething()类。之前我们谈到了感知是 web 应用的现实;在 Android 世界中,如果你的
onStart()
、onCreate()
和onResume()
类快如闪电,那么这个应用会被认为是一个更快的 Android 应用。因此,如果你有任何代码可以放在其他地方,或者你可能想要应用优化,那么花时间在这些类上会带来回报。尽可能长时间等待,以扩大任何观点。使用android.view.ViewStub
将允许在需要时创建对象,这种技术被称为延迟膨胀视图。 -
使用 Relativelayouts 而不是 Linearlayouts。新的 Android 开发者倾向于创建一个过度使用的 UI。随着应用变得越来越复杂,这些线性布局经常会变得非常嵌套。用一个
RelativeLayout
代替这些LinearLayouts
将会提高你的 UI 加载速度。Lint 和 Hierarchy Viewer 将帮助您识别深度嵌套的LinearLayouts
。
Java 性能
有写 Java 性能的书和书籍,Android 也可以从一些写得很好的 Java 代码中受益。Java 性能调优页面(http://www.javaperformancetuning.com/tips/rawtips.shtml
)是一个链接页面,其中链接了关于 Java 优化的文章,以及这些页面中每一页优化技巧的总结和回顾。
最常见的优化如下:
- 使用+连接两个字符串;使用
Stringbuffer
连接更多的字符串。
- 除非需要同步,否则不要同步代码。
- 完成后,关闭所有资源,如连接和会话对象。
- 不会被重定义的类和方法应该被声明为 final。
- 访问数组比访问向量、字符串和字符串缓冲区快得多。
SQLite 性能
网站效率低下通常可以总结为“是数据库的问题,笨蛋。”虽然在 Android 上这不是一个问题,因为 SQLite 更多地用于客户端信息缓存,但是没有理由解释计划在性能调优中仍然非常有用。不要忘记,如果你需要,你也可以在 SQLite 上创建索引(见图 3-3 )。
图 3-3 。SQLite 索引
学习 SQLite Android 库,使用DatabaseUtils.InsertHelper
命令插入大量数据,或者在适当的时候使用compileStatement
。不要将数据库存储在 SD 卡上。最后,不要在 SELECT 语句中返回整个数据表;始终使用精心设计的 SQL 语句返回最少的行数。
网络服务性能
对于网络服务来说,这是一个“一切旧的都是新的”的例子我们又回到了我之前提到的网站优化技术。使用服务器日志,如前面的图 3-2 所示,查看每个调用花费的时间,并优化最慢的、最常用的 Web 服务。一些常见的 Web 服务优化如下:
- 最小化 Web 服务信封的大小;尽可能选择 REST 而不是 SOAP,JSON 而不是 XML。
- 减少往返次数,避开喋喋不休的 Web 服务调用,并将 Web 服务事务的数量保持在最低水平。
- 删除任何重复的呼叫,它们并不像看起来那样不常见。
- 与数据库 SELECT * FROM TABLE 语句类似,仔细选择查询参数可以极大地限制通过 Web 服务返回的数据量。
- 避免跨调用维护状态;最具伸缩性的 Web 服务不维护任何状态。
- 压缩数据。
像 Charles Proxy ( http://www.charlesproxy.com/
)这样的 Web 代理工具是一种很好的方式来查看你的应用是如何与 Web 服务交互的。
Web 服务的主题在第八章中有更详细的介绍。
优化代码
在接下来的几页中,您将看到这些优化是如何在 ToDo List 应用中使用的。首先,清单 3-2 展示了 Splash.java,它有一个基本的 onCreate()方法。
清单 3-2 。待办事项应用的 Splash.java 页面
package com.logicdrop.todos;
import android.app.Activity;
import android.os.Bundle;
import android.content.Intent;
public class Splash extends Activity {
public void onCreate(Bundle savedInstanceState) {
// TIP: Optimized the onSomething() classes, especially onCreate()
super.onCreate(savedInstanceState);
// TIP: View - inflate the views a minimum number of times
// inflating views are expensive
/*for (int i=0; i<10000; i++)
setContentView(R.layout.splash);*/
// TIP: Splashscreen optional (DONE)
setContentView(R.layout.splash);
Thread timer = new Thread() {
public void run() {
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
Intent openStartingPoint = new Intent("com.logicdrop.todos.TodoActivity");
startActivity(openStartingPoint);
}
}
};
timer.start();
}
}
清单 3-3 中显示的ToDoActivity.java
、,有很多本章提到的 Android 和 Java 优化;有关更多信息,请参见代码中的注释。它还展示了如何使用 Traceview API 停止和启动分析。
清单 3-3 。待办事项列表应用的 ToDoActivity.java 页面
package com.logicdrop.todos;
import java.util.List;
import java.util.ArrayList;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import android.os.StrictMode;
import com.logicdrop.todos.R;
public class TodoActivity extends Activity
{
public static final String APP_TAG = "com.logicdrop.todos";
private ListView taskView;
private Button btNewTask;
private EditText etNewTask;
private TodoProvider provider;
// TIP: Use static/final where appropriate
private final OnClickListener handleNewTaskEvent = new OnClickListener()
{
@Override
public void onClick(final View view)
{
Log.d(APP_TAG, "add task click received");
TodoActivity.this.provider.addTask(TodoActivity.this
.etNewTask
.getText()
.toString());
TodoActivity.this.renderTodos();
}
};
// TIP: Traceview
@Override
protected void onStop()
{
super.onStop();
// Debug.stopMethodTracing();
}
@Override
protected void onStart()
{
// Debug.startMethodTracing("ToDo");
super.onStart();
}
// TIP: Use floats judiciously
@SuppressWarnings("unused")
private void showFloatVsIntegerDifference()
{
int max = 1000;
float f = 0;
int i = 0;
long startTime, elapsedTime;
// Compute time for floats
startTime = System.nanoTime();
for (float x = 0; x < max; x++)
{
f += x;
}
elapsedTime = System.nanoTime() - startTime;
Log.v(APP_TAG, "Floating Point Loop: " + elapsedTime);
// Compute time for ints
startTime = System.nanoTime();
for (int x = 0; x < max; x++)
{
i += x;
}
elapsedTime = System.nanoTime() - startTime;
Log.v(APP_TAG, "Integer Point Loop: " + elapsedTime);
}
// TIP: Avoid creating unnecessary objects or memory allocation
private void createPlaceholders()
{
// TIP: Avoid internal getters/setters
provider.deleteAll();
if (provider.findAll().isEmpty())
{
// TIP: Arrays are faster than vectors
List<String> beans = new ArrayList<String>();
// TIP: Use enhanced for loop (DONE)
// This is example of the enhanced loop but don't allocate objects if not necessary
/*for (String task : beans) {
String title = "Placeholder ";
this.provider.addTask(title);
beans.add(title);
}*/
/*for (int i = 0; i < 10; i++)
{
String title = "Placeholder " + i;
this.getProvider().addTask(title);
beans.add(title);
}*/
}
}
// TIP: Avoid private getters/setters - consider using package (DONE)
/*EditText getEditText()
{
return this.etNewTask;
}*/
/*private TodoProvider getProvider()
{
return this.provider;
}*/
/*private ListView getTaskView()
{
return this.taskView;
}*/
@Override
public void onCreate(final Bundle bundle)
{
// TIP: Use Strictmode to detect unwanted disk or network access
// Remove from production code (DONE)
//StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
// .detectDiskReads()
// .detectDiskWrites()
// .detectNetwork()
// .penaltyLog()
// .build());
super.onCreate(bundle);
// TIP: Do not overuse Linearlayouts, as they become more complex (DONE)
// Replace them with Relativelayouts, increasing UI loading speed
this.setContentView(R.layout.main);
this.provider = new TodoProvider(this);
this.taskView = (ListView) this.findViewById(R.id.tasklist);
this.btNewTask = (Button) this.findViewById(R.id.btNewTask);
this.etNewTask = (EditText) this.findViewById(R.id.etNewTask);
this.btNewTask.setOnClickListener(this.handleNewTaskEvent);
this.renderTodos();
// TIP: Again, don't allocate unnecessary objects that expand the heap size to significant proportions (DONE)
// Once GC occurs, a large amount of the heap memory is dumped, especially with
// local data structures, which renders a large portion of the heap unused.
// SEE: optimizedHeap.png, deoptimizedHeap.png, heap-before.tiff, heap-after.tiff
/*ArrayList<uselessClass> uselessObject = new ArrayList<uselessClass>();
for (int i=0; i<180000; i++)
uselessObject.add(new uselessClass());*/
}
private void renderTodos()
{
final List<String> beans = this.provider.findAll();
Log.d(TodoActivity.APP_TAG, String.format("%d beans found", beans.size()));
this.taskView.setAdapter(new ArrayAdapter<String>(this,
android.R.layout.simple_list_item_1,
beans.toArray(new String[]
{})));
this.taskView.setOnItemClickListener(new OnItemClickListener()
{
@Override
public void onItemClick(final AdapterView<?> parent, final View view, final int position, final long id)
{
Log.d(TodoActivity.APP_TAG, String.format("item with id: %d and position: %d", id, position));
final TextView v = (TextView) view;
TodoActivity.this.provider.deleteTask(v.getText().toString());
TodoActivity.this.renderTodos();
}
});
}
// Class with 26 double data members used to expand heap size in example
/*private class uselessClass {
double a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z;
}*/
}
最后,清单 3-4 中显示的ToDoProvider.java
、有一些剩余优化的例子,比如总是关闭资源和只使用 SELECT 语句返回最少的数据。
清单 3-4 。待办事项列表应用的 ToDoProvider.java 页面
package com.logicdrop.todos;
import java.util.ArrayList;
import java.util.List;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;
final class TodoProvider
{
private static final String DB_NAME = "tasks";
private static final String TABLE_NAME = "tasks";
private static final int DB_VERSION = 1;
private static final String DB_CREATE_QUERY = "CREATE TABLE " + TodoProvider.TABLE_NAME + " (id integer primary key autoincrement, title text not null);";
// TIP: Use final wherever possible (DONE)
private final SQLiteDatabase storage;
private final SQLiteOpenHelper helper;
public TodoProvider(final Context ctx)
{
this.helper = new SQLiteOpenHelper(ctx, TodoProvider.DB_NAME, null, TodoProvider.DB_VERSION)
{
@Override
public void onCreate(final SQLiteDatabase db)
{
db.execSQL(TodoProvider.DB_CREATE_QUERY);
}
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldVersion,
final int newVersion)
{
db.execSQL("DROP TABLE IF EXISTS " + TodoProvider.TABLE_NAME);
this.onCreate(db);
}
};
this.storage = this.helper.getWritableDatabase();
}
// TIP: Avoid synchronization (DONE)
public void addTask(final String title)
{
final ContentValues data = new ContentValues();
data.put("title", title);
this.storage.insert(TodoProvider.TABLE_NAME, null, data);
}
public void deleteAll()
{
this.storage.delete(TodoProvider.TABLE_NAME, null, null);
}
public void deleteTask(final long id)
{
this.storage.delete(TodoProvider.TABLE_NAME, "id=" + id, null);
}
public void deleteTask(final String title)
{
this.storage.delete(TodoProvider.TABLE_NAME, "title='" + title + "'", null);
}
// TIP: Don't return the entire table of data. (DONE)
// Unused
public List<String> findAll()
{
Log.d(TodoActivity.APP_TAG, "findAll triggered");
final List<String> tasks = new ArrayList<String>();
final Cursor c = this.storage.query(TodoProvider.TABLE_NAME, new String[]
{ "title" }, null, null, null, null, null);
if (c != null)
{
c.moveToFirst();
while (c.isAfterLast() == false)
{
tasks.add(c.getString(0));
c.moveToNext();
}
// TIP: Close resources (DONE)
c.close();
}
return tasks;
}
}
工具
在这一节中,我们将研究两种有助于发现性能瓶颈的工具 Android SDK 附带的工具和 Unix 命令行工具。
Android SDK 附带了以下工具来帮助我们识别任何性能问题:
- 启动
- 特蕾西
- 线头
- 层次结构查看器
- 电视观众
Dalvik Debug Monitor Server (DDMS)是一个 Android SDK 应用,可以作为独立工具或 Eclipse 插件使用。DDMS 做了很多事情,包括设备屏幕捕获和提供一个查找日志输出的地方。但是它也提供堆分析、方法分配和线程监控信息。Android SDK 也有 Traceview 工具用于方法分析,layoutopt
用于优化 XML 布局,以及 Hierarchy Viewer 用于优化 UI。
由于 Android 基本上是一个 Linux 外壳,我们可以利用以下许多命令行 Unix 工具进行性能测试:
Top
Dumpsys
Vmstat
Procstats
在这一节中,我们将看看如何使用这些工具来快速了解您的应用在哪里花费了大部分时间。
DDMS
在这一节中,我们将讨论系统性能、堆使用、线程和 Traceview 工具,所有这些都是 DDMS 的一部分。我们还将查看内存分析器工具(MAT) ,它可以作为 Eclipse 工具的一部分下载,并用于报告如何在堆中管理内存。
系统性能
DDMS 套件中最基本的工具是系统性能,它给出了当前 CPU 负载、内存使用和帧渲染时间的快速概览,如图图 3-4 所示。当你的应用消耗了太多的 CPU 或内存时,你的应用表现不佳的第一个迹象。
图 3-4 。系统性能工具显示 CallCenterApp 的 CPU 负载
堆使用
DDMS 也提供了一个堆使用工具。采取以下步骤来查看内存堆,从中可以看到正在创建哪些对象,以及它们是否被垃圾收集正确地销毁了。(参见图 3-5 。)
- 在“设备”选项卡中,选择要查看堆的进程。
- 单击“更新堆”按钮,为进程启用堆信息。
- 单击 Heap 选项卡中的 Cause GC 调用垃圾收集,这将启用堆数据收集。
- 当垃圾收集完成时,您将看到一组对象类型和为每种类型分配的内存。
- 单击列表中的对象类型,查看以字节为单位显示为特定内存大小分配的对象数量的条形图。
- 再次单击“导致 GC”以刷新数据。给出了堆的详细信息以及特定分配类型的分配大小图表。观察堆大小的总体趋势,确保它不会在应用运行期间持续增长。
图 3-5 。查看 DDMS 堆
Eclipse 内存分析器
Eclipse 有一个集成的内存分析器工具(MAT) 插件,您可以从http://www.eclipse.org/mat/downloads.php
下载并安装它。MAT 可以帮助您理解堆输出。现在,当你转储堆配置文件或 hprof 文件时(见图 3-6 ,它将被自动分析,这样你就可以对堆文件有所了解。
图 3-6 。转储 hprof 文件
MAT 提供了许多报告,包括最大类的支配者树、顶级消费者报告和泄漏嫌疑报告。图 3-7 显示了保留尺寸最大的物体。
图 3-7 。内存分析器工具概述
存储器分配
关于分配的下一层细节显示在分配跟踪器视图(图 3-8 )中。要显示它,请单击开始跟踪,在应用中执行操作,然后单击获取分配。列表按分配顺序显示,最新的内存分配显示在最前面。突出显示它会给出一个堆栈跟踪,显示该分配是如何创建的。
图 3-8 。分配跟踪器
线
DDMS 中的线程监视器和分析视图对于管理大量线程的应用非常有用。要启用它,点击更新线程图标,如图 3-9 所示。
图 3-9 。DDMS 螺纹
一个线程运行用户代码(utime)和系统代码(stime)所花费的总时间以 jiffies 来衡量。瞬间原本是光传播 1 厘米所需的时间,但对于 Android 设备来说,它是系统定时器中断一次的持续时间。它因设备而异,但通常被接受为大约 10ms。星号表示守护线程,状态 Native 表示线程正在执行本机代码。
查看图 3-9 中的样本数据,很明显 GC 花费了不寻常的时间。仔细观察应用如何处理对象创建可能是提高性能的一个好主意。
方法剖析
方法概要分析是 DDMS 选择的工具,用于快速了解应用中真正花费时间的地方,并且是找出花费太多时间的方法的第一步。当您的应用正在运行并理想地执行一些有趣的任务(您希望获得更多的性能数据)时,采取以下步骤来使用方法分析:
-
单击开始方法分析。
-
几秒钟后,再次单击该图标停止收集。
-
IDE 将自动启动 Traceview 窗口,并允许您直接在 IDE 中分析结果。
-
Click a method call in the bottom pane to create a hierarchy, showing you the current method, the parent(s) that call this method, and then the children methods called from within the selected method (Figure 3-10).
图 3-10 。使用 Traceview 在 DDMS 进行方法分析
-
确定花费时间最多的方法,这样您就可以通过创建 Traceview 文件来更仔细地查看它们,我们将在本节的稍后部分探讨这些文件。
每个方法都有其父方法和子方法,列如下:
- Inc % 方法加上任何被调用的方法所花费的总时间的百分比
- 包含在方法中花费的时间加上在任何被调用的方法中花费的时间
- Excl % 该方法花费的时间占总时间的百分比
- 独占该方法花费的时间
- 调用+递归调用该方法的次数加上任何递归调用
- 每次通话时间每次通话的平均时间
特蕾西
一旦您确定了要仔细研究的方法,您就可以使用 Traceview 的命令行版本和跟踪 API 进行更精确的测量。在您想要分析的代码周围添加 Debug.startMethodTracing 和 Debug.stopMethodTracing,如清单 3-5 所示。再次编译您的代码,并将 APK 推送到您的设备。
清单 3-5 。startMethodTracing 和 stopMethodTracing
public class ScoresActivity extends ListActivity {
public void onStart() {
// start tracing to "/sdcard/scores.trace"
Debug.startMethodTracing("scores");
super.onStart();
// other start up code here
}
public void onStop() {
super.onStop();
// other shutdown code here
Debug.stopMethodTracing();
}
// Other implementation code
}
现在可以使用以下命令将跟踪文件从设备中取出并显示在 Traceview 中:
adb pull /sdcard/scores.trace scores.before.trace
图 3-11 显示了代码优化前的结果。
图 3-11 。优化前的跟踪文件
使用本章前面的一些建议优化代码,并再次测量,这一次使用以下命令:
adb pull /sdcard/scores.trace scores.after.trace
图 3-12 显示了优化后的结果;区别很明显。
图 3-12 。优化后的跟踪文件
功能区
Lint 和它最初的 Unix 同名,是一个静态代码分析工具。它取代了 layoutopt 工具,该工具用于分析布局文件并指出潜在的性能问题,以通过重组 UI 布局来快速获得性能提升。它现在做得更多,包括以下错误检查类别:
- 正确性
- 正确性:消息
- 安全性
- 性能
- 可用性:排版
- 可用性:图标
- 可用性
- 易接近
- 国际化
如果您运行命令lint --list Performance
,它会告诉您 Lint 会进行以下性能检查,其中许多我们已经在 Android 提示部分看到过:
FloatMath
:建议将android.util.FloatMath
通话换成java.lang.Math
。- 建议在一个类中用直接字段访问代替 getters 的使用。
InefficientWeight:
在LinearLayouts
中寻找无效的重量声明。NestedWeights
:寻找嵌套布局权重,代价很高。DisableBaselineAlignment
:寻找LinearLayouts
,?? 应该设置android:baselineAligned=false
。ObsoleteLayoutParam
:查找对给定父布局无效的布局参数。MergeRootFrame
:检查一个根 <框架布局> 是否可以被一个<merge>
标签替换。UseCompoundDrawables
:检查当前节点是否可以被使用复合 drawables 的TextView
替换。UselessParent
:检查是否可以删除父布局。UselessLeaf
:检查是否可以删除叶布局。TooManyViews
:检查布局是否有太多视图。TooDeepLayout
:检查布局层次是否太深。ViewTag
:使用View.setTag
时发现潜在泄漏。HandlerLeak
:确保处理程序类不会保留对外部类的引用。UnusedResources
:寻找未使用的资源。UnusedIds
:寻找未使用的 id。SecureRandom
:查找SecureRandom
类的可疑用法。Overdraw
:查找过度绘制问题(绘制视图只是为了完全覆盖)。UnusedNamespace
:查找 XML 文档中未使用的名称空间。DrawAllocation
:查找绘图代码内的内存分配。UseValueOf
:查找包装类的“new”实例,它应该使用valueOf
来代替。UseSparseArrays
:寻找机会用更高效的SparseArray
取代HashMaps
。Wakelock
:查找wakelock
用法的问题。Recycle
:寻找资源上丢失的recycle()
调用。
Lint 可以从 Eclipse 内部运行,也可以在命令行上运行。如果您只想对您的项目运行性能检查,请在命令行中键入lint --check Performance``<
project name>
。清单 3-6 显示了样例应用的这个命令的输出,显示了一些需要更好组织的布局。
清单 3-6 。CallCenterApp 项目的 Lint 性能输出
Scanning CallCenterV3: ...................................................
Scanning CallCenterV3 (Phase 2): ......................
res\layout\custom_titlebar.xml:6: Warning: Possible overdraw: Root element paints background #004A82 with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="#004A82"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\custom_titlebar_with_logout.xml:6: Warning: Possible overdraw: Root element paints background #004A82 with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="#004A82"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\custom_titlebar_with_settings.xml:6: Warning: Possible overdraw: Root element paints background #004A82 with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="#004A82"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\login_screen.xml:5: Warning: Possible overdraw: Root element paints background @drawable/bg_app with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="@drawable/bg_app"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queues_screen.xml:5: Warning: Possible overdraw: Root element paints background @drawable/bg_app with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="@drawable/bg_app"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\settings_screen.xml:5: Warning: Possible overdraw: Root element paints background #1D1D1D with a theme that also paints a background (inferred theme is @style/CustomTheme) [Overdraw]
android:background="#1D1D1D"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\drawable-hdpi\bg_login.9.png: Warning: The resource R.drawable.bg_login appears to be unused [UnusedResources]
res\drawable-hdpi\btn_ok_xlarge.png: Warning: The resource R.drawable.btn_ok_xlarge appears to be unused [UnusedResources]
res\drawable-hdpi\no_xlarge.png: Warning: The resource R.drawable.no_xlarge appears to be unused [UnusedResources]
res\menu\settings_menu.xml: Warning: The resource R.menu.settings_menu appears to be unused [UnusedResources]
res\values\strings.xml:7: Warning: The resource R.string.loginMessage appears to be unused [UnusedResources]
<string name="loginMessage">Enter Your Login Credentials</string>
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\values\strings.xml:8: Warning: The resource R.string.CSQ_default appears to be unused [UnusedResources]
<string name="CSQ_default">Log In</string>
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\values\strings.xml:11: Warning: The resource R.string.default_time appears to be unused [UnusedResources]
<string name="default_time">00:00:00</string>
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\values\strings.xml:12: Warning: The resource R.string.oldest_in_queue appears to be unused [UnusedResources]
<string name="oldest_in_queue">Oldest Call In Queue: </string>
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\values\strings.xml:16: Warning: The resource R.string.add_to_queue appears to be unused [UnusedResources]
<string name="add_to_queue">Add To Queue</string>
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\login_screen.xml:9: Warning: This LinearLayout view is useless (no children, no background, no id, no style) [UselessLeaf]
<LinearLayout
^
res\layout\custom_titlebar.xml:10: Warning: This RelativeLayout layout or its LinearLayout parent is useless; transfer the background attribute to the other view [UselessParent]
<RelativeLayout
^
res\layout\custom_titlebar_with_logout.xml:10: Warning: This RelativeLayout layout or its LinearLayout parent is useless; transfer the background attribute to the other view [UselessParent]
<RelativeLayout
^
res\layout\custom_titlebar_with_settings.xml:10: Warning: This RelativeLayout layout or its LinearLayout parent is useless; transfer the background attribute to the other view [UselessParent]
<RelativeLayout
^
res\layout\queue_list_item.xml:13: Warning: This TableRow layout or its TableLayout parent is possibly useless [UselessParent]
<TableRow
^
res\layout\queue_list_item.xml:45: Warning: This TableRow layout or its TableLayout parent is possibly useless [UselessParent]
<TableRow
^
res\layout\custom_titlebar.xml:3: Warning: The resource R.id.photo_titlebar appears to be unused [UnusedIds]
android:id="@+id/photo_titlebar"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:7: Warning: The resource R.id.nameTable appears to be unused [UnusedIds]
android:id="@+id/nameTable"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:14: Warning: The resource R.id.tableRow1 appears to be unused [UnusedIds]
android:id="@+id/tableRow1"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:19: Warning: The resource R.id.activeIndicatorDummy appears to be unused [UnusedIds]
android:id="@+id/activeIndicatorDummy"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:46: Warning: The resource R.id.tableRow2 appears to be unused [UnusedIds]
android:id="@+id/tableRow2"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:62: Warning: The resource R.id.callsInQueueLabel appears to be unused [UnusedIds]
android:id="@+id/callsInQueueLabel"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
0 errors, 27 warnings
res\layout\queue_list_item.xml:7: Warning: The resource R.id.nameTable appears to be unused [UnusedIds]
android:id="@+id/nameTable"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:14: Warning: The resource R.id.tableRow1 appears to be unused [UnusedIds]
android:id="@+id/tableRow1"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:19: Warning: The resource R.id.activeIndicatorDummy appears to be unused [UnusedIds]
android:id="@+id/activeIndicatorDummy"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:46: Warning: The resource R.id.tableRow2 appears to be unused [UnusedIds]
android:id="@+id/tableRow2"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
res\layout\queue_list_item.xml:62: Warning: The resource R.id.callsInQueueLabel appears to be unused [UnusedIds]
android:id="@+id/callsInQueueLabel"
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼
0 errors, 27 warnings
层级查看器
调试性能问题的另一个有用的工具,特别是对于布局,是层次查看器。最基本的是,它会告诉你需要多长时间来展开布局。通过添加透视图,您可以从 Eclipse 中启动层次结构查看器;这类似于如果 DDMS 消失了,你会把它加回去。
层次结构查看器首先显示设备和模拟器的列表;从列表中单击应用的名称,然后单击加载视图等级。树形视图、树形总览和树形布局将打开,如图图 3-13 所示。树状视图显示了您在 XML 文件中定义的所有布局。我们在本章早些时候讨论了嵌套布局如何影响性能,而树形总览是一个很好的方式来查看你的布局的嵌套程度,并判断是否是时候将它们合并成一个RelativeLayout
。树状视图显示了每个布局显示的时间,因此您可以确定需要调试和优化哪些视图来加快您的 UI。
图 3-13 。CallCenterApp 登录屏幕的层次结构查看器
在图 3-13 中,我们可以看到我们的登录视图花了将近 33 毫秒才显示出来。它还显示了哪些布局是登录视图的一部分,通过将鼠标悬停在特定视图上,您可以看到每个视图显示了多长时间。
层次查看器还包括一个完美的像素设计工具。我们不会在本书中涉及这一点。
Unix 工具
因为 Android 是在 Linux 上构建的,所以我们可以利用许多与 Linux 相同的 shell 命令工具来进行性能测试。主要工具关注总进程负载、单个进程细节和内存利用率。
顶端
顶部的命令会让你知道你的应用相对于设备上所有其他进程的位置。列表位置越高,消耗的资源就越多。您可以使用 adb shell 命令登录到电话,也可以从命令行使用 adb shell top 远程运行该命令。图 3-14 显示了结果。
图 3-14 。top 命令的输出
倾印系统〔??〕
Top 还会获取应用的进程 ID 或 PID,然后您可以将它用于 dumpsys 命令,如下所示:
adb shell dumpsys meminfo 1599
Dumpsys 将为您提供关于您的应用正在使用的内存和堆的信息;参见图 3-15 。
图 3-15 。dumpsys meminfo
本节中提到的所有 Unix 工具都在某个时间点进行测量。Procstats
在 Android 4.4 或 KitKat 中引入,用于显示后台运行的应用将消耗多少内存和 CPU。使用命令查看procstats
输出:
adb shell dumpsys procstats
结果如图 3-16 所示。
图 3-16 。倾印系统 procstats〔??〕
vmstat〔??〕
Vmstat 允许您查看设备上的虚拟内存级别;参见图 3-17 。这是一个简单的 Linux 命令,报告进程、内存、分页、块 IO、陷阱和 CPU 活动。“b”列显示哪些进程被阻止。使用如下命令:adb shell vmstat。
图 3-17 。dumpsys meminfo
摘要
在这一章中,我们已经看到了一些工具,它们首先发现您是否有性能问题,然后确定需要修复的调用;我们还看到了一些可以用来优化应用的技术。由于 Android SDK 和 Android 平台与 Unix 的密切关系,它们提供了大量工具来帮助您识别问题。
四、敏捷
作为一名开发人员,您希望在 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
headers
in thecustom_rules.xml
file 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. Thejavah
tool 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 theMainActivity
class 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
javah
tool on the specified class files to parse the method signatures of your native methods. While thejavah
tool 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
javah
tool will generate a set of header files in thejni
subdirectory 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.h
header 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.mk
build 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.mk
build 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-vectorize
argument to theLOCAL_CFLAGS
build 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_READABLE
orWORLD_WRITEABLE
as done in Listing 6-1; the default behavior is not to open a file asWORLD_READABLE
orWORLD_WRITEABLE
See.清单 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.SQLiteDatabase
instead ofandroid.database.sqlite.SQLiteDatabase
and 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.config
in theproject.properties
file, 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++ 编写来隐藏它。但是要注意;有人可能会通过反汇编代码找到它,所以如果不需要,请避免存储任何重要信息。
七、设备测试
如果评论对你很重要,那么理想情况下,你会想在离评论者的机器很近的地方测试你的 Android 应用,看看它是否能正常工作。有各种各样的事情可能会出错:不同版本的 Android 操作系统上的奇怪行为,没有正确处理硬件或软件按钮,没有足够流畅的设计来处理大小设备屏幕,没有在 Wi-Fi 上进行测试,没有考虑不同运营商之间的网络速度差异。不胜枚举。
OpenSignal 在 2013 年 7 月(http://opensignal.com/reports/fragmentation-2013/
)发布了一份报告,称运行在八种不同 Android 操作系统上的 Android 设备数量不到 12000 台,而且这个数字显然还在增长。因此,现在有如此多的设备和配置,测试所有可能的变化是不现实的。
过去,在一小部分手机样本和一小部分平板电脑样本上进行测试是一种合理的策略,以获得所有组合的合理组合。但现在有这么多种类,将这些设备分为手机、平板电脑甚至平板电脑类别对于我们的测试来说太简单了。
或许,如果我们回到 OpenSignal 报告,按品牌查看使用统计数据,我们可能会取得更大的成功。图 7-1 中的图表告诉我们,超过 50%的设备是由三星制造的。
图 7-1 。安卓品牌碎片化
仔细观察 OpenSignal 数据,我们发现 Galaxy S4、SIII、SII、S、Y 以及 Note 和 Note II 以及谷歌 Nexus 占据了大约 20%的市场份额。但这仍然只是可用设备的 20%。
而不同的安卓操作系统呢?我们可能还想在多个 Android 操作系统上进行测试。在 2013 年 11 月 1 日之前的七天里,谷歌的不同 Android 操作系统登陆谷歌 Play 商店的图表可以在图 7-2 中看到。
图 7-2 。使用 Google Play 的 Android 操作系统
至少你的应用应该支持冰淇淋三明治和果冻豆,这两种产品总共拥有 72%的 Android 用户。有一个强有力的论据支持姜饼,它在 Android 用户中占 26.3%。然而,支持 Gingerbread 甚至 Froyo 需要一些开发权衡,比如在这些早期版本中寻找支持动作栏的方法。早期版本的手机通常也有更少的 CPU 能力和内存,并可以完全不同的行为。
选择策略
我们知道我们不会在 12,000 台设备上进行测试,所以我们需要制定一个策略来确定我们的用户在发布应用之前可能会遇到的问题。我们可以将这些选项分为以下几个选项:
- 使用我们的三星/Nexus 短列表中的设备进行测试。
- 使用各种通用屏幕尺寸的设备进行测试。
- 使用运行各种主要 Android 操作系统的设备进行测试。
- 找到特定的目标市场,比如 Kindle 或 Nook 用户。
- 使用 Android 模拟器测试更多设备。
- 使用制造商的仿真器测试更多设备。
- 使用第三方测试服务。
- 向制造商借设备。
- 做人群测试。
我已经在前一节中讨论了设备的简短列表,如果这符合您的预算和需求,那么像这样简单的东西可能非常适合您。但它并没有完全占领 80/20 的市场;更像是 20/80。当然,它总是一个移动的目标,所以对大多数人来说,这是行不通的。如果你幸运的话,你可能会将它限制在冰淇淋三明治及以上,或者只限于平板电脑,或者如果你真的很幸运,只限于特定的目标市场,如 Kindle 平板电脑。
或者,您可以购买小、普通、大和超大屏幕尺寸以及低、中、高和超高密度的设备。这些可以仔细选择,以便他们运行不同的 Android 操作系统,以获得更好的测试横截面。如果这是您的方法,您可能想尝试使用 Android 模拟器,它正好提供了这样的组合。我们将在下一节看到,您还可以下载额外的仿真器来测试更多的设备。还有其他第三方模拟器;有些是免费的(三星),有些是付费服务(Perfecto Mobile)。
制造商也意识到了这些问题,一些制造商,如 LG,允许开发者短期出租设备。
最后,还有一些人群测试选项,如 TestFairy,你的社交媒体朋友和家人在他们的 Android 设备上进行测试,TestFairy 组织测试,这样你就可以看到用户测试的视频。
你需要一些标准来帮助你做决定,例如:
- 预算
- 测试自动化
- 支持的操作系统
- 支持的设备
在本章中,我们将更详细地探讨这些选项,以便您在形成自己的标准后能够做出明智的决定。
仿真器
如果你像大多数开发人员一样,你的预算可能会延伸到最多购买或借用半打设备,这接近于我们最初的场景。在有限的预算下,如果你想在更多的设备上测试,总有 Android 模拟器,如图图 7-3 所示。
图 7-3 。Android 虚拟设备(AVD)模拟器
Android 虚拟设备管理器(AVD) 配有以下默认设备:
- Nexus 4
- Nexus 10
- Nexus 7
- 银河连锁公司
- Nexus
- Nexus One
- 10.1 WXVGA(平板电脑)
- 7.0 WSVGA(平板电脑)
- 5.4 FWVGA
- 5.1 WVGA
- 4.7 WXGA
- 4.65 720 便士
- 4.0 WVGA
- 3.7 FWVGA
- 3.7 WVGA
- 3.4 瓦 VGA
- 3.3 瓦 VGA
- 3.2 QVGA
- 3.2 英寸 wvga 滑块
- 2.7 QVGA 滑块
- 2.7 QVGA
正如你所看到的,它是谷歌 Nexus 设备和一些旧的通用设备的混合物。因为模拟器也是众所周知的非常慢和低效的测试方式,所以除了一些初始的 alpha 测试之外,它似乎没有太大的用处。
但是您可以采取一些简单的步骤来扩展 AVD,使其更加有用:
- 安装英特尔 x86 凌动系统映像
- 创建您自己的设备
- 安装制造商附加组件
- 在 Jenkins 中使用多个仿真器进行测试
安装英特尔 x86 凌动系统映像
Android 模拟器可能需要 3-5 分钟甚至更长时间才能启动,这一直是 Android 开发人员感到沮丧的原因。如果您在英特尔 PC 或 Mac 上运行,那么安装英特尔 x86 Atom 系统映像将使仿真器在 1-2 分钟内启动。图 7-4 显示了如何从 Android SDK 管理器安装英特尔加速器。注意,它必须为每个 Android API 级别安装。
图 7-4 。安装英特尔 x86 凌动系统映像
安装完成后,系统映像将作为 AVD 中 CPU/ABI 的下拉选项出现,正如您在图 7-4 中的早期 AVD 设置屏幕中看到的那样。
创建您自己的设备
虽然结果没有设备皮肤,但创建自己的设备模拟器版本非常简单。例如,采取以下步骤创建一个银河 S4 设备。(S4 的尺寸来自维基百科,http://en.wikipedia.org/wiki/Samsung_Galaxy_S4
)。)
-
打开 AVD。
-
单击设备定义选项卡。
-
单击新设备。
-
Enter the details shown in Figure 7-5.
图 7-5 。创建 S4 设备
-
单击创建设备。
-
Click on the Android Device Definitions, as shown in Figure 7-6.
图 7-6 。创建 S4 AVD
-
选择 S4,它现在在 AVD 设备下拉列表中可用。
-
选择 API 等级 17。
-
选择英特尔凌动 CPU。
-
选中使用主机 GPU。
-
再次单击创建设备。
现在,虽然模拟器只是真实事物的复制品,但如果你启动你的应用,它将适合屏幕,并以与 S4 相同的方式运行。
下载制造商的 AVDs
创建你自己的 avd 并不总是必要的,因为许多 Android 设备制造商提供了他们自己的 avd 甚至皮肤供你下载和安装。例如,亚马逊提供了许多 Kindle 设备定义。采取以下步骤安装 Kindle 设备:
-
打开 Android SDK 管理器
-
单击工具➤管理附加组件站点。
-
单击用户定义的站点选项卡。
-
Click on New and enter
http://kindle-sdk.s3.amazonaws.com/addon.xml
, as shown in Figure 7-7.图 7-7 。用于附加 avd 的用户自定义站点
-
Back in the Android SDK Manager, add the Kindle Fire HD 7", HDX 7" and HDX 8.9" (3rd Generation) packages; see Figure 7-8.
图 7-8 。在 SDK 管理器中添加 Kindle Fire 软件包
-
Scroll down to the Extras section and add the Amazon AVD Launcher and the Kindle Fire Device Definitions, as shown in Figure 7-9.
图 7-9 。在 SDK 管理器中添加 Kindle 启动器包
-
安装软件包。
你现在可以在 Android 虚拟设备管理器中创建一个 Kindle AVD,或者使用亚马逊 AVD 启动器,你可以在sdk/extras
文件夹中找到它;参见图 7-10 。
图 7-10 。亚马逊 AVD 启动器
图 7-11 显示了一个 Kindle HDX AVD 的例子。
图 7-11 。hdx avd kindle(kindle hdx avd)
表 7-1 列出了一些提供仿真器附件的设备制造商。
表 7-1 。安卓附加设备供应器
|
制造商
|
统一资源定位器
|
| --- | --- |
| 索尼 | http://dl-developer.sonymobile.com/sdk_manager/Sony-Add-on-SDK.xml
|
| 亚马孙 | http://kindle-sdk.s3.amazonaws.com/addon.xml
|
| 角落 | http://su.barnesandnoble.com/nook/sdk/nook_hd_addon.xml
|
| 常简称为 HTC 或宏达电 | http://dl.htcdev.com/apis/addon.xml
|
用 Jenkins 自动化仿真器测试
到目前为止,您应该能够为您认为需要的任意多的目标设备创建或安装 avd。表 7-2 提供了一个 avd 列表,这样你就可以很好的覆盖当前的 Android 市场;它来自另一个 Android AVD 库,这次是在https://github.com/j5at/AndroidAVDRepo
。
表 7-2 。仿真器测试的 avd 列表
|
智能手机
|
药片
|
| --- | --- |
| 谷歌的 Nexus One | 亚马逊的 Kindle Fire |
| 谷歌的 Nexus S | 亚马逊的 Kindle Fire HD (7 英寸和 8.9 英寸) |
| 谷歌的 Galaxy Nexus | 三星的 Galaxy 平板电脑 |
| 谷歌的 Nexus 4 | |
| G1 又名梦想,由宏达电 | |
| 机器人(原创),摩托罗拉 | |
| 机器人 X,作者摩托罗拉 | |
| 摩托罗拉的 Droid Razr Maxx HD | |
| 三星的银河 S2 | |
| 三星的银河 S3 | |
| 三星的银河 S4 | |
现在创建这些 avd 可以让你在当前最流行的设备上测试你的 Android APK。然而,手动这样做真的很耗时,而且随着新的 KitKat 手机的发布,列表也会不断变化。我们需要一种自动化测试的方法,以确保我们正在测试我们对 APK 所做的任何更改。
我们在第四章第一次使用 Jenkins,同时实现了敏捷的 Android 技术。在这一章中,我们将使用它来自动化我们的构建,使用尽可能多的仿真器,因为 Jenkins 将允许您为多个仿真器设置一个变量。
采取以下步骤来使用多个模拟器自动化构建。
-
Create a new job in Jenkins, and make it a multi-configuration project, as shown in Figure 7-12.
图 7-12 。多配置詹金斯项目
-
添加源代码管理和构建触发器,如第四章所示。
-
在配置矩阵下,添加一个轴,并将其命名为 AVD_Name。
-
Enter the AVDs you want to test, as shown in Figure 7-13.
图 7-13 。向配置矩阵添加多个仿真器
-
选中按顺序运行每个配置框。
-
In the Run Existing Emulator section, add the AVD Name as ${AVD_NAME} so that Jenkins can do the substitution correctly; see Figure 7-14.
图 7-14 。添加仿真器变量
-
Add the Calabash and Build steps as shown in Chapter 4.
当 Jenkins 运行下一个构建时,它将显示测试是否通过了每个不同的仿真器,如图 7-15 所示。
图 7-15 。多个仿真器测试结果
硬件测试
许多人不想在模拟器上测试,而更喜欢在真实设备上测试。无论仿真器软件有多好,还有许多其他因素可能会影响您的应用在仿真器无法测试的领域中的性能,例如您的应用在不同运营商上的行为,或者您是否需要作为应用的一部分进行实际呼叫。
如果您必须在真实硬件上进行测试,除了简单地购买您需要的所有设备之外,还有一些选项可供选择:
- 使用第三方测试服务。
- 向制造商借设备。
- 做人群测试。
第三方测试服务
有许多在线测试服务。这些网站有些是免费的,有些是付费服务。
正如您在本章前面所看到的,市场上的大多数设备都是由三星制造的。他们不提供附加仿真器,但是在云中提供他们自己的仿真器作为免费服务,见http://developer.samsung.com/remotetestlab/rtlDeviceList.action
和图 7-16 。
图 7-16 。三星远程测试实验室
像所有的云测试服务一样,它首先检查一个设备,然后将 APK 上传到远程服务器;然后设备的视频输出被投射到你的远程模拟器上。然而,基本开发人员帐户的测试仅限于使用基本免费帐户每天在 10 台设备上进行测试。
目前付费服务领域的领跑者是 Perfecto Mobile,但还有许多其他公司。Perfecto 还为 Jenkins 提供了一个插件,允许您在多种设备上自动进行 APK 测试。
从制造商处借用设备
制造商知道,在测试 Android 设备时,设备碎片即使不是问题,也是一个问题。大多数都有自己的开发者网站,试图缓解这个问题。虽然索尼和 Kindle 已经创建了仿真器插件,三星也有自己的仿真器,但 LG 采取了不同的方法,将借给你一个物理设备供你自己测试。目前设备如下,您可以在http://developer.lge.com
注册:
- G2 VS980(威瑞森)
- G2 D800(美国电话电报公司)
- Optimus G Pro E980(美国电话电报公司)
- 颁布 VS890(威瑞森)
- 擎天柱 P769 (T-Mobile)
人群测试
解决碎片化的一个更具创新性的解决方案是使用人群测试。开发人员向他们的朋友和家人乞讨、借用和窃取测试设备是很常见的。然而,因为没有人愿意放弃他们的手机,所以这种策略除了非常特别的测试之外没有任何作用。TestFairy 等公司的人群测试以一种更有组织的方式来组织这种测试。apk 被发送到一系列电子邮件中,或者 LinkedIn 或脸书的链接中,测试人员与应用的互动被录制成视频,以便日后查看。
摘要
这一章已经展示了一些方法,你可以解决在 Android 设备的巨大碎片世界中测试你的应用的问题,而不需要让你自己破产去购买 12,000 或更多不同的硬件。通过使用制造商 avd 和第三方设备测试服务,巧妙锁定设备子集,您可以大大扩大设备测试的覆盖范围。
八、网络服务
大多数 Android 应用都处理用户生成或消费的数据。在大多数情况下,数据量太大,无法存储在本地设备上,因此我们需要一种方法将数据保存在其他地方并进行检索。Web 服务通过在我们的 Android 应用使用的远程服务器上公开应用编程接口(API)来提供这种功能。在本章中,你将学习如何访问这些 API,以及如何安全地构建自己的 web 服务。但是首先你需要从总体上理解 web 服务。在本章中,我们将重构我们的 ToDo Android 应用,将数据存储从本地 SQLite 数据库转移到云中托管的 web 服务。
Web 服务类型
两种最流行的 web 服务架构是简单对象访问协议(SOAP) 和表述性状态转移(REST) 。这些架构以不同的方式公开远程 API,并且都有各自的优点和缺点。一般来说,您的应用可能使用的大多数服务都属于这两类。
休息还是肥皂?
在移动设备兴起之前,web 服务通常基于 SOAP,这种类型的服务架构通常被称为面向服务的架构(SOA)。SOAP web 服务使用远程过程调用(RPC)架构,其中 SOAP 消息(或信封)通过单个 URI(统一资源标识符)从客户端传递到服务器。SOAP 信封通常是一个 XML 文档,包含要执行的函数名、必要的参数和安全细节。服务器执行请求的函数,创建一个新的信封,插入结果,并将新的信封返回给客户机。信封的性质使 SOAP 非常安全,SOAP 的 WS-Security 扩展提供了确保消息完整性和安全性的方法。
对于开发人员来说,SOAP 的另一个优势是 WSDL (Web 服务描述语言)文件,它详细描述了每个函数调用的输入和输出结构。通常,用于构建 SOAP 服务的开发人员工具会自动生成 WSDL,创建关于 SOAP 服务的即时文档。WSDL 代表合同,即提供给客户的服务。开发人员工具通常还可以读取 WSDL 文件,并自动生成与客户端应用的 SOAP 服务的输入和输出相匹配的 Java 对象。SOAP 服务独立于传输层运行,但通常使用 HTTP 协议。
SOAP 的缺点是使用 XML 导致的消息大小,以及与处理信封相关的开销。移动网络的带宽通常是有限的,因此较大的消息需要更长的时间来传输。尽管移动设备在不断改进,但它们的 CPU 和可用内存是有限的;因此,对于大多数基于服务的移动解决方案来说,XML 解析并不是最佳实践。但是如果安全性在您的设计中是最重要的,那么尽管有缺点,SOAP 还是一个可行的解决方案。
REST 在许多方面与 SOAP 有很大的不同。使用 REST 架构构建的服务依赖于 HTTP 协议过程。SOAP 没有这种依赖性,尽管它通常使用 HTTP。REST 通过将表示资源名称的 URI 与 HTTP 动词结合起来,允许客户端应用管理服务器端资源,如数据库,从而实现了对 HTTP 的依赖。URIs 和动词到资源和动作的映射将 REST 与 SOAP 基于功能的架构区分开来。在 SOAP 实现中,函数名和动作是信封的一部分,而不是 URI 的一部分,这带来了灵活性,但也可能导致复杂性增加。
REST 调用中使用的 HTTP 动词包括但不限于GET
、POST
、PUT
和DELETE
。服务器端 REST 应用经常将这些动词分别映射到读取、更新、创建和删除操作。此外,这些服务使用与网站相同的 HTTP 响应代码。例如,如果我们使用数据库中不存在的 ID 向服务请求数据库记录,服务将返回 404(未找到)响应。当用户请求不属于网站的页面时,浏览器会收到同样的响应。
注意关于PUT
或POST
是否应该映射到一个创建动作,并由另一个映射来更新,还存在一些争议。目前还没有确定的答案;事实上,你可以花一些有趣的、相当多的时间来研究这一争议。目前,在创建自己的服务时,选择一个动词表示更新,另一个动词表示创建,并保持一致。
REST 服务可以接受和返回多种格式的数据,包括 HTML、XML(可扩展标记语言)、纯文本和 JSON (JavaScript 对象符号)。
理查森成熟度模型
REST web 服务实现在遵守 REST 服务的最纯粹定义方面有所不同。Richardson 成熟度模型 通过为服务指定一个从 0 到 3 的等级来描述 REST 服务遵守定义的程度。
0 级实现简单地使用 HTTP 作为客户机和 web 服务器之间的传输机制。0 级 web 服务客户端对所有调用使用相同的 URI 和 HTTP 动词,比如 POST,通常来回移动 XML。大多数早期的 Ajax 风格的 web 服务都是这样构建的。除了不使用 SOAP 信封之外,0 级与传统的 SOAP 实现没有什么不同。
第 1 级实现通过引入与特定 URI 相关的资源的定义,向纯粹的 REST 定义迈近了一步。例如,数据库中表示的某个项目的 ID 成为 URI 的一部分,因此 URI 只指向该数据库记录。第 1 级实现仍然只使用一两个 HTTP 动词,通常是POST
和GET
,尽管现在有许多 URIs。
将 HTTP 动词添加到唯一的 URI 定义了第 2 级实现。HTTP 谓词与在资源上执行的操作非常匹配:
PUT
=创建GET
=阅读POST
=更新DELETE
=删除
注意这些操作统称为 CRUD,是创建、读取、更新和删除的缩写。
现在我们有许多 URIs,每个都响应一个或多个 HTTP 动词。当服务以这种方式运行时,它使 web 的基本路由基础结构能够使用 web 页面使用的相同缓存机制,从而提高了性能和可靠性。
在最高级别,第 3 级,服务实现了所有的第 2 级特性,但是增加了超媒体格式。这通常被称为超文本作为应用状态的引擎(HATEOAS) 。这意味着服务在响应头和/或响应体中提供 URIs。例如,通过 PUT 创建的记录将返回对相同数据执行 GET 所必需的 URI;对 ToDos 列表的 GET 请求产生的响应数据将包括操作结果集中每个元素所必需的 URIs。这允许服务成为自描述的,开发者不需要学习或编写与服务交互所需的所有 URIs。3 级服务符合最严格的 REST 定义。
消费 Web 服务
作为一名 Android 开发者,因此也是一名客户端构建者,最终你的开发工作将会涉及到消费 web 服务。在 Android 应用中,我们将遵循特定的流程与 web 服务对话:
-
在你的
Activity
中,发送一个Intent
给一个IntentService
。 -
IntentService
接收并处理Intent
并调用 web 服务。 -
IntentService
将结果放入一个新的Intent
中,并将其发送回Activity
。 -
The
Activity
processes the newIntent
and displays the result, perhaps in aListView
.我们的应用将调用 web 服务来获取所需的数据,或者调用服务来保存它们生成的数据。我们需要了解我们将消费的数据是什么样的。
XML 或 JSON
大多数 web 服务以 XML 或 JSON 的形式提供数据,或者两者都提供,尽管其他格式也是可能的。XML 在 1998 年成为 W3C 规范,它长期以来被用于面向服务的系统。JSON 稍微新一些,最近越来越受欢迎。JSON 是在 2006 年发布的 RFC 中定义的,尽管在那之前它就已经在使用了。
几年前,大多数关于 SOA 服务的讨论和实现都集中在 SOAP 服务上,并实现了 WS-Security 之类的协议。因此,基于 SOAP(因此也是基于 XML)的服务在许多企业中被广泛使用。
移动设备有一个受约束的网络管道,所以您想要尽可能小的消息。此外,移动设备往往受到 CPU 的限制,因此解析大消息需要更多的 CPU 能力,从而需要更多的电池电量。
让我们看一个简单的消息,一个地址,在清单 8-1 中被格式化为 XML 和 JSON。
清单 8-1 。用 XML 和 JSON 两种格式表示的地址
XML
<address>
<street>123 Main St.</street>
<city>Anytown</city>
<state>MI</state>
<postal>48123</postal>
</address>
JSON
{
"street": "123 Main St.",
"city" : "Anytown",
"state" : "MI",
"postal" : "48123"
}
JSON 消息使用 100 个字符来表示数据,而 XML 消息使用 128 个字符。虽然这种差异看起来不是很大,但是传输 JSON 消息将花费更少的时间和带宽。如果应用经常使用 web 服务,这种大小差异会很快增加。请记住,您的应用的许多用户正在为他们消耗的带宽付费。
对于 Android 应用消费的服务,首选 JSON。JSON 消息允许结构化数据,并且它们比格式化为 XML 消息的相同数据更小,适应移动应用遇到的有限带宽。此外,解析 JSON 比解析 XML 容易,因此移动设备使用的 CPU、内存和电池电量更少。Web 应用也擅长使用 JSON,所以一个设计良好的服务可以被移动应用和 web 应用使用。
JSON 的好处并不总是排除使用 XML。基于 REST 的服务还不能提供 SOAP 中存在的所有标准。如果您的应用需要高级别的安全性,比如 SOAP 中的 WS-Security 标准提供的安全性,或者如果只有基于 XML 的服务可用,那么您可能需要使用 XML。
HTTP 状态代码
HTTP 协议规定,对 web 服务器的每个调用都在响应中返回一个状态代码和数据(如果有数据的话)。该协议定义了大量代码,但是 web 服务通常使用一个公共代码子集来响应:
- 200–好的。请求成功。
- 302 -找到了。资源已经移动,在 Location HTTP 头中返回一个新的 URI。浏览器通常会自动加载新的 URI,无需用户干预。
- 304–未修改。请求的资源没有改变。例如,浏览器检查 HTML 页面上的图像,发现它可以使用图像的缓存副本,而不是从 web 服务器请求另一个副本。304 状态和缓存的正确使用对于网络受限的移动设备可能是重要的。
- 400–错误的请求。发送到 web 服务器的请求包含格式错误的语法,例如无效的 JSON 或 XML。
- 401–未经授权。服务器要求验证,但请求不包含正确的凭证。
- 404–未找到。资源不再位于 URI。
- 500–内部服务器错误。服务器遇到错误,无法响应请求。
当处理来自 web 服务的响应时,我们的 Android 应用可能需要显式地处理这些情况。万维网联盟(W3C) 在http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
托管 HTTP 协议中状态代码的完整列表。
读取和发送数据
我们知道 web 服务由一个 URI 组成,我们调用它来访问一些可能被格式化为 JSON 或 XML 的数据。让我们用 Android API 访问一个 web 服务。
Android API 中有两个类允许您连接到 web 服务。它们是 Apache HTTP 客户端 ( DefaultHttpClient
)和HttpURLConnection
。Android 团队建议使用HttpURLConnection
,除非你是为比姜饼更老的 Android 版本开发。此外,如果您需要使用 NTLM 认证协议来安全地连接到基于 Windows 的网络和服务,您将需要使用 Apache HTTP 客户端。另一方面,HttpURLConnection
有更多的特性来提高应用的性能。HttpURLConnection
可以跟踪多达五个 HTTP 302(找到的)重定向,这在处理针对 web 服务器的身份验证或与现有 web 应用的资源进行交互时非常重要。Apache HTTP 客户端要求您用自己的代码处理重定向。HttpURLConnection
还包括从 Gingerbread 开始支持 gzip 压缩,从 Ice Cream Sandwich 开始支持基于 HTTP 304(未修改)响应代码的资源缓存。Android 团队将所有的开发工作都放在了HttpURLConnection
上,所以计划使用这个类。
为了在我们的 ToDo 应用中使用 web 服务,我们需要实现一个使用HttpURLConnection
的函数。清单 8-2 展示了一个函数,它可以执行基于 HTTP 的动作来调用带有HttpURLConnection
的 web 服务。该功能包含三个部分。第一部分建立连接,指定 HTTP 方法和 URI。如果请求体是必需的,第二部分将 JSON 输入添加到请求体中。该函数的最后一部分从 URI 的服务器读取响应,并将其转换为字符串。该函数返回一个名为WebResult
的普通旧 Java 对象(POJO) ,其中包含 HTTP 状态代码和响应数据。Android 应用可以检查成功或错误的状态代码,并适当地处理结果数据。
清单 8-2 。执行基于 REST 的 HTTP 任务的函数
public WebResult executeHTTP(String url, String method, String input) throws IOException {
OutputStream os = null;
BufferedReader in = null;
final WebResult result = new WebResult();
try {
final URL networkUrl = new URL(url);
final HttpURLConnection conn = (HttpURLConnection) networkUrl.openConnection();
conn.setRequestMethod(method);
if (input !=null && !input.isEmpty()) {
//Create HTTP Headers for the content length and type
conn.setFixedLengthStreamingMode(input.getBytes().length);
conn.setRequestProperty("Content-Type", "application/json");
//Place the input data into the connection
conn.setDoOutput(true);
os = new BufferedOutputStream(conn.getOutputStream());
os.write(input.getBytes());
//clean up
os.flush();
}
final InputStream inputFromServer = conn.getInputStream();
in = new BufferedReader(new InputStreamReader(inputFromServer));
String inputLine;
StringBuffer json = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
json.append(inputLine);
}
result.setHttpBody(json.toString());
result.setHttpCode(conn.getResponseCode());
return result;
} catch (Exception ex) {
Log.d("WebHelper", ex.getMessage());
result.setHttpCode(500);
return result;
} finally {
//clean up
if (in != null) {
in.close();
}
if (os != null) {
os.close();
}
}
}
public class WebResult {
private int mCode;
private String mBody;
public int getHttpCode() {
return mCode;
}
public void setHttpCode(int mCode) {
this.mCode = mCode;
}
public String getHttpBody() {
return mBody;
}
public void setHttpBody(String mResult) {
this.mBody = mResult;
}
}
从 web 服务返回的结果实际上只是字符串,要么是 XML,要么是 JSON,我们希望将它们转换成 POJOs。有很多方法可以解析 JSON,从内置 API 到许多第三方库。为了简化字符串结果的处理,我们将使用一个名为 Gson 的库将 JSON 结果转换成 POJOs。
从https://code.google.com/p/google-gson/
下载 Gson 库。提取 jar 文件,并将它们导入到 Android 项目的libs
文件夹中。使用 Eclipse 中的构建路径将这个库添加到您的类路径中。
使用 Gson 很简单。向它传递来自 web 服务调用的 JSON 字符串结果和您期望从 JSON 得到的 POJO 类型,如清单 8-3 中的所示。
清单 8-3 。从 JSON 创建 ToDo 对象的数组列表
final Gson parser = new Gson();
results = parser.fromJson(webResult, new TypeToken<ArrayList<ToDo>>(){}.getType());
parser.toJson(newToDo, ToDo.class);
当然,解析 JSON 不仅仅是向库中传递一些参数。
要使用 Gson,您必须注释您的对象。我们在清单 8-4 中的ToDo
类中这样做,将 JSON 结果中的字段名映射到ToDo
类的成员变量。这允许我们根据惯例命名我们的类成员变量,而不是被迫匹配 JSON 中字段的名称和大小写。
清单 8-4 。一个带注释的、可打包的 ToDo 类,用于 Gson 序列化,省略了 Getter 和 setter
public class ToDo implements Parcelable {
@SerializedName("id")
private Long mId;
@SerializedName("title")
private String mTitle;
@SerializedName("email")
private String mEmail;
// Default constructor for general object creation
public ToDo() {
}
// Constructor needed for parcelable object creation
public ToDo(Parcel item) {
mId = item.readLong();
mTitle = item.readString();
mEmail = item.readString();
}
//Getters and setters omitted
// Used to generate parcelable classes from a parcel
public static final Parcelable.Creator<ToDo> CREATOR
= new Parcelable.Creator<ToDo>() {
public ToDo createFromParcel(Parcel in) {
return new ToDo(in);
}
public ToDo[] newArray(int size) {
return new ToDo[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel parcel, int i) {
if(mId != null) {
parcel.writeLong(mId);
}
else {
parcel.writeLong(-1);
}
parcel.writeString(mTitle);
parcel.writeString(mEmail);
}
}
还要注意清单 8-4 中实现Parcelable
接口的类。Android 中的 parcelable 类允许应用使用意图跨进程传递数据。关于清单 8-4 中的Parcelable
类有两点需要注意。首先是writeToParcel()
函数和匹配的构造函数以相同的顺序将项目写入和读取到包中。没有与字段匹配的键;你必须把顺序弄对。第二个值得注意的是一个Creator
函数,它允许从一个包中存储和重新生成Parcelable
类。
性能
访问 web 服务意味着我们的应用通常通过一个缓慢且有时不太可靠的网络进行通信。因此,这些集成点会成为应用性能的瓶颈。为了让我们的应用在这些调用中感受到响应,我们可以采取许多设计方法,从运行在不同的线程上到优化 HTTP 调用,再到应用使用的服务。
服务和 AsyncTask 类
当调用 web 服务时,我们的应用必须异步进行这些调用。因此,对 web 服务的任何调用都应该发生在与 UI 线程不同的线程上。如果应用不这样做,可能会发生一些不好的事情,这取决于运行该应用的 Android 版本:
- 用户界面变得无响应或被阻止。
- 用户得到一个应用不响应(ANR)对话框。
- 应用会立即抛出异常。
任何写得好的应用都应该避免所有这些情况。基本思想是将任何对 web 服务的调用转移到它们自己的线程中。
许多例子展示了如何在一个Activity
中使用AsyncTask
类来实现这一点。虽然这种方法在大多数情况下都有效,但是从纵向到横向或从横向到纵向的方向变化会产生意想不到的效果。创建AsyncTask
的Activity
在方向改变时被破坏,并在新的方向上被重新创建。AsyncTask
仍然与被销毁的活动相关联,因此结果不能返回给新的活动。此外,在 AsyncTask 中引用原始Activity
中的回调方法会阻止垃圾收集器回收原始Activity
的内存,除非在活动被销毁时特别小心。使用AsyncTask
可以解决这个问题,但是更好的解决方法是使用IntentService
类,因为它不在Activity
的生命周期内。
除了一个IntentService
,Android SDK 还提供了一个Service
类。与Service
级相比,IntentService
级有很多好处。首先,它在自己的线程上异步运行。但是IntentService
最大的特点之一是一旦完成,它会自动停止。您的应用不需要管理IntentService
的状态。相比之下,Service
类要求您自己管理线程,以及启动和停止Service
。有些情况可能需要通过实现一个Service
来实现控制,但是一个IntentService
可以用更少的代码处理大多数服务调用。
在我们可以在应用中使用一个IntentService
之前,它必须在我们项目的androidmanifest.xml
文件中,在<application>
标签中注册,如清单 8-5 所示。将android:exported
属性设置为false
可以确保应用之外的组件不能使用该服务。
清单 8-5 。在 androidmanifest.xml 中声明 intent service
<service android:name="com.logicdrop.todos.service.RestService" android:exported="false"></service>
一旦我们的IntentService
在androidmanifest.xml
中注册,启动它就很简单了。简单地创建一个Intent
,然后调用startService(intent)
,如清单 8-6 所示。
清单 8-6 。从活动中启动 IntentService】
Intent intent = new Intent(this, ToDoService.class);
intent.setAction("todo-list");
intent.putExtra("email", emailAddress);
startService(intent);
IntentService
本身也很简单。它只有一个方法可以实现,onHandleIntent()
。这个函数是发送给IntentService
的任何意图的监听器。如果IntentService
处理多个功能,设置传入意图的动作以区分传入请求,然后检查IntentService
内的动作。
清单 8-7 显示了onHandleIntent()
的一个实现。该函数通过从Activity
发送的Intent
传递。它检查Intent
的动作,并通过调用不同的函数进行响应,这些函数最终使用前面描述的executeHTTP()
函数调用 REST 服务。
清单 8-7 。实现一个处理多个意图动作的 IntentService】
public class RestService extends IntentService {
public static final String SERVICE_NAME ="REST-TODO";
public static final String LIST_ACTION = "todo-list";
public static final String ADD_ACTION = "todo-add";
public static final String DELETE_ACTION = "todo-remove";
public RestService() {
super("RestService");
}
@Override
protected void onHandleIntent(Intent intent) {
if (LIST_ACTION.equals(intent.getAction())) {
final String email = intent.getStringExtra("email");
listToDos(email);
}
else if (ADD_ACTION.equals(intent.getAction())) {
final ToDo item = intent.getParcelableExtra("todo");
addToDo(item);
}
else if (DELETE_ACTION.equals(intent.getAction())) {
final long id = intent.getLongExtra("id", -1);
final int position = intent.getIntExtra("position", -1);
removeToDo(id, position);
}
}
//Other private methods not shown....
}
一旦executeHTTP()
函数返回一些 JSON,它就被转换回一个Parcelable
POJO,并通过另一个Intent
返回给活动。
通过清单 8-8 中的Intent
发回数据的最重要的方面是LocalBroadcastManager
类。这个类是支持库的一部分,是 Android SDK 的一个附件,提供了一些重要的好处。首先,Intent
的范围保持在我们的应用中。安装在 Android 设备上的其他应用可以看到并响应应用抛出的正常Intents
,包括恶意软件。此外,LocalBroadcastManager
允许Activity
在后台处理结果,所以当一个长时间运行的结果返回时,你的应用不会像监听一个启动Activity
的Intent
时那样被强制到前台。
清单 8-8 。从 IntentService 发回意向
final Intent sendBack = new Intent(SERVICE_NAME);
sendBack.putExtra("result", result);
sendBack.putExtra("function", LIST_ACTION);
if(results != null){
sendBack.putParcelableArrayListExtra("data", results);
}
//Keep the intent local to the application
LocalBroadcastManager.getInstance(this).sendBroadcast(sendBack);
IntentService
的主要缺点是它按顺序处理所有请求;请求不会并行运行。如果你需要在短时间内下载很多项目,一个IntentService
可能不是一个好的解决方案。
既然IntentService
正在发回结果,让我们检查一下如何使用我们用来发送Intents
的同一个LocalBroadcastManager
正确地处理这些结果。之前我们讨论过AsyncTask
在设备旋转方面的缺点。LocalBroadcastManager
提供了一个解决方案,如清单 8-9 所示。
清单 8-9 。在活动中处理退货意向
// Unhook the BroadcastManager that is listening for service returns before rotation
@Override
protected void onPause() {
super.onPause();
LocalBroadcastManager.getInstance(this).unregisterReceiver(onNotice);
}
// Hook up the BroadcastManager to listen to service returns
@Override
protected void onResume() {
super.onResume();
IntentFilter filter = new IntentFilter(RestService.SERVICE_NAME);
LocalBroadcastManager.getInstance(this).registerReceiver(onNotice, filter);
//Check for records stored locally if service returned while activity was not in the foreground
mData = findPersistedRecords();
if(!mData.isEmpty()) {
BindToDoList();
}
}
// The listener that responds to intents sent back from the service
private BroadcastReceiver onNotice = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
final int serviceResult = intent.getIntExtra("result", -1);
final String action = intent.getStringExtra("function");
if (serviceResult == RESULT_OK) {
if(action.equalsIgnoreCase(RestService.LIST_ACTION)){
mData = intent.getParcelableArrayListExtra("data");
}
else if(action.equals(RestService.ADD_ACTION)) {
final ToDo newItem = intent.getParcelableExtra("data");
mData.add(newItem);
etNewTask.setText("");
} else if(action.equals(RestService.DELETE_ACTION)) {
final int position = intent.getIntExtra("position", -1);
if(position > -1){
mData.remove(position);
}
}
BindToDoList();
} else {
Toast.makeText(TodoActivity.this, "Rest call failed.", Toast.LENGTH_LONG).show();
}
Log.d("BroadcastReciever", "onNotice called");
}
};
在OnResume
事件处理程序中,我们为从IntentService
返回的意图创建一个IntentFilter
。OnResume
事件是活动生命周期的一部分,并且总是在创建活动时被调用。我们用LocalBroadcastManager
注册了一个BroadcastReceiver
来使用这个过滤器监听输入的意图。
在OnPause
事件处理程序中,我们将BroadcastReceiver
与LocalBroadcastManager
脱钩。此事件也是活动生命周期的一部分,在活动被销毁时调用,例如在方向更改期间。因为IntentService
生活在自己的线程上,与AsyncTask
相反,活动的创建和销毁与它的行为无关。这种模式的一个缺陷是,当活动不再处于前台或者已经被销毁时,IntentService
可能会完成并发送结果意图。为了减轻这种情况,IntentService
应该将 web 服务调用结果写入数据库。当活动恢复时,它可以检查数据库中挂起的 web 服务结果。
当BroadcastManager
从服务调用中获得数据时,它将 ToDos 列表放在类级成员变量中,并将列表绑定到 UI。
处理长时间运行的呼叫
如果您正在解决的问题需要长时间运行的 web 服务调用,那么实现服务而不是 IntentService 可能会更好。在一个长时间的通话中,有一些问题需要解决。首先,长时间运行的呼叫应该通知用户呼叫的状态。否则,应用可能看起来没有响应。此外,如果需要内存,操作系统可能会终止我们的服务,因为它在长时间运行的调用中似乎是空闲的。
为了解决这个问题,我们实现了一个Service
而不是一个IntentService
。Service
类提供了通知用户状态和防止操作系统在应用繁忙时杀死它所需的工具。每个服务都可以在独立于应用的自己的进程中运行,并且可以设置为在操作系统终止服务时重新启动。这种类型的服务在AndroidManifest.xml
文件中声明,如清单 8-10 所示。这种类型的服务实现应该只在必要的时候使用。启动另一个进程会占用更多的内存资源,好的应用会尽量减少这些资源。
清单 8-10 。AndroidManifest.xml 文件中的服务声明,它在自己的进程中运行
<service
android:name="LongRunningService"
android:process=":serviceconsumer_process"
android:icon="@drawable/service-icon"
android:label="@string/service_name">
</service>
为了告诉 Android 操作系统我们的服务在长时间的调用中是有效的,我们需要在我们的服务上调用startForeground()
,如清单 8-11 中的所示。函数onStartCommand()
返回常数Service.START_REDELIVER_INTENT
。这允许操作系统在内存不足的情况下终止服务,然后以最后一次提交的意图重新启动服务。然后,服务可以尝试重新处理它需要重新处理的最后一个意图,因为操作系统在上一次完成之前终止了服务。
清单 8-11 。为服务中的长期运行调用建立服务的方法
private static final int mServiceId = 42;
@Override
public IBinder onBind(Intent intent) {
Notification notice;
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
notice = APIv11.createNotice(this);
} else {
notice = new Notification(R.drawable.icon, "Service Finished", System.currentTimeMillis());
}
startForeground(mServiceId, notice);
return null;
}
private static class APIv11 {
public static Notification createNotice(Service context){
Notification notice = new Notification.Builder(context.getApplicationContext()).setContentTitle("Service finished").build();
return notice;
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return Service. START_REDELIVER_INTENT;
}
@Override
public boolean onUnbind (Intent intent){
stopForeground(true);
return false;
}
最佳化
因为网络带宽是移动设备的一个约束,我们的 web 服务需要利用现代 web 服务器中可用的优化来减少我们的 Android 应用消耗的带宽。这些优化包括压缩和缓存。
压缩
现代的 web 服务器,像 IIS、Nginx 和 Apache 的 httpd 都支持 gzip 压缩。文本压缩非常有效,因为我们的 web 服务只传递文本,所以我们的代码应该利用压缩。如前所述,HttpURLConnection
对象已经内置了对来自 Gingerbread 的 gzip 的支持,事实上,当在连接上调用getInputStream()
时,默认情况下会使用这个特性。这个调用使得HttpURLConnection
对象自动将 HTTP 头Accept-Encoding: gzip
添加到请求中。只要 web 服务器配置了 gzip,HTTP 请求/响应对就会被压缩,这对于使用有限带宽的移动设备非常重要。
基于 Http 的缓存
当 web 服务器向客户机发送内容时,它可以通过expires
HTTP 头向内容添加截止日期。通常,像图像这样的较大的静态项目经常有一个expires
头设置在未来几天或几周,以防止当用户返回到已经下载了图像的页面时,web 浏览器不断地重新下载这些文件。对于冰淇淋三明治,HttpURLConnection
支持基于 HTTP 的缓存。如果先前由HttpURLConnection
获取的项目带有 future expires 标头,或者由于条件过期,web 服务器以 304 代码响应,则这些项目从本地存储而不是通过网络加载,从而再次最小化带宽使用。因为只有冰激凌三明治或更新的产品支持这个特性,一点小小的反射允许我们的代码使用这个特性,如清单 8-12 所示。
清单 8-12 。一种为支持缓存的 Android 版本选择性启用 HTTP 缓存的方法
private void enableHttpResponseCache() {
try {
long httpCacheSize = 10 * 1024 * 1024; // 10 MiB
File httpCacheDir = new File(getCacheDir(), "http");
Class.forName("android.net.http.HttpResponseCache")
.getMethod("install", File.class, long.class)
.invoke(null, httpCacheDir, httpCacheSize);
} catch (Exception httpResponseCacheNotAvailable) {}
}
我们之前注意到IntentServices
是串行操作的,这在所有情况下都不好,比如下载大量图片。在 Google I/O 2013 上,谷歌宣布发布一个名为凌空(http://www.youtube.com/watch?v=yhv8l9F44qo
)的新库。凌空支持并发下载,并内置了对图像处理和客户端可控缓存的支持。凌空目前不是 Android SDK 的一部分,但将来可能会是,但当串行 HTTP 连接不能提供必要的性能时,它代表了一个很好的解决方案。
安全
在一个频繁出现安全漏洞和组织侵犯用户隐私的时代,应用的客户端和服务器端的安全性已经成为应用开发人员主要关心的问题。
开放 Web 应用安全项目(OWASP) 是一个非营利组织,致力于教育开发人员了解安全威胁,并为他们提供减轻这些威胁的工具和信息。OWASP 定期发布应用安全的十大威胁列表。2013 年,OWASP 发布了新的十大网络应用(https://www.owasp.org/index.php/Category:OWASP_Top_Ten_Project
),并在 2011 年扩展到包括十大移动威胁列表:
- M1:不安全的数据存储
- M2:服务器端控制薄弱
- M3:传输层保护不足
- M4:客户端注入
- M5:授权和认证较差
- M6:会话处理不当
- M7:通过不受信任的输入做出安全决策
- M8:侧信道数据泄漏
- M9:破解的加密技术
- M10:敏感信息泄露
其中许多威胁直接适用于使用 web 服务的 Android 应用,包括:
- M1:不安全的数据存储。这个问题是由于没有正确保护或加密存储在设备上的数据(如用户 ID)或存储临时数据(如密码)而引起的。
- M3:传输层保护不足。不使用传输层安全性或忽略安全警告(如证书错误)的应用容易受到此漏洞的攻击。
- M5:授权和认证不佳。当应用使用基于硬件的标识符(如 IMEI(国际移动设备身份)号)时,通常会发生这种情况,攻击者可以确定该标识符并用来冒充用户。
- M6:会话处理不当。移动用户会话往往比网站会话长得多,因此在用户方便时,移动应用会让用户登录更长时间。长时间的会话会导致未经授权的访问,尤其是在设备丢失的情况下。让用户定期重新认证,并确保您的服务器端应用可以在必要时远程撤销会话。
- M9:破解密码术。不要将编码、模糊处理或序列化与加密混淆。尽可能使用最强的加密算法。不要将用于双向加密的密钥与数据一起存储在不安全的位置,如应用代码中。
- M10:敏感信息泄露。Android 应用代码可以很容易地反编译成 Java 代码。存储在代码中的任何敏感信息,如加密密钥、用户名、密码和 API 密钥都将被发现。
请务必花时间访问 OWASP 网站上的https://www.owasp.org
,以便更加熟悉每种威胁以及 OWASP 能够提供的帮助您构建安全应用的所有工具。
Web 服务的注意事项
安全性是一个广泛的主题,在其他书籍中有更深入的介绍,如 Sheran Gunasekera 的Android Apps Security(a press,2012)。当我们继续讨论构建您自己的 web 服务时,这里有一些在您构建 web 服务时要遵循的一般实践。
不存储密码
如果必须将密码存储在服务数据库中,不要以明文形式存储。正确的步骤是用一个唯一的值对密码加盐,然后用一个强哈希算法(至少是 SHA-256 或 bcrypt)对加盐的密码执行单向哈希。更简单的哈希算法,如 MD-5 或 SHA-1,通常因其速度而被选择,但安全专业人员已经证明这些算法不安全。快速散列算法是散列密码存储的敌人,因为破解这些散列的计算能力变得越来越可用。
Salting 是将一个值附加或预先添加到密码中。当用户尝试进行身份验证时,应用可以根据用户输入的密码重新创建 salted hash,然后将该结果与存储在数据库中的结果进行比较。盐增加了密码的随机性和大小,使得数据库被破坏时更难猜测。
攻击者试图使用一种称为彩虹表的技术来破解哈希,这实际上是哈希算法的预编译逆向工程。足够强的散列算法使得彩虹表非常大,并且需要非常长的时间来计算。盐的添加迫使攻击者对每个可能的盐使用单独的彩虹表,这增加了找到匹配和成功恢复密码所需的时间和计算能力。在 2013 年末的这个时候,一个含有随机盐的足够强的散列很难被打破。随着计算能力和受扰计算的可用性随着时间的推移而增加,哈希算法将变得越来越不安全。
不要发送密码
如果您可以避免通过网络发送用户密码,攻击者就无法远程发现密码。在 Android 设备上,用户通常已经通过存储在设备上的谷歌账户获得了谷歌的认证。您的 web 服务可以与 Google 的 OAuth 服务集成,使用设备帐户进行身份验证(这个用户是谁)和授权(这个用户被允许做什么)。清单 8-13 展示了如何获取 Android 设备上当前的谷歌账户名称。当然,一些 Android 设备,如 Kindle Fire,不允许在设备上存储谷歌账户。
清单 8-13 。一种在 Android 设备上获取 Google 账户列表的方法
private String[] getAccountNames() {
try {
AccountManager accountManager = AccountManager.get(this);
Account[] accounts = accountManager.getAccountsByType(GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE);
String[] names = new String[accounts.length];
for (int i = 0; i < names.length; i++) {
names[i] = accounts[i].name;
}
return names;
} catch (Exception ex) {
Log.d(APP_TAG, "Account error", ex);
return null;
}
}
一旦从设备上获得了 Google 帐户,就可以为基于 OAuth 的 Google 服务生成一个令牌,如清单 8-14 中的所示。这个令牌允许用户访问其他 Google 服务和 API,而不需要为每个服务重新认证,也不需要从设备通过网络发送密码。令牌通常作为请求的 JSON 主体的一部分发送,尽管它也可以是 URI 的一部分或在 HTTP 头中发送。令牌还带有到期日期,接受令牌的服务将在满足请求之前检查令牌的有效性。这个函数还需要一个来自应用的client_id
,我们正在使用这个应用的服务。该值由 web 服务开发人员在与 Google 集成时获取,并且必须与客户端共享,以便执行 Google OAuth 身份验证。
清单 8-14 。获得谷歌 OAuth 令牌
private String authenticateGoogle(String accountName) {
String token = "";
try {
String key = "audience:server:client_id:123456.apps.googleusercontent.com";
token = GoogleAuthUtil.getToken(this, accountName, key, null);
} catch (IOException e) {
Log.d("IO error", e.getMessage());
} catch (GoogleAuthException ge) {
Log.d("Google auth error", ge.getMessage());
} catch (Exception ex) {
Log.d("error", ex.getMessage());
}
return token;
}
不拥有密码
如果您不将密码存储在数据库中,那么当您的数据库遭到破坏时,攻击者就无法利用您的用户。像 Google、Yahoo、Twitter 和脸书这样的大型供应器提供集成 API,允许用户使用这些供应器提供的凭证登录您的应用。虽然您需要信任这些提供者来保证他们用户的安全,但是通过与这些提供者之一集成,您的应用的风险会更小。
OpenID 是一个分散的、开放的身份验证协议,使得人们可以使用移动应用轻松注册和访问 web 帐户。刚刚列出的许多相同的供应器都参与了 OpenID。StackOverflow 是一个流行的面向开发人员的众包讨论网站,它使用 OpenID 来认证用户。OpenID Connect 是 OpenID 之上的一个 API 层,设计用于移动应用,应该会在不久的将来正式发布。
使用传输层安全性(TLS/SSL )
至少,传输用户凭证或任何种类的个人信息的 web 服务需要使用传输层安全性(TLS) 来保护。TLS 保护移动应用和 web 服务之间传输的数据不被未经授权的访问或修改。术语 TLS 通常与安全套接字层(SSL) 互换使用。TSL 1.0 版实际上相当于 SSL v3.1 版。大多数现代浏览器都支持各种版本的 SSL 和 TLS。
使用会话
使用 REST 架构构建的 Web 服务本质上是无状态的,因此是无会话的。我并不是说我们违反了休息的原则。对于会话,我的意思是使用登录时创建的会话令牌,随后与每个请求一起发送,以验证请求的真实性。令牌不应该作为 URL 的一部分发送,而应该在请求体或 HTTP 头中发送。这些会话令牌应该有一个到期日期,并随着每个请求轮换,以防止令牌重放。OWASP 提供了一个称为企业安全 API 的开源 web 应用安全控制库,您可以使用它来创建和管理 web 服务中的会话令牌(https://www.owasp.org/index.php/Category:OWASP_Enterprise_Security_API
)。这个库是为 Java 和 Ruby 发布的,还在为其他平台开发,包括 PHP。Net 和 Python。
认证
有许多方法可以向 web 服务验证用户身份,大多数都是基于传统的 web 技术。虽然可以使用自己的身份验证方法,但这不是一个好主意。许多聪明的、以安全为导向的专业人士已经花费了数千小时来思考、设计和实现这些协议,以保证数据的安全。您的 web 服务应该利用这些协议。
HTTP 基本认证是最简单的协议,由 Android SDK 提供本地支持。用户名和密码在Authorization
HTTP 头中传递。用户名和密码用冒号连接,然后进行 Base-64 编码。编码不是加密,也不安全。因此,任何基本身份验证的使用都需要使用 TLS/SSL。因为基本身份验证是 HTTP 协议规范的一部分,所以所有现代 web 服务器都支持它,这使得开发使用基本身份验证的服务变得容易,因为这种管道已经存在于服务器端。
清单 8-15 展示了如何在 Android 客户端应用中实现基本认证。Authenticator
类为对HttpURLConnection
的后续调用设置认证处理程序,所以将这段代码放在对HttpURLConnection
的任何调用之前。您可以自己计算 HTTP Authorization
头,并通过调用Request
对象上的setHeader()
函数来添加它,但是这种方法不支持许多 web 服务器支持的抢先认证检查。抢先检查发生在实际请求之前,以便减少进行初始 HTTP 连接的开销,这是很重要的,因为移动设备通常操作的带宽是有限的。
清单 8-15 。使用 Authenticator 类在 Android 客户端实现基本身份验证
Authenticator.setDefault(new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password.toCharArray());
});
}
互联网上许多最大的网站,包括 Google、Twitter、脸书、Yahoo 和 LinkedIn,都是通过 OAuth 实现其 web 服务认证的,OAuth 是一种开放的授权标准,更像是一个框架,而不是严格定义的协议。目前 OAuth 有两个版本,1.0 和 2.0,这两个版本都在前面提到的站点中生产。一般来说,OAuth 允许用户授权应用代表他们行事,而无需与应用共享他们的密码。作为这个授权过程的副作用,用户也需要被认证,从而也允许应用认证用户。OAuth 服务器通常会向经过身份验证的用户提供一个在未来某个时间到期的令牌。应用可以使用这个令牌来提供附加服务。例如,拥有谷歌账户的 Android 设备用户可以从谷歌获得一个令牌,允许手机访问其他谷歌服务,例如可以在您的自定义应用中使用的谷歌地图 API(参见前面的清单 8-14 )。用户从不输入密码,也不会将任何密码发送到 Google Maps API。从 Google OAuth 服务获得的令牌管理所有这些授权。
创建自己的 Web 服务
几乎每种编程语言中的大多数 web 框架都提供了创建自己的 web 服务的方法。因为我们用 Java 编写 Android 代码,所以在我们的例子中,我们将使用 Java 创建一些基本的 web 服务。虽然使用 Java 创建 web 服务有很多选择,但我们将把重点放在 JAX-RS API 上。
JAX-RS 是 Java SE 5 中首次引入的用于 RESTful web 服务的 Java API。JAX-RS 使用注释将 POJO(普通旧 Java 对象)映射到 web 服务。我们可以在 Java 类中编写一个函数,就像我们过去多次做的那样,但是这一次我们添加了注释,使这个函数可以通过 REST URI 使用。许多框架都实现了 JAX-RS 规范,包括 Jersey、Restlet、Apache CXF 和 RESTeasy。Jersey 提供了一种简单易懂的方法,所以我们将使用 Jersey 来构建我们的示例。
示例 Web 服务
您可以在应用中使用的 Web 服务可以来自任何地方。大型互联网公司如谷歌、脸书、推特、ESPN、亚马逊、易贝和雅虎,地方和联邦政府部门都提供无数你(或你的用户)可以消费的服务。可用服务的一些示例包括运费、位置服务、社交媒体集成、金融数据,甚至是梦幻体育。这些服务中有许多是免费的,而其他的则需要很少的费用。在所有这些情况下,服务供应器通常会要求您注册他们的开发者计划。还有一些网站,如programmableweb.com
和usgovxml.com
,它们充当提供您可以消费的 web 服务的网站的目录。
谷歌应用引擎
开始编写自己的 web 服务的一个简单且经济的方法是将它们托管在 Google App Engine (GAE)的云中。作为一名 Android 开发者,你可能已经在谷歌注册了一个账户。
GAE 支持用 Java 编写的 web 应用(以及 Python、Go 和 PHP),因此我们可以使用 Jersey 库构建一个 REST 服务,将我们的 ToDo 数据存储在云中,而不是设备上。这为我们的应用提供了许多优势,包括存储比移动设备上更大的数据量,允许我们的待办事项列表在同一用户的多个设备上共享,如手机和平板电脑,并为设备上的应用提供更简单的升级,因为我们不再需要担心在升级过程中本地数据库会发生什么。
我们将浏览到https://appengine.google.com
,开始在 GAE 上建立我们自己的网络服务。使用您的 Google 帐户登录。可能会提示您输入第二个身份验证因素,例如接收带有代码的短信或自动电话。该步骤完成后,系统会提示您创建一个应用。单击该按钮,然后会提示您输入一些关于该应用的附加信息。每个 GAE 应用都需要一个唯一的 URL,所以您需要为您的服务创造一个唯一的应用标识符。让服务对所有谷歌账户用户开放,同意服务条款,并创建应用(见图 8-1 )。
图 8-1 。在谷歌应用引擎网站上创建一个应用
设置 Eclipse
在我们开始编写 Java 代码之前,我们需要下载 Google App Engine SDK for Java,并设置 ADT(或 Eclipse)与 GAE 一起工作。首先,我们需要下载 Eclipse 的 Google 插件和 GAE SDK:
-
In ADT, open the Help menu and click Install New Software (see Figure 8-2).
图 8-2 。安装软件对话框
-
Click the Add button in the upper-right part of the dialog. The Add Repository dialog opens (see Figure 8-3).
图 8-3 。添加存储库对话框,用于将 URL 添加到 GAE Eclipse 插件的存储库
-
将存储库命名为 GAE 插件,并在 Location 字段中输入来自 Google Developer 站点的 URL:
http://dl.google.com/eclipse/plugin/4.2
。 -
单击确定。
-
Expand the Google App Engine Tools for Android (requires ADT) item, and select Google App Engine Tools for Android (see Figure 8-4).
图 8-4 。选择支持 GAE 所需的插件
-
展开 Google Plugin for Eclipse(required)项,选择 Google Plugin for Eclipse 4.2(参见图 8-4 )。
-
展开 SDKs 项,选择 Google App Engine Java SDK 1.8.1.1(参见图 8-4 )。
-
单击下一步。
-
在安装详细信息对话框中再次单击下一步。
-
查看并接受许可协议。
-
单击完成,软件将安装到 ADT 中。可能会提示您重新启动 ADT。
我们还将使用 JAX-RS 的球衣实现。在本例中,我们将从https://jersey.java.net
下载并使用 Jersey 1 . 17 . 1 版的 zip 包。将归档文件解压缩到您计算机上的一个位置,在那里您将获得项目所需的 JAR 文件。
创建项目
现在您已经下载了必要的组件和 SDK,在 Eclipse 中设置项目。
-
In Eclipse, in the GDT pull-down menu in the toolbar, choose New Web Application Project (see Figure 8-5).
图 8-5 。创建一个新的网络应用项目
-
Enter a name for your project, such as AppEngineToDoService, and a Package name, such as com.example.todo (see Figure 8-6).
图 8-6 。为 ToDo 服务设置新的 web 应用项目
-
取消选中使用谷歌网络工具包选项。
-
取消选中示例代码选项。
-
单击完成按钮。Eclipse 创建项目结构。
既然项目已经创建,我们需要在项目中进行配置更改,以确保 GAE SDK 和 Jersey 之间的兼容性。
配置项目
该项目需要一些配置更改,以便允许 GAE 使用球衣库 。在本例中,JDO/JPA 版本 2 与泽西岛版本相冲突。
-
在包资源管理器中右键单击该项目,然后选择“属性”。
-
Expand the Google item in the left pane, and then click App Engine (see Figure 8-7).
图 8-7 。为 GAE 更改项目配置
-
将 Datanucleus JDO/JPA 版本更改为 v1,然后单击确定。
该项目现在准备好让我们进口球衣罐子。
将球衣添加到项目中
既然您已经在项目配置中保存了 JDO/JPA 版本的变更,那么添加组成 Jersey 库的 jar。
-
Expand the
war
,WEB-INF
, andlib
folders of your project in the Package Explorer (see Figure 8-8).图 8-8 。新泽西罐子的项目地点
-
右键单击
lib
文件夹并选择导入。 -
展开常规项目。
-
Click the File System item under General (see Figure 8-9).
图 8-9 。从文件系统导入
-
单击下一步。
-
浏览到之前下载的 Jersey 档案文件的展开位置,并选择
lib
文件夹。 -
In the Import dialog, click the
lib
folder in the left pane, which selects all the jars in the right pane (see Figure 8-10).图 8-10 。进口球衣罐子
-
单击完成。
将 Jersey 添加到类路径
在将 jar 添加到项目中之后,您必须将它们添加到类路径中,以便 Eclipse 能够正确编译您的项目。
-
Right click the project in the Package Explorer, and choose Build Path and then Configure Build Path (see Figure 8-11).
图 8-11 。用于将 Jersey 添加为库的构建路径对话框
-
单击“库”选项卡。
-
单击“添加库”按钮。
-
Click User Library (see Figure 8-12).
图 8-12 。为构建路径创建一个用户库
-
单击下一步。
-
单击“用户库”按钮。
-
单击首选项对话框右侧的新建按钮。
-
键入 Jersey 作为库名称。
-
单击确定按钮。
-
单击添加 JARs 按钮。
-
Select the JARs that belong to Jersey (see Figure 8-13).

图 8-13 。选择要包含在用户库中的 jar
- 单击确定。
- 单击下一步确定按钮。
- 单击完成。
- Click the OK button. The Jersey Library should appear in the Package Explorer (see Figure 8-14).

图 8-14 。将 Jersey JARs 添加为用户库的结果
既然我们已经设置了库,我们应该为 Jersey 配置web.xml
文件。web.xml
文件位于项目的 WEB-INF 文件夹中。打开web.xml
,您将看到一个 XML 标签<web-app>
。注意,这个标签中的版本是 2.5,这是 Google App Engine 目前支持的 servlet 规范。
在web.xml
的<web-app>
标签中添加 servlet 标签,如清单 8-16 所示。
清单 8-16 。泽西岛图书馆的 Servlet 标签内容
<servlet>
<servlet-name>Jersey REST Service</servlet-name>
<servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
<init-param>
<param-name>com.sun.jersey.config.property.packages</param-name>
<param-value>com.example.todo.service</param-value>
</init-param>
<init-param>
<param-name>com.sun.jersey.api.json.POJOMappingFeature</param-name>
<param-value>true</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
注意在清单 8-16 中,我们的包名包含在一个<init-param>
标签中。这告诉 Jersey 在哪里寻找组成我们的 web 服务的类。POJOMappingFeature
也很重要;它允许类自动序列化为 XML 或 JSON,使我们不必编写代码来将类映射到 web 服务的输入和输出格式。
在 servlet 标签下,我们将添加一个 servlet 映射,但是仍然在<web-app>
标签内,如清单 8-17 所示。清单 8-17 中的<url-pattern>
标签提供了一种映射基本 URI 模式的方法,让 Jersey 在接收来自客户端的请求时监听。该映射还允许 web 项目承载网页和服务。
清单 8-17 。在 web.xml 中映射基本 URI 结构
<servlet-mapping>
<servlet-name>Jersey REST Service</servlet-name>
<url-pattern>/api/*</url-pattern>
</servlet-mapping>
创建服务
既然项目已经设置好了,我们终于可以为我们的服务编写一些 Java 代码了。我们将从数据开始,逐步向客户提供服务。
这些数据将被存储在 GAE 内置的 NoSQL 数据库中,该数据库被称为数据存储库。数据存储保存称为实体的对象,这些对象映射到我们服务中的 Java 类。每个实体都包含属性,这些属性映射到 Java 类的成员变量。我们存储的每个实体在所有存储的相似实体的实例中必须有一个唯一的键。当部署到 GAE 的应用包含实体定义时,数据存储将能够存储这些实体,而无需任何管理工作,例如创建表或设置数据模式。此外,可以从 Google App Engine 网站的管理控制台操作数据存储(参见图 8-15 )。
图 8-15 。GAE 数据存储管理网页
我们的服务将利用 Google App Engine SDK 附带的 JPA 1.0 实现。这个版本与 Jersey 兼容,而 JPA 2.0 版本目前与 Jersey 不兼容——尽管它与 Jersey 共享一些库;不幸的是,Jersey 和 GAE JPA 2.0 使用了这些库的不兼容版本。
让我们从一个ToDo
类开始,它是我们之前构建的一个小变化。ToDo
类有两个用途。第一个是充当我们将在 GAE 上持久存储的数据的模式。第二个目的是提供一个数据结构,它将成为我们 web 服务的输入和输出。
在清单 8-18 中的类被标注了许多 JPA 属性,包括@PersistenceCapable
告诉 JPA 保持这个数据结构,以及@Persistent
标记我们想要保存的成员变量。请注意,主键是一个长整型,这是 GAE 的要求,并且主键将在创建新记录时自动生成。我们还添加了一个电子邮件地址,这样我们可以存储许多不同用户的记录。
清单 8-18 。为 JPA 持久性注释的 ToDo 数据类
@PersistenceCapable
public class ToDo {
@PrimaryKey
@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
private Long id;
@Persistent
private String title;
@Persistent
private String email;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
清单 8-19 定义了数据层中的不同动作,以操纵 GAE 数据存储中的 ToDo 记录:
- 创造
- 删除
- 为用户列出待办事项
清单 8-19 。用于在云中操作 ToDos 的数据层类
public class ToDoAppEngineData {
//Ensure there is only one instance of the factory
private static final PersistenceManagerFactory factory = JDOHelper
.getPersistenceManagerFactory("transactions-optional");
private PersistenceManager manager;
public ToDoAppEngineData(){
manager = factory.getPersistenceManager();
}
public Long createToDo(ToDo item) {
ToDo newItem;
Transaction trans = manager.currentTransaction();
try {
trans.begin();
newItem = manager.makePersistent(item);
trans.commit();
return newItem.getId();
} catch (Exception ex) {
trans.rollback();
return -1l;
} finally {
manager.close();
}
}
public boolean deleteToDo(Long id) {
ToDo item = getToDo(id);
if(item == null)
return false;
Transaction trans = manager.currentTransaction();
try {
trans.begin();
manager.deletePersistent(item);
trans.commit();
return true;
} catch (Exception ex) {
trans.rollback();
return false;
} finally {
manager.close();
}
}
public List<ToDo> getAll(String email) {
if(email == null || email.isEmpty()) {
return new ArrayList<ToDo>();
}
PersistenceManager manager = factory.getPersistenceManager();
Query query = manager.newQuery(ToDo.class);
query.setFilter("email == emailParam");
query.declareParameters("String emailParam");
List<ToDo> results;
try {
List<ToDo> temp = (List<ToDo>) query.execute(email);
if (temp.isEmpty()) {
return new ArrayList<ToDo>();
}
results = (List<ToDo>) manager.detachCopyAll(temp);
} catch (Exception e){
results = new ArrayList<ToDo>();
e.printStackTrace();
} finally {
query.closeAll();
manager.close();
}
return results;
}
}
清单 8-19 中的大部分代码相当简单,有些重复。每个方法检索一个PersistenceManager
的实例,该实例接受带注释的ToDo
类并执行包装在Transaction
中的 CRUD 操作。
清单 8-19getAll()
中的最后一个函数包含了一些有趣的元素。该函数创建一个Query
对象,并搜索与输入参数中指定的电子邮件地址相匹配的已保存的 ToDo 实体。同样,读取数据的函数调用将对象从PersistenceManager
分离的函数。分离操作确保了如果我们的 read 函数的消费者修改了任何 ToDo 实体,这些更改不会被意外地保存到数据存储中。
既然 ToDo 实体是持久化的,那么这些 CRUD 操作就可以作为 web 服务公开,方法是将它们包装在一个用 Jersey 库中的属性进行注释的类中。
在清单 8-20 的开始,我们的类用@Path
属性进行了注释。该属性为 Jersey 提供了我们的类所响应的 URI 的一部分。此时,我们类中所有 URIs 映射的函数调用都将以http://localhost:8888/api/todo
开始。记住 URI 的/api/
部分来自web.xml
中的球衣配置。每个函数还可能有一个@Path
注释,表示映射传入参数所需的 URI 的附加元素。
清单 8-20 一个通过 REST 操作公开 ToDo 实体的新泽西注释类
@Path("/todo")
public class ToDoResource {
private ToDoAppEngineData datastore;
public ToDoResource(){
datastore = new ToDoAppEngineData();
}
@GET
@Path("list/{email}")
@Produces(MediaType.APPLICATION_JSON)
public List<ToDo> getToDoList(@PathParam("email") String email) {
List<ToDo> result = datastore.getAll(email);
return result;
}
@DELETE
@Path("{id}")
public void deleteToDo(@PathParam("id") long id) {
if(!datastore.deleteToDo(id)) {
throw new WebApplicationException(Response.Status.NOT_FOUND);
}
}
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ToDoId createToDo(ToDo item) {
Long newId = datastore.createToDo(item);
if(newId == -1){
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
}
ToDoId result = new ToDoId(newId);
return result;
}
}
每个函数都用对应于我们创建的数据层类中的 CRUD 操作的 HTTP 动词进行了注释。每个 HTTP 谓词可以有多个操作。例如,我们的类可以有多个 GET 操作。在我们的 REST URIs 中,@Path
注释将这些函数区分开来。每个函数必须用 HTTP 动词和@Path
属性的唯一组合进行注释。
返回数据的函数用@Produces(MediaType.APPLICATION_JSON)
标注,它告诉 Jersey 将函数的输出序列化为 JSON。此外,响应的 HTTP 头中的内容类型将是application/JSON
,告诉我们的 REST 服务的消费者在响应体中期待 JSON。接受 ToDo 实体作为输入的函数用@Consumes(MediaType.APPLICATION_JSON)
标注。这迫使调用 REST 函数的客户机将内容类型application/JSON
添加到传入 HTTP 调用的头部。如果没有这个注释,客户机可能会发送 XML 甚至纯文本。因为移动应用使用这些服务,所以 JSON 是我们的首选格式。如果 Android 客户端没有设置内容类型,我们的服务将抛出 HTTP 400 错误,表明组合的 HTTP 头和正文没有正确格式化。
注意,没有一个函数调用包含任何将来自 JSON 的传入消息或传出结果转换成 JSON 的代码。在web.xml
文件中,启用了POJOMappingFeature
,这允许 Jackson 库(作为组成 Jersey 的 jar 集的一部分)自动执行我们的 ToDo 对象与 JSON 之间的序列化。
createToDo()
函数返回一个新类型,即ToDoId
类。当一个新的 ToDo 插入数据存储时,这个简单的类简单地返回由数据存储生成的新 id。
通过返回一个类而不是一个数字,我们 API 的客户端将获得一个 JSON 对象,而不仅仅是响应体中的纯文本,如果我们只是简单地返回一个 long 类型,就会出现这种情况。注意清单 8-21 中缺少注释。我们不在数据存储中保存这个类;我们只是把它作为一个数据传输对象。
清单 8-21 。用于返回新生成的 ToDo IDs 的数据传输对象
public class ToDoId {
public ToDoId(Long id){
Id = id;
}
private Long Id;
public Long getId() {
return Id;
}
public void setId(Long id) {
Id = id;
}
}
对于我们正在使用的 Jersey 版本,我们需要创建一个应用类,它知道我们希望将哪些特定的 Java 类公开为 web 服务,如清单 8-22 所示。
清单 8-22 。注册服务类的 Jersey 应用对象
public class ToDoApplication extends Application {
public Set<Class<?>> getClasses() {
Set<Class<?>> s = new HashSet<Class<?>>();
s.add(ToDoResource.class);
return s;
}
}
工具
一旦我们创建了一个服务,我们希望能够测试我们的代码。在我们构建 Android 客户端之前,有几个测试服务的选项。
测试 web 服务的第一个也是最有价值的工具是编写单元测试。无论您选择哪种测试框架,您都可以编写调用服务的 Java 代码。如果您远程托管服务,测试可能是一个挑战,因为数据不在单元测试的本地。单元测试的好处比手工测试大得多,因为单元测试可以成为持续集成(CI)周期的一部分。CI 构建提供了定期的反馈,可以帮助开发团队比发布服务进行手动测试更快地发现并修复服务中的错误。
锻炼一项服务的另一个选择是使用预制的 REST 客户端,比如 Google Chrome 的高级 REST 客户端或者 Firefox 的 REST Client(https://addons.mozilla.org/en-us/firefox/addon/restclient/``)
)。这些手动工具帮助您编写原始 HTTP 调用,包括 HTTP 方法、头和主体。它们还显示了产生的 HTTP 响应和头。
我们将使用 Google Chrome 的高级 REST 客户端手动测试我们的服务。
-
在 ADT 中,运行 web 服务项目。
-
打开谷歌 Chrome。
-
在 Chrome (
https://chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo?hl=en-US
)中跟随这个网址,安装应用。 -
Open a new tab in Chrome, and navigate to the installed apps. Click Advanced REST Client (see Figure 8-16).
图 8-16 。用于测试 ToDo web 服务的高级 REST 客户端
-
在 URL 框中,输入
http://localhost:8080/api/todo/
。 -
选择卖出方法。
-
In the Payload box enter:
{
"title": "This is a test",
"email":"
dave@androidbestpractices.com
"
-
在有效载荷框下方的下拉列表中,选择
application/json
。 -
Click the Send button. The service should return a new ID for the record sent to the web service (see Figure 8-17).
图 8-17 。使用高级 REST 客户端插入新的待办事项记录
一旦从调试服务转移到调试 Android 应用,您可能希望在 Android 客户端生成原始 HTTP 消息后检查它。Android 模拟器和 web 服务器之间设置的 HTTP 代理允许捕获和检查消息和响应。有很多 HTTP 代理工具,包括 Charles Proxy ( http://www.charlesproxy.com/
)、WireShark ( http://www.wireshark.org/
)和 PC 专用的 Fiddler ( http://fiddler2.com/
)。如果您的电脑设置为共享其无线网络连接,并且 Android 设备连接到电脑而不是普通的无线接入点,则可以捕获设备流量。
负载平衡
当我们创建自己的服务时,我们必须考虑一些基础设施。这些是可用性和可伸缩性的概念。可用性是我们的应用“运行”的时间,即 web 服务可供我们的客户使用的时间。可用性通常用百分比表示,比如 99.9%,或“三个九”这意味着每年有 8.76 个小时的停机时间,也就是说每周大约有 10 分钟的停机时间。系统通过添加冗余服务器来获得可用性,这样,在一台服务器停机的情况下,不管是有意还是无意,另一台服务器都可以为请求提供服务。
另一方面,可伸缩性是您的服务处理数量不断增加或激增的请求的能力。如果您使用自己的 web 服务构建一个成功的移动应用,您最终会遇到可伸缩性问题。您将需要更多的服务器来处理越来越多的请求。
负载平衡器是一种用于管理可用性和可伸缩性的网络工具,由软件或专用硬件提供。负载平衡器位于托管您的 web 服务的服务器池的前面。负载平衡器在池中的可用服务器之间分发请求。如果服务器池增加或减少,负载平衡器会自动处理这种情况,自动转移流量。从外部来看,web 服务的消费者看到的是一个作为负载平衡器的 URL,这使得池中的服务器数量与消费应用无关。
负载平衡并不能完全解决可扩展性问题,因为应用仍然会产生比服务器池所能处理的更多的流量。负载平衡器允许您轻松地在池中添加或删除服务器,以便在不中断现有服务器的情况下调整传入流量。
此外,您的代码可能会以抑制可伸缩性的方式编写。您的应用代码应该正确地使用内存和外部资源,比如到数据库或文件系统的连接,以便很好地伸缩。糟糕的设计和编码是应用伸缩性差的主要原因。
如果您将 web 服务托管在云中,那么云平台的主要好处之一就是快速扩展。快速而相对容易地创建新的服务器实例,因此应用的扩展速度比传统托管更快,在传统托管中,需要购买、配置和部署硬件服务器,然后应用才能向上扩展。或者,如果您的流量下降,从池中移除的服务器将立即停止成为成本,这与您购买不再需要的硬件服务器的情况不同。Google App Engine 是我们托管服务的地方,在应用的生命周期中,它会自动平衡和扩展应用的负载。GAE 管理您的应用本身,自动建立新的服务器和安装您的应用。其他云服务,如亚马逊的 Elastic Beanstalk 和微软的 Windows Azure,也提供自动化的可扩展性。
摘要
在这一章中,我们讨论了 web 服务的许多方面,因为它们与 Android 应用相关。我们研究了 web 服务的类型,发现 REST 最适合移动应用。我们还研究了数据格式化,注意到 JSON 更小,更适合通过移动网络传输数据。
然后,我们研究了使用 Android 访问 web 服务的许多方法,检查了设计选项,以及如何将 JSON 数据转换成 Java 对象。
最后,我们在云中构建了自己的 web 服务,使用了 Google App Engine 和 Jersey REST 库。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?