专题:Android 好手

专题:Android好手

复杂的安全性,复杂的漏洞利用

{%}

作者/ Joshua J. Drake

Joshua是国际知名黑客,Accuvant LABS公司研究部门总监,曾在世界著名黑客大赛Pwn2Own上攻陷IE浏览器中的Java插件,曾发现Google Glass漏洞。

Android系统由许多承担安全检查与策略执行任务的机制构成。与任何现代操作系统一样,Android中的这些安全机制互相交互,交换关于主体(应用、用户)、客体(其他应用、文件和设备)以及将要执行操作(读、写、删除等)的各种信息。安全策略执行通常不会发生故障,但偶尔也会出现一些裂缝,为滥用提供了机会。本文将讨论Android系统的安全设计与架构,为分析Android平台的整体攻击面打好基础。

理解Android系统架构

Android的总体架构有时被描述为“运行在Linux上的Java”,然而这种说法不够准确,并不能完全体现出这一平台的复杂性和架构。Android的总体架构由5个主要层次上的组件构成,这5层是:Android应用层、Android框架层、Dalvik虚拟机层、用户空间原生代码层和Linux内核层。图-1显示了这些层是如何构成Android软件栈的。

{%}

图1 Android系统的总体架构

Android应用层允许开发者无须修改底层代码就对设备的功能进行扩展和提升,而Android框架层则为开发者提供了大量的用来访问Android设备各种必需设备的API,也就是充当应用层与Dalvik虚拟机(DalvikVM)层之间的“粘合剂”。API中包含各种构件(building block)以允许开发者执行通用任务,比如管理UI元素、访问共享数据存储,以及在应用组件间传递信息等。

Android应用和Android框架都是用Java语言开发的,并在DalvikVM中运行。DalvikVM的作用主要是为底层操作系统提供一个高效的抽象层。DalvikVM是一种基于寄存器的虚拟机,能够解释执行Dalvik可执行格式(DEX)的字节码;另一方面,DalvikVM依赖于一些由支持性原生代码程序库所提供的功能。

Android系统中的用户空间原生代码组件包括系统服务(如vold和DBus)、网络服务(如dhcpd和wpa_supplicant)和程序库(如bionic libc、WebKit和OpenSSL)。其中一些服务和程序库会与内核级的服务与驱动进行交互,而其他的则只是便利底层原生操作管理代码。

Android的底层基础是Linux内核,Android对内核源码树作了大量的增加与修改,其中有些代码存在一些独特的安全后果。内核级驱动也提供了额外的功能,比如访问照相机、Wi-Fi以及其他网络设备。需要特别注意Binder驱动,它实现了进程间通信(IPC)机制。

理解安全边界和安全策略执行

安全边界,有时也会称为信任边界,是系统中分隔不同信任级别的特殊区域。一个最直接的例子就是内核空间与用户空间之间的边界。内核空间中的代码可以对硬件执行一些底层操作并访问所有的虚拟和物理内存,而用户空间中的代码则由于CPU的安全边界控制,无法访问所有内存。

Android操作系统应用了两套独立但又相互配合的权限模型。在底层,Linux内核使用用户和用户组来实施权限控制,这套权限模型是从Linux继承过来的,用于对文件系统实体进行访问控制,也可以对其他Android特定资源进行控制。这一模型通常被称为Android沙箱。以DalvikVM和Android框架形式存在的Android运行时实施了第二套权限模型。这套模型在用户安装应用时是向用户公开的,定义了应用拥有的权限,从而限制Android应用的能力。事实上,第二套权限模型中的某些权限直接映射到底层操作系统上的特定用户、用户组和权能(Capability)。

Android沙箱

Android从其根基Linux继承了已经深入人心的类Unix进程隔离机制与最小权限原则。具体而言,进程以隔离的用户环境运行,不能相互干扰,比如发送信号或者访问其他进程的内存空间。因此,Android沙箱的核心机制基于以下几个概念:标准的Linux进程隔离、大多数进程拥有唯一的用户ID(UID),以及严格限制文件系统权限。

Android系统沿用了Linux的UID/GID(用户组ID)权限模型,但并没有使用传统的passwd和group文件来存储用户与用户组的认证凭据,作为替代,Android定义了从名称到独特标识符Android ID(AID)的映射表。初始的AID映射表包含了一些与特权用户及系统关键用户(如system用户/用户组)对应的静态保留条目。Android还保留了一段AID范围,用于提供原生应用的UID。Android 4.1之后的版本为多用户资料档案和隔离进程用户增加了额外的AID范围段(如Chrome沙箱)。你可以从AOSP树的system/core/include/private/android_filesystem_config.h文件中找到AID的定义。以下是一个简化过的示例。

#define AID_ROOT         0 /*传统的unix跟用户*/

#define AID_SYSTEM    1000 /*系统服务器*/

#define AID_RADIO     1001 /*通话功能子系统,RIL*/
#define AID_BLUETOOTH 1002 /*蓝牙子系统*/
...
#define AID_SHELL     2000 /*adb shell与debug shell用户*/
#define AID_CACHE     2001 /*缓存访问*/
#define AID_DIAG      2002 /*访问诊断资源*/

/*编号3000系列只用于辅助用户组们,表示出了内核所支持的Android权能*/
#define AID_NET_BT_ADMIN 3001 /*蓝牙:创建套接字*/
#define AID_NET_BT       3002 /*蓝牙:创建sco、rfcomm或l2cap套接字*/
#define AID_INET         3003 /*能够创建AF_INET和AF_INET6套接字*/
#define AID_NET_RAW      3004 /*能够创建原始的INET套接字*/
...
#define AID_APP            10000 /*第一个应用用户*/

#define AID_ISOLATED_START 99000 /*完全隔绝的沙箱进程中UID的开始编号 */
#define AID_ISOLATED_END   99999 /*完全隔绝的沙箱进程中UID的末尾编号*/
#define AID_USER          100000 /*每一用户的UID编号范围偏移*/

除了AID,Android还使用了辅助用户组机制,以允许进程访问共享或受保护的资源。例如,sdcard_rw用户组中的成员允许进程读写/sdcard目录,因为它的加载项规定了哪些用户组可以读写该目录。这与许多Linux发行版中对辅助用户组机制的使用是类似的。

注意

尽管所有的AID条目都映射到一个UID和GID,但是UID在描述系统上的一个用户时并不是必需的。例如,AIDD_SDCARD_RW映射到sdcard_rw,但是它仅仅用作一个辅助用户组,而不是系统上的UID。

除了用来实施文件系统访问,辅助用户组还会被用于向进程授予额外的权限。例如,AID_INET用户组允许用户打开AF_INETAF_INET6套接字。在某些情况下,权限也可能以Linux权能的形式出现,例如,AID_INET_ADMIN用户组中的成员授予CAP_NET_ADMIN权能,允许用户配置网络接口和路由表。

在4.3及之后的版本中,Android提升了对Linux权能的使用,比如Android 4.3将二进制程序/system/bin/run-as从原先设置成set-UID root权限,修改为使用Linux权能来访问特权资源。在这里,这一权能方便了对packages.list文件的访问。

注意 对Linux权能的完整讨论已经超出了本文的范围。你可以分别从Linux内核的Documentation/security/credentials.txt文档和capabilities的用户手册页面获得更多关于Linux进程安全和Linux权能的信息。

在应用执行时,它们的UID、GID和辅助用户组都会被分配给新创建的进程。在一个独特UID和GID环境下运行,使得操作系统可以在内核中实施底层的限制措施,也让运行环境能够控制应用之间的交互。这就是Android沙箱的关键所在。

下面的代码给出了在一台HTC One V手机上运行ps命令后的输出结果,注意,最左侧显示的UID对于每个应用的进程都是独特的。

app_16   4089   1451 304080 31724 ... S com.htc.bgp
app_35   4119   1451 309712 30164 ... S com.google.android.calendar
app_155  4145   1451 318276 39096 ... S com.google.android.apps.plus
app_24   4159   1451 307736 32920 ... S android.process.media
app_151  4247   1451 303172 28032 ... S com.htc.lockscreen
app_49   4260   1451 303696 28132 ... S com.htc.weather.bg
app_13   4277   1451 453248 68260 ... S com.android.browser

通过使用应用包中的一种特殊指令,应用也可以共享UID。

实际上,进程显示的用户与用户组名称是由一种POSIX函数的Android专有实现所提供的,这种函数通常就是用来设置和获取这些值的。例如,考虑在Bionic库的stubs.cpp文件中定义的getpwuid函数。

345 passwd* getpwuid(uid_t uid) { // NOLINT:实现不良函数
346   stubs_state_t* state = __stubs_state();
347   if (state == NULL) {
348     return NULL;
349   }
350
351   passwd* pw = android_id_to_passwd(state, uid);
352   if (pw != NULL) {
353     return pw;
354   }
355   return app_id_to_passwd(uid, state);
356 }


与它的同胞函数一样,getpwuid函数会调用一些额外的Android专有函数,如android_id_to_passwd()app_id_to_passwd()函数。这些函数会把Unix的口令结构填充上相应的AID映射信息表。android_id_to_passwd()函数会调用android_iinfo_to_passwd()函数来完成这一替换。

static passwd* android_iinfo_to_passwd(stubs_state_t* state,
                                       const android_id_info* iinfo) {
  snprintf(state->dir_buffer_, sizeof(state->dir_buffer_), "/");
  snprintf(state->sh_buffer_, sizeof(state->sh_buffer_),
"/system/bin/sh");

  passwd* pw = &state->passwd_;
  pw->pw_name= (char*) iinfo->name;
  pw->pw_uid = iinfo->aid;
  pw->pw_gid = iinfo->aid;
  pw->pw_dir = state->dir_buffer_;
  pw->pw_shell = state->sh_buffer_;
  return pw;
}

Android权限

Android的权限模型是多方面的,有API权限、文件系统权限和IPC权限。在很多情况下,这些权限都会交织在一起。正如前面提到的,一些高级权限会后退映射到低级别的操作系统权能,这可能包括打开套接字、蓝牙设备和文件系统路径等。

要确定应用用户的权限和辅助用户组,Android系统会处理在应用包的AndroidManifest.xml文件中指定的高级权限。应用的权限由PackageManager在安装时从应用的Manifest文件中提取,并存储在/data/system/packages.xml文件中。这些条目然后会在应用进程的实例化阶段用于向进程授予适当的权限(比如设置辅助用户组GID)。下面的代码片段显示了packages.xml文件中的Chrome浏览器条目,包括这个应用的唯一UID以及它所申请的权限。

<package name="com.android.chrome"
codePath="/data/app/com.android.chrome-1.apk"
nativeLibraryPath="/data/data/com.android.chrome/lib"
flags="0" ft="1422a161aa8" it="1422a163b1a"
ut="1422a163b1a" version="1599092" userId="10082"
installer="com.android.vending">
<sigs count="1">
<cert index="0" />
</sigs>
<perms>
<item name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<item name="android.permission.NFC" />
...
<item name="android.permission.WRITE_EXTERNAL_STORAGE" />
<item name="android.permission.ACCESS_COARSE_LOCATION" />
...
<item name="android.permission.CAMERA" />
<item name="android.permission.INTERNET" />
...
</perms>
</package>

权限至用户组的映射表存储在/etc/permissions/platform.xml文件中。它被用来确定应用设置的辅助用户组GID。下面的代码片段显示了一些映射。

...
    <permission name="android.permission.INTERNET" >
        <group gid="inet" />
    </permission>

    <permission name="android.permission.CAMERA" >
        <group gid="camera" />
    </permission>

    <permission name="android.permission.READ_LOGS" >
        <group gid="log" />
    </permission>

    <permission name="android.permission.WRITE_EXTERNAL_STORAGE" >
        <group gid="sdcard_rw" />
    </permission>
...

在应用包条目中定义的权限后面会通过两种方式实施检查:一种检查在调用给定方法时进行,由运行环境实施;另一种检查在操作系统底层进行,由库或内核实施。

1. API权限

API权限用于控制访问高层次的功能,这些功能存在于Android API、框架层,以及某种情况下的第三方框架中。一个使用API权限的常见例子是READ_PHONE_STATE,这个权限在Android文档中定义为允许“对手机状态的只读访问”。应用若申请该权限,随后就会授予该权限,从而可以调用关于查询手机信息的多种方法,其中包括在TelephonyManager类中定义的方法,如getDeviceSoftwareVersiongetDeviceId等。

前面提到过,一些API权限与内核级的安全实施机制相对应。例如,被授予INTERNET权限,意味着申请权限应用的UID将会被添加到inet用户组(GID 3003)的成员中。该用户组的成员具有打开AF_INETAF_INET6套接字的能力,而这是一些更高层次API功能(如创建HttpURLConnection对象)所必需的。

2. 文件系统权限

Android的应用沙箱严重依赖于严格的Unix文件系统权限模型。默认情况下,应用的唯一UID和GID都只能访问文件系统上相应的数据存储路径。注意,以下代码清单中的UID和GID(分别在第2列和第3列)对于目录都是唯一的,它们的权限被设置为只有这些UID和GID才能访问这些目录。

root@android:/ # ls -l /data/data
drwxr-x--x  u0_a3       u0_a3  ... com.android.browser
drwxr-x--x  u0_a4       u0_a4  ... com.android.calculator2
drwxr-x--x  u0_a5       u0_a5  ... com.android.calendar
drwxr-x--x  u0_a24      u0_a24 ... com.android.camera
...
drwxr-x--x  u0_a55      u0_a55 ... com.twitter.android
drwxr-x--x  u0_a56      u0_a56 ... com.ubercab
drwxr-x--x  u0_a53      u0_a53 ... com.yougetitback.androidapplication.virgin.
mobile
drwxr-x--x  u0_a31      u0_a31 ... jp.co.omronsoft.openwnn

相应地,由这些应用创建的文件也会拥有相应的权限设置。以下代码清单中显示了某个应用的数据目录,子目录和文件的属主和权限都被只设置给该应用的UID和GID。

root@android:/data/data/com.twitter.android # ls -lR

.:
drwxrwx--x u0_a55       u0_a55              2013-10-17 00:07 cache
drwxrwx--x u0_a55       u0_a55              2013-10-17 00:07 databases
drwxrwx--x u0_a55       u0_a55              2013-10-17 00:07 files
lrwxrwxrwx install   install2013-10-22 18:16 lib ->
/data/app-lib/com.twitter.android-1
drwxrwx--x u0_a55       u0_a55              2013-10-17 00:07 shared_prefs

./cache:
drwx------ u0_a55       u0_a55              2013-10-17 00:07
com.android.renderscript.cache

./cache/com.android.renderscript.cache:

./databases:
-rw-rw---- u0_a55       u0_a55      184320 2013-10-17 06:47 0-3.db
-rw------- u0_a55       u0_a55        8720 2013-10-17 06:47 0-3.db-journal
-rw-rw---- u0_a55       u0_a55       61440 2013-10-22 18:17 global.db
-rw------- u0_a55       u0_a55       16928 2013-10-22 18:17 global.db-journal

./files:
drwx------ u0_a55       u0_a55               2013-10-22 18:18
com.crashlytics.sdk.android

./files/com.crashlytics.sdk.android:
-rw------- u0_a55       u0_a55            80 2013-10-22 18:18
5266C1300180-0001-0334-EDCC05CFF3D7BeginSession.cls

./shared_prefs :
-rw-rw---- u0_a55       u0_a55           155 2013-10-17 00:07 com.crashlytics.prefs.
xml
-rw-rw---- u0_a55       u0_a55           143 2013-10-17 00:07
com.twitter.android_preferences.xml

正如前面所提到的,特定的辅助用户组GID用于访问共享资源,如SD卡或其他外部存储器。作为一个例子,注意在HTC One V手机上运行mountls命令的输出结果,特别是/mnt/sdcard的路径。

root@android:/ # mount
...
/dev/block/dm-2 /mnt/sdcard vfat rw,dirsync,nosuid,nodev,noexec,relatime,uid=1000,gid=1015,fmask=0702,dmask=0702,allow_utime=0020,codepage=cp437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro 0 0
...
root@android:/ # ls -l /mnt
...
d---rwxr-x system    sdcard_rw           1969-12-31 19:00 sdcard

这里你可以看到SD卡被使用GID 1015进行挂载,对应为sdcard_rw用户组。应用请求WRITE_EXTERNAL_STORAGE权限后,会将自己的UID添加到这个组中,得到对这一路径的写权限。

3. IPC权限

IPC权限直接涉及应用组件(以及一些系统的IPC设施)之间的通信,虽然与API权限也有一些重叠。这些权限的声明和检查实施可能发生在不同层次上,包括运行环境、库函数,或直接在应用上。具体来说,这个权限集合应用于一些在Android Binder IPC机制之上建立的主要Android应用组件。

深入理解各个层次

本部分将详细介绍Android软件栈中与安全最相关的组件,包括应用层、Android框架层、DalvikVM、用户空间的支持性原生代码与相关服务,以及Linux内核层。这将为我们今后理解这些组件打下基础,并为我们攻击这些组件提供必要的知识。

Android应用层

为了了解如何评估和攻击Android应用层的安全性,你首先需要了解它们是如何工作的。我们讨论了Android应用、应用运行时和支持性IPC机制的安全相关部分。

应用通常被分为两类:预装应用与用户安装的应用。预装应用包括谷歌、原始设备制造商(OEM)或移动运营商提供的应用,如日历、电子邮件、浏览器和联系人管理应用等。这些应用的程序包保存在/system/app目录中。其中有些应用可能拥有提升的权限或权能,因此人们会特别感兴趣。用户安装的应用是指那些由用户自己安装的应用,无论是通过Google Play商店等应用市场直接下载,还是通过pm installadb install进行安装。这些应用以及预安装应用的更新都将保存在/data/app目录中。

Android在与应用相关的多种用途中使用公共密钥加密算法。首先,Android使用一个特殊的平台密钥来签署预安装的应用包。使用这个密钥签署的应用的特殊之处它们拥有system用户权限。其次,第三方应用是由个人开发者生成的密钥签名的。对于预安装应用和用户安装应用,Android都使用签名机制来阻止未经授权的应用更新。

主要的应用组件

尽管Android应用由无数个组件组成,但我们将重点介绍那些与Android系统版本无关,在大多数应用中都值得关注的组件。这些组件包括AndroidManifest、Intent、Activity、BroadcastReceiver、Service和Content Provider。后面4类组件代表IPC通信端点(endpoint),它们有一些非常有趣的安全属性。

AndroidManifest.xml

所有的Android应用包(APK)都必须包括AndroidManifest.xml文件,这个XML文件含有应用的信息汇总,具体包括如下内容。

  • 唯一的应用包名(如com.wiley.SomeApp)及版本信息。

  • Activity、Service、BroadcastReceiver和插桩定义。

  • 权限定义(包括应用请求的权限以及应用自定义的权限)。

  • 关于应用使用并一起打包的外部程序库的信息。

  • 其他支持性的指令,比如共用的UID信息、首选的安装位置和UI信息(如应用启动时的图标)等。

Manifest文件中一个特别有趣的部分是sharedUserId属性。简单地说,如果两个应用由相同的密钥签名,它们就可以在各自的Manifest文件中指明同一个用户标识符。在这种情况下,这两个应用就会在相同的UID环境下运行,从而能使这些应用访问相同的文件系统数据存储以及潜在的其他资源。

Manifest文件经常是由开发环境自动产生,比如Eclipse或Android Studio,然后在构建过程中由明文XML文件转换为二进制XML文件。

Intent

应用间通信的一个关键组件是Intent。Intent是一种消息对象,其中包含一个要执行操作的相关信息,将执行操作的目标组件信息(可选),以及其他一些(对接收方可能非常关键的)标志位或支持性信息。几乎所有常用的动作——比如在一个邮件中点击链接来启动浏览器,通知短信应用收到条短信,以及安装和卸载应用,等等——都涉及在系统中传递Intent。

这类似于一个进程间调用(IPC)或远程过程调用(RPC)机制,其中应用组件可以通过编程方式和其他组件进行交互,调用功能或者共享数据。在底层沙箱(文件系统、AID等)进行安全策略实施的情况下,应用之间通常使用这个API进行交互。如果调用方或被调用方指明了发送或接收消息的权限要求,那么Android运行时将作为一个参考监视器,对Intent执行权限检查。

当在Manifest文件中声明特定的组件时,可以指明一个Intent Filter,来定义端点处理的标准。Intent Filter特别用于处理那些没有指定目标组件的Intent(即隐式Intent)。

例如,假设一个应用的Manifest文件中包含了一个自定义的权限(com.wiley.permission.INSTALL_WIDGET)和一个Activity(com.wiley.MyApp.InstallWidgetActivity),后者使用这个权限来限制启动InstallWidgetActivity

<manifest android:versionCode="1" android:versionName="1.0"
package="com.wiley.MyApp"
...
<permission android:name="com.wiley.permission.INSTALL_WIDGET"
android:protectionLevel="signature" />
...
<activity android:name=".InstallWidgetActivity"
android:permission="com.wiley.permission.INSTALL_WIDGET"/>

在这里,我们看到了权限声明和Activity声明。还要注意,权限拥有签名的ProtectionLevel属性。这限定了可以请求这一权限的应用,它们必须是与初始定义这一权限的应用使用同一私钥进行签名的其他应用。

Activity

简单地说,Activity是一种面向用户的应用组件或用户界面(UI)。Activity基于Activity基类,包括一个窗口和相关的UI元素。Activity的底层管理是由被称为Activity管理服务(Activity Manager)的组件来进行处理的,这一组件也处理应用之间或应用内部用于调用Activity的发送Intent。这些Activity在应用的Manifest文件中定义,具体如下:

...
        <activity android:theme="@style/Theme_NoTitle_FullScreen"
android:name="com.yougetitback.androidapplication.ReportSplashScreen"
android:screenOrientation="portrait" />
        <activity android:theme="@style/Theme_NoTitle_FullScreen"
android:name="com.yougetitback.androidapplication.SecurityQuestionScreen"
android:screenOrientation="portrait" />
        <activity android:label="@string/app_name"
android:name="com.yougetitback.androidapplication.SplashScreen"
android:clearTaskOnLaunch="false" android:launchMode="singleTask"
android:screenOrientation="portrait">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
        </intent-filter>
...

这里,我们可以看到Activity的定义,以及对样式/UI、屏幕方向等信息的指定。其中launchMode属性值得关注,因为它会影响Activity的启动方式。在这种情况下, singleTask值表示在同一时间只能有一个特定Activity实例存在,而不是每次调用时启动一个单独的实例。这一应用的当前实例(如果有的话)将接收并处理调用该Activity的Intent。

Broadcast Receiver

另一种类型的IPC端点是Broadcast Receiver。它们通常会在应用希望接收一个匹配某种特定标准的隐式Intent时出现。例如,一个应用想要接收与短消息关联的Intent,它需要在Manifest文件中注册一个Receiver,使用Intent Filter来匹配android.provider.Telephony.SMS_RECEIVED动作。

<receiver android:name=".MySMSReceiver">
  <intent-filter android:priority:"999">
    <action android:name="android.provider.Telephony.SMS_RECEIVED" />
  </intent-filter>
</receiver>

注意

Broadcast Receiver也可以使用registerReceiver方法在运行时以编程方式注册,这个方法可以被重载以对Receiver设置权限。

在Broadcast Receiver上设置权限要求可以限定哪些应用能够往这个端点发送Intent。

Service

Service是一类在后台运行而无需用户界面的应用组件,用户不用直接与Service所属应用进行交互。Android系统上一些常见的Service例子包括SmsReceiverServiceBluetoothOppService。虽然这些Service都运行在用户直接可见视图之外,但与其他Android应用组件一样,它们也可以利用IPC机制来发送和接收Intent。

Service必须在应用的Manifest文件中声明,例如,以下是一个Service的简单定义,同时设置了Intent Filter:

        <service
android:name="com.yougetitback.androidapplication.FindLocationService">
            <intent-filter>
                <action
android:name="com.yougetitback.androidapplication.FindLocationService" />
            </intent-filter>
        </service>

Service通常可以被停止、启动或绑定,所有这些动作都通过Intent来触发。在最后一种情况中,绑定一个Service后,另外一组IPC或RPC过程将会提供给调用者。这些过程取决于Service的具体实现,并更深入地利用了Binder服务。

Content Provider

Content Provider是为各种通用、共享的数据存储提供的结构化访问接口。例如,Contacts Provider(联系人提供者)和Calendar Provider(日历提供者)分别对联系人信息和日历条目进行集中式仓库管理,这两项内容可以被其他应用(使用适当权限)访问。应用还可以创建自己的Content Provider,并且可以选择暴露给其他应用。通过这些Provider公开的数据的后台通常是SQLite数据库,或是直接访问的系统文件路径(如播放器对MP3文件编排的索引和共享路径)。

像其他的应用组件一样,对Content Provider的读写能力也可以用权限进行控制。考虑如下从一个AndroidManifest.xml文件中截取的代码片段:

<provider android:name="com.wiley.example.MyProvider"
android:writePermission="com.wiley .example.permission.WRITE"
android:authorities="com.wiley .example.data" />

该应用声明了一个名为MyProvider的Content Provider,对应于实现Provider功能的类。然后,它声明了一个名为com.wiley.example.permission.WRITEwritePermission,表明只有携带这一自定义权限的应用才能写入这个Provider。最后,它指明了Provider将采取动作的authorities或内容统一资源描述符(URI)。Content URI采用content://[authorityname]的格式,可以额外包含路径和参数信息(如content://com.wiley.example.data/foo),而这些信息对Provider的底层实现可能非常关键。

Android框架层

作为应用和运行时之间的连接纽带,Android框架层为开发者提供了执行通用任务的部件——程序包及其类。这些任务可能包括管理UI元素、访问共享数据存储,以及在应用组件中传递消息等。也就是说,框架层中包含任何仍然在DalvikVM中执行的非应用特定代码。

通用的框架层程序包位于android.*名字空间中,如android.contentandroid.telephony。Android也提供了许多Java标准类(在java.*和的javax.*名字空间中),以及一些第三方程序包,如Apache HTTP客户端库和SAXXML解析器。Android框架层还包括许多用于管理内部类所提供功能的服务。这些被称为管理器的服务由system_server在系统初始化之后启动。表1显示了其中的一些服务器,以及它们在框架层中的描述与角色。

表1 框架层中的管理器

框架层服务

描述

Activity管理器

管理Intent 的解析与目标、应用/Activity的启动等

视图系统

管理Activity 中的视图(用户可见的UI组合)

程序包

管理器管理系统上之前或正在进入安装队列的程序包相关信息

电话管理器

管理与电话服务、无线电状态、网络与注册信息相关的信息与任务

资源管理器

为诸如图形、UI 布局、字符串数据等非代码应用资源提供访问

位置管理器

提供设置和读取(GPS、手机、Wi-Fi)位置信息的接口,位置信息包括具体定位信息、经纬度等

通知管理器

管理不同的事件通知,比如播放声音,震动,LED 闪灯,以及在状态栏中显示图标等

使用ps命令,并指明system_server的PID和-t选项,可以从结果中看到一些管理器是以system_server进程中的线程运行的。

root@generic:/ # ps -t -p 376
USER     PID   PPID   ... NAME
system   376     52   ... system_server
...
system   389    376   ... SensorService
system   390    376   ... WindowManager
system   391    376   ... ActivityManager
...
system   399    376   ... PackageManager

DalvikVM

DalvikVM是基于寄存器而不是栈的。虽然有人说Dalvik是基于Java的,但它并不是Java,因为Google并不使用Java的Logo,而且Android的应用模型也与JSR(Java标准规范要求)没有关系。Android应用开发者要记住,DalvikVM虽然看起来和感觉上都像Java,但实际上并不是。整体的开发流程大致如下:

1. 开发者以类似Java的语法进行编码;

2. 源代码被编译成.class文件(也类似于Java);

3. 得到的类文件被翻译成Dalvik字节码;

4. 所有类文件被合并为一个Dalvik可执行文件(DEX)文件;

5. 字节码被DalvikVM加载并解释执行。

作为一个基于寄存器的虚拟机,Dalvik拥有大约64 000个虚拟寄存器。不过通常只会用到最前16个,偶尔会用到前256个。这些寄存器被指定为虚拟机内存的存储位置,用于模拟微处理器的寄存器功能。就像实际的微处理器一样,DalvikVM在执行字节码时,使用这些寄存器来保持运行状态,并跟踪一些值。

DalvikVM是专门针对嵌入式系统的约束(如内存小和处理器速度慢)而设计的。因此,在DalvikVM设计时考虑到了速度和运行效率。但虚拟机毕竟只是对底层CPU寄存器机的一个抽象,本质上就意味着在运行效率上有所损失,而这也正是谷歌力求减轻这些副作用的原因。

为了在这些约束中发挥更大的能力,DEX文件在被虚拟机解释执行之前会进行优化处理。对于从一个Android应用中启动的DEX文件,这种优化通常只在应用第一次启动时进行一次。优化过程的结果是一个优化后的DEX文件(ODEX)。需要注意,ODEX文件是无法在不同版本的DalvikVM之间或是不同设备之间进行移植的。

与Java虚拟机类似,DalvikVM使用Java Native Interface(JNI)与底层原生代码进行交互。这一功能允许在Dalvik代码和原生代码之间相互调用。欲了解DalvikVM、DEX文件格式以及JNI on Android的更详细信息,可查阅Dalvik官方文档,网址为http://milk.com/kodebase/dalvik-docs-mirror/docs/

Zygote

Android设备启动时,Zygote进程是最先运行的进程之一。接下来,Zygote负责启动其他服务以及加载Android框架所使用的程序库。然后,Zygote进程作为每个Dalvik进程的加载器,通过复制自身进程副本(也被称为forking,分支)来创建进程。这种优化方案可以避免重复那些不必要且消耗大量资源的加载过程,即启动Dalvik进程(包括应用)时加载Android框架及其依赖库。作为优化结果,核心库、核心类和对应的堆结构会在DalvikVM的所有实例之间共享。这也给攻击带来了一些有趣的可能性。

Zygote的第二大功能是启动system_server进程,这个进程容纳了所有系统核心服务,并在system的AID用户环境中以特权权限运行。接下来,system_server进程启动所有在表2-1中介绍的Android框架层服务。

注意

system_server进程是如此重要,以至于杀死这一进程会让设备看上去像重新启动了一样。然而,实际上只有设备的Dalvik子系统重新启动了。

在初始启动后,Zygote通过RPC和IPC机制为其他Dalvik进程提供程序库访问,这是承载Android应用组件的进程实际启动的机制。

用户空间原生代码层

操作系统用户空间内的原生代码构成了Android系统的一大部分,这一层主要由两大类组件构成:程序库和核心系统服务。本文将讨论这两大类组件,并详述一些属于这两大类的单独组件。

1. 程序库

Android框架层中的较高层次类所依赖的许多底层功能都是通过共享程序库的方式来实现,并通过JNI进行访问的。在这其中,许多程序库都也是在其他类Unix系统中所使用的知名开源项目。比如,SQLite提供了本地数据存储功能,Webkit提供了可嵌入的Web浏览器引擎,FreeType提供了位图和矢量字体渲染功能。

供应商特定的程序库,即那些为某一设备型号提供硬件支持的代码库,保存在/vendor/lib(或/system/vendor/lib)路径。其中包括对图形显示设备、GPS收发器或蜂窝式无线电的底层支持库等。非厂商特定的程序库则保存在/system/lib路径中,通常会包括一些外部项目,比如像下面这些库。

  • libexif:一个JPEG EXIF格式的处理库。

  • libexpat:Expat的XML解析器。

  • libaudioalsa/libtinyalsa:ALSA音频库。

  • libbluetooth:BlueZ Linux蓝牙库。

  • libdbus:D-Bus的IPC库。

这些只是Android的大量程序库中的一小部分,一个运行Android 4.3的设备中包含了超过200个共享程序库。

然而,并非所有的底层程序库都是标准的,Bionic就是一个值得注意的特例。Bionic是BSD C运行时库的一个变种,旨在提供更小的内存使用空间,更好的优化,同时避免产生GNU公共许可证(GPL)授权问题。这些差异也带来了少许代价。Bionic的libc并不像GNU libc那么完整,甚至比不上Bionic源头的BSD libc实现。Bionic中也包含了大量自己的代码,为了努力降低C运行时库的内存使用空间,Android开发者还实现了一个自定义的动态链接器和线程API。

这些库是使用原生代码开发的,因而很容易出现内存破坏漏洞,这使得该层成为探索Android安全性时的一个特别有趣的部分。

2. 核心服务

核心服务是指建立基本操作系统环境的服务与Android原生组件。这些服务包括初始化用户空间的服务(如init)、提供关键调试功能的服务(如adbddebugggerd)等。注意,某些核心服务可能是硬件或版本特定的,本文当然不能详尽描述所有的用户空间服务。

Init

init程序通过执行一系列命令对用户空间环境进行初始化。然而, Android使用自定义的init实现。代替从/etc/init.d路径执行基于运行级别的shell脚本,Android基于从/init.rc中找到的指令来执行命令。对于设备特定的指令,可能存在一个名为/init.[hw].rc的文件,这里[hw]是特定设备的硬件代号。以下是HTC One V手机上/init.rc文件中的内容代码片段。

service dbus /system/bin/dbus-daemon --system --nofork
    class main
    socket dbus stream 660 bluetooth bluetooth
    user bluetooth
    group bluetooth net_bt_admin

service bluetoothd /system/bin/bluetoothd -n
    class main
    socket bluetooth stream 660 bluetooth bluetooth
    socket dbus_bluetooth stream 660 bluetooth bluetooth

# init.rc does not yet support applying capabilities, so run as root and
# let bluetoothd drop uid to bluetooth with the right linux capabilities

    group bluetooth net_bt_admin misc
    disabled

service bluetoothd_one /system/bin/bluetoothd -n
    class main
    socket bluetooth stream 660 bluetooth bluetooth
    socket dbus_bluetooth stream 660 bluetooth bluetooth
# init.rc does not yet support applying capabilities, so run as root and
# let bluetoothd drop uid to bluetooth with the right linux capabilities
    group bluetooth net_bt_admin misc
    disabled
    oneshot
# Discretix DRM
service dx_drm_server /system/bin/DxDrmServerIpc -f -o allow_other \
 /data/DxDrm/fuse

on property:ro.build.tags=test-keys
    start htc_ebdlogd

on property:ro.build.tags=release-keys
    start htc_ebdlogd_rel

service zchgd_offmode /system/bin/zchgd -pseudooffmode
    user root
    group root graphics
    disabled

这些初始化脚本指定几个任务,包括:

  • 通过service指令,启动在开机时应该运行的服务或守护进程;

  • 通过每个服务条目下缩进的参数,指定服务应该在哪个用户和用户组环境下运行;

  • 设置向Property服务公开的系统范围属性与配置选项;

  • 通过on指令,注册在特定事件发生时,如修改系统属性或装载文件系统,要执行的动作或命令。

Property服务

Property服务位于Android的初始化服务中,它提供了一个持续性的(每次启动)、内存映射的、基于键值对的配置服务。许多操作系统和框架层的组件都依赖于这些属性,其中包括网络接口配置、无线电选项甚至安全相关设置。

属性可以通过多种方式进行读取和设置。例如,分别使用命令行实用程序getpropsetProp进行读取和设置,在原生代码中分别使用libcutils库中的property_getproperty_set函数以编程方式读取和设置,或使用android.os.SystemProperties类以编程方式读取和设置(这个类函数又会继续调用上述原生函数)。Propery服务的概述如图2所示。

{%}

图2 Android系统的Property服务

在Android设备(在本例中是一台HTC One V手机)上运行getprop命令,可以看到输出结果中包含DalvikVM配置、当前设置壁纸、网络接口配置设置和厂商特定的更新URL等。

root@android:/ # getprop
[dalvik.vm.dexopt-flags]: [m=y]
[dalvik.vm.heapgrowthlimit]: [48m]
[dalvik.vm.heapsize]: [128m]
...
[dhcp .wlan0.dns1]: [192.168.1.1]
[dhcp .wlan0.dns2]: []
[dhcp .wlan0.dns3]: []
[dhcp .wlan0.dns4]: []
[dhcp .wlan0.gateway]: [192.168.1.1]
[dhcp .wlan0.ipaddress]: [192.168.1.125]
[dhcp .wlan0.leasetime]: [7200]
...
[ro.htc.appupdate.exmsg.url]:
    [http://apu-msg.htc.com/extra-msg/rws/and-app/msg]
[ro.htc.appupdate.exmsg.url_CN]:
    [http://apu-msg.htccomm.com.cn/extra-msg/rws/and-app/msg]
[ro.htc.appupdate.url]:
    [http://apu-chin.htc.com/check-in/rws/and-app/update]
...
[service.brcm.bt.activation]: [0]
[service.brcm.bt.avrcp_pass_thru]: [0]

被设置为“只读”的一些属性不可更改,即便是root用户(尽管有一些设备特有的例外情况)。这些属性以ro为前缀。

[ro.secure]: [0]
[ro.serialno]: [HT26MTV01493]
[ro.setupwizard.enterprise_mode]: [1]
[ro.setupwizard.mode]: [DISABLED]
[ro.sf.lcd_density]: [240]
[ro.telephony.default_network]: [0]
[ro.use_data_netmgrd]: [true]
[ro.vendor.extension_library]: [/system/lib/libqc-opt.so]

无线接口层

无线接口层(RIL)为智能手机提供了手机本身应该有通讯功能。如果没有这个组件,Android设备将无法拨打电话,发送或接收短信,或者在没有Wi-Fi网络时上网。因此,它会在任何拥有蜂窝数据或电话功能的Android设备上运行。

debuggerd

Android的基本崩溃报告功能是由一个称为debuggerd的守护进程提供的,当调试器守护进程启动时,它将打开到Android日志功能的一个连接,然后在一个抽象名字空间套接字开始监听客户端的连入。每次程序开始运行,链接器会安装信号处理程序,然后处理某些信号。

当要捕获的某个信号发生时,内核执行信号处理函数debugger_signal_handler。这个函数连接到之前提到的由DEBUGGER_SOCKET_NAME定义的套接字上,连接之后,链接器将通知套接字的另一端(即debuggerd)目标进程已经崩溃了。这会通知debuggerd应该调用它的处理流程并创建一个崩溃报告。

ADB

Android调试桥(ADB)是由几个部件组成的,包括在Android设备上的adbd守护进程,在宿主工作站上运行的adb服务器,以及相应的adb命令行客户端。adb服务器管理客户端与在目标设备上运行的守护进程之间的连接,便于各种任务操作,比如执行一个shell、调试应用(通过Java调试网络协议)、套接字和端口转发、文件传输,以及安装/卸载应用包等。

作为一个简单的例子,你可以运行adb devices命令来列出你连接的设备。因为ADB在我们的主机上尚未运行,因此它会被初始化,在5037/tcp上监听客户端连接。然后你可以通过序列号来指明一个目标设备,并运行adb shell命令,这会获得一个在设备上运行的命令行shell。

% adb devices
* daemon not running. starting it now on port 5037 *
* daemon started successfully *
List of devices attached
D025A0A024441MGK device
HT26MTV01493 device

% adb -s HT26MTV01493 shell
root@android:/ #

通过对进程列表进行grep搜索(此例中使用pgrep)也可以看到,ADB守护进程adbd已在目标设备上运行。

root@android:/ # busybox pgrep -l adbd
2103 /sbin/adbd

ADB对于使用Android设备和模拟器进行开发是非常关键的,因此我们将在本书中频繁使用它。你可以从http://developer.android.com/tools/help/adb.html找到如何使用adb命令的详细信息。

Volume守护进程

Volume守护进程,或称为vold,是Android系统上负责安装和卸载各种文件系统的服务。例如,插入SD卡时,vold会处理这一事件,检查SD卡的文件系统错误(如通过启动fsck)并将SD卡安装到相应的路径(也就是/mnt/sdcard)。当卡被用户取出后,vold会卸载目标卷。

vold也处理Android Secure Container(ASEC)文件的安装与卸载。当应用包存储到FAT等不安全的文件系统上时,ASEC会对其进行加密处理。它们会在应用加载时通过环回(loopback)设备进行安装,通常挂接到/mnt/asec。

不透明二进制块(OBB)也是由vold进行安装和卸载的。这些文件与应用共同打包,以存储由一个共享密钥加密的数据。然而与ASEC容器不同的是,对OBB的安装和卸载是由应用自身而非系统来执行的。以下代码片段演示了使用SuperSecretKey作为共享密钥创建一个OBB的过程。

obbFile = "path/to/some/obbfile";
storageRef = (StorageManager) getSystemService(STORAGE_SERVICE);
storageRef.mountObb(obbFile, "SuperSecretKey", obbListener);
obbContent = storageRef.getMountedObbPath(obbFile);

鉴于vold是以root身份运行的,它的功能和潜在的安全漏洞都让它成为一个诱人的目标。

其他服务

在许多Android设备上还运行着许多其他服务,提供一些不一定是必需的额外功能(取决于设备和服务)。表2重点介绍其中的一些服务、它们的用途及在系统中的权限级别(UID、GID和运行用户所属的辅助用户组,这些会在系统的init.rc文件中指明)。

表2 用户空间的原生服务

服务 描述 UID、GID和辅助用户组
netd 在Android 2.2以上版本中存在,由网络管理服务用于配置网络接口,运行PPP守护程序(pppd)、以太网与其他类似服务 UID:0 / root
GID:0 / root
mediaserver 负责启动媒体相关服务,这些服务包括Audio Flinger、Media Player Service、Camera Service和Audio Policy Service UID:1013 / media
GID:1005 / audio
用户组:1006 / camera
1026/ drmpc
3001 / net_bt_admin
3002 / net_bt
3003 / inet
3007 / net_bw_acct
dbus-daemon 管理D-Bus特有的IPC/消息传递(主要针对非Android特有的组件) UID:1002 / bluetooth
GID:1002 / bluetooth
用户组:3001 / net_bt_admin
installd 管理设备上的应用程序包安装(以程序包管理器的名义),包括对应用程序包(APK)中Dalvik可执行字节码(DEX)的初始优化 UID:1012 / install
GID:1002 / install
4.2之前的版本:
UID:0 / root
GID:0 / root
keystore 负责对系统上键值对的安全存储(通过用户定义的口令进行保护) UID:1017 / keystore
GID:1017 / keystore
用户组:1026 / drmpc
drmserver 提供对数字版权保护的底层操作,应用通过与高层次上的DRM程序包与这个服务进行交互 UID:1019 / drm
GID:1019 / drm
用户组:1026 / drm rpc
3003 / inet
serviceman-ager 作为注册/注销应用服务的Binder IPC端点的仲裁者 UID:1000 / system
GID:1000 / system
surface-flinger 在Android 4.0以上版本中存在的显示合成器,负责创建进行演示的图形帧、屏幕,并发送给显示卡驱动 UID:1000 / system
GID:1000 / system
Ueventd 在Android 2.2以上版本中存在的用户空间守护程序,处理系统和设备事件并采取相应动作,比如装载恰当的内核模块 UID:0 / root
GID:0 / root

如前所述,这份清单并不详尽。对比定制设备与Nexus设备的进程列表、init.rc文件以及文件系统,通常会发现大量的非标准服务。这些服务非常能够引起人的兴趣,因为它们的代码质量与Android设备中的核心服务无法相比。

内核

尽管Android的根基——Linux内核文档相当完备而且已经被深入理解,但是Linux内核和Android使用的内核还是有很多显著的差异。本文将介绍其中的一些变化,特别是那些和Android安全相关的。

1. Android分支

在早期,Google创建了Linux内核的一个Android分支,因为许多修改和添加已经不再与Linux内核主代码树相互兼容。总体而言,这其中包括了大约250个补丁,涉及文件系统支持、网络处理调整,以及进程和内存管理功能等。根据一位内核工程师的说法,绝大部分的补丁“代表着Android开发者在Linux内核中发现的一些局限性”。2012年3月,Linux内核维护者将Android特有的内核修改合并到了主代码树。表3显示了一些对主代码树的添加与修改,本文将详细介绍其中的一部分。

表3 Android对Linux内核的主要修改

内核修改

描述

Binder

IPC机制,提供额外的一些特性,比如对调用者和被调用者的安全验证。它已被大量的系统和框架服务所使用

ashmem

匿名共享内存,一种基于文件的共享内存分配器,使用Binder IPC来允许进程识别内存区域文件描述符

pmem

进程内存分配器,用于管理大块、连续的共享内存区域

日志记录器

系统范围的日志功能

RAM_CONSOLE

在内核错误后,在RAM中存储内核日志消息,以便查看

OOM修改

"Out Of Memory"-killer在内存空间低的时候杀掉进程,在Android分支中,OOM在内存即将用尽时,较传统Linux内核能更快地杀掉进程

wakelocks

电源管理特性,使得设备进入低功率省电模式,同时保持可响应状态

Alarm Timers

AlarmManager的内核接口,用于指示内核调度“醒来”时间

Paranoid Networking

将网络操作和功能特性限制在特定的用户组ID

timed output/gpio

允许用户空间程序在一定时间后修改和重置GPIO寄存器

yaffs2

对yaffs2 Flash文件系统的支持

2. Binder

对Android的Linux内核最为重要的一个添加也许是Binder驱动。Binder是一个基于OpenBinder修改版本的IPC机制,OpenBinder最初由Be公司开发,后来又由Palm公司开发和维护。Android的Binder代码量相对较小(大约有4000行源码,存在于2个文件中),但是对于大部分的Android功能都是非常关键的。

概括地说,Binder内核驱动是整个Binder架构的粘合剂。Binder作为一个架构,以客户端—服务器模型运行,允许一个进程同时调用多个“远程”进程中的多个方法。Binder架构将底层细节进行了抽象,使得这些方法调用看起来就像是本地函数调用。图3显示了Binder的通信流图。

{%}

图3 Binder的通信流

Binder也使用进程ID(PID)和UID信息作为一种标识调用进程的手段,允许被调用方作出访问控制的决策。通常会调用Binder.getCallingUidBinder.getCallingPid等函数,或者调用checkCallingPermission等高层次上的检查函数。

在实际情况中会遇到的一个例子是ACCESS_SURFACE_FLINGER权限。这一权限通常只授予图形系统用户,并允许访问Surface Flinger图形服务的Binder IPC接口。此外,调用者的用户组成员关系(以及随后所需要的权限)会通过一系列对前述函数的调用进行检查,如以下代码片段所示。

const int pid = ipc->getCallingPid();
const int uid = ipc->getCallingUid();
    if ((uid != AID_GRAPHICS) &&
            !PermissionCache::checkPermission(sReadFramebuffer,
                pid, uid)) {
        ALOGE("Permission Denial: "
                "can't read framebuffer pid=%d, uid=%d", pid, uid);
        return PERMISSION_DENIED;
}

在更高的层次上所暴露的IPC方法,如那些由绑定服务所提供的IPC方法,通常会通过Android接口定义语言(AIDL)提炼成一个抽象接口。AIDL允许两个应用使用“协商确定”或者标准化的接口,来发送和接收数据,使得接口独立于具体的实现。AIDL类似于其他的接口定义语言文件,比如C/C++中的头文件。以下是一个AIDL代码片段的示例。

// IRemoteService.aidl
 package com.example.android;

 // Declare any non-default types here with import statements
 //在此声明任何非默认类型导入声明

 /*范例服务接口*/
 interface IRemoteService {
     /**请求这一服务的进程ID,做点“有趣”的事情**/
     int getPid();

     /**显示一些用作AIDL参数和返回值的基本类型**/
     void basicTypes(int anInt, long aLong, boolean aBoolean,
             float aFloat,
             double aDouble, String aString);
   }

这个AIDL的例子定义了一个简单的接口——IRemoteService,包含两个方法:getPidbasicTypes。如果一个应用绑定到暴露此接口的服务,随之就可以在Binder支持下调用前面提到的这两个方法。

3. ashmem

匿名共享内存服务,简称ashmem,是另一个在Linux内核Android分支中添加的代码模块。ashmem驱动基本上提供了基于文件、通过引用计数的共享内存接口。它广泛应用于大多数Android核心组件中,包括Surface Flinger、Audio Flinger、系统服务器和DalvikVM等。ashmem能够自动收缩内存缓存,并在全局可用内存较低时回收内存区域,因而非常适用于低内存环境。

在底层使用ashmem很简单,只需调用ashmem_create_region并对返回的文件描述符使用mmap函数:

int fd = ashmem_create_region("SomeAshmem", size);
if(fd == 0) {
    data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
     ...

在较高层次上,Android框架层中提供了MemoryFile类,作为ashmem驱动的封装器。此外,进程可以使用Binder机制在以后共享这些内存对象,并利用Binder的安全特性来限制访问。作为一起安全事件,在2011年年初,ashmem被证明存在一个非常严重的安全缺陷,允许通过Android属性进行特权提升。

4. pmem

另一个Android特有的自定义驱动是pmem,用来管理1~16MB(或更多,取决于具体实现)的大块物理上连续的内存区块。这些区块是特殊的,可以在用户空间进程和其他内核驱动(比如GPU驱动)之间共享。与ashmem不同的是,pmem驱动需要一个分配进程,为pmem的内存堆保留一个文件描述符,直到所有其他索引都关闭。

5. 日志记录器

虽然Android内核仍然维护自己基于Linux内核的日志机制,但它也使用另一个日志记录子系统,即俗称的“日志记录器”(logger)。作为logcat命令的支持,这个驱动用于查看日志缓冲区。它根据信息的类型,提供了4个独立的日志缓冲区:main(主缓冲区)、radio(无线电缓冲区)、event(事件缓冲区)与system(系统缓冲区)。图2-4显示了日志事件的流图以及辅助日志记录器的组件。

主缓冲区通常是日志数量最大的,并且是应用相关事件的日志源。应用通常从android.util.Log类中调用一个方法,而调用的方法对应于不同的日志条目优先级别,例如,Log.i方法记录“信息性”日志,Log.d方法记录“调试”日志,而Log.e方法记录“错误”日志(很像syslog)。

{%}

图4 Android日志记录系统架构

系统缓冲区也是许多信息的来源,即由系统进程生成的系统级事件。这些进程利用android.util.Slog类中的println_native方法,而println_native方法又会调用特定的原生代码,将日志写入这个缓冲区。

日志消息可以使用logcat命令来获取,而主缓冲区与系统缓冲区作为默认的日志信息源。在以下代码中,我们运行adb -d logcat命令,来看看连接的设备上发生了什么。

 $ adb -d logcat
 --------- beginning of /dev/log/system
D/MobileDataStateTracker( 1600): null: Broadcast received :
 ACTION_ANY_DATA_CONNECTION_STATE_CHANGEDmApnType=null != received
 apnType=internet
D/MobileDataStateTracker( 1600): null: Broadcast received:
ACTION_ANY_DATA_CONNECTION_STATE_CHANGEDmApnType=null != received
apnType=internet
D/MobileDataStateTracker( 1600): httpproxy: Broadcast received:
ACTION_ANY_DATA_CONNECTION_STATE_CHANGEDmApnType=httpproxy != received
apnType=internet
D/MobileDataStateTracker( 1600): null: Broadcast received:
ACTION_ANY_DATA_CONNECTION_STATE_CHANGEDmApnType=null != received
apnType=internet
 ...
 --------- beginning of /dev/log/main
 ...
D/memalloc( 1743): /dev/pmem: Unmapping buffer base:0x5396a000
size:12820480 offset:11284480
D/memalloc( 1743): /dev/pmem: Unmapping buffer base:0x532f8000
size:1536000 offset:0
D/memalloc( 1743): /dev/pmem: Unmapping buffer base:0x546e7000
size:3072000 offset:1536000
D/libEGL   ( 4887): loaded /system/lib/egl/libGLESv1_CM_adreno200.so
D/libEGL   ( 4887): loaded /system/lib/egl/libGLESv2_adreno200.so
I/Adreno200-EGLSUB( 4887): <ConfigWindowMatch:2078>: Format RGBA_8888.
D/OpenGLRenderer( 4887): Enabling debug mode 0
V/chromium( 4887): external/chromium/net/host_resolver_helper/host_
resolver_helper.cc:66: [0204/172737:INFO:host_resolver_helper.cc(66)]
DNSPreResolver::Init got hostprovider:0x5281d220
V/chromium( 4887): external/chromium/net/base/host_resolver_impl.cc:1515:
[0204/172737:INFO:host_resolver_impl.cc(1515)]
HostResolverImpl::SetPreresolver preresolver:0x013974d8
V/WebRequest( 4887): WebRequest::WebRequest, setPriority = 0
I/InputManagerService( 1600): [unbindCurrentClientLocked] Disable input
method client.
I/InputManagerService( 1600): [startInputLocked] Enable input
method client.
V/chromium( 4887): external/chromium/net/disk_cache/
hostres_plugin_bridge.cc:52: [0204/172737:INFO:hostres_
plugin_bridge.cc(52)] StatHubCreateHostResPlugin initializing.. .
...

这个logcat命令是如此常用,以至于ADB为在目标设备上运行它提供了一个快捷方式。在整本书中,我们会大量使用logcat命令来监视进程和整个系统的状态。

6. Paranoid Networking

Android内核基于一个调用进程的辅助用户组来限制网络操作,而这个调用进程就是被称为Paranoid Networking的内核修改模块。在高层次上,这个模块将一个AID(以及随后的GID)映射到应用层的权限声明或请求上。例如,Manifest文件中的权限android.permission.INTERNET有效地映射到AID_INET AID(或GID 3003)上。这些用户组、UID以及它们相应的权能在内核源码树的include/linux/android_aid.h文件中定义,详见表4。

表4 根据用户组定义的网络权能

AID 定义

用户组ID和名称

权能

AID_NET_BT_ADMIN

3001 / net_bt_admin

允许创建任意蓝牙套接字,以及可以诊断和管理蓝牙连接

AID_NET_BT

3002 / net_bt

允许创建SCO、RFCOMM 或L2CAP(蓝牙)套接字

AID_INET

3003 / inet

允许创建AF_INET或AF_INET6 套接字

AID_NET_RAW

3004 / net_raw

允许使用RAW 和PACKET 套接字

AID_NET_ADMIN

3005 / net_admin

授予CAP_NET_ADMIN权能,允许对网络接口、路由表和套接字的操纵

你可以从AOSP代码库中的system/core/include/private/android_filesystem_config.h文件中找到其他Android特有的GID。

复杂的安全性,复杂的漏洞利用

在仔细观察了Android的设计与架构之后,我们已经清楚地了解到,Android操作系统是一种非常复杂的系统。设计者坚持了最低权限原则,也就是说任何特定组件都应该只能访问它真正所需要访问的东西。不过,这虽然有助于提高安全性,却也增加了复杂性。

进程隔离和减少特权往往是安全系统设计中的基石。无论对于开发者还是攻击者,这些技术的复杂性也让系统都变得更加复杂,从而增加两方的开发成本。当攻击者在打磨他的攻击工具时,他必须花时间去充分了解问题的复杂性。对于像Android这样一个系统,单单攻击一个安全漏洞,可能不足以获取到系统的完全控制权。攻击者可能需要利用多个安全漏洞才能达到目的。总之,要成功地攻击一个复杂系统,需要一个复杂的漏洞利用。

一个能够很好地说明这一点的真实例子是,用于root HTC J Butterfly手机的“diaggetroot”漏洞利用。为了获取root访问控制权,它利用了多个互为补充的安全问题。

 

{%}

《Android安全攻防权威指南》由世界顶尖级黑客打造,是目前最全面的一本Android系统安全手册。书中细致地介绍了Android系统中的漏洞挖掘、分析,并给出了大量利用工具,结合实例从白帽子角度分析了诸多系统问题,是一本难得的安全指南。移动设备管理者、安全研究员、Android应用程序开发者和负责评估Android安全性的顾问都可以在本书中找到必要的指导和工具。 本文节选自《Android安全攻防权威指南》

隐藏的Android API

{%}

作者/ Erik Hellman

Erik是Factor10咨询公司资深移动开发顾问,曾任索尼公司Android团队首席架构师,主导Xperia系列产品开发;精通移动应用、Web技术、云计算和三维图形,定期在DroidCon、JFokus、JavaOne和其他专业开发人员大会上发表演讲。关于Erik的更多信息,可访问他的博客http://blog.hellsoft.se

Android平台的很多功能并没有公开的API。例如,尽管Android支持发送SMS(SmsManagerSmsMessage),但这些类并没有包含在正式的API中。不过,开发者还是能在Google Play Store上发现一些提供功能全面的SMS客户端,并且许多应用程序要和收到的短信打交道。虽然可以简单地搜索如何在Android应用中接收SMS,但在很多情况下,这些隐藏的平台API对开发者还是很有用的。

本文将解释如何以及在哪里可以找到隐藏的API。本文还会描述两种以安全的方式访问它们的方法。

大部分隐藏的API还具有signature或者systemprotectionLevel权限保护。虽然不能在Google Play Store发布的应用中使用这些API,但是可以在为自定义固件开发的应用程序中使用它们。这样做可在不修改Android平台的前提下使用它们。

官方API和隐藏API

SDK文档中的所有类、接口、方法以及常量都属于官方API。虽然这些API通常能满足大多数应用的需求,但开发者有时候想访问更多的东西,而不知道如何在官方API中找到它们。

Android SDK中包含了一个JAR文件(android.jar),在编译代码时会引用它。该文件位于<sdk root>/platforms/android-<API level>/目录。不过它里面全是空的类,方法中所有的代码都被移除了,只声明了publicprotected的类。构建Android平台时,SDK会包含该JAR文件。

通过检查每一个源文件,并移除所有被@hide注解的域(如常量)、方法和类,在构建SDK时会生成方法体为空的android.jar文件。这意味着仍然可以在运行的设备上访问这些符号,但是在编译时却找不到它们。

{%}

图1 隐藏类CountryDetector的源码。注意@hide JavaDoc注解

Android会自动隐藏某些API,而不需要使用@hide注解。这些API位于com.android.internal包中,不属于android.jar文件,但确实包含大量供Android平台使用的内部代码。Android系统应用还包含一些其他隐藏API,这些API通常提供没有包含在官方SDK中的系统ContentProvider信息。

发现隐藏API

寻找隐藏API最简单的方法是在Android源代码中搜索它们。但是Android源代码量是巨大的,幸好有几个在线的网站已经对这些代码进行了索引,并提供了搜索功能。AndroidXRef(http://androidxref.com/)就是这样的网站,它将所有Android版本的源代码进行了索引(见图2)。

{%}

图2 AndroidXRef的搜索对话框

另一种寻找隐藏API的方法是通过Android API参考网站上的查看源码(View Source)链接(见图3)。虽然该网站并不像AndroidXRef那样提供搜索功能,但是它能很方便地从官方API文档中直接访问。

{%}

图3 API参考网站上Settings.Global类的view source链接

如果开发者知道在哪里以及如何寻找隐藏API,使用搜索功能会很有用,但是找到需要的代码很困难。大部分隐藏API都位于frameworks项目。所有android包中的API都可以在frameworks项目中找到,该项目还包含大部分com.android.internal包中的API。

通常,开发者寻找的隐藏API是公开类的一部分。例如,WifiManager有几个公开的未隐藏方法,但也有一些实用的隐藏方法和字段。在其他情况下,这些隐藏API并不是公开的,比如前面图15-1所示的CountryDetector类。

安全地调用隐藏API

常量字段,比如广播的action或者ContentProviderUri,是实用隐藏API的主要部分。开发者可以把这些字段复制到自己的代码中,并像使用其他API一样使用它们。最简单的方法是把整个类(例如,直接从AndroidXRef)复制到项目中。如果这些隐藏的API已经在不同的Android API级别中被修改过,开发者还需要在自己的包中保持每个版本的副本。通过这种方式,开发者不仅可以使用隐藏API,还能同时支持多种Android版本的设备。

大多数情况下,在需要使用包含常量值的隐藏API时(比如广播action),建议从Android源代码中复制这些常量。

对于需要编译时链接的API,也就是接口、类以及方法,开发者有两个选择。第一种,可以修改SDK的JAR文件,使之包含所有需要的类和接口,并使用该SDK来编译应用程序。另一种解决方案是使用Java反射API来动态查找要调用的类和方法。每种方法都有优缺点,需根据不同的情况选择不同的方法。

通过修改SDK来生成android.jar可以有效地把代码绑定到使用的设备上,但如果不小心的话,在其他设备上运行可能会导致崩溃。然而,这种解决方案没有任何性能损失。使用反射API允许同时支持多个Android版本,但是会有性能问题,因为它需要在运行时查找类和方法。

隐藏API示例

下面会展示一些如何使用隐藏API的典型例子。

接收和阅读SMS

Android中使用隐藏API最常见的例子是接收和阅读SMS。虽然官方API包含了RECEIVE_SMSREAD_SMS这两个权限,但实际执行的API却是隐藏的。

应用程序要想接收SMS必须声明使用RECEIVE_SMS权限,并且实现BroadcastReceiver,以处理收到的短信。

public class MySmsReceiver extends BroadcastReceiver {
    // Telephony.java中隐藏的常量
    public static final String SMS_RECEIVED_ACTION= "android.provider.Telephony.SMS_RECEIVED";

    public static final String MESSAGE_SERVICE_NUMBER = "+461234567890";
    private static final String MESSAGE_SERVICE_PREFIX = "MYSERVICE";

    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (SMS_RECEIVED_ACTION.equals(action)) {
            // 通过"pdus"获取SMS数据的隐藏键
            Object[] messages =(Object[]) intent.getSerializableExtra("pdus");
            for (Object message : messages) {
                byte[] messageData = (byte[]) message;
                SmsMessage smsMessage =SmsMessage.createFromPdu(messageData);
                processSms(smsMessage);
            }
        }
    }

    private void processSms(SmsMessage smsMessage) {
        String from = smsMessage.getOriginatingAddress();
        if (MESSAGE_SERVICE_NUMBER.equals(from)) {
            String messageBody = smsMessage.getMessageBody();
            if (messageBody.startsWith(MESSAGE_SERVICE_PREFIX)) {
                // TODO:消息验证通过,开始处理
            }
        }
    }
}

上面的代码使用BroadcastReceiver监听Intent操作android.provider.Telephony.SMS_RECEIVED(记得同样要把它加到清单文件中的intent-filter中)。在这个例子中,唯一“隐藏的”部分是Intent操作,以及用来从Intent("pdus")检索SMS数据的字符串。

要阅读已经收到的SMS,需要查询一个隐藏的ContentProvider,并声明使用READ_SMS权限。android.provider包中的Telephony类提供了所有需要的信息。使用该类最佳的方式是把它复制到自己的项目中,并修改类的包结构。由于Telephony类还包含对其他隐藏类和方法的调用,所以还必须删除或者重构这些调用,以便能够编译代码。取决于使用的隐藏API的数量,有时候简单复制一些常量声明而不是整个类就足够了。

@Override
public void onActivityCreated(Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    mAdapter = new SimpleCursorAdapter(this,
        R.layout.sms_list_item, null,
        new String[] {Telephony.Sms.ADDRESS, Telephony.Sms.BODY,Telephony.Sms.DATE},
        new int[] {R.id.sms_from, R.id.sms_body, R.id.sms_received},
        CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER);
    setListAdapter(mAdapter);
    getLoaderManager().initLoader(0, null, this);
}

public Loader<Cursor> onCreateLoader(int id, Bundle args) {
    Uri smsUri = Telephony.Sms.CONTENT_URI;
    return new CursorLoader(getActivity(), smsUri, new String[] {
        Telephony.Sms._ID,
        Telephony.Sms.ADDRESS,
        Telephony.Sms.BODY,
        Telephony.Sms.DATE},
        null, null, Telephony.Sms.DEFAULT_SORT_ORDER);
}

这两个方法来自一个自定义的ListFragment,它从ContentProvider加载SMS Cursor,并把它放到SimpleCursorAdapter中。本例仅仅使用了Telephony类的Uri以及数据库列的名字。

Wi-Fi网络共享

Android智能手机可以启用Wi-Fi网络共享,这使得它可以创建一个移动的Wi-Fi热点,让其他设备(通常是笔记本电脑)连接互联网。此功能在Android上非常流行,但它给应用开发者引入了一些问题。

当用户启用Wi-Fi网络共享时,Wi-Fi的状态既不是打开的,也不是关闭的,如果通过API查询,得到的状态会是“未知”。前面的例子展示了如何使用isWifiApEnabled()隐藏方法检测Wi-Fi网络共享是已启用。WifiManager类的一些其他隐藏方法提供了更多关于Wi-Fi网络共享的信息。

private WifiConfiguration getWifiApConfig() {
    WifiConfiguration wifiConfiguration = null;
    try {
        WifiManager wifiManager =(WifiManager) getSystemService(WIFI_SERVICE);
        Class clazz = WifiManager.class;
        Method getWifiApConfigurationMethod =clazz.getMethod("getWifiApConfiguration");
        return (WifiConfiguration)
            getWifiApConfigurationMethod.invoke(wifiManager);
    } catch (NoSuchMethodException e) {
        Log.e(TAG, "Cannot find method", e);
    } catch (IllegalAccessException e) {
        Log.e(TAG, "Cannot call method", e);
    } catch (InvocationTargetException e) {
        Log.e(TAG, "Cannot call method", e);
    }
    return wifiConfiguration;
}

上面的代码显示了如何为设备上的Wi-Fi网络共享设置检索WifiConfiguration对象。注意,调用这些方法需要应用程序具有android.permission.ACCESS_WIFI_STATE权限。所有Android设备配置(也就是连接的)的Wi-Fi网络都可以通过WifiManager.getConfiguredNetworks()枚举出来,返回的结果是WifiConfiguration对象列表。安全起见,每个通过该方法获取的WifiConfiguration对象,其preSharedKey都被设置成了null。然而,如果像前面的代码那样获取Wi-Fi网络共享设置的WifiConfiguration对象,会发现preSharedKey呈现的是明文密码。这样一来,当激活Wi-Fi网络共享时,应用程序可以获取创建的访问点的名字和密码。

虽然可以认为这个功能是一个安全漏洞,但是激活Wi-Fi网络共享的权限需要应用程序使用系统证书进行签名。因此,即使一个应用程序可以读取密码,但是未经用户同意没有办法为它激活Wi-Fi网络共享。

隐藏设置

Android设备有数百种不同的设置,都可以通过Settings类访问。除了为每个设置提供访问的值,Android还提供了一系列Intent操作,使用它们可以打开特定的设置UI。例如,要启动飞行模式设置,在创建Intent时可以使用Settings.ACTION_AIRPLANE_MODE_SETTINGS。图4显示了AndroidXRef中Settings.java的内容:

{%}

图4 Settings.java文件中的部分隐藏常量

Settings类包含了一些隐藏的设置键和Intent操作,当应用程序需要弄清楚设备的细节或者呈现一个特定系统设置的快捷方式时,其中的一些常量值是非常有用的。

小结

本文介绍了如何在Android平台上发现和使用隐藏API。尽管只有几个例子,但隐藏API的数量是相当大的。大多数这类API不仅是隐藏的,通过使用signature或者system保护级别,它们还有权限保护,这使得它们对大多数开发者来说并不可用。然而,如果创建自定义的固件,使用这些方法可以非常有效地构建访问系统API的高级应用程序。

这些API,有些是用来访问ContentProvider的简单常量,有些是用来启动ActivityIntent操作,或者是用于读取系统设置的设置键,而另一些则需要开发者去调用相应的方法。

虽然大多数应用程序可能永远也不需要这些API,但是在某些情况下,开发者能从这些非官方API中获益。聪明地使用隐藏API可以进一步增强你的应用程序。

 

{%}

《Android编程实战》针对如火如荼的Android 市场,深入挖掘Android 平台的功能,帮助开发者构建更高级的应用程序。书中内容包括三大部分。第一部分介绍了Android 开发者可用的工具及用于Android 开发的Java 编程语言。第二部分介绍了核心Android 组件及其最优使用方式。第三部分主要介绍一些最新技术,包括Android 平台及可供Android 设备使用的服务。本文节选自《Android编程实战》

你真的会用AsyncTask吗?

作者/ 张新勇

张新勇任OneAPM的Android工程师。OneAPM是中国基础软件领域的新兴领军企业。专注于提供下一代应用性能管理软件和服务,帮助企业用户和开发者轻松实现:缓慢的程序代码和SQL语句的实时抓取。本文首发于OneAPM官方技术博客

导读】在Android应用开发的过程中,我们需要时刻注意保证应用程序的稳定和UI操作响应及时,因为不稳定或响应缓慢的应用将给应用带来不好的印象,严重的用户卸载你的APP,这样你的努力就没有体现的价值了。本文试图从AsnycTask的作用说起,进一步的讲解一下内部的实现机制。如果有一些开发经验的人,读完之后应该对使用AsnycTask过程中的一些问题豁然开朗,开发经验不丰富的也可以从中找到使用过程中的注意点。

为何引入AsnyncTask?

在Android程序开始运行的时候会单独启动一个进程,默认情况下所有这个程序操作都在这个进程中进行。一个Android程序默认情况下只有一个进程,但是一个进程却是可以有许线程的。

在这些线程中,有一个线程叫做UI线程,也叫做Main Thread,除了Main Thread之外的线程都可称为Worker Thread。Main Thread主要负责控制UI页面的显示、更新、交互等。因此所有在UI线程中的操作要求越短越好,只有这样用户才会觉得操作比较流畅。一个比较好的做法是把一些比较耗时的操作,例如网络请求、数据库操作、复杂计算等逻辑都封装到单独的线程,这样就可以避免阻塞主线程。为此,有人写了如下的代码:

private TextView textView;
    public void onCreate(Bundle bundle){
        super.onCreate(bundle);
        setContentView(R.layout.thread_on_ui);
        textView = (TextView) findViewById(R.id.tvTest);
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    HttpGet httpGet = new HttpGet("http://www.baidu.com");
                    HttpClient httpClient = new DefaultHttpClient();
                    HttpResponse httpResp = httpClient.execute(httpGet);
                    if (httpResp.getStatusLine().getStatusCode() == 200) {
                        String result = EntityUtils.toString(httpResp.getEntity(), "UTF-8");
                        textView.setText("请求返回正常,结果是:" + result);
                    } else {
                    textView.setText("请求返回异常!");
                }
            }catch (IOException e){
               e.printStackTrace();
            }
        }
    }).start();
}

运行,不出所料,异常信息如下:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

怎么破?可以在主线程创建Handler对象,把textView.setText地方替换为用handler把返回值发回到handler所在的线程处理,也就是主线程。这个处理方法稍显复杂,Android为我们考虑到了这个情况,给我们提供了一个轻量级的异步类可以直接继承AsyncTask,在类中实现异步操作,并提供接口反馈当前异步执行的结果以及执行进度,这些接口中有直接运行在主线程中的,例如onPostExecute,onPreExecute等方法。

也就是说,Android的程序运行时是多线程的,为了更方便的处理子线程和UI线程的交互,引入了AsyncTask。

AsnyncTask内部机制

AsyncTask内部逻辑主要有二个部分:

1、与主线的交互,它内部实例化了一个静态的自定义类InternalHandler,这个类是继承自Handler的,在这个自定义类中绑定了一个叫做AsyncTaskResult的对象,每次子线程需要通知主线程,就调用sendToTarget发送消息给handler。然后在handler的handleMessage中AsyncTaskResult根据消息的类型不同(例如MESSAGE_POST_PROGRESS会更新进度条,MESSAGE_POST_CANCEL取消任务)而做不同的操作,值得一提的是,这些操作都是在UI线程进行的,意味着,从子线程一旦需要和UI线程交互,内部自动调用了handler对象把消息放在了主线程了。源码地址

mFuture = new FutureTask<Result>(mWorker) {
       @Override
        protected void More ...done() {
            Message message;
           Result result = null;

            try {
                result = get();
           } catch (InterruptedException e) {
                android.util.Log.w(LOG_TAG, e);
           } catch (ExecutionException e) {
                throw new RuntimeException("An error occured while executing doInBackground()",
                        e.getCause());
            } catch (CancellationException e) {
                message = sHandler.obtainMessage(MESSAGE_POST_CANCEL,
                       new AsyncTaskResult<Result>(AsyncTask.this, (Result[]) null));
                message.sendToTarget();
                return;
            } catch (Throwable t) {
                throw new RuntimeException("An error occured while executing "
                       + "doInBackground()", t);
            }

            message = sHandler.obtainMessage(MESSAGE_POST_RESULT,
                    new AsyncTaskResult<Result>(AsyncTask.this, result));
            message.sendToTarget();
       }
    };


private static class InternalHandler extends Handler {
    @SuppressWarnings({"unchecked", "RawUseOfParameterizedType"})
    @Override
    public void More ...handleMessage(Message msg) {
        AsyncTaskResult result = (AsyncTaskResult) msg.obj;
        switch (msg.what) {
            case MESSAGE_POST_RESULT:
                // There is only one result
                result.mTask.finish(result.mData[0]);
                break;
            case MESSAGE_POST_PROGRESS:
                result.mTask.onProgressUpdate(result.mData);
                break;
            case MESSAGE_POST_CANCEL:
                result.mTask.onCancelled();
                break;
        }
    }
}

2、AsyncTask内部调度,虽然可以新建多个AsyncTask的子类的实例,但是AsyncTask的内部Handler和ThreadPoolExecutor都是static的,这么定义的变量属于类的,是进程范围内共享的,所以AsyncTask控制着进程范围内所有的子类实例,而且该类的所有实例都共用一个线程池和Handler。代码如下:

public abstract class AsyncTask<Params, Progress, Result> {
private static final String LOG_TAG = "AsyncTask";

private static final int CORE_POOL_SIZE = 5;
private static final int MAXIMUM_POOL_SIZE = 128;
private static final int KEEP_ALIVE = 1;

private static final BlockingQueue<Runnable> sWorkQueue =
        new LinkedBlockingQueue<Runnable>(10);

private static final ThreadFactory sThreadFactory = new ThreadFactory() {
    private final AtomicInteger mCount = new AtomicInteger(1);

    public Thread More ...newThread(Runnable r) {
        return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
    }
};

private static final ThreadPoolExecutor sExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
        MAXIMUM_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, sWorkQueue, sThreadFactory);

private static final int MESSAGE_POST_RESULT = 0x1;
private static final int MESSAGE_POST_PROGRESS = 0x2;
private static final int MESSAGE_POST_CANCEL = 0x3;

从代码还可以看出,默认核心线程池的大小是5,缓存任务队列是10。意味着,如果线程池的线程数量小于5,这个时候新添加一个异步任务则会新建一个线程;如果线程池的数量大于等于5,这个时候新建一个异步任务这个任务会被放入缓存队列中等待执行。限制一个APP内AsyncTask并发的线程的数量看似是有必要的,但也带来了一个问题,假如有人就是需要同时运行10个而不是5个,或者不对线程的多少做限制,例如有些APP的瀑布流页面中的N多图片的加载。

另一方面,同时运行的任务多,线程也就多,如果这些任务是去访问网络的,会导致短时间内手机那可怜的带宽被占完了,这样总体的表现是谁都很难很快加载完全,因为他们是竞争关系。所以,把选择权交给开发者吧。

事实上,大概从Android从3.0开始,每次新建异步任务的时候AsnycTask内部默认规则是按提交的先后顺序每次只运行一个异步任务。当然了你也可以自己指定自己的线程池。

可以看出,AsyncTask使用过程中需要注意的地方不少

  • 由于Handler需要和主线程交互,而Handler又是内置于AsnycTask中的,所以,AsyncTask的创建必须在主线程。

  • AsyncTaskResult的doInBackground(mParams)方法执行异步任务运行在子线程中,其他方法运行在主线程中,可以操作UI组件。

  • 不要手动的去调用AsyncTask的onPreExecute, doInBackground, publishProgress, onProgressUpdate, onPostExecute方法,这些都是由Android系统自动调用的

  • 一个任务AsyncTask任务只能被执行一次。

  • 运行中可以随时调用cancel(boolean)方法取消任务,如果成功调用isCancelled()会返回true,并且不会执行onPostExecute() 方法了,取而代之的是调用 onCancelled() 方法。而且从源码看,如果这个任务已经执行了这个时候调用cancel是不会真正的把task结束,而是继续执行,只不过改变的是执行之后的回调方法是onPostExecute还是onCancelled。

AsnyncTask和Activity OnConfiguration

上面提到了那么多的注意点,还有其他需要注意的吗?当然有!我们开发App过程中使用AsyncTask请求网络数据的时候,一般都是习惯在onPreExecute显示进度条,在数据请求完成之后的onPostExecute关闭进度条。这样做看似完美,但是如果您的App没有明确指定屏幕方向和configChanges时,当用户旋转屏幕的时候Activity就会重新启动,而这个时候您的异步加载数据的线程可能正在请求网络。当一个新的Activity被重新创建之后,可能由重新启动了一个新的任务去请求网络,这样之前的一个异步任务不经意间就泄露了,假设你还在onPostExecute写了一些其他逻辑,这个时候就会发生意想不到异常。

一般简单的数据类型的,对付configChanges我们很好处理,我们直接可以通过onSaveInstanceState()和onRestoreInstanceState()进行保存与恢复。Android会在销毁你的Activity之前调用onSaveInstanceState()方法,于是,你可以在此方法中存储关于应用状态的数据。然后你可以在onCreate()或onRestoreInstanceState()方法中恢复。

但是,对于AsyncTask怎么办?问题产生的根源在于Activity销毁重新创建的过程中AsyncTask和之前的Activity失联,最终导致一些问题。那么解决问题的思路也可以朝着这个方向发展。Android官方文档也有一些解决问题的线索。

这里介绍另外一种使用事件总线的解决方案,是国外一个安卓大牛写的。中间用到了Square开源的EventBus类库http://square.github.io/otto/。首先自定义一个AsyncTask的子类,在onPostExecute方法中,把返回结果抛给事件总线,代码如下:

 @Override
protected String doInBackground(Void... params) {
    Random random = new Random();
    final long sleep = random.nextInt(10);
    try {
        Thread.sleep(10 * 6000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "Slept for " + sleep + " seconds";
}

@Override
protected void onPostExecute(String result) {
    MyBus.getInstance().post(new AsyncTaskResultEvent(result));
}

在Activity的onCreate中注册这个事件总线,这样异步线程的消息就会被otta分发到当前注册的activity,这个时候返回结果就在当前activity的onAsyncTaskResult中了,代码如下:

 @Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.otto_layout);

    findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
        @Override public void onClick(View v) {
            new MyAsyncTask().execute();
        }
    });

    MyBus.getInstance().register(this);
}

@Override
protected void onDestroy() {
    MyBus.getInstance().unregister(this);
    super.onDestroy();
}

@Subscribe
public void onAsyncTaskResult(AsyncTaskResultEvent event) {
    Toast.makeText(this, event.getResult(), Toast.LENGTH_LONG).show();
}

个人觉的这个方法相当好,当然更简单的你也可以不用otta这个库,自己单独的用接口回调的方式估计也能实现,大家可以试试。

如何监控AsyncTask?

不过,AsyncTask虽然很好用,但是问题也不少,需要注意的地方也很多。万一又出现问题了怎么办?客户反馈一个问题,开发人员第一反应是:“这不可能啊,我这里测试没问题的!”但是,老板让你解决问题,客户的环境无法复现、操作步骤无法重复,这个时候开发就犯难了......总之,监控这个事情是非常重要的。

以应用性能管理(APM)领军企业OneAPM为例,其提供了新一代应用性能管理软件和服务,能够帮助企业用户和开发者轻松实现,缓慢的程序代码和SQL语句的实时抓取。OneAPM推出了针对移动端的应用性能监控产品Mobile Insight,用户可以访问OneAPM官方网站,下载移动端监控SDK,测试下AsyncTask。

阅读图灵社区原文

Android电源管理的结构

作者/ 金大佑

西江大学电子工程学士及硕士学位。大学时期沉迷于Linux,并创建了一个Linux社团。曾参与LG电子安卓项目,目前在瑞萨移动参与安卓LTE手机项目。虽然热衷于安卓开发,但目前仍在使用非智能手机。主要关注领域是安卓平台、SW设计以及ARM架构。希望有一天可以将创建开源项目作为个人爱好。

Android平台基于 Linux内核,所以设计时也以 Linux内核电源管理为基础。传统的 Linux内核没有考虑到移动设备的特点,因此在 Android平台中,创建了考虑到移动设备的特点,即有限电池容量的控制电源管理的服务,这就是用户空间的 Power Manager Service。另外,内核空间也引入了由用户空间 Power Manager Service控制的 Early Suspend、Late Resume以及 Wake Lock新概念。

本文将介绍 Android平台电源管理的结构及相应对象的作用。

Android电源管理的层级结构

下面将介绍Android平台的电源管理层级结构及各层级作用,以及之后在何处使用何种内容。

图1只显示了 Android平台层级结构下的电源管理相关对象。

{%}

图1

Android应用层包括利用 Power Manager Service的应用程序及 Power Manager。

Android框架层中有 Power Manager Service这一远程服务。

JNI(Java Native Interface,Java本地接口)层是由 Power Manager Service的本地方法调用的本地函数,或是本地函数调用 Power Manager Service方法的本地空间。

com_android_server_PowerManagerService.cpp(framewoks/base/services/jni)文件中定义了本地函数。

HAL(Hardware Adaptation Layer,硬件适配层)层抽象化了从Android 用户空间到内核空间硬件设备的访问,存在用于Android 电源管理的Legacy HAL 库—libpower。libpower 将Wake Lock 的信息传递到内核空间。

系统核心库libsuspend 触发内核空间的Early Suspend 及Late Resume 操作。sysfs 虚拟文件系统是用户空间及内核空间通信的方法之一。

图2不能准确说明Power Manager Service 这一远程服务。从客户端与服务器端角度体现的Android 电源管理,如图2所示。

{%}

图2

远程服务—Power Manager Service 相当于服务的服务器端,有用于连接远程服务的RPC接口IPowerManager。使用远程服务的服务客户端通过Power Manager 连接并使用远程服务—Power Manager Service。

Power Manager

为了使应用程序及服务等的客户端访问远程服务,Android 通过提供包装(Wrapper)类的管理器类提供利用远程服务的间接性标准化方法。

×××Manager 类由系统Fetcher 类(静态类)生成对象,此时,RPC Binder 的asInterface()方法返回×××Manager Service 的代理对象引用。×××Manager 可以通过获取返回的代理对象引用访问×××Manager Service,如图3所示。

{%}

图3

另外, 客户端获取××× Manager 类的对象引用后, 可以由××× Manager 访问××× Manager Service, 为获取××× Manager 类的对象引用,Android Java 空间提供了getSystemService() 方法。因此,客户端使用getSystemService() 方法即可随时访问所需的远程服务,如图4 所示。

{%}

图4

客户端利用 Power Manager Service的方法有两种,一种通过 Power Manager间接调用,另一种通过 Power Manager Service的代理对象引用直接调用。

Power Manager Service

Power Manager Service类在 Android Java空间继承了 IPowerManager.Stub服务存根类,实3 现 Power Manager Service的实质性功能。Power Manager Service的主要操作是控制屏幕亮度及防止 Sleep(睡眠),并将相应信息传递至本地及内核空间。

图5为决定 Power Manager Service状态的核心方法—setPowerState()方法将屏幕亮度信息传递给本地空间的过程。

{%}

图5

从 Gingerbread(2.3)版本到 Jelly Bean(4.1)版本,Power Manager Service的结构没有变化。但从 Jelly Bean Plus(4.2)版本开始,结构上完全更改为全新的代码。

setPowerState()方法决定 Power Manager Service的电源状态,并根据电源状态控制灯光对象的亮度。

Java本地方法 nativeSetPowerState()方法通过 JNI向本地空间发送屏幕 On(开)/Off(关)状态及屏幕调光状态。输入管理器在发生按键事件及触控事件时,浏览相应信息以控制屏幕。

Java本地方法 nativeSetScreenState()方法通过 JNI调用系统内核库 libsuspend的 autosuspend_ disable()函数或 autosuspend_enable()函数控制屏幕 On(开)/Off(关)状态。另外,autosuspend_ disable()函数根据内核的电源管理方式选择并调用 autosuspend_earlysuspend_disable()autosuspend_ autosleep_disable()autosuspend_wakeup_count_disable()函数之一,autosuspend_enable()函数选择并调用 autosuspend_earlysuspend_enable ()autosuspend_autosleep_enable ()autosuspend_wakeup_ count_enable ()函数之一。

制造商及平台开发商为了与已有 Android版本兼容,选择 state文件管理电源的方式,所以此处也调用 autosuspend_earlysuspend_disable()函数或 autosuspend_earlysuspend_enable ()函数进行介绍。autosuspend_earlysuspend_disable()函数或 autosuspend_earlysuspend_enable ()函数决定内核的电源状态,由 sysfs接口触发内核空间的 Late Resume及 Early Suspend操作。

图6表示对应 Power Manager Service主操作中第二种操作的防止 Sleep(睡眠)操作。

{%}

图6

Jave本地方法 nativeAcquireWakeLock()方法及 nativeReleaseWakeLock()方法通过 JNI调用 Legacy HAL库 libpower库的 acquire_wake_lock()函数及release_wake_lock()函数,向本地空间传递 Wake Lock获取及解除信息。不是向内核空间传递所有 Wake Lock信息,只传递具有 PARTIAL_WAKE_LOCK标记的 Wake Lock信息。

本地空间

Icecream Sandwitch(ICS)及其之前的版本中,Android本地空间内电源管理相关对象只有 Legacy HAL库 libpower。

图7表示在 Legacy HAL中通过 sysfs文件系统①向内核空间传递 Wake Lock获取及解除信息、屏幕 On(开)/Off(关)信息。由传递的信息控制内核空间的电源管理操作。

{%}

图7

Jelly Bean(4.1)版本不仅有 Legacy HAL,还添加了具有 POWERHARDWARE_MODULE ID的电源 HAL模块及系统核心库 libsuspend。电源 HAL模块的用途是,为运行时调节 CPU频率的 CPU governor提供功率方法,或控制触控设备的性能,libsuspend在多种控制内核空间电源管理的方法中选择一种。

目前,电源 HAL模块不再由 Android及芯片制造商实现,也不再是不稳定的代码,所以此处只介绍在 libsuspend中控制内核空间电源管理的方法。

图8为 Jelly Bean(4.1)版本中实际使用的本地空间的操作。

{%}

图8

通过 sysfs文件系统提供的 wake_lockwake_unlock及 state文件向内核空间传递 Wake Lock信息及屏幕亮度信息,各库中提供的函数及说明如表1所示。

表1

状态

说明

acquire_wake_lock()

只有相当于居右PARTIAI_WAKE_LOCK标记的Wake Lock的情况,通过向sysfs文件wake_lock文件输入Wake Lock的名称这一标签信息,传递给内核空间,然后在Early Suspend状态下进入Suspend状态时,检查wake_lock文件中是否存在激活的Wake Lock

release_wake_lock()

只相当于具有PARTIAI_WAKE_LOCK标记的Wake Lock的情况,通向向sysfs文件wake_unlock文件输入Wake Lock的名称这一标签信息,传递给内核空间,然后再保存激活的Wake Lock信息的wake_lock文件中删除该信息
与acquire_wake_lock()函数相同,在Early Suspend状态下进入Suspend状态时,检查wake_lock文件中是否在激活的Wake Lock

autosuspend_disable()

在Power Manager Service中将屏幕的状态设置为On(开)时,是调用的本地空间函数,这样该函数即可选择内核空间的电源管理方式
第一种为调用autosuspend_earlysuspend_disable()函数,利用原有Android版本中一直使用的state文件,触发Early Suspend及Late Resume操作
第二种为调用autosuspend_autosleep_disable()函数,利用sutosleep文件而没有wakeup源时,内核进行进入Suspend状态,这在内核高于3.4版本时才能实现
第三种为调用autosuspend_wakeup_count_disable()函数,wakeup_count文件与state文件通过检查计数的suspend_thread控制内核电源管理
此处为了与原有版本兼容,选择第一种方式进行说明
autosuspend_disable()函数调用autosuspend_earlysuspend_disable()函数,然后通过向sysfs文件state文件输入“on”传递到内核空间,随之触发Late Resume操作

sutosuspend_enable()

在Power Manager Service中间屏幕的状态设置为Off(关)时,是调用的本地空间函数,与autosuspend_disable()函数相同,为了与原有版本兼容,选择第一种方法机型说明autosuspend_enable{}函数调用autosuspend_earlysuspend_enable()函数,然后通过向sysfs文件state文件输入“mem”传递到内核空间,随之Early Suspend操作

sysfs文件系统是 Linux内核中提供的一种文件系统,用于用户空间与内核空间的数据交换,从2.6版本开始引入。不使用系统调用及设备驱动,仅用于简单的数据传输。此处,Power Manager Service向内核空间传递 Wake Lock11 信息及屏幕亮度相关信息,在内核空间读取用户空间传递来的信息,然后执行想要的操作。

内核空间

以用户空间传递来的信息为基础进行设备电源管理及系统电源管理。图9简单表示了传统的 Linux内核中提供的电源管理。

{%}

图9

如果系统由 Running状态进入 Suspend状态,然后转为 CPU Sleep状态并发生指定中断,则系统进入 Resume状态。原有 Linux内核假设的工作环境不是移动设备,而是台式机等固定的、有外部电源持续供给的环境,所以只利用 Suspend及 Resume状态也可进行电源管理。

然而,接受有限的电源 —电池供电的移动设备只用原有 Linux内核中提供的 Suspend及 Resume状态无法有效运行电源管理。因此,在 Android平台中,与原有 Linux内核不同,为反映移动设备的环境并有效控制受限的电源 —电池的容量,引入了 Wake Lock概念,这样就添加了 Early Suspend状态及 Late Resume状态。图10体现了 Android内核中修正的电源管理。

{%}

图10

与原有 Linux内核不同,系统不是从 Running状态直接进入 Suspend状态,而是进入 Suspend状态及 Running状态的中间状态 —Early Suspend状态。到屏幕熄灭时,系统保持 Running状态。另外,在 CPU Sleep状态下发生指定中断时,即使进入 Resume状态,也不会像原有 Linux内核一样再次激活全部中断,而只激活几个指定的中断,将耗电量降至最低。

图11表示在用户空间利用sysfs文件系统调用内核空间。

{%}

图11

在用户空间对 wake_lockwake_unlock及 state文件进行写操作时,由 sysfs文件系统在内核空间调用 wake_lock_store()wake_unlock_store()state_store()函数。

Android电源管理主要方法调用过程

以前面介绍的内容为基础,图12整理了用户空间的 Power Manager到内核空间的 Android平台电源管理主要操作中方法及函数的调用。

Wake Lock获取操作从调用 Power Manager的acquire()方法开始,调用 Power Manager Service的 acquire()方法后,执行实际的 Power Manager Service中的操作。具有 FULL_WAKE_ LOCKSCREEN_BRIGHT_WAKE_LOCKSCREEN_DIM_WAKE_LOCKPROXIMITY_ SCREEN_OFF_WAKE_LOCK标记的 Wake Lock控制屏幕亮度,具有 PARTIAL_WAKE_ LOCK标记的 Wake Lock不控制屏幕亮度,只向内核空间传递 Wake Lock标签信息。也就是说,除 PARTIAL_WAKE_LOCK标记外,具有其他标记的 Wake Lock不向内核空间传递该信息。因此,只有带有 PARTIAL_WAKE_LOCK标记的 Wake Lock调用 Java本地方法 nativeAcquireWakeLock()方法,通过 JNI及 Legacy HAL调用内核空间的 wake_lock_store()函数。

{%}

图12

Wake Lock取消操作从调用 Power Manager的 release()方法开始,调用 Power Manager Service的 release()方法后,运行实际的 Power Manager Service中的操作。与 acquire()方法相同,具有 FULL_WAKE_LOCKSCREEN_BRIGHT_WAKE_LOCKSCREEN_DIM_WAKE_ LOCKPROXIMITY_SCREEN_OFF_WAKE_LOCK标记的 Wake Lock控制屏幕亮度,具有 PARTIAL_WAKE_LOCK标记的 Wake Lock不控制屏幕亮度,只向内核空间传递 Wake Lock标签信息。

屏幕亮度控制由决定 Power Manager Service状态的核心方法—setPowerState()方法执行, nativeSetScreenState()方法经过本地空间调用内核空间的 state_store()函数以控制内核空间的电源管理,native SetPowerState()方法将屏幕亮度信息传递给本地空间,本地空间的输入管理器浏览该信息。

 


{%}

与安卓刚出现时相比,安卓开发人员现在已有了大幅增长,人们也可轻松搜索到相关资料。但安卓开发仍然很有难度,每当版本升级时,结构变动都会使之前的代码无法重新使用。虽然需要深入掌握安卓平台,但开发人员的主要工作就是修复Bug,所以对实际情况往往“只见树木不见森林”。《Android系统服务开发》着眼点在于“开发人员如何改善开发流程”,这个问题的关键就是深入挖掘安卓的基本实现原理。本文节选自《Android系统服务开发》

继续进阶,你还应该掌握的高级技巧

{%}

作者/ 郭霖

Android软件开发工程师。从事Android开发工作四年,有着丰富的项目实战经验,负责及参与开发过多款移动应用与游戏,对Android系统架构及应用层开发有着深入的理解。2013年3月开始,在CSDN上发表Android技术相关博文,很快就获得了大量网友的好评。短短一年时间博客访问量超过50万次,评价近3000条。荣获CSDN认证专家,并被评选为2013年CSDN年度博客之星。现就职于蜗牛移动,继续从事Android开发工作。

相信基础性的Android知识已经没有太多能够难倒你的了,那么我们就来学习一些你还应该掌握的高级技巧吧。

使用Intent传递对象

Intent的用法相信你已经比较熟悉了,我们可以借助它来启动活动、发送广播、启动服务等。在进行上述操作的时候,我们还可以在Intent中添加一些附加数据,以达到传值的效果,比如在FirstActivity中添加如下代码:

Intent intent = new Intent(FirstActivity.this, SecondActivity.class);
intent.putExtra("string_data", "hello");
intent.putExtra("int_data", 100);
startActivity(intent);

这里调用了Intent的putExtra()方法来添加要传递的数据,之后在SecondActivity中就可以得到这些值了,代码如下所示:

getIntent().getStringExtra("string_data");
getIntent().getIntExtra("int_data", 0);

但是不知道你有没有发现,putExtra()方法中所支持的数据类型是有限的,虽然常用的一些数据类型它都会支持,但是当你想去传递一些自定义对象的时候就会发现无从下手。不用担心,下面我们就学习一下使用Intent来传递对象的技巧。

Serializable方式

使用Intent来传递对象通常有两种实现方式,Serializable和Parcelable,接下来我们先来学习一下第一种的实现方式。

Serializable是序列化的意思,表示将一个对象转换成可存储或可传输的状态。序列化后的对象可以在网络上进行传输,也可以存储到本地。至于序列化的方法也很简单,只需要让一个类去实现Serializable这个接口就可以了。比如说有一个Person类,其中包含了name和age这两个字段,想要将它序列化就可以这样写:

public class Person implements Serializable{

    private String name;

    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

其中get、set方法都是用于赋值和读取字段数据的,最重要的部分是在第一行。这里让Person类去实现了Serializable接口,这样所有的Person对象就都是可序列化的了。

接下来在FirstActivity中的写法非常简单:

Person person = new Person();
person.setName("Tom");
person.setAge(20);
Intent intent = new Intent(FirstActivity.this, SecondActivity.class);
intent.putExtra("person_data", person);
startActivity(intent);

可以看到,这里我们创建了一个Person的实例,然后就直接将它传入到putExtra()方法中了。由于Person类实现了Serializable接口,所以才可以这样写。

接下来在SecondActivity中获取这个对象也很简单,写法如下:

Person person = (Person) getIntent().getSerializableExtra("person_data");

这里调用了getSerializableExtra()方法来获取通过参数传递过来的序列化对象,接着再将它向下转型成Person对象,这样我们就成功实现了使用Intent来传递对象的功能了。

Parcelable方式

除了Serializable之外,使用Parcelable也可以实现相同的效果,不过不同于将对象进行序列化,Parcelable方式的实现原理是将一个完整的对象进行分解,而分解后的每一部分都是Intent所支持的数据类型,这样也就实现传递对象的功能了。

下面我们来看一下Parcelable的实现方式,修改Person中的代码,如下所示:

    private String name;

    private int age;
    ……
    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(name);  // 写出name
        dest.writeInt(age);  // 写出age
    }

     public static final Parcelable.Creator<Person> CREATOR = new Parcelable.Creator<Person>() {

       @Override
       public Person createFromParcel(Parcel source) {
           Person person = new Person();
           person.name = source.readString(); // 读取name
           person.age = source.readInt(); // 读取age
           return person;
       }

       @Override
       public Person[] newArray(int size) {
           return new Person[size];
       }
    };

}

Parcelable的实现方式要稍微复杂一些。可以看到,首先我们让Person类去实现了Parcelable接口,这样就必须重写describeContents()writeToParcel()这两个方法。其中describeContents()方法直接返回0就可以了,而writeToParcel()方法中我们需要调用Parcel的writeXxx()方法将Person类中的字段一一写出。注意字符串型数据就调用writeString()方法,整型数据就调用writeInt()方法,以此类推。

除此之外,我们还必须在Person类中提供一个名为CREATOR的常量,这里创建了Parcelable.Creator接口的一个实现,并将泛型指定为Person。接着需要重写createFromParcel()newArray()这两个方法,在createFromParcel()方法中我们要去读取刚才写出的name和age字段,并创建一个Person对象进行返回,其中name和age都是调用Parcel的readXxx()方法读取到的,注意这里读取的顺序一定要和刚才写出的顺序完全相同。而newArray()方法中的实现就简单多了,只需要new出一个Person数组,并使用方法中传入的size作为数组大小就可以了。

接下来在FirstActivity中我们仍然可以使用相同的代码来传递Person对象,只不过在SecondActivity中获取对象的时候需要稍加改动,如下所示:

Person person = (Person) getIntent().getParcelableExtra("person_data");

注意这里不再是调用getSerializableExtra()方法,而是调用getParcelableExtra()方法来获取传递过来的对象了,其他的地方都完全相同。

这样我们就把使用Intent来传递对象的两种实现方式都学习完了,对比一下,Serializable的方式较为简单,但由于会把整个对象进行序列化,因此效率方面会比Parcelable方式低一些,所以在通常情况下还是更加推荐使用Parcelable的方式来实现Intent传递对象的功能。

定制自己的日志工具

虽然Android中自带的日志工具功能非常强大,但也不能说是完全没有缺点,例如在打印日志的控制方面就做得不够好。

打个比方,你正在编写一个比较庞大的项目,期间为了方便调试,在代码的很多地方都打印了大量的日志。最近项目已经基本完成了,但是却有一个非常让人头疼的问题,之前用于调试的那些日志,在项目正式上线之后仍然会照常打印,这样不仅会降低程序的运行效率,还有可能将一些机密性的数据泄露出去。

那该怎么办呢,难道要一行一行把所有打印日志的代码都删掉?显然这不是什么好点子,不仅费时费力,而且以后你继续维护这个项目的时候可能还会需要这些日志。因此,最理想的情况是能够自由地控制日志的打印,当程序处于开发阶段就让日志打印出来,当程序上线了之后就把日志屏蔽掉。

看起来好像是挺高级的一个功能,其实并不复杂,我们只需要定制一个自己的日志工具就可以轻松完成了。比如新建一个LogUtil类,代码如下所示:

    public static final int VERBOSE = 1;

    public static final int DEBUG = 2;

    public static final int INFO = 3;

    public static final int WARN = 4;

    public static final int ERROR = 5;

    public static final int NOTHING = 6;

    public static final int LEVEL = VERBOSE;

    public static void v(String tag, String msg) {
        if (LEVEL <= VERBOSE) {
            Log.v(tag, msg);
        }
    }

    public static void d(String tag, String msg) {
        if (LEVEL <= DEBUG) {
            Log.d(tag, msg);
        }
    }

    public static void i(String tag, String msg) {
        if (LEVEL <= INFO) {
            Log.i(tag, msg);
        }
    }

    public static void w(String tag, String msg) {
        if (LEVEL <= WARN) {
            Log.w(tag, msg);
        }
    }

    public static void e(String tag, String msg) {
        if (LEVEL <= ERROR) {
            Log.e(tag, msg);
        }
    }

}

可以看到,我们在LogUtil中先是定义了VERBOSE、DEBUG、INFO、WARN、ERROR、NOTHING这六个整型常量,并且它们对应的值都是递增的。然后又定义了一个LEVEL常量,可以将它的值指定为上面六个常量中的任意一个。

接下来我们提供了v()、d()、i()、w()、e()这五个自定义的日志方法,在其内部分别调用了Log.v()、Log.d()、Log.i()、Log.w()、Log.e()这五个方法来打印日志,只不过在这些自定义的方法中我们都加入了一个if判断,只有当LEVEL常量的值小于或等于对应日志级别值的时候,才会将日志打印出来。

这样就把一个自定义的日志工具创建好了,之后在项目里我们可以像使用普通的日志工具一样使用LogUtil,比如打印一行DEBUG级别的日志就可以这样写:

LogUtil.d("TAG", "debug log");

打印一行WARN级别的日志就可以这样写:

LogUtil.w("TAG", "warn log");

然后我们只需要修改LEVEL常量的值,就可以自由地控制日志的打印行为了。比如让LEVEL等于VERBOSE就可以把所有的日志都打印出来,让LEVEL等于WARN就可以只打印警告以上级别的日志,让LEVEL等于NOTHING就可以把所有日志都屏蔽掉。 使用了这种方法之后,刚才所说的那个问题就不复存在了,你只需要在开发阶段将LEVEL指定成VERBOSE,当项目正式上线的时候将LEVEL指定成NOTHING就可以了。

编写测试用例

测试是软件工程中一个非常重要的环节,而测试用例又可以显著地提高测试的效率和准确性。测试用例其实就是一段普通的程序代码,通常是带有期望的运行结果的,测试者可以根据最终的运行结果来判断程序是否能正常工作。

我相信大多数的程序员都是不喜欢编写测试用例的,因为这是一件很繁琐的事情。明明运行一下程序,观察运行结果就能知道对与错了,为什么还要通过代码来进行判断呢?确实,如果只是普通的一个小程序,编写测试用例是有些多此一举,但是当你正在维护一个非常庞大的工程时,你就会发现编写测试用例是非常有必要的。

举个例子吧,比如你确实正在维护一个很庞大的工程,里面有许许多多数也数不清的功能。某天,你的领导要求你对其中一个功能进行修改,难度也不高,你很快就解决了,并且测试通过。但是几天之后,突然有人发现其他功能出现了问题,最终定位出来的原因竟然就是你之前修改的那个功能所导致的!这下你可冤死了。不过千万别以为这是天方夜谭,在大型的项目中,这种情况还是很常见的。由于项目里的很多代码都是公用的,你为了完成一个功能而去修改某行代码,完全有可能因此而导致另一个功能无法正常工作。

所以,当项目比较庞大的时候,一般都应该去编写测试用例的。如果我们给项目的每一项功能都编写了测试用例,每当修改或新增任何功能之后,就将所有的测试用例都跑一遍,只要有任何测试用例没有通过,就说明修改或新增的这个功能影响到现有功能了,这样就可以及早地发现问题,避免事故的出现。

创建测试工程

介绍了这么多,也是时候该动手尝试一下了,下面我们就来创建一个测试工程。在创建之前你需要知道,测试工程通常都不是独立存在的,而是依赖于某个现有工程的,一般比较常见的做法是在现有工程下新建一个tests文件夹,测试工程就存放在这里。

那么我们就假设给BroadcastBestPractice这个项目创建一个测试工程吧。在Eclipse的导航栏中点击File→New→Other,会打开一个对话框,展开Android目录,在里面选中Android Test Project,如图1所示。

{%}

图1

点击Next后会弹出创建Android测试工程的对话框,在这里我们可以输入测试工程的名字,并选择测试工程的路径。按照惯例,我们将路径选择为BroadcastBestPractice项目的tests文件夹下,如图2所示。

{%}

图2

继续点击Next,这时会让我们选择为哪一个项目创建测试功能,这里当然选择BroadcastBestPractice了,如图3所示。

{%}

图3

现在点击Finish就可以完成测试工程的创建了。观察测试工程中AndroidManifest.xml文件的代码,如下所示:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.broadcastbestpractice.test"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="14" />

    <instrumentation
        android:name="android.test.InstrumentationTestRunner"
        android:targetPackage="com.example.broadcastbestpractice" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <uses-library android:name="android.test.runner" />
    </application>

</manifest>

其中<instrumentation>和<uses-library>标签是自动生成的,表示这是一个测试工程,在<instrumentation>标签中还通过android:targetPackage属性指定了测试目标的包名。

进行单元测试

创建好了测试工程,下面我们来对BroadcastBestPractice这个项目进行单元测试。单元测试是指对软件中最小的功能模块进行测试,如果软件中的每一个单元都能通过测试,说明代码的健壮性就已经非常好了。

BroadcastBestPractice项目中有一个ActivityCollector类,主要是用于对所有的Activity进行管理的,那么我们就来测试这个类吧。首先在BroadcastBestPracticeTest项目中新建一个ActivityCollectorTest类,并让它继承自AndroidTestCase,然后重写setUp()tearDown()方法,如下所示。

public class ActivityCollectorTest extends AndroidTestCase {

    @Override
    protected void setUp() throws Exception {
        super.setUp();
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
    }

}

其中setUp(方法会在所有的测试用例执行之前调用,可以在这里进行一些初始化操作。tearDown()方法会在所有的测试用例执行之后调用,可以在这里进行一些资源释放的操作。

那么该如何编写测试用例呢?其实也很简单,只需要定义一个以test开头的方法,测试框架就会自动调用这个方法了。然后我们在方法中可以通过断言(assert)的形式来期望一个运行结果,再和实际的运行结果进行对比,这样一条测试用例就完成了。测试用例覆盖的功能越广泛,程序出现bug的概率就会越小。 比如说ActivityCollector中的addActivity()方法是用于向集合里添加活动的,那么我们就可以给这个方法编写一些测试用例,代码如下所示:

public class ActivityCollectorTest extends AndroidTestCase {

    @Override
    protected void setUp() throws Exception {
        super.setUp();
    }

    public void testAddActivity() {
        assertEquals(0, ActivityCollector.activities.size());
        LoginActivity loginActivity = new LoginActivity();
        ActivityCollector.addActivity(loginActivity);
        assertEquals(1, ActivityCollector.activities.size());
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
    }

}

可以看到,这里我们添加了一个testAddActivity()方法,在这个方法的一开始就调用了assertEquals()方法来进行断言,认为目前ActivityCollector中的活动个数是0。接下来new出了一个LoginActivity的实例,并调用addActivity()方法将这个活动添加到ActivityCollector中,然后再次调用assertEquals()方法进行断言,认为目前ActivityCollector中的活动个数是1。

现在可以右击测试工程→Run As→Android JUnit Test来运行这个测试用例,结果如图4所示。

{%}

图4

可以看到,我们刚刚编写的测试用例已经成功跑通了。

不过,现在这个测试用例其实只是覆盖了很少的情况而已,我们应该再编写一些特殊情况下的断言,看看程序是不是仍然能够正常工作。修改ActivityCollectorTest中的代码,如下所示。

public class ActivityCollectorTest extends AndroidTestCase {
    ……
    public void testAddActivity() {
        assertEquals(0, ActivityCollector.activities.size());
        LoginActivity loginActivity = new LoginActivity();
        ActivityCollector.addActivity(loginActivity);
        assertEquals(1, ActivityCollector.activities.size());
        ActivityCollector.addActivity(loginActivity);
        assertEquals(1, ActivityCollector.activities.size());
    }
    ……
}

可以看到,这里我们又调用了一次addActivity()方法来添加活动,并且添加的仍然还是LoginActivity。连续添加两次相同活动的实例,这应该算是一种比较特殊的情况了。这时我们觉得ActivityCollector有能力去过滤掉重复的数据,因此在断言的时候认为目前ActivityCollector中的活动个数仍然是1。重新运行一遍测试用例,结果如图5所示。

{%}

图5

很遗憾,测试用例没有通过,提示我们期望结果是1,但实际结果是2。从这个测试用例中我们发现,addActivity()方法中的代码原来是不够健壮的,这个时候就应该对代码进行优化了。修改ActivityCollector中的代码,如下所示:

public class ActivityCollector {

    public static List<Activity> activities = new ArrayList<Activity>();

    public static void addActivity(Activity activity) {
        if (!activities.contains(activity)) {
            activities.add(activity);
        }
    }

    ……

}

这里我们在addActivity()方法中加入了一个if判断,只有当集合中不包含传入的Activity实例的时候才会将它添加到集合中,这样就可以解决掉活动重复的bug了。现在重新运行一遍测试用例,你就会发现测试又能成功通过了。

之后你可以不断地补充新的测试用例,让程序永远都可以跑通所有的测试用例,这样的程序才会更加健壮,出现bug的概率也会更小。

 

{%}

《第一行代码——Android》是Android初学者的最佳入门书。全书由浅入深、系统全面地讲解了Android软件开发的方方面面。本文节选自《第一行代码——Android》

Android应用UI设计流程

{%}

作者/ Greg Nudelman

Greg Nudelman是DesignCaffeine公司CEO兼移动体验策略师,具有15年的移动体验从业经验,曾为eBay、WebEx、Wells Fargo、PayPal、Safeway、Cisco、IBM、美联社和Groupon等财富500强企业,以及非盈利性组织和创业公司实现跨平台的数字体验,受到数百万客户的好评。Greg还是很多著名用户体验设计图书的作者,其中包括Designing Search: UX Strategies for eCommerce Success。

有效的移动设计流程是什么样的?本文末尾将通过一个详尽的移动设计案例,阐述一种使用便利贴进行移动设计的方法。但在此之前,有必要讨论一下移动时代面临的设计挑战,以及如何对传统的以用户为中心的设计(UCD)方法进行调整,使其既适用于这种新媒体又卓有成效。

现场观察用户如何与移动设备交互

以前设计软件时,使用环境是需要考虑的因素之一,但其重要性通常排在其他分析方法之后。为什么会这样呢?因为移动设备出现之前,除非设计的是计算机化的咖啡机,否则使用环境就是用户坐在计算机前。因此,与软件交互时,用户基本上都坐在计算机屏幕前的椅子上,旁边是鼠标和键盘。

然而,在移动设计中,使用环境是最关键的因素,你和你的团队最好亲自到现场去观察。现在,不可能凭想象准确获悉交互过程——用户手握鼠标坐在计算机前,因为用户的行为以及他与设备交互的方式严重依赖于使用环境。在下述使用环境下,即便是最基本的设计参数(如设备朝向和握持方式)也大相径庭:站在喧嚣的街头查看地图;与配偶坐在沙发上欣赏孩子的照片;一边停车,一边单手握持设备与老板通话;在行进的公交车上阅读。要准确地了解实情,你和你的团队必须亲历现场,实时地观察交互。另外,要获得准确而精密的数据,光在现场提问还不够。要做出可靠的设计决策,最好使用逼真的应用原型来诱导受试者做出反应,并观察他们的行为。

原型设计方法必须考虑尺寸因素

Mac和PC之争作为科技领域的主旋律持续了多年,紧随其后的是浏览器之争。从用户体验(UX)设计的角度看,PC在某些方面确实与Mac不同,但从用户的角度看,两者之间的差别也许并不那么大——这两种设备都使用鼠标、键盘和大屏幕。另外,对于针对Web浏览器设计的软件,用户体验在很大程度上都独立于设备:不管用户浏览时使用的是Mac OS X和Safari,还是Vista和Internet Explorer,Yahoo!和Facebook看起来都没有太大不同。

然而,在移动触摸计算时代,平台种类繁多,设备尺寸各异。当前,市面上充斥着小型手机、大型手机、小型平板、中型平板和大型平板,它们在人体工程学、尺寸和使用方式等方面各不相同(如多人同时使用一台大型平板),要求采用不同的软件设计方法。然而,你需要操心的并非只有手机和平板。Android操作系统迟早会被用于滑雪镜、冰箱和汽车等各类设备,届时必须大刀阔斧地修改用户界面,以满足这些设备的特定需求。这意味着古老的线框模型再也无法反映丰富多样的现实。为反映新的设计约束,需要修改设计方法,将设备的物理尺寸以及动画和过渡等临时元素纳入考虑范围。

用户测试必须涵盖运动、声音和多点触控等方面

进行移动设计和测试时,请将你知道的有关与计算机交互的一切都抛到脑后。与计算机交互时,用户只使用鼠标和键盘,这种大一统模式并不适用于移动设备。移动时代的一个重要特征是充分利用人体的自然运动:刮划表示深入挖掘;摇动表示拒绝;将手机放到耳边表示要说话。从语音识别数字助理到计步器(它利用GPS传感器根据身体摇摆情况判断日常身体活动的速度和健康状况),当今的移动设备正以前所未有的程度大量利用运动、声音和多点触摸手势,以便从用户那里获取日益复杂的输入。要设计出有效的用户界面,原型创建和用户测试方法必须考虑所有这些与设备交互的新模式。

触控界面必须既简约又精巧

在台式机上运行的浏览器和操作系统即便构想拙劣,也能吸引用户,这是因为屏幕很大、界面很复杂也关系不大,同时用户坐在椅子上使用计算机,能将注意力高度集中到软件上。

移动时代的核心是移动性,这意味着用户的注意力比以往任何时候都要分散,分散程度之高是任何人在5年前都想不到的。这意味着界面必须简约,但简约不等于简单。Edward Tufte有一句名言:“简约与简单化相隔十万八千里。”相反,软件必须将以前由用户面对的复杂性消化掉。

别误会,用户实际上希望使用手机和平板电脑能做的事情比使用台式机多,只是再也不能让用户觉得界面很复杂了。因此,触控界面必须独特而不复杂,同时又非常精巧。这意味着从很多方面说,创建触控界面原型都比创建台式机Web界面原型更容易(如果使用的是不那么逼真的方法,如在纸上绘制原型,这一点尤为明显),但前提条件是必须深入研究界面的微妙方面。

愉悦不可或缺

在PC和Mac系统中,用户也能享受愉悦、娱乐和游戏,但娱乐主要源自某些活动,如玩计算机游戏。新的移动平台是伴随着游戏成长起来的,游戏已溶入其血液和DNA,因此,不管要完成的任务有多单调、多微不足道,设计人员都必须确保软件使用起来令人愉悦,最起码也要帮助用户尽快完成任务。

这种新平台带来的一个结果是,软件更加游戏化(gamification),正如John Ferrara在其著作Playful Design(Rosenfeld Media,2012)中指出的那样,必须让用户在把玩过程中感到愉悦,而不能将愉悦作为附属品。这意味着软件使用起来必须像在玩游戏,这样才能提供最佳的移动体验。由于移动设备的屏幕较小,娱乐元素(如过渡)必然在体验中扮演重要角色,而老式浏览器模型包含的过渡很少。这意味着创建移动应用设计原型时,必须花时间去探索过渡、娱乐和游戏化。

讲述完整的故事——为跨界而设计

以前,大家几乎都只在工作时使用PC或Mac。少数超级计算机迷带着计算机上厕所,为尽可能缩短离线时间不洗澡,但大多数人都只在特定时间使用计算机来完成数字任务。然而,现在很多“正常人”都将移动设备始终带在身边,越来越多的人带着数字设备睡觉、吃饭甚至上厕所。(真不可思议!)凭借数量众多的传感器,如话筒、GPS、光学传感器、相机、近场通信(NFC)、触摸传感器、运动传感器等,移动设备以前所未有的方式将线下世界(现实世界)与数字世界联系在一起。人类犹如有了一个新器官,能够连接到原本看不见的数字世界,读取二维码和NFC芯片以及随时访问地图和评论等数字信息。这个新的“移动器官”如影随形,提供了一种截然不同的途径,让人们能够轻松而迅速地访问和操作信息。

当前,可以肯定这个“移动器官”将被用于丰富每项线下活动,如到游乐园游玩、购物乃至在森林中远足。作为设计人员,你需要特别注意的是,移动设备可能用于跨界交互。例如,执行任务时,可能始于移动设备,续于台式机和社交网络,终于物理存储。这些作为“副产品”瞬时完成或在等待其他事情发生时完成的快捷任务,正是移动领域最常见的。

介绍面临的一些挑战后,下面提供一个移动设计案例,帮助你理解如何将所有这些因素都纳入UCD流程,使其适用于移动领域。

移动设计案例研究

借助于一种轻量级敏捷移动设计流程,我在ThirstyPocket中做了一项重大革新,提供了一个“60秒内将商品上架”的流程。ThirstyPocket是一款iPhone应用,但即将推出Android版并发布到Android Marketplace。这里以这个项目为例,演示如何将UCD用于移动设计;提供这个案例只是为了阐述前面讨论过的一些概念,如轻量级原型。你可能需要根据具体情况调整设计方法和流程,关键在于既保持灵活性又以用户为中心。

第1步:范围、概念和规划

提出设计方案前,先召开项目启动会,回答谁使用、在什么地方使用、如何使用以及投入多少等问题,即明确使用环境(context)、角色(persona)、设想(vision)和预算。根据项目的具体情况,这些问题可能很容易回答,只需一句话。整个团队必须就上述四个方面达成一致,并找出所有悬而未决的问题,以便通过研究做出回答。这至关重要。

1. 使用环境:将在什么地方使用

前面说过,要打造出杰出的移动设计方案,关键是了解使用环境。潜在用户将在什么地方使用你的应用?他们使用的是什么设备?在与你的应用交互时,他们同时做哪些其他事情、情绪如何、对使用流程最关心的是什么?你必须对这些心中有数,这有助于你拿出设计方案。这些因素还将决定你最终选择的设计方法、产品应提供的功能和系统行为。在规划和确定范围期间,需要将团队的当前想法记录下来,作为用户调查的依据。就ThirstyPocket应用而言,使用环境为“在城市私人车库里进行旧货出售”。

2. 角色:目标用户是谁

对目标用户的看法可能一开始就很清晰,也可能是通过内部讨论或现场调查慢慢形成的。不管是哪种情况,整个团队都必须在这一点上达成一致,哪怕看法是错误的。如果团队对目标用户的看法存在分歧,那么将它记录下来——分歧指出了哪些方面还需做更深入的研究。虽然推荐花大量时间详细而深入地确定移动项目的目标用户,但并非必须如此。有时候,一句话的“角色素描”(例如,对于ThirstyPocket,我们一致的认识是“钱和时间都不充裕的年青大学生”)就够了,可以接着进行测试。角色素描最重要的作用在于,让团队就目标用户面临的困难和挑战达成一致并产生共鸣。如果信息有限的角色素描让你感觉怪怪的,别忘了即便是虚构的角色素描也聊胜于无。你至少可将团队的假设记录下来,如果假设不正确,可在现场调查阶段很快发现并做必要的修改。

3. 现场调查和情景访谈

确定使用环境和角色素描后,就可以测试了!为设计ThirstyPocket,我们调查了大量旧物出售现场,并与潜在目标用户就旧物出售进行了交流,从而对现有出售系统和流程面临的挑战以及用户在出售过程中遭遇的挫折有更深入的认识。做现场调查时,务必与其他团队成员一起前往。调查结束后马上展开讨论,集思广益,这特别适合在调查结束后的咖啡、午餐或晚餐时间进行。无需做大量记录,只需在纸上画出草图并与整个团队分享,这常常是拿出好点子、改进方案和产品设想的最佳方式。别忘了对假设进行测试,并在必要时调整对目标用户和使用环境的认识。

4. 设想:用户将如何使用产品

你认为用户将如何使用你的产品?是长时间使用还是繁忙之余浏览一下信息?使用频率有多高?用户为什么使用它?用户与应用交互时,服务窗口是什么样的?交互是否跨多个接触点,用户以后还得回来吗?用户需要准备或培训才能使用吗?在前台,用户彼此如何交互?软件或服务需要在后台做什么?总之,你的职责是在设计过程中提出并优化设想。对于ThirstyPocket应用,我们提出的口号是“60秒内将商品上架”。理想情况下,有关用户将如何使用产品的设想应基于团队在现场对目标用户的观察。

5. 预算:打算在设计和开发上投入多少时间和资金

UX设计只是产品开发魔方的一小块。请花时间搞明白设计与整个开发计划的关系,并按时间表行事,以充分发挥团队的技术能力。通常,设计需要3~6个月。就ThirstyPocket应用而言,设计时间为3个月。

确定使用环境、角色、设想和预算后,便可召开设计研讨会了,这将在下面介绍。

第2步:设计研讨会

设计研讨会开始后,在提出任何设计方案之前,先要敲定四项基本信息:角色、使用环境、场景和设想。重点是加深整个团队的认识,争取整个团队的支持。为此,可编写用例场景和经过改进的设想陈述,这有助于对第1步制定的框架查漏补缺。下面是ThirstyPocket应用的用例场景和改进后的设想。

场景:在方圆5英里的社区内卖车;音乐会开场前通过短信或电话卖票。

改进后的设想:现场、社交、电子商务。像出售旧货一样,现场验货、现金交易;只提供一张照片;销售流程简单、自然、轻松,无需注册和登录。

在敲定四项基本信息的过程中,整个团队通常能协调一致,提出一些备选设计方案。

在设计过程中,最好心中有多个设计方向,不给集思广益过程加上严格的条条框框。不要过度专注于特定方法,而将方法快速记录下来并放到一边,再提出如下问题:

  • 还有其他设计方式吗?

  • 如果从X而不是Y开始,结果如何呢?

  • 能让它更像X吗?

  • 能让这个新的工作流程与用户既有行为一致吗?

  • 能让这种工作流程更像是在玩游戏吗?

为实时地将各种设计方案完整记录下来,一种很不错的方法是使用故事板。这里的关键在于,尽可能少考虑实际的界面设计,只对用户如何使用应用加以描述。这种方法非常适用于描述ThirstyPocket的两个用例场景,如图1所示。其中的故事板很好地阐述了产品设想。

{%}

图1 这些故事板描述了使用ThirstyPocket应用出售二手货的流程

例如,这些故事板描述了如下场景:小伙子Gene与一帮朋友驱车前去参加一场很不错的音乐会,途中突然收到朋友Jen的短信,说她来不了了,并问Gene能否帮她把门票卖掉。Gene回答说:“没问题,我可以使用ThirstyPocket把票卖掉。”第一个故事板很重要,因为它指出了交互的背景:传达了一些有关场景的信息,还让人对场景发生的地点有清晰认识。

接下来,Gene启动ThirstyPocket应用(“Snap it, Post it, Sell it!”),并轻按Start Selling按钮。这将开启内置相机。Gene轻按“Snap It!”按钮,为要出售的门票拍照。接下来,Gene在预览屏幕上填写简短的描述,再轻按“Post It!”按钮。这就搞定了:出售过程简单、快捷,不涉及复杂的界面。

设计研讨会的重点是速度:使用白板或小型便利贴快速研究各种设计场景。你追求的首要目标是相互理解和深刻的洞察,而非详尽的记录。更多有关移动故事板和用例的示例,请参阅Scott McCloud举世无双的著作《制造漫画》(Making Comics,Harper,2006)。

在设计研讨会上,鼓励每个团队成员都参与绘制故事板。没有必要绘制价值很高的故事板;只要白板上的火柴人周围充斥着线条(chicken scratch),足以让整个团队理解相应的移动场景,那就够了。使用故事板描绘重要的用例场景后,就该进入下一步了。

第3步:使用便利贴做RITE调查

前面讨论过,鉴于移动设计约束不同寻常,对移动设计来说,常用的UCD流程(创建计算机生成的线框,再打造高度逼真的原型)并非总是可行。

设计过程的核心是进行便宜而有效的RITE(Rapid Iterative Testing and Evaluation,快速迭代式测试和评估)调查(study),而不是花大量时间和精力创建高度逼真的线框。务必在设计过程中尽早进行RITE调查,这样打造出的移动产品将更讨人喜欢、更适用、更成功,且所需的时间少得超乎你的想象。

我通常建议对9~12个参与者做3~4轮RITE调查,每轮调查3位参与者。如果你喜欢,也可称之为“RITE测试”,但我喜欢使用“调查”一词,以强调设计会变化。RITE调查的关键在于,在两轮调查之间留出一些时间,以便修改原型,解决前一轮调查中发现的问题。RITE调查基本上是一系列成对的设计和测试,期间将根据用户、工程师和管理层的反馈迅速对原型做必要的修改。

多年来,RITE一直是UCD工具箱的一部分。为让RITE在移动设计流程中也能扮演核心角色,我对其做了简单修改:使用在便利贴上绘制的原型。

便利贴移动原型有很多优点。首先,一叠大型便利贴(我喜欢使用3英寸×5英寸的便利贴)的尺寸与典型手机相当。这意味着不需要制作外盒来模拟移动设备,成叠的便利贴就够了。

便利贴原型价格低廉、易于创建且非常牢靠,无论从多高的地方落下,都不会摔成碎片,也不会散落开来。无论在街头还是咖啡馆,你都可放心地将一叠便利贴递给陌生人,就你的应用提出一些问题(将自己珍爱的最新型号手机交给陌生人时,大多数人的心情都不会如此轻松)。哪怕便利贴“手机”被受试者不小心摔了,它也不会受损;就算受试者拿着它跑了,你的损失也只有大约1美元!

如图2所示,一叠便利贴的尺寸与移动设备相当,这种简单而精致的原型让你能够测试人体工程、多点触摸、加速运动等,而这些是使用传统线框无法完成的。

{%}

图2 使用一叠便利贴来模拟手机,这是一种轻量级的原型创建方法,效果不错图3是我在便利贴上绘制的线框,可用于测试ThirstyPocket的“60秒出售商品”流程

便利贴原型易于修改:发现设计中的问题后,可使用橡皮和铅笔当场修改界面,也可使用细笔尖记号笔在新便利贴上重画。同样,如果要测试另一个交互流程,只需几分钟就能画出新的屏幕设计,将它交给下一位受试者,马上就能将它的效果与原有设计方案进行比较。这样你就能够快速改进设计,整个核心团队都在测试现场时尤其如此。

{%}

图3 使用便利贴创建的移动设计原型,用于早期测试

由于我个人使用便利贴原型时效果很好,并深信它有助于实现设计目标,因此介绍本文的模式时,我都在3英寸×5英寸的便利贴上绘制线框。探索这种原型创建方法时,请你牢记下面几点。

使用成叠的便利贴模拟移动设备时,不需要在便利贴上绘制表示屏幕的方框。为节省时间,并让绘图更易懂,可假定整个便利贴表面就是手机屏幕。模拟Android手机时,应在合适时添加硬件按钮(“返回”按钮、“主屏幕”按钮等)。

本文的图画都是黑白的,它们都是使用Pigma Micron钢笔和档案级墨水(Archival Ink)绘制的,这样做旨在让图画更清晰。实际绘制原型时,我使用2.0自动铅笔,以便能够轻松地涂改“屏幕”上的元素。你可根据喜好使用任意铅笔或钢笔,黑色和彩色都行。

绘制线框时我经常使用尺子。这是因为徒手画直线有点难,在现场快速修改时尤其如此。不管你是否使用尺子,最好在整个原型中保持一致,以免分散调查参与者的注意力。有很多模板和辅助工具可提供帮助,但除小型的透明三角尺外,我别的什么都不用。别忘了,重要的不是图画得有多漂亮,而是以最快捷、最有效的方式测试概念。最简单的绘图方式就是最好的,请选择对你来说最管用、最能有效传达设计方案的方式。

使用便利贴原型很容易模拟分支。为此,可只将一叠便利贴最上面的那个“屏幕”递给受试者;等受试者轻按该屏幕上的按钮或执行某项功能后,再背着受试者从手里一系列表示分支的便利贴中选出合适的“屏幕”,并递给受试者。这样,测试将非常逼真:如果下一个受试者轻按了另一个控件,他将看到不同的屏幕;这让你能够测试分支和迂回工作流程、回溯以及其他真实行为,从而获得丰富而可靠的行为数据。

经过一段时间的练习,还可使用便利贴原型来测试过渡。如果过渡对交互来说很重要,可试着“滑入”下一个“屏幕”,同时嘴里念念有词,比如这样说:“如果下一个页面像这样从底部滑入,你觉得怎样?”如果受试者的回答不痛不痒(如“还行”),问他是否更喜欢其他过渡或当前过渡在他看来意味着什么。对于复杂的过渡,你可能需要比划多次才能让受试者领会;你也许应该在不同的便利贴上画出各个过渡状态。 模拟键盘很容易,只需在一片较小的便利贴上画出键盘,将其放到受试者手握的便利贴“设备”上面即可。这样,测试期间可随时将任何屏幕动态地变成“键盘输入屏幕”。这进一步提高了原型的灵活性,同时避免了反复绘制复杂的键盘设计元素的麻烦。

请参阅Boxes and Arrows 2011年1月5日刊中的文章“Storyboarding iPad Transitions”,网址为http://t.cn/z8ixTRS

别忘了重新审视故事板。线框表示的工作流程应与产品设想一致,例如,如果你比较图1和图3,那么将发现线框扩展了原始设想,添加了一些细节和界面元素。例如,开始测试后我很快发现,用户有时想先拍摄一系列照片,以后再出售商品。因此,我添加了一个屏幕,让用户能够选择:拍摄一张照片或选择既有照片,如图3所示。这样的修改很常见,这就是你想首先使用便利贴对设计方案进行测试的原因!通过RITE调查获得新的洞见后,如果变化足够大,就得相应地修改设想故事板。在这个例子中,新增的屏幕没有改变基本故事板场景,而只是让它更准确,因此无需修改原来的设想故事板(见图1)。

便利贴原型成本低廉,你无需使用精密的相机设备和其他精巧装置,就能快速探索多种设计方案。利用便利贴原型,你和你的团队就可以走出办公室,前往移动交互现场:咖啡馆、喧嚣的街头、出租车站附近和地铁站。请前往交互现场,将纸质原型交到潜在用户手中,这样做对你要设计的产品意义重大。

另外,受试者可放心大胆地提出宝贵意见,因为原型看起来还没有最后定型。这相当于在产品的实际使用环境中召开用户深入参与的设计会议。这些设计会议让你能够获得宝贵的洞见,并迅速将这些洞见融入到设计中——速度之快远远超出你的想象。但愿它们能赋予你灵感,让你能够手绘便利贴原型,并让目标用户进行测试。如果需要帮助,请访问网站(http://AndroidDesignBook.com),这里提供了使用便利贴原型进行RITE调查的视频、移动用例故事板以及众多其他的资源,可帮助你充分发挥这种方法的威力。

第4步:视觉设计

RITE调查并非可交付的最终产品,而只是设计流程中关键的一步。看待设计流程的最佳方式是,原型和可交付产品的状态应折射出产品的总体完成状态。这样,在开发流程的早期,你就能采用轻量级设计流程,侧重于设计出可行的工作流程和屏幕布局,从而快速前进。拿出大致的工作流程并对其进行测试后,就该进入最后一步——视觉设计了。

值得一提的是,视觉设计既能提升交互设计意图,也能让它逊色。有时候,视觉设计影响重大,因此最好再对应用做几次测试,确保即便经过演化后,最终的设计版本依然秉承了设想故事板的简约和优雅。风格能营造情感联系,也能破坏情感联系,因此也有必要对其进行测试。

要做最后的测试,只需将测试设备递给你在乎其看法的人——现在可得小心了!或许可以找那些在咖啡店前排队的人,跟他说“我请你喝咖啡,说说你对这款应用的看法”。找5~8人来做最终测试,全部测试时间应不超过1小时。测试过程非常简单:理想情况下,在早高峰期间,受试者还未排到咖啡店柜台前测试就结束了。界面应引人入胜又一目了然,让人在睡眼惺忪的状态下也能操控自如。

 

{%}

《Android应用UI设计模式》介绍了58种必不可少的交互设计模式,帮助你处理Android应用程序设计最具挑战性的方方面面,以及同样重要的12种反模式,描述了在追求客户完善、愉悦和享受的过程中的常见错误,非常适合各层次的Android应用开发者、UI设计师阅读、参考。本文节选自《Android应用UI设计模式》