`
在 ICS Android 模拟器中,我们使用GridLayout
查看活动,如图图 10–10 所示。
图 10–10。 GridDemo 示例应用
我们的按钮按照它们不同的重力方向将自己放置在GridLayout
上,使用默认的rowSpec
和columnSpec
计数。通过在main.xml
的声明中添加另一个按钮,我们可以观察到GridLayout
的效用,它不需要TableLayout
有些乏味的静态布局指令
... <Button android:text="Defying gravity!" android:layout_gravity="top" /> <**Button** **android:text="Floating middle right"** **android:layout_gravity="right|center_vertical"** **/>** <Button android:text="Falling like an apple" android:layout_gravity="bottom" /> ...
图 10–11 显示了我们的GridLayout
如何适应显示其子节点。
图 10–11。 ??【GridDemo】修订版
十一、输入法框架
Android 1.5 推出了输入法框架(IMF),也就是通常所说的软键盘 。然而,这一术语不一定准确,因为 IMF 可用于手写识别或通过屏幕接受文本输入的其他手段。
键盘,硬键盘和软键盘
一些 Android 设备有一个有时可见的硬件键盘(当它滑出时)。一些 Android 设备有一个总是可见的硬件键盘(所谓的“条形”或“平板”手机)。然而,大多数安卓设备根本没有硬件键盘。国际货币基金组织处理所有这些情况。
简而言之,如果没有硬件键盘,当用户点击一个启用的EditText
小部件时,一个输入法编辑器(IME)将对用户可用。如果您想要提供 IME 的默认功能,则无需对应用进行任何代码更改。幸运的是,Android 在猜测您想要什么方面相当聪明,所以您可能只需要用 IME 进行测试,而不需要进行特定的代码更改。
但是 IME 可能不会像您希望的那样运行。例如,在Basic/Field
示例项目中,FieldDemo
活动的 IME 覆盖了多行EditText
,如图图 11–1 所示。如果能对这种情况有更多的控制,并且能够控制 IME 的其他行为,那就太好了。幸运的是,正如本章所述,国际货币基金组织作为一个整体给了你很多选择。
图 11–1。 输入法编辑器,参见 FieldDemo 示例应用
根据您的需求量身定制
Android 1.1 和更早的版本在EditText
小工具上提供了许多属性来控制它们的输入风格,例如android:password
指示一个字段应该用于密码输入(遮住密码键盘以免被窥探)。从 Android 1.5 开始,有了 IMF,这些属性中的许多被合并成一个单一的android:inputType
属性。
android:inputType
属性接受一个类加修饰符,在一个管道分隔的列表中(其中|
是管道字符)。该类通常描述允许用户输入的内容,这决定了软键盘上可用的基本键集。可用的类别如下:
text
(默认)
number
phone
datetime
date
time
这些类中有许多提供了一个或多个修饰符来进一步细化允许用户输入的内容。为了更好地理解这些修改器是如何工作的,请看一下来自InputMethod/IMEDemo1
项目的res/layout/main.xml
文件:
<?xml version="1.0" encoding="utf-8"?> <TableLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:stretchColumns="1" > <TableRow> <TextView android:text="No special rules:" /> <EditText /> </TableRow> <TableRow> <TextView android:text="Email address:" /> <EditText android:inputType="text|textEmailAddress" /> </TableRow> <TableRow> <TextView android:text="Signed decimal number:" /> <EditText android:inputType="number|numberSigned|numberDecimal" /> </TableRow> <TableRow> <TextView android:text="Date:" /> <EditText android:inputType="date" /> </TableRow> <TableRow> <TextView android:text="Multi-line text:" /> <EditText android:inputType="text|textMultiLine|textAutoCorrect" android:minLines="3" android:gravity="top" /> </TableRow> </TableLayout>
这显示了一个包含 5 行的TableLayout
,每一行展示了一个略有不同的EditText
风格:
第一行在EditText
上没有任何属性,这意味着您得到的是一个纯文本输入字段。
第二行有android:inputType = "text|textEmailAddress"
,这意味着它是一个专门查找电子邮件地址的文本输入字段。
第三行允许通过android:inputType = "number|numberSigned|numberDecimal"
输入带符号的十进制数字。
第四行设置为允许数据输入日期(android:inputType = "date"
)。
最后一行允许多行输入,自动纠正可能的拼写错误(android:inputType = "text|textMultiLine|textAutoCorrect"
)。
类和修饰符定制键盘。例如,一个纯文本输入字段会产生一个纯软键盘,如图 Figure 11–2 所示。
图 11–2。 标准输入法编辑器(又称软键盘)
一个电子邮件地址域可能会把@
符号放在软键盘上,代价是一个更小的空格键,如图 Figure 11–3 所示。
图 11–3。 电子邮件地址的输入法编辑器
请注意,此行为特定于 IME。有些编辑可能会在主键盘上为电子邮件字段放置@
符号。有些人可能会在主键盘上放一个.com
按钮。有些人可能根本没有反应。这取决于 IME 的实现——您所能做的就是提供提示。
数字和日期字段将键限制为数字键,加上一组在给定字段上可能有效也可能无效的符号,如图 Figure 11–4 所示。
图 11–4。 ??【有符号十进制数字输入法编辑器】??
这些只是可能的 ime 的几个例子。通过选择合适的android:inputType
,您可以为用户提供一个最适合他们应该输入的数据类型的软键盘。
告诉安卓它能去哪里
你可能已经注意到图 11–2 中所示的 IME 和图 11–3 中所示的 IME 之间的细微差别,除了增加了@
键。图 11–3 中软键盘的右下角有一个 Next 按钮,而图 11–2 中的软键盘有一个换行按钮。这指出了两件事:
如果不指定android:inputType
,默认情况下EditText
小部件是多行的。
你可以控制右下角的按钮,称为附件 按钮。
默认情况下,在您指定了android:inputType
的EditText
微件上,附件按钮将是下一个,按顺序将您移动到下一个EditText
微件,或者如果您在屏幕上的最后一个EditText
微件上,则完成。您可以通过android:imeOptions
属性手动指定附件按钮的标签。例如,在来自InputMethod/IMEDemo2
的res/layout/main.xml
文件中,您将看到上一个示例的增强版本,其中两个输入字段指定了它们的附件按钮应该是什么样子:
`
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="fill_parent"
`
在这里,我们为电子邮件地址的附件按钮(android:imeOptions = "actionSend"
)附加了一个发送动作,并为中间字段(android:imeOptions = "actionDone"
)附加了一个完成动作。
默认情况下,Next 将焦点移动到下一个EditText
,Done 关闭 IME。然而,对于那些动作,或者任何其他类似 Send 的动作,您可以使用EditText
上的setOnEditorActionListener()
(技术上来说,是在TextView
超类上)来获得当附件按钮被点击或者用户按下回车键时的控制权。为您提供了一个指示所需操作的标志(例如,IME_ACTION_SEND
),然后您可以做一些事情来处理该请求(例如,向所提供的电子邮件地址发送一封电子邮件)。
融入
请注意,上一节中显示的IMEDemo2
布局与其IMEDemo1
前身有另一个不同之处:使用ScrollView
集装箱包裹TableLayout
。这与您对《时代》的另一个控制层次有关:当 IME 出现时,您的活动本身的布局会发生什么。根据具体情况,有三种可能性:
Android 可以“平移”你的活动,有效地向上滑动整个布局以适应 IME,或者覆盖你的布局,这取决于正在编辑的EditText
是在顶部还是底部。这实际上隐藏了用户界面的一部分。
Android 可以调整你的活动大小,有效地使其缩小到更小的屏幕尺寸,允许 IME 位于活动本身的下方。当布局可以很容易地缩小时,这是很好的(例如,它由一个列表或多行输入字段控制,不需要整个屏幕都起作用)。
Android 可能会全屏显示 IME,遮住你的整个活动。这允许更大的键盘和更容易的数据输入。
Android 使用其历史默认设置控制全屏选项。默认情况下,Android 会根据你的布局选择平移和调整大小模式。如果你想在平移和调整大小之间做出选择,你可以通过AndroidManifest.xml
文件中<activity>
元素的android:windowSoftInputMode
属性来实现。例如,下面是来自IMEDemo2
的清单:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.imf.two" android:versionCode="1" android:versionName="1.0"> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".IMEDemo2" android:label="@string/app_name" android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter>
</activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
因为我们指定了 resize,Android 将缩小我们的布局以适应 IME。当ScrollView
就位时,这意味着滚动条将根据需要出现,如图 11–5 所示。
图 11–5。 缩小的、可滚动的布局
您可以通过使用 Honeycomb 中引入的附加方法来控制 Android 的行为,以最大化屏幕空间,并在 Ice Cream Sandwich 中进行完善。使用 Java 方法setSystemUiVisibility()
和STATUS_BAR_HIDDEN
选项来隐藏系统栏,允许更大的全屏模式,或者使用方法setDimAmount()
来调整主页按钮的亮度,以消除对您定期调整大小的全屏布局的干扰。
简,停止这疯狂的事情!
有时候,你需要 IME 走开。例如,如果您将附件按钮设置为搜索按钮,当用户点击该按钮时,IME 不会自动隐藏,而您可能希望隐藏它。要隐藏 IME,您需要调用InputMethodManager
,这是一个控制这些 ime 的系统服务:
`InputMethodManager mgr=(InputMethodManager)getSystemService(INPUT_METHOD_SERVICE);
mgr.hideSoftInputFromWindow(fld.getWindowToken (), 0);`
(在前一行中,fld
是您要隐藏其 IME 的EditText
。)
这将始终关闭指定的 IME。但是,请记住,用户首先可以通过两种方式打开 IME:
如果用户的设备没有暴露硬件键盘,并且用户点击EditText
,IME 应该会出现。
如果用户之前关闭了 IME,或者正在使用通常不会弹出窗口的小部件的 IME(例如ListView
),并且用户按下了菜单按钮,则 IME 应该会出现。
如果您只想在第一种情况下关闭 IME,而不想在第二种情况下关闭,那么使用InputMethodManager.HIDE_IMPLICIT_ONLY
作为调用hideSoftInputFromWindow()
的第二个参数的标志,而不是前面例子中的0
。
十二、使用选择小部件
在第十一章的中,您看到了如何对字段进行约束以限制可能的输入,比如只能输入数字或只能输入电话号码。这些约束帮助用户在输入信息时“正确无误”,尤其是在键盘狭窄的移动设备上。
当然,约束输入的最终目的是只允许从一组项目中进行选择,比如一组单选按钮。经典的 UI 工具包有列表框、组合框、下拉列表等等,就是为了这个目的。Android 提供了许多相同种类的小工具,加上其他移动设备特别感兴趣的小工具(例如,用于检查保存的照片的Gallery
)。
此外,Android 提供了一个灵活的框架来确定这些小部件中哪些选项可用。具体来说,Android 提供了一个数据适配器框架,为选择列表提供了一个公共接口,范围从静态数组到数据库内容。选择视图——用于呈现选项列表的小部件——有一个适配器来提供实际的选项。
适应环境
抽象地说,适配器为多个不同的 API 提供了一个公共接口。更具体地说,在 Android 的情况下,适配器为选择样式的小部件(如列表框)背后的数据模型提供了一个公共接口。Java 接口的这种使用相当普遍(例如,Java/Swing 的模型适配器用于JTable
),Java 远不是唯一提供这种抽象的环境(例如,Flex 的 XML 数据绑定框架接受 XML 作为静态数据内联或从互联网检索)。
Android 的适配器不仅负责为选择小部件提供数据列表,还负责将单个数据元素转换成特定的视图,在选择小部件中显示。适配器系统的后一个方面听起来可能有点奇怪,但实际上,它与其他 GUI 工具包覆盖默认显示行为的方式没有什么不同。例如,在 Java/Swing 中,如果您希望一个由JList
支持的列表框实际上是一个检查列表(其中每一行都是一个复选框加标签,单击调整复选框的状态),那么您不可避免地要调用 setCellRenderer()
来提供您自己的ListCellRenderer
,这又会将列表的字符串转换成JCheckBox
-plus- JLabel
复合小部件。
最容易使用的适配器是ArrayAdapter
。您只需将其中一个封装在 Java 数组或java.util.List
实例中,您就有了一个全功能的适配器:
String[] items={"this", "is", "a", "really", "silly", "list"}; new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, items);
一种风格的ArrayAdapter
构造函数有三个参数:
要使用的Context
(通常这将是您的活动实例)
要使用的视图的资源 ID(如前面示例中所示的内置系统资源 ID)
要显示的实际项目数组或列表
默认情况下,ArrayAdapter
将对列表中的对象调用toString()
,并将这些字符串包装在由提供的资源指定的视图中。android.R.layout.simple_list_item_1
只是将这些字符串转换成TextView
对象。这些TextView
小部件将依次显示在列表、微调器或任何使用这个ArrayAdapter
的小部件中。如果你想看看android.R.layout.simple_list_item_1
是什么样子,你可以在你的 SDK 安装中找到它的副本——只需搜索simple_list_item_1.xml
。
在第十三章中,你将看到如何子类化一个适配器并覆盖行创建,给你更多的控制行如何出现。
淘气和乖孩子的名单
Android 中经典的列表框小部件被称为ListView
。在您的布局中包含其中一个,调用setAdapter()
提供您的数据和子视图,并通过setOnItemSelectedListener()
附加一个监听器来发现选择何时改变。这样,你就有了一个功能齐全的列表框。
但是,如果您的活动由一个列表控制,您可以考虑将您的活动创建为ListActivity
的子类,而不是常规的Activity
基类。如果你的主视图只是列表,你甚至不需要提供布局— ListActivity
会为你构建一个全屏列表。如果您确实想定制布局,您可以,只要您将您的ListView
标识为@android:id/list
,这样ListActivity
就知道哪个小部件是活动的主列表。
例如,这里有一个来自Selection/List
示例项目的布局,一个简单的列表,顶部有一个标签显示当前的选择:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <ListView android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" android:drawSelectorOnTop="false" /> </LinearLayout>
配置列表并将列表与标签连接起来的 Java 代码如下:
`public class ListViewDemo extends ListActivity {
private TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_1,
items));
selection=(TextView)findViewById (R.id.selection);
}
public void onListItemClick (ListView parent, View v, int position,
long id) {
selection.setText(items[position]);
}
}`
使用ListActivity
,您可以通过setListAdapter()
设置列表适配器——在这种情况下,提供一个ArrayAdapter
来包装一组无意义的字符串。为了找出列表选择何时改变,覆盖onListItemClick()
并根据提供的子视图和位置采取适当的步骤——在本例中,用该位置的文本更新标签。结果如图 12–1 所示。
图 12–1。 ??【listview demo】示例应用
我们的ArrayAdapter
、android.R.layout.simple_list_item_1
的第二个参数控制行的外观。上例中使用的值提供了标准的 Android 列表行:大字体、大量填充和白色文本。
选择模式
默认情况下,ListView
被设置为简单地收集列表条目的点击量。如果您想要一个跟踪用户选择的列表,或者可能是多个选择的列表,ListView
也可以处理,但是需要做一些修改。
首先,您需要在 Java 代码中调用ListView
上的setChoiceMode()
来设置选择模式,提供CHOICE_MODE_SINGLE
或CHOICE_MODE_MULTIPLE
作为值。你可以通过getListView()
从ListActivity
得到你的ListView
。您也可以通过布局 XML 中的android:choiceMode
属性来声明这一点。
然后,不使用android.R.layout.simple_list_item_1
作为ArrayAdapter
构造函数中列表行的布局,而是需要使用android.R.layout.simple_list_item_single_choice
或android.R.layout.simple_list_item_multiple_choice
分别用于单选或多选列表。
例如,下面是来自Selection/Checklist
示例项目的活动布局:
<?xml version="1.0" encoding="utf-8"?> <ListView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" android:drawSelectorOnTop="false" android:choiceMode="multipleChoice" />
它是一个全屏的ListView
,用android:choiceMode="multipleChoice"
属性表示我们想要多选支持。
我们的活动只是在我们的无意义单词列表中使用一个标准的ArrayAdapter
,但是使用android.R.layout.simple_list_item_multiple_choice
作为行布局:
`package com.commonsware.android.checklist;
import android.os.Bundle;
import android.app.ListActivity;
import android.widget.ArrayAdapter;
import android.widget.ListView;
public class ChecklistDemo extends ListActivity {
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_multiple_choice,
items));
}
}`
用户在左边看到单词列表,右边有复选框,如图 Figure 12–2 所示。
图 12–2。 多选模式
如果我们愿意,我们可以在我们的ListView
上调用getCheckedItemPositions()
来找出用户检查了哪些条目,或者我们自己调用setItemChecked()
来检查(或取消检查)一个特定的条目。
旋转控制
在 Android 中,Spinner
相当于其他工具包中的下拉选择器(例如,Java/Swing 中的JComboBox
)。按下键盘上的中央按钮会弹出一个选择对话框,用户可以从中选择一个项目。Spinner
基本上提供列表选择功能,而不会占用ListView
的所有屏幕空间,代价是额外的点击或屏幕点击来做出改变。
与ListView
一样,您通过setAdapter()
为数据和子视图提供适配器,并通过setOnItemSelectedListener()
为选择挂接一个监听器对象。
如果您想定制显示下拉透视图时使用的视图,您需要配置适配器,而不是Spinner
小部件。使用setDropDownViewResource()
方法提供要使用的视图的资源 ID。
例如,从Selection/Spinner
示例项目中挑选出来的,下面是一个带有Spinner
的简单视图的 XML 布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <Spinner android:id="@+id/spinner" android:layout_width="fill_parent" android:layout_height="wrap_content" android:drawSelectorOnTop="true" /> </LinearLayout>
这是与上一节所示相同的视图,但是用Spinner
代替了ListView
。Spinner
属性android:drawSelectorOnTop
控制是否在Spinner
UI 右侧的选择器按钮上绘制箭头。
为了填充和使用Spinner
,我们需要一些 Java 代码:
`public class SpinnerDemo extends Activity
implements AdapterView.OnItemSelectedListener {
private TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
selection=(TextView)findViewById (R.id.selection);
Spinner spin=(Spinner)findViewById (R.id.spinner);
spin.setOnItemSelectedListener (this);
ArrayAdapter aa=new ArrayAdapter(this,
android.R.layout.simple_spinner_item,
items);
aa.setDropDownViewResource (
android.R.layout.simple_spinner_dropdown_item);
spin.setAdapter (aa);
}
public void onItemSelected (AdapterView<?> parent,
View v, int position, long id) {
selection.setText (items[position]);
}
public void onNothingSelected (AdapterView<?> parent) {
selection.setText ("");
}
}`
这里,我们将活动本身附加为选择监听器(spin.setOnItemSelectedListener(this)
)。这是因为活动实现了OnItemSelectedListener
接口。我们不仅用假词列表来配置适配器,还用特定的资源来用于下拉视图(通过aa.setDropDownViewResource()
)。还要注意使用android.R.layout.simple_spinner_item
作为内置的View
来显示微调器中的项目。
最后,我们实现了OnItemSelectedListener
所需的回调,以根据用户输入调整选择标签。图 12–3 和 12–4 显示了结果。
图 12–3。 最初启动的 SpinnerDemo 示例应用
图 12–4。 同样的应用,用微调器下拉列表显示
给你的狮子网格(或者类似的东西...)
顾名思义,GridView
为您提供了一个可供选择的二维项目网格。您可以适度控制列的数量和大小;行数是根据提供的适配器所说的可供查看的项目数动态确定的。
有几个属性组合在一起决定了列的数量及其大小:
android:numColumns
:表示有多少列,或者,如果您提供一个值auto_fit
,Android 将根据可用空间和列表中的以下属性计算列数。
android:verticalSpacing
和android:horizontalSpacing
:指示网格中的项目之间应该存在多少空白。
android:columnWidth
:表示每列应该有多少像素宽。
android:stretchMode
:对于auto_fit
代表android:numColumns
的网格,表示任何未被列或间距占据的可用空间应该发生的情况。这可以是columnWidth
,让列占据可用空间,或者是spacingWidth
,让列之间的空白空间吸收额外的空间。
否则,GridView
的工作方式与任何其他选择小部件非常相似——使用setAdapter()
来提供数据和子视图,调用setOnItemSelectedListener()
来注册选择监听器,等等。
例如,下面是来自Selection/Grid
示例项目的 XML 布局,显示了一个GridView
配置:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <GridView android:id="@+id/grid" android:layout_width="fill_parent" android:layout_height="fill_parent" android:verticalSpacing="40dip" android:horizontalSpacing="5dip" android:numColumns="auto_fit" android:columnWidth="100dip" android:stretchMode="columnWidth" android:gravity="center" /> </LinearLayout>
对于这个网格,除了我们的选择标签所要求的以外,我们占据了整个屏幕。Android ( android:numColumns = "auto_fit"
)根据我们的水平间距(android:horizontalSpacing = "5dip"
)和列宽(android:columnWidth = "100dip"
)计算列数,列吸收任何剩余的“倾斜”宽度(android:stretchMode = "columnWidth"
)。
配置GridView
的 Java 代码如下:
`package com.commonsware.android.grid;
import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.GridView;
import android.widget.TextView;
public class GridDemo extends Activity
implements AdapterView.OnItemSelectedListener {
private TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
selection=(TextView)findViewById (R.id.selection);
GridView g=(GridView) findViewById (R.id.grid);
g.setAdapter (new ArrayAdapter(this,
R.layout.cell,
items));
g.setOnItemSelectedListener (this);
}
public void onItemSelected (AdapterView<?> parent, View v,
int position, long id) {
selection.setText (items[position]);
}
public void onNothingSelected (AdapterView<?> parent) {
selection.setText ("");
}
}`
网格单元由一个单独的res/layout/cell.xml
文件定义,在我们的ArrayAdapter
中称为R.layout.cell
:
<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="14dip" />
通过与 XML 布局(android:verticalSpacing = "40dip"
)的垂直间距,网格溢出模拟器屏幕的边界,如图图 12–5 和 12–6 所示。
图 12–5。 GridDemo 示例应用,最初启动时为
图 12–6。 同一个应用,滚动到网格底部
字段:现在减少了 35%的打字量!
AutoCompleteTextView
是EditText
(场)和Spinner
的混合体。使用自动完成功能,当用户键入时,文本被视为前缀过滤器,将输入的文本作为前缀与候选列表进行比较。匹配项显示在从字段下拉的选择列表中(与Spinner
一样)。用户可以键入完整的条目(例如,列表中没有的内容),或者从列表中选择一个项目作为字段的值。
AutoCompleteTextView
子类EditText
,这样你就可以配置所有的标准外观,比如字体和颜色。此外,AutoCompleteTextView
有一个android:completionThreshold
属性,用来指示用户在列表过滤开始之前必须输入的最少字符数。
您可以通过setAdapter()
给AutoCompleteTextView
一个包含候选值列表的适配器。然而,由于用户可以输入列表中没有的内容,AutoCompleteTextView
不支持选择监听器。相反,您可以注册一个TextWatcher
,就像您可以注册任何一个EditText
小部件一样,当文本改变时,您会得到通知。这些事件会因为手动键入或从下拉列表中选择而发生。
下面是一个熟悉的 XML 布局,这次包含一个AutoCompleteTextView
(来自Selection/AutoComplete
示例应用):
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <AutoCompleteTextView android:id="@+id/edit" android:layout_width="fill_parent" android:layout_height="wrap_content" android:completionThreshold="3"/> </LinearLayout>
相应的 Java 代码如下:
`package com.commonsware.android.auto;
import android.app.Activity;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import android.widget.TextView;
public class AutoCompleteDemo extends Activity
implements TextWatcher {
private TextView selection;
private AutoCompleteTextView edit;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
selection=(TextView)findViewById (R.id.selection);
edit=(AutoCompleteTextView)findViewById (R.id.edit);
edit.addTextChangedListener (this);
edit.setAdapter (new ArrayAdapter(this,
android.R.layout.simple_dropdown_item_1line,
items));
}
public void onTextChanged (CharSequence s, int start, int before,
int count) {
selection.setText(edit.getText ());
}
public void beforeTextChanged (CharSequence s, int start,
int count, int after) {
// needed for interface, but not used
}
public void afterTextChanged (Editable s) {
// needed for interface, but not used
}
}`
这次,我们的活动实现了TextWatcher
,这意味着我们的回调是onTextChanged()
、beforeTextChanged()
和afterTextChanged()
。在这种情况下,我们只对onTextChanged()
感兴趣,我们更新选择标签以匹配AutoCompleteTextView
的当前内容。图 12–7、12–8 和 12–9 显示了结果。
图 12–7。 autocompleted emo 示例应用,如同最初启动的
图 12–8。 同一个应用,输入几个匹配的字母后,显示自动完成下拉框
图 12–9。 同样的应用,在自动完成值被选中后
画廊,给予或接受艺术
Gallery
小部件在 GUI 工具包中并不常见。实际上,它是一个水平布局的列表框。在水平面上,一个选项接着一个选项,当前选定的项目高亮显示。在 Android 设备上,用户通过左右方向键在选项间旋转。
与ListView
相比,Gallery
占用更少的屏幕空间,同时仍然可以一次显示多个选项(假设它们足够短)。与Spinner
相比,Gallery
总是一次显示多个选择。
用于Gallery
的典型例子是图像预览。给定一组照片或图标,Gallery
让人们在选择一个的过程中预览图片。
就代码而言,Gallery
的工作方式很像Spinner
或GridView
。在 XML 布局中,有几个属性供您使用:
android:spacing
:控制列表中条目之间的像素数。
android:spinnerSelector
:控制用来表示选择的内容。这既可以是对一个Drawable
(参见参考资料章节)的引用,也可以是一个用#AARRGGBB
或类似符号表示的 RGB 值。
android:drawSelectorOnTop
:表示选择条(或Drawable
)是画在false
之前还是true
之后。如果你选择true
,确保你的选择器有足够的透明度,通过选择器显示子;否则,用户将无法阅读选择。
十三、喜欢上列表
不起眼的ListView
是所有安卓系统中最重要的部件之一,因为它被频繁使用。无论是选择要呼叫的联系人、要转发的电子邮件还是要阅读的电子书,ListView
widgets 被广泛应用于各种活动中。当然,如果它们不仅仅是纯文本就好了。
好消息是,安卓列表可以随心所欲,当然是在移动设备屏幕的限制范围内。然而,让它们变得有趣需要一些工作,需要本章中提到的 Android 的特性。
到达一垒
经典的 Android ListView
是一个简单的文本列表——坚实但缺乏灵感。基本上,我们把一串单词放在一个数组中交给ListView
,并告诉 Android 使用一个简单的内置布局将这些单词放入一个列表中。
然而,我们可以有一个列表,它的行由图标、图标和文本、复选框和文本或者我们想要的任何东西组成。它仅仅是向适配器提供足够的数据,并帮助适配器为每一行创建一组更丰富的View
对象。
例如,假设我们想要一个ListView
,它的条目由一个图标和一些文本组成。我们可以为行构建一个布局,如下所示,在FancyLists/Static
示例项目的res/layout/row.xml
中可以找到:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content"/> <ListView android:id="@android:id/list" android:layout_width="fill_parent"
android:layout_height="fill_parent" android:drawSelectorOnTop="false" /> </LinearLayout>
这个布局使用一个LinearLayout
来设置一行,图标在左边,文本(用漂亮的大字体)在右边。
然而,在默认情况下,Android 并不知道我们想要将这种布局用于我们的ListView
。为了建立连接,我们需要向我们的Adapter
提供前面显示的定制布局的资源 ID:
`public class StaticDemo extends ListActivity {
private TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new ArrayAdapter(this,
R.layout.row, R.id.label,
items));
selection=(TextView)findViewById (R.id.selection);
}
public void onListItemClick (ListView parent, View v,
int position, long id) {
selection.setText (items[position]);
}
}`
这遵循前一个ListView
样本的一般结构。这里的关键区别是,我们已经告诉ArrayAdapter
我们想要使用我们的自定义布局(R.layout.row
),而单词应该出现的TextView
在自定义布局中被称为R.id.label
。
注意: 记住,要引用一个布局(row.xml
),使用R.layout
作为布局 XML 文件(R.layout.row
)的基本名称的前缀。
结果是一个左侧带有图标的ListView
;在这个例子中,所有的图标都是一样的,如图 Figure 13–1 所示。
图 13–1。 static demo 应用
动态演示
如前一节所示,提供用于行的替代布局的技术非常好地处理了简单的情况。但是,如果我们想让图标根据行数据改变呢?例如,假设我们想对小单词使用一个图标,对大单词使用不同的图标。在ArrayAdapter
的例子中,我们需要扩展它,创建我们自己的定制子类(例如,IconicAdapter
),合并我们的业务逻辑。特别是,它需要超越getView()
。
Adapter
的getView()
方法是AdapterView
(像ListView
或Spinner
)在需要与Adapter
管理的给定数据相关联的View
时调用的方法。在使用ArrayAdapter
的情况下,根据需要为数组中的每个位置调用getView()
——“为第一行获取View
,“为第二行获取View
”,依此类推。
例如,让我们重新编写上一节中的代码以使用getView()
,这样我们可以为不同的行显示不同的图标——在本例中,一个图标代表短词,一个代表长词(来自FancyLists/Dynamic
示例项目):
public class DynamicDemo extends ListActivity { TextView selection; private static final String[] items={"lorem", "ipsum", "dolor", "sit", "amet", "consectetuer", "adipiscing", "elit", "morbi", "vel",
` "ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new IconicAdapter ());
selection=(TextView)findViewById (R.id.selection);
}
public void onListItemClick (ListView parent, View v,
int position, long id) {
selection.setText (items[position]);
}
class IconicAdapter extends ArrayAdapter {
IconicAdapter () {
super(DynamicDemo.this, R.layout.row, R.id.label, items);
}
public View getView (int position, View convertView,
ViewGroup parent) {
View row=super.getView (position, convertView, parent);
ImageView icon=(ImageView)row.findViewById (R.id.icon);
if (items[position].length ()>4) {
icon.setImageResource (R.drawable.delete);
}
else {
icon.setImageResource (R.drawable.ok);
}
return(row);
}
}
}`
我们的IconicAdapter
——活动的内部类——有两个方法。首先,它有一个构造函数,简单地将我们在StaticDemo
的ArrayAdapter
构造函数中使用的相同数据传递给ArrayAdapter
。第二,它有我们的getView()
实现,它做两件事:
它链接到超类的实现getView()
,它返回给我们一个由ArrayAdapter
准备的行View
的实例。特别是,我们的字已经放入了TextView
,因为ArrayAdapter
通常会这样做。
它找到我们的ImageView
,并应用业务规则来设置应该使用哪个图标,引用两个可绘制资源(R.drawable.ok
和R.drawable.delete
)中的一个。
我们修改后的示例结果如图图 13–2 所示。
图 13–2。 ??【dynamic demo】应用
给我们自己充气
先前版本的DynamicDemo
应用运行良好。然而,有时ArrayAdapter
甚至不能用于设置我们行的基础。例如,有可能有一个ListView
,其中的行实际上是不同的,比如分类标题散布在常规的行中。在这种情况下,我们可能需要自己做所有的工作,从膨胀我们的行开始。我们将在简要介绍通货膨胀之后再做那件事。
关于通货膨胀的补充报道
“膨胀”指的是将 XML 布局规范转换成 XML 表示的实际的View
对象树的行为。这无疑是一段乏味的代码:获取一个元素,创建一个指定的View
类的实例,遍历属性,将这些属性转换成属性设置器调用,遍历所有子元素,生成、清洗并重复。
好消息是,Android 团队的优秀人员将所有这些打包成了一个名为LayoutInflater
的类,我们可以自己使用它。例如,当涉及到漂亮的列表时,我们希望为列表中显示的每一行增加一个View
,这样我们就可以使用 XML 布局的简便简写来描述这些行应该是什么样子。
例如,让我们看一下FancyLists/DynamicEx
项目中DynamicDemo
类的一个稍微不同的实现:
`public class DynamicDemo extends ListActivity {
TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new IconicAdapter ());
selection=(TextView)findViewById (R.id.selection);
}
public void onListItemClick (ListView parent, View v,
int position, long id) {
selection.setText (items[position]);
}
class IconicAdapter extends ArrayAdapter {
IconicAdapter () {
super(DynamicDemo.this, R.layout.row, items);
}
public View getView (int position, View convertView,
ViewGroup parent) {
LayoutInflater inflater=getLayoutInflater ();
View row=inflater.inflate (R.layout.row, parent, false);
TextView label=(TextView)row.findViewById (R.id.label);
label.setText (items[position]);
ImageView icon=(ImageView)row.findViewById (R.id.icon);
if (items[position].length ()>4) {
icon.setImageResource (R.drawable.delete);
}
else {
icon.setImageResource (R.drawable.ok);
}
return(row);
}
}
}`
在这里,我们通过使用一个通过getLayoutInflater()
从我们的Activity
获得的LayoutInflater
对象来扩展我们的R.layout.row
布局。这给了我们一个View
对象,实际上,它是我们的LinearLayout
,带有一个ImageView
和一个TextView
,正如R.layout.row
所指定的。然而,XML 和LayoutInflater
为我们处理“重担”,而不是必须自己创建所有这些对象并将它们连接在一起。
现在,回到我们的故事
所以我们用LayoutInflater
给我们一个代表行的View
。这一行是“空的”,因为静态布局文件不知道实际有什么数据进入这一行。我们的工作是在返回行之前,按照我们认为合适的方式定制和填充行,如下所示:
在提供的位置使用单词,为我们的标签小部件填充文本标签
查看单词是否超过四个字符,如果是,找到我们的ImageView
图标小部件,用一个不同的替换股票资源
用户看不到任何不同——我们只是改变了这些行的创建方式。显然,这是一个相当不自然的例子,但是您可以看到这种技术可以用于基于任何类型的标准定制行。
更好。更强。更快。
在FancyLists/DynamicEx
项目中显示的getView()
实现可以工作,但是效率很低。每当用户滚动时,我们必须创建一堆新的View
对象来容纳新显示的行。这在开销和感知性能方面都很糟糕。
如果列表看起来很慢,可能会影响用户的即时体验。然而,更有可能的是,由于电池的使用,它会变坏 CPU 的每一点使用都会耗尽电池。垃圾收集器需要做额外的工作来清除我们创建的所有额外的对象,这就更复杂了。所以我们的代码效率越低,手机的电池消耗得越快,用户就越不高兴。我们想要快乐的用户,对吗?
所以,让我们来看看一些技巧,让我们的花哨的ListView
小部件更有效率。
使用 convertView
按照惯例,getView()
方法接收一个名为convertView
的View
作为其参数之一。有时候,convertView
会是null
。在这些情况下,我们需要从头开始创建一个新行View
(例如,通过膨胀),就像我们在前面的例子中所做的那样。但是,如果convertView
不是null
,那么它其实就是我们之前创建的View
对象之一!这主要发生在用户滚动ListView
的时候。随着新行的出现,Android 将尝试回收滚动到列表另一端的行的视图,以使我们不必从头开始重建它们。
假设我们的每一行都有相同的基本结构,我们可以使用findViewById()
来获取组成我们的行的各个小部件并改变它们的内容,然后从getView()
返回convertView
,而不是创建一个全新的行。例如,下面是前面例子中的getView()
实现,现在通过convertView
(来自FancyLists/Recycling
项目)进行了优化:
`public class RecyclingDemo extends ListActivity {
private TextView selection;
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
setListAdapter (new IconicAdapter ());
selection=(TextView)findViewById (R.id.selection);
}
public void onListItemClick (ListView parent, View v,
int position, long id) {
selection.setText (items[position]);
}
class IconicAdapter extends ArrayAdapter {
IconicAdapter () {
super(RecyclingDemo.this, R.layout.row, items);
}
public View getView (int position, View convertView,
ViewGroup parent) {
View row=convertView;
if (row==null) {
LayoutInflater inflater=getLayoutInflater ();
row=inflater.inflate (R.layout.row, parent, false);
}
TextView label=(TextView)row.findViewById (R.id.label);
label.setText (items[position]);
ImageView icon=(ImageView)row.findViewById (R.id.icon);
if (items[position].length ()>4) {
icon.setImageResource (R.drawable.delete);
}
else {
icon.setImageResource (R.drawable.ok);
}
return(row);
}
}
}`
在这里,我们检查一下convertView
是否是null
。如果是这样,我们膨胀我们的行;否则,我们只是重复使用它。在这两种情况下,填充内容(图标图像和文本)的工作是相同的。好处是我们避免了潜在的昂贵的通货膨胀步骤。事实上,根据谷歌在 2010 年谷歌 I|O 大会上引用的统计数据,使用回收的ListView
比不使用回收的ListAdapter
运行速度快 150%。对于复杂的行,这甚至可能低估了好处。
这不仅更快,而且使用的内存也更少。每个小部件或容器——换句话说,View
的每个子类——保存高达 2kB 的数据,这还不包括ImageView
小部件中的图像。因此,我们的每一行可能有 6kB 那么大。对于我们的 25 个无意义单词的列表,消耗 150kB 的非循环列表(25 行,每行 6kB)将是低效的,但不是一个大问题。然而,一个包含 1000 个无意义单词的列表会消耗多达 6MB 的内存,这将是一个更大的问题。请记住,您的应用可能只有 16MB 的 Java 堆内存可供使用,特别是当您的目标是资源有限的旧设备时。回收允许我们处理任意长度的列表,只消耗屏幕上可见行所需的View
内存。
请注意,只有当我们自己创建行时,行回收才是一个问题。如果我们让ArrayAdapter
通过利用getView()
的实现来创建行,如FancyLists/Dynamic
项目所示,那么它处理回收。
使用固定器模式
另一个有点昂贵的操作是调用findViewById()
。这将深入到我们展开的行中,并根据分配给它们的标识符提取小部件,这样我们就可以定制小部件的内容(例如,更改TextView
的文本或ImageView
中的图标)。由于findViewById()
可以在该行的根View
的子树中的任何地方找到小部件,这可能需要相当数量的指令来执行,特别是如果我们需要重复找到相同的小部件。
在一些 GUI 工具包中,这个问题是通过让复合的View
对象(比如行)完全在程序代码中声明(在本例中是 Java)来避免的。然后,访问单个小部件仅仅是调用一个 getter 或访问一个字段的问题。我们当然可以用 Android 做到这一点,但代码变得相当冗长。最好是一种方法,使我们仍然能够使用布局 XML,同时缓存我们的行的关键子部件,这样我们只需要找到它们一次。这就是 holder 模式发挥作用的地方,在一个我们称为ViewHolder
的类中。
所有的View
对象都有getTag()
和setTag()
方法。这些允许我们将任意对象与小部件相关联。holder 模式使用那个“标签”来保存一个对象,该对象又保存每个感兴趣的子部件。通过将该容器附加到行View
,每次我们使用该行时,我们已经可以访问我们关心的子部件,而不必再次调用findViewById()
。
因此,让我们来看看其中一个 holder 类(取自FancyLists/ViewHolder
示例项目):
`package com.commonsware.android.fancylists.five;
import android.view.View;
import android.widget.ImageView;
class ViewHolder {
ImageView icon=null;
ViewHolder (View base) {
this.icon=(ImageView)base.findViewById (R.id.icon);
}
}`
ViewHolder
持有子部件,通过其构造函数中的findViewById()
初始化。小部件只是受包保护的数据成员,可以从这个项目中的其他类访问,比如一个ViewHolderDemo
活动。在这种情况下,我们只持有一个小部件——图标——因为我们将让ArrayAdapter
为我们处理标签。
使用ViewHolder
就是每当我们膨胀一行时创建一个实例,并通过setTag()
将所述实例附加到行View
,如在ViewHolderDemo
中找到的getView()
的重写所示:
`public View getView (int position, View convertView,
ViewGroup parent) {
View row=super.getView (position, convertView, parent);
ViewHolder holder=(ViewHolder)row.getTag ();
if (holder==null) {
holder=new ViewHolder (row);
row.setTag (holder);
}
if (getModel (position).length ()>4) {
holder.icon.setImageResource (R.drawable.delete);
}
else {
holder.icon.setImageResource (R.drawable.ok);
}
return(row);
}`
在这里,我们回到让ArrayAdapter
为我们处理我们的行膨胀和回收。如果对行上的getTag()
的调用返回null
,我们知道我们需要创建一个新的ViewHolder
,然后通过setTag()
将它附加到行上,供以后重用。然后,访问子部件仅仅是访问容器上的数据成员。第一次显示ListView
时,所有新行都需要膨胀,我们最终为每个行创建了一个ViewHolder
。当用户滚动时,行被回收,我们可以重用它们对应的ViewHolder
小部件缓存。
使用固定器有助于提高性能,但效果并不明显。虽然回收可以让你的性能提高 150%,但增加一个支架可以让性能提高 175%。因此,虽然您可能希望在创建适配器时预先实现回收,但是添加一个容器可能是您以后要处理的事情,当您专门从事性能调优工作时。
在这种特殊情况下,我们当然可以通过跳过ViewHolder
并直接使用getTag()
和setTag()
以及ImageView
来简化这一切。这个例子是为了演示如何处理一个更复杂的场景,在这个场景中,您可能有几个小部件需要通过 holder 模式进行缓存。
交互式行
旁边有漂亮图标的列表都很好。但是,我们可以创建行中包含交互式子部件的ListView
部件,而不仅仅是像TextView
和ImageView
这样的被动部件吗?例如,有一个RatingBar
小部件,允许用户通过点击一组星形图标来分配评级。我们能不能将RatingBar
和文本结合起来,让人们滚动一个列表,比如说,歌曲列表,并在列表中对它们进行评分?有好消息也有坏消息。
好消息是,成排的交互式小部件工作得很好。坏消息是这有点棘手,特别是当交互式小部件的状态改变时(例如,在字段中键入一个值),需要采取行动。我们需要将该状态存储在某个地方,因为当滚动ListView
时,我们的RatingBar
小部件将被回收。当RatingBar
被回收时,我们需要能够基于被查看的实际单词来设置RatingBar
状态,并且我们需要在它改变时保存状态,以便当这个特定的行被滚动回视图时它可以被恢复。
有趣的是,默认情况下,RatingBar
完全不知道它代表的是ArrayAdapter
中的哪一项。毕竟,RatingBar
只是一个小部件,在一排ListView
中使用。我们需要告诉行它们当前显示的是ArrayAdapter
中的哪个项目,这样当它们的RatingBar
被选中时,它们就知道要修改哪个项目的状态。
因此,让我们使用FancyLists/RateList
示例项目中的活动来看看这是如何完成的。我们将使用与上一个例子中相同的基本类。我们正在显示一个无意义单词的列表,然后可以对其进行评级。此外,获得最高评级的单词全部大写。
`package com.commonsware.android.fancylists.six;
import android.app.Activity;
import android.os.Bundle;
import android.app.ListActivity;
import android.view.View;
import android.view.ViewGroup;
import android.view.LayoutInflater;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.RatingBar;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
public class RateListDemo extends ListActivity {
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet",
"consectetuer", "adipiscing", "elit", "morbi", "vel",
"ligula", "vitae", "arcu", "aliquet", "mollis",
"etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
ArrayList list=new ArrayList();
for (String s : items) {
list.add (new RowModel (s));
}
setListAdapter (new RatingAdapter (list));
}
private RowModel getModel (int position) {
return(((RatingAdapter)getListAdapter ()).getItem (position));
}
class RatingAdapter extends ArrayAdapter {
RatingAdapter (ArrayList list) {
super(RateListDemo.this, R.layout.row, R.id.label, list);
}
public View getView (int position, View convertView,
ViewGroup parent) {
View row=super.getView (position, convertView, parent);
ViewHolder holder=(ViewHolder)row.getTag ();
if (holder==null) {
holder=new ViewHolder (row);
row.setTag (holder);
RatingBar.OnRatingBarChangeListener l=
new RatingBar.OnRatingBarChangeListener () {
public void onRatingChanged (RatingBar ratingBar,
float rating,
boolean fromTouch) {
Integer myPosition=(Integer)ratingBar.getTag ();
RowModel model=getModel (myPosition);
model.rating=rating;
LinearLayout parent=(LinearLayout)ratingBar.getParent ();
TextView label=(TextView)parent.findViewById (R.id.label);
label.setText (model.toString ());
}
};
holder.rate.setOnRatingBarChangeListener (l);
}
RowModel model=getModel (position);
holder.rate.setTag (new Integer (position));
holder.rate.setRating (model.rating);
return(row);
}
}
class RowModel {
String label;
float rating=2.0f;
RowModel (String label) {
this.label=label;
}
public String toString () {
if (rating>=3.0) {
return(label.toUpperCase ());
}
return(label);
}
}
}`
以下列表解释了本活动和getView()
实施与之前的不同之处:
我们仍然使用String
[]项作为无意义单词的列表,但是我们没有将那个String
数组直接注入一个ArrayAdapter
,而是将它转化为一个RowModel
对象的列表。RowModel
是可变模型:它保存无意义单词和当前选中状态。在真实的系统中,这些可能是从数据库中填充的对象,并且这些属性将具有更多的业务意义。
我们更新了效用方法,比如onListItemClick()
,以反映从纯String
模型到使用RowModel
的变化。
在getView()
中的ArrayAdapter
子类(RatingAdapter
,让ArrayAdapter
膨胀并回收行,然后检查行的标签中是否有ViewHolder
。如果没有,我们创建一个新的ViewHolder
并将它与该行相关联。对于该行的RatingBar
,我们添加了一个匿名的onRatingChanged()
监听器,它查看该行的标签(getTag()
)并将其转换成一个Integer
,表示该行在ArrayAdapter
中显示的位置。使用它,评级栏可以获得该行的实际RowModel
,并根据评级栏的新状态更新模型。选中时,它还会更新与RatingBar
相邻的文本,以匹配评级栏状态。
我们总是确保RatingBar
有正确的内容,并且有一个标签(通过setTag()
)指向适配器中行显示的位置。
行布局很简单,一个LinearLayout
里面就一个RatingBar
和一个TextView
:
`
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
`
ViewHolder
同样简单,只是从行View
中提取出RatingBar
用于缓存:
`package com.commonsware.android.fancylists.six;
import android.view.View;
import android.widget.RatingBar;
class ViewHolder {
RatingBar rate=null;
ViewHolder (View base) {
this.rate=(RatingBar)base.findViewById (R.id.rate);
}
}`
从视觉上看,结果就是你所期望的,如图 Figure 13–3 所示。
图 13–3。 RateListDemo 应用,如同最初启动的
Figure 13–4 显示了一个切换的分级栏,它将单词全部转换成大写字母。
图 13–4。 同一个应用,显示一个置顶词
十四、更多的小部件和容器
到目前为止,这本书已经介绍了许多小部件和容器。本章是最后一章,专门讨论小部件和容器,涵盖了许多流行的选项,从日期和时间小部件到选项卡。后续章节偶尔会介绍新的小部件,但是是在一些其他主题的背景下,比如在第二十章中介绍ProgressBar
(涵盖线程)。
挑选
对于像手机这样输入受限的设备,拥有能够感知用户应该输入的内容类型的小工具和对话框是非常有帮助的。它们最大限度地减少了击键和屏幕点击,并减少了用户犯某种错误的机会(例如,在只需要数字的地方输入字母)。
如第九章中的所示,EditText
具有输入数字和文本的内容感知功能。Android 还支持小工具(DatePicker
和TimePicker
)和对话框(DatePickerDialog
和TimePickerDialog
)来帮助用户输入日期和时间。
DatePicker
和DatePickerDialog
允许您设置选择的开始日期,以年、月和月中的某一天值的形式。请注意,月份是从一月的0
到十二月的11
。最重要的是,DatePicker
和DatePickerDialog
都允许您提供一个回调对象(OnDateChangedListener
或OnDateSetListener
),以便在用户选择了新日期时通知您。由您决定是否将该日期存储在某个地方,尤其是在使用对话框的情况下,因为您没有其他方法可以在以后访问所选择的日期。
类似地,TimePicker
和TimePickerDialog
让您执行以下操作:
设置用户可以调整的初始时间,以小时(0
到23
)和分钟(0
到59
)的形式
指出选择应该是 12 小时模式(带 AM/PM 切换)还是 24 小时模式(在美国被认为是“军事时间”,在世界其他地方被认为是“正常时间”)
提供一个回调对象(OnTimeChangedListener
或OnTimeSetListener
),当用户选择了一个新的时间时,它会被通知,以小时和分钟的形式提供给你
作为使用日期和时间选取器的一个例子,来自Fancy/Chrono
示例项目,这里有一个包含一个标签和两个按钮的简单布局,它将弹出日期和时间选取器风格的对话框:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/dateAndTime" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <Button android:id="@+id/dateBtn" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Set the Date" android:onClick="chooseDate" /> <Button android:id="@+id/timeBtn" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Set the Time" android:onClick="chooseTime" /> </LinearLayout>
更有趣的东西来自 Java 源代码:
`package com.commonsware.android.chrono;
import android.app.Activity;
import android.os.Bundle;
import android.app.DatePickerDialog;
import android.app.TimePickerDialog;
import android.view.View;
import android.widget.DatePicker;
import android.widget.TimePicker;
import android.widget.TextView;
import java.text.DateFormat;
import java.util.Calendar;
public class ChronoDemo extends Activity {
DateFormat fmtDateAndTime=DateFormat.getDateTimeInstance ();
TextView dateAndTimeLabel;
Calendar dateAndTime=Calendar.getInstance ();
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
dateAndTimeLabel=(TextView)findViewById (R.id.dateAndTime);
updateLabel ();
}
public void chooseDate (View v) {
new DatePickerDialog (ChronoDemo.this, d,
dateAndTime.get (Calendar.YEAR),
dateAndTime.get (Calendar.MONTH),
dateAndTime.get (Calendar.DAY_OF_MONTH))
.show ();
}
public void chooseTime (View v) {
new TimePickerDialog (ChronoDemo.this, t,
dateAndTime.get (Calendar.HOUR_OF_DAY),
dateAndTime.get (Calendar.MINUTE),
true)
.show ();
}
private void updateLabel () {
dateAndTimeLabel.setText (fmtDateAndTime
.format (dateAndTime.getTime ()));
}
DatePickerDialog.OnDateSetListener d=new DatePickerDialog.OnDateSetListener () {
public void onDateSet (DatePicker view, int year, int monthOfYear,
int dayOfMonth) {
dateAndTime.set (Calendar.YEAR, year);
dateAndTime.set (Calendar.MONTH, monthOfYear);
dateAndTime.set (Calendar.DAY_OF_MONTH, dayOfMonth);
updateLabel ();
}
};
TimePickerDialog.OnTimeSetListener t=new TimePickerDialog.OnTimeSetListener () {
public void onTimeSet (TimePicker view, int hourOfDay,
int minute) {
dateAndTime.set (Calendar.HOUR_OF_DAY, hourOfDay);
dateAndTime.set (Calendar.MINUTE, minute);
updateLabel ();
}
};
}`
这个活动的模型只是一个Calendar
实例,最初设置为当前日期和时间。我们通过一个DateFormat
格式化程序将其注入视图。在updateLabel()
方法中,我们获取当前的Calendar
,对其进行格式化,并将其放入TextView
。
每个按钮都有一个对应的方法,当用户点击它时会得到控制(chooseDate()
和chooseTime()
)。点击按钮时,会显示DatePickerDialog
或TimePickerDialog
。对于DatePickerDialog
,我们给它一个OnDateSetListener
回调,用新的日期(年、月、日)更新Calendar
。我们还给对话框最后选择的日期,从Calendar
中获取值。在TimePickerDialog
的情况下,它得到一个OnTimeSetListener
回调来更新Calendar
的时间部分、最后选择的时间和一个值true
,该值指示我们想要时间选择器上的 24 小时模式。
将所有这些连接在一起,最终的活动如图 14–1、14–2 和 14–3 所示。
图 14–1。 chrono demo 示例应用,最初启动时
图 14–2。 同样的应用,显示日期选择器对话框
图 14–3。 同样的应用,显示时间选择器对话框
时间像河流一样不停地流淌
如果您想显示时间,而不是让用户输入时间,您可能希望使用DigitalClock
小部件或AnalogClock
小部件。这些小部件非常容易使用,因为它们会随着时间的推移自动更新。你所需要做的就是把它们放到你的布局中,让它们做自己的事情。
例如,在Fancy/Clocks
示例应用中,有一个包含DigitalClock
和AnalogClock
的 XML 布局:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" > <AnalogClock android:id="@+id/analog" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:layout_alignParentTop="true" /> <DigitalClock android:id="@+id/digital" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:layout_below="@id/analog" /> </RelativeLayout>
除了生成的存根之外,没有任何 Java 代码,我们可以构建这个项目并获得如图 Figure 14–4 所示的活动。
图 14–4。 clocks demo 示例应用
如果你正在寻找更多的计时器,Chronometer
可能会感兴趣。使用Chronometer
,您可以从一个起点开始跟踪经过的时间,如图 14–5 中的示例所示。您只需告诉它何时执行start()
和stop()
,并可能覆盖显示文本的格式字符串。
图 14–5。 来自 Android SDK 的 Views/Chronometer API 演示
求解析
SeekBar
是一个输入小部件,允许用户从一系列可能的值中选择一个值。图 14–6 显示了一个例子。
图 14–6。 来自 Android SDK 的 Views/SeekBar API 演示
用户可以拖动拇指或单击拇指的任一侧来重新定位它。然后拇指指向一个范围内的特定值。这个范围将会是0
到某个最大值,默认情况下是100
,你可以通过调用setMax()
来控制它。你可以通过getProgress()
找到当前位置,或者通过setOnSeekBarChangeListener()
注册一个监听器找到用户何时改变了拇指的位置。
我们在第十三章的中看到了这个主题的一个变化。
记在我的账上
一般的 Android 哲学是保持活动简短和甜蜜。如果有更多的信息超出了一个屏幕的合理容纳范围,尽管可能需要滚动,那么它可能属于通过Intent
开始的另一个活动,正如将在第二十二章中描述的。然而,这可能是复杂的设置。此外,有时确实需要收集大量信息,以作为原子操作进行处理。
在传统的 UI 中,您可能会使用选项卡来收集和显示信息,例如 Java/Swing 中的JTabbedPane
。在 Android 中,你现在可以选择以同样的方式使用一个TabHost
容器。活动屏幕的一部分被标签占据,当点击标签时,会换出视图的一部分并用其他内容替换。例如,您可能有一个活动,其中一个选项卡用于输入位置,另一个选项卡用于显示该位置的地图。
一些 GUI 工具包称“标签”为用户点击从一个视图切换到另一个视图的东西。其他 GUI 工具包将“选项卡”称为可点击的按钮状元素和选择该元素时出现的内容的组合。Android 将选项卡按钮和内容视为离散的实体,因此在本节中它们被称为“选项卡按钮”和“选项卡内容”。
片段
您可以使用以下小部件和容器来设置视图的选项卡部分:
TabHost
:标签按钮和标签内容的总体容器。
TabWidget
:实现标签按钮行,包含文本标签和图标(可选)。
FrameLayout
:标签内容的容器。每个选项卡内容都是FrameLayout
的子级。
这类似于 Mozilla 的 XUL 采取的方法。在 XUL 的例子中,tabbox
元素对应安卓的TabHost
,tabs
元素对应TabWidget
,tabpanels
对应FrameLayout
。
例如,下面是一个选项卡式活动的布局定义,来自Fancy/Tab
:
<?xml version="1.0" encoding="utf-8"?> <TabHost xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/tabhost" android:layout_width="fill_parent" android:layout_height="fill_parent"> <LinearLayout android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent"> <TabWidget android:id="@android:id/tabs" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <FrameLayout android:id="@android:id/tabcontent" android:layout_width="fill_parent" android:layout_height="fill_parent"> <AnalogClock android:id="@+id/tab1" android:layout_width="fill_parent" android:layout_height="fill_parent" /> <Button android:id="@+id/tab2" android:layout_width="fill_parent" android:layout_height="fill_parent" android:text="A semi-random button"
/> </FrameLayout> </LinearLayout> </TabHost>
注意,TabWidget
和FrameLayout
是TabHost
的间接子节点,FrameLayout
本身有代表不同选项卡的子节点。在本例中,有两个选项卡:一个时钟和一个按钮。在一个更复杂的场景中,选项卡可以是某种形式的容器(例如,LinearLayout
),有自己的内容。
将它们连接在一起
您可以将这些小部件放在常规的Activity
或TabActivity
中。TabActivity
和ListActivity
一样,将一个通用的 UI 模式(一个完全由选项卡组成的活动)包装成一个模式感知的活动子类。如果你想使用TabActivity
,你必须给TabHost
一个@android:id/tabhost
的android:id
。相反,如果您不希望使用TabActivity
,您需要通过findViewById()
获得TabHost
,然后在进行其他操作之前,在TabHost
上调用setup()
。
其余的 Java 代码需要告诉TabHost
哪些视图代表了选项卡内容,以及选项卡按钮应该是什么样子。这些都被包装在TabSpec
对象中。您通过newTabSpec()
从主机获得一个TabSpec
实例,填充它,然后按照正确的顺序将其添加到主机。
TabSpec
有两个关键方法:
setContent()
:表示该标签页的标签内容,通常是选择该标签页时希望显示的视图的android:id
setIndicator()
:设置标签按钮的标题,在这种方法的某些风格中,提供一个Drawable
来表示标签的图标
请注意,如果您需要比简单的标签和可选图标更多的控制,选项卡“指示器”实际上可以是它们自己的视图。
还要注意,在配置任何这些TabSpec
对象之前,您必须调用TabHost
上的setup()
。如果您的活动使用的是TabActivity
基类,那么就不需要调用setup()
。
例如,下面是将前面布局示例中的选项卡连接在一起的 Java 代码:
`package com.commonsware.android.fancy;
import android.app.Activity;
import android.os.Bundle;
import android.widget.TabHost;
public class TabDemo extends Activity {
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
TabHost tabs=(TabHost)findViewById (R.id.tabhost);
tabs.setup ();
TabHost.TabSpec spec=tabs.newTabSpec ("tag1");
spec.setContent (R.id.tab1);
spec.setIndicator ("Clock");
tabs.addTab (spec);
spec=tabs.newTabSpec ("tag2");
spec.setContent (R.id.tab2);
spec.setIndicator ("Button");
tabs.addTab (spec);
}
}`
我们通过熟悉的findViewById()
方法找到我们的TabHost
,然后通过setup()
设置它。之后,我们通过newTabSpec()
得到一个TabSpec
,提供一个标签,这个标签的目的现在还不知道。给定规范,我们调用setContent()
和setIndicator()
,然后调用TabHost
上的addTab()
来注册标签为可用。最后,我们可以通过setCurrentTab()
选择显示哪个选项卡,提供基于0
的选项卡索引。
结果如图图 14–7 和图 14–8 所示。
图 14–7。 tab demo 示例应用,显示第一个选项卡
图 14–8。 同样的应用,显示第二个标签页
请注意,如果您的应用运行在较旧的 SDK 级别下,在 Honeycomb 和 Ice Cream Sandwich 发布之前,那么您的菜单将以老式的“按钮”样式出现,如图 Figure 14–9 所示。通过在您的AndroidManifest.xml
中指定android:targetSdkVersion
和android:minSdkVersion
,您可以控制是使用旧行为还是新行为。第二十九章有一个有用的 SDK 版本列表。
图 14–9。 tab demo 示例应用,显示了第一个带有旧式 UI 的选项卡
相加
TabWidget
的设置是为了让你在编译时轻松定义制表符。但是,有时您可能希望在运行时向活动添加选项卡。例如,设想一个电子邮件客户端,它在自己的选项卡中打开每个单独的电子邮件,以便在邮件之间轻松切换。在这种情况下,直到运行时,当用户选择打开一条消息时,您才知道需要多少选项卡或者它们的内容是什么。幸运的是,Android 还支持在运行时动态添加标签。
在运行时动态添加选项卡的工作方式与前面描述的编译时选项卡非常相似,只是您使用了另一种风格的setContent()
,它采用了一个TabHost.TabContentFactory
实例。这只是一个将被调用的回调。您提供了一个createTabContent()
的实现,并使用它来构建和返回成为选项卡内容的View
。
我们来看一个例子(Fancy/DynamicTab
)。首先,下面是一个活动的布局 XML,它设置了选项卡并定义了一个选项卡,包含一个按钮:
<?xml version="1.0" encoding="utf-8"?> <TabHost xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/tabhost" android:layout_width="fill_parent" android:layout_height="fill_parent"> <LinearLayout android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent"> <TabWidget android:id="@android:id/tabs" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <FrameLayout android:id="@android:id/tabcontent" android:layout_width="fill_parent" android:layout_height="fill_parent"> <Button android:id="@+id/buttontab" android:layout_width="fill_parent" android:layout_height="fill_parent" android:text="A semi-random button" android:onClick="addTab" /> </FrameLayout> </LinearLayout> </TabHost>
每当单击按钮时,我们都希望添加新的选项卡,这可以通过下面的代码来实现:
`package com.commonsware.android.dynamictab;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.AnalogClock;
import android.widget.TabHost;
public class DynamicTabDemo extends Activity {
private TabHost tabs=null;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
tabs=(TabHost)findViewById (R.id.tabhost);
tabs.setup ();
TabHost.TabSpec spec=tabs.newTabSpec ("buttontab");
spec.setContent (R.id.buttontab);
spec.setIndicator ("Button");
tabs.addTab (spec);
}
public void addTab (View v) {
TabHost.TabSpec spec=tabs.newTabSpec ("tag1");
spec.setContent (new TabHost.TabContentFactory () {
public View createTabContent (String tag) {
return(new AnalogClock (DynamicTabDemo.this));
}
});
spec.setIndicator ("Clock");
tabs.addTab (spec);
}
}`
在我们的按钮的addTab()
回调中,我们创建了一个TabHost.TabSpec
对象,并给它一个匿名的TabHost.TabContentFactory
。工厂依次返回用于选项卡的View
——在本例中,只是一个AnalogClock
。构建选项卡的View
的逻辑可以更加复杂,比如使用LayoutInflater
从布局 XML 构建一个视图。
最初,当活动启动时,我们只有一个选项卡,如图 Figure 14–10 所示。图 14–11 显示了三个动态创建的选项卡。
图 14–10。 ??【dynamic tab】应用,带单个初始标签
图 14–11。 dynamic tab 应用,有三个动态创建的选项卡
表格处理是真正动态的,适应你的屏幕大小。Android 将表格格式化,以适应平板电脑甚至电视等更大尺寸的屏幕。图 14–12 在一个更大的平板电脑大小的屏幕上显示了四个动态创建的选项卡。
图 14–12。 dynamic tab 应用,在平板电脑大小的屏幕上展示适应性
把它们翻过来
有时,您想要选项卡的整体效果(一次只有一些View
可见),而不是选项卡的实际 UI 实现。也许标签占据了太多的屏幕空间。也许你想根据一个手势或一个设备摇动来切换视角。或者你只是喜欢与众不同。Android 4.0 冰淇淋三明治提供了在空间允许的情况下,将标签“推”到动作栏的空白空间的能力,例如当你旋转到横向时,但这并不能满足你可能会有的疯狂的“摇晃、拨浪鼓和滚动”想法。
好消息是选项卡的视图翻转逻辑的核心可以在ViewFlipper
容器中找到,它可以以传统选项卡之外的其他方式使用。
ViewFlipper
继承自FrameLayout
,就像我们用它来描述TabWidget
的内部一样。然而,最初,ViewFlipper
只是显示第一个子视图。您可以通过用户交互手动或通过计时器自动安排视图的翻转。
例如,下面是一个使用Button
和ViewFlipper
的简单活动(Fancy/Flipper1
)的布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/flip_me" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Flip Me!" android:onClick="flip" /> <ViewFlipper android:id="@+id/details" android:layout_width="fill_parent"
android:layout_height="fill_parent" > <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:textStyle="bold" android:textColor="#FF00FF00" android:text="This is the first panel" /> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:textStyle="bold" android:textColor="#FFFF0000" android:text="This is the second panel" /> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:textStyle="bold" android:textColor="#FFFFFF00" android:text="This is the third panel" /> </ViewFlipper> </LinearLayout>
注意,布局为ViewFlipper
定义了三个子视图,每个视图都是一条简单的消息。当然,如果你愿意,你可以有非常复杂的孩子视图。
要手动翻转视图,我们需要挂入Button
并在点击按钮时自己翻转它们:
`package com.commonsware.android.flipper1;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.ViewFlipper;
public class FlipperDemo extends Activity {
ViewFlipper flipper;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
flipper=(ViewFlipper)findViewById (R.id.details);
}
public void flip (View v) {
flipper.showNext ();
}
}`
这只是在ViewFlipper
上调用showNext()
的问题,就像在任何ViewAnimator
类上一样。结果是一个简单的活动:单击按钮,显示序列中的下一个TextView
,在查看完最后一个后返回到第一个,如图图 14–13 和 14–14 所示。
图 14–13。 flipper demo 应用,显示第一个面板
图 14–14。 同样的应用,切换到第二个面板后
当然,这可以通过使用一个TextView
并在每次点击时改变文本和颜色来更简单地处理。然而,您可以想象一下,ViewFlipper
的内容可能更复杂,比如您可能放入TabView
的内容。
与TabWidget
一样,有时在编译时可能不知道ViewFlipper
的内容。和TabWidget
一样,你可以轻松地随时添加新内容。
例如,让我们看看另一个示例活动(Fancy/Flipper2
),使用以下布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <ViewFlipper android:id="@+id/details" android:layout_width="fill_parent" android:layout_height="fill_parent" > </ViewFlipper> </LinearLayout>
注意,ViewFlipper
在编译时没有内容。还要注意,没有用于在内容之间翻转的Button
——稍后会详细介绍。
对于ViewFlipper
内容,我们将创建大的Button
小部件,每个小部件包含本书许多章节中使用的一个随机单词。并且,我们将设置ViewFlipper
在Button
部件之间自动旋转。
`package com.commonsware.android.flipper2;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ViewFlipper;
public class FlipperDemo2 extends Activity {
static String[] items={"lorem", "ipsum", "dolor", "sit", "amet",
"consectetuer", "adipiscing", "elit",
"morbi", "vel", "ligula", "vitae",
"arcu", "aliquet", "mollis", "etiam",
"vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque",
"augue", "purus"};
ViewFlipper flipper;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
flipper=(ViewFlipper)findViewById (R.id.details);
for (String item : items) {
Button btn=new Button (this);
btn.setText (item);
flipper.addView (btn,
new ViewGroup.LayoutParams (
ViewGroup.LayoutParams.FILL_PARENT,
ViewGroup.LayoutParams.FILL_PARENT));
}
flipper.setFlipInterval (2000);
flipper.startFlipping ();
}
}`
在迭代完时髦的单词后,将每个单词变成一个Button
,并将Button
添加为ViewFlipper
的子单词,我们设置 flipper 在子单词(flipper.setFlipInterval(2000);
)之间自动翻转,并开始翻转(flipper.startFlipping(
)。
结果是一系列无止境的按钮,每个按钮都会出现,如图图 14–15 所示,然后在 2 秒钟后被下一个按钮依次替换,在最后一个按钮出现后返回到第一个按钮。
图 14–15。 flipper demo 2 应用
自动滑动ViewFlipper
对于状态面板或其他有大量信息要显示但没有足够空间显示的情况很有用。然而,由于它自动在视图之间切换,期望用户与单个视图交互是冒险的,因为视图可能会在交互过程中中途切换。
进入某人的抽屉
很长一段时间以来,Android 开发人员渴望有一个滑动抽屉容器,像主屏幕上那样工作,包含启动应用的图标。官方实现在开源代码中,但不是 SDK 的一部分,直到 Android 1.5,开发者发布了SlidingDrawer
供其他人使用。
与大多数其他 Android 容器不同,SlidingDrawer
可以移动,从关闭位置切换到打开位置。这就对哪个容器可以容纳SlidingDrawer
提出了一些限制。它需要放在一个容器中,允许多个小部件相互叠加。RelativeLayout
和FrameLayout
满足这个要求。FrameLayout
是一个纯粹的容器,用来堆叠小部件。另一方面,LinearLayout
不允许小部件堆叠(它们在一行或一列中一个接一个地落下),所以你不应该将SlidingDrawer
作为LinearLayout
的直接子元素。
这里是一个布局,显示了来自Fancy/DrawerDemo
项目的FrameLayout
中的SlidingDrawer
:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:background="#FF4444CC" > <SlidingDrawer android:id="@+id/drawer" android:layout_width="fill_parent" android:layout_height="fill_parent" android:handle="@+id/handle" android:content="@+id/content"> <ImageView android:id="@id/handle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/tray_handle_normal" /> <Button android:id="@id/content" android:layout_width="fill_parent" android:layout_height="fill_parent" android:text="I'm in here!" /> </SlidingDrawer> </FrameLayout>
SlidingDrawer
应该包含两件事:
一个句柄,通常是一个ImageView
或类似的东西,比如这里使用的,来自 Android 开源项目
抽屉本身的内容,通常是某种容器,但在本例中是一个Button
此外,SlidingDrawer
需要知道句柄和内容的android:id
值,分别通过android:handle
和android:content
属性。这些告诉抽屉如何在滑动打开和关闭时显示动画。
图 14–16 显示了SlidingDrawer
关闭时的样子,使用提供的手柄,图 14–17 显示了打开时的样子。
图 14–16。 抽屉滑动,关闭
图 14–17。 一个滑动抽屉,打开
正如您所料,您可以通过 Java 代码以及用户触摸事件来打开和关闭抽屉。然而,你有两组这样的方法:一组是即时发生的(open()
、close()
和toggle()
),另一组是使用动画的(animateOpen()
、animateClose()
和animateToggle()
)。你也可以lock()
和unlock()
抽屉;锁定时,抽屉不会响应触摸事件。
如果愿意,您还可以注册三种类型的回调:
抽屉打开时要调用的侦听器
抽屉关闭时要调用的侦听器
当抽屉“滚动”(即用户拖动或投掷手柄)时调用的侦听器
例如,Android launcher 的SlidingDrawer
将手柄上的图标从打开切换到关闭再切换到“删除”(如果你长按桌面上的某个东西)。它实现这一点,部分是通过像这样的回调。
SlidingDrawer
可以是垂直的也可以是水平的。不过,请注意,不管屏幕方向如何,它都会保持自己的方向。换句话说,如果你旋转运行DrawerDemo
的 Android 设备或模拟器,抽屉总是从底部打开——它并不总是“粘”在它打开的原始一侧。这意味着如果你希望抽屉总是从同一边打开,就像启动器一样,你将需要单独的纵向和横向布局,这是在第二十三章中讨论的话题。
其他好东西
Android 提供AbsoluteLayout
,内容根据具体坐标位置进行布局。你告诉AbsoluteLayout
在精确的 x 和 y 坐标上把一个孩子放在哪里,Android 就放在那里,不问任何问题。从好的方面来说,这给了你精确的定位。不利的一面是,这意味着您的视图只能在特定尺寸的屏幕上看起来合适,或者您需要编写一堆代码来根据屏幕大小调整坐标。由于 Android 屏幕可能会有各种尺寸,新尺寸会定期出现,使用AbsoluteLayout
可能会变得相当烦人。另外,请注意AbsoluteLayout
已被正式否决,这意味着尽管您可以使用它,但不鼓励使用它。
安卓也有ExpandableListView
。这提供了简化的树表示,支持两个深度级别:组和孩子。群组包含孩子;孩子是树的“叶子”。这需要一组新的适配器,因为ListAdapter
系列没有为列表中的项目提供任何类型的组信息。
除了本书中提到的,Android 中还有一些其他可用的小部件:
CheckedTextView
:一个TextView
,旁边可以有一个复选框或单选按钮,用于单选和多选列表
Chronometer
:秒表式倒计时器
Gallery
:水平滚动选择小工具,设计用于图像的缩略图预览(例如,相机照片和相册封面)
MultiAutoCompleteTextView
:类似于AutoCompleteTextView
,除了用户可以从下拉列表中进行多项选择,而不是只有一项
QuickContactBadge
:给定来自用户联系人数据库的联系人的身份,显示代表将对该联系人执行的动作(拨打电话、发送文本消息、发送电子邮件等)的图标列表。)
ToggleButton
:两种状态的按钮,用“灯”和散文(“开”、“关”)来表示状态,而不是复选标记
ViewSwitcher
(以及ImageSwitcher
和TextSwitcher
子类):像一个简化的ViewFlipper
,用于在两个视图之间切换
十五、嵌入 WebKit 浏览器
其他 GUI 工具包允许您使用 HTML 来呈现信息,从有限的 HTML 呈现器(例如 Java/Swing 和 wxWidgets)到将 Internet Explorer 嵌入。NET 应用。Android 也是如此,你可以将内置的网络浏览器作为一个小部件嵌入到你自己的活动中,用于显示 HTML 或全面的浏览。Android 浏览器基于 WebKit,与苹果的 Safari 和谷歌的 Chrome 等网络浏览器使用的引擎相同。
Android 浏览器足够复杂,它有自己的 Java 包(android.webkit
)。根据您的需求,使用WebView
widgetitself 可以是简单的,也可以是强大的。
浏览器,小字体
对于简单的东西来说,WebView
与 Android 中的任何其他小部件没有明显的不同——将它弹出到一个布局中,通过 Java 代码告诉它要导航到哪个 URL,然后就完成了。
例如,下面是一个带有WebView
(来自WebKit/Browser1
)的简单布局:
<?xml version="1.0" encoding="utf-8"?> <WebViewxmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/webkit" android:layout_width="fill_parent" android:layout_height="fill_parent" />
与任何其他小部件一样,您需要告诉它应该如何填充布局中的空间(在这种情况下,它填充所有剩余的空间)。
Java 代码同样简单:
`package com.commonsware.android.browser1;
importandroid.app.Activity;
importandroid.os.Bundle;
importandroid.webkit.WebView;
public class BrowserDemo1 extends Activity {
WebView browser;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
browser=(WebView)findViewById (R.id.webkit);
browser.loadUrl("http://commonsware.com ");
}
}`
这个版本的onCreate()
唯一不同寻常的地方是我们调用了WebView
小部件上的loadUrl()
,告诉它加载一个网页(在这个例子中,是某个随机公司的主页)。
然而,我们还需要对AndroidManifest.xml
做一个修改,请求访问互联网的许可:
<?xml version="1.0"?> <manifestxmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.browser1"> <uses-permission android:name="android.permission.INTERNET"/> <applicationandroid:icon="@drawable/cw"> <activityandroid:name=".BrowserDemo1" android:label="BrowserDemo1"> <intent-filter> <actionandroid:name="android.intent.action.MAIN"/> <categoryandroid:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
如果我们未能添加此权限,浏览器将拒绝加载页面。权限将在第三十八章中详细介绍。
最终的活动看起来像一个网络浏览器,但是带有隐藏的滚动条,如图 Figure 15–1 所示。
图 15–1。 ??【browser demo 1 示例应用】??
与常规的 Android 浏览器一样,你可以通过拖动它来浏览页面,而 D-pad 可以让你浏览页面上所有可聚焦的元素。缺少的是组成网络浏览器的所有额外的东西,比如导航工具栏。
现在,您可能想用依赖于 JavaScript 的东西来替换源代码中的 URL,比如 Google 的主页。默认情况下,JavaScript 在WebView
小部件中是关闭的。如果您想启用 JavaScript,在WebView
实例上调用getSettings().setJavaScriptEnabled(true);
。本章稍后会更详细地介绍这个选项。
装载完毕
将内容放入WebView
有两种主要方式。一种方法是为浏览器提供一个 URL,并让浏览器通过loadUrl()
显示该页面。浏览器将通过特定设备当前可用的任何方式(Wi-Fi、2G、3G、4G、WiMAX、EDGE、HSDPA、HSPA、训练有素的小信鸽等)访问互联网。).
另一种方法是使用loadData()
。在这里,您提供 HTML 供浏览器查看。您可以使用它来执行以下操作:
显示作为应用包文件安装的手册
显示作为其他处理的一部分检索到的 HTML 片段,例如 Atom 提要中的条目描述
使用 HTML 生成一个完整的用户界面,而不是使用 Android widget 集
loadData()
有两种口味。更简单的方法允许您以字符串的形式提供内容、MIME 类型和编码。通常,对于普通的 HTML,你的 MIME 类型是text/html
,你的编码是UTF-8
。
例如,您可以用以下代码替换前面示例中的loadUrl()
调用:
browser.loadData("<html><body>Hello, world!</body></html>", "text/html", "UTF-8");
您将得到如图图 15–2 所示的结果。
图 15–2。 browser demo 2 示例应用
这也是一个完全可构建的示例,如WebKit/Browser2
。
在水中航行
如前所述,WebView
小部件没有导航工具栏。这允许你在这样的工具栏毫无意义和浪费屏幕空间的地方使用它。也就是说,如果你想提供导航功能,你可以,但你必须提供用户界面。
WebView
提供了执行普通浏览器导航的方法,包括以下方法:
reload()
:刷新当前查看的网页
goBack()
:在浏览器历史中后退一步
canGoBack()
:确定是否有任何历史可以返回
goForward()
:在浏览器历史中前进一步
canGoForward()
:确定是否有任何要前进的历史
goBackOrForward()
:在浏览器历史中后退或前进,负数作为参数表示后退多少步,正数表示前进多少步
canGoBackOrForward()
:确定浏览器是否可以后退或前进指定的步数(遵循与goBackOrForward()
相同的正/负约定)
clearCache()
:清除浏览器资源缓存
clearHistory()
:清除浏览历史
招待客户
如果你打算将WebView
用作本地 UI(而不是浏览网页),你将希望能够在关键时刻获得控制权,尤其是当用户点击链接时。你需要确保这些链接得到正确的处理,要么将你自己的内容加载回WebView
,要么向 Android 提交一个Intent
以在一个完整的浏览器中打开 URL,要么通过其他方式(见第二十二章)。
您对WebView
活动的挂钩是通过setWebViewClient()
实现的,它将一个WebViewClient
实现的实例作为参数。所提供的回调对象将被通知各种各样的事件,从页面的部分被检索时开始(onPageStarted()
等等)。)到当您作为主机应用需要处理某些用户或环境发起的事件时,例如onTooManyRedirects()
或onReceivedHttpAuthRequest()
。
一个常见的钩子是shouldOverrideUrlLoading()
,在这里你的回调被传递一个 URL(加上WebView
本身),如果你要处理请求,你返回true
,或者如果你想要默认处理,你返回false
(例如,实际获取 URL 引用的网页)。例如,对于提要阅读器应用,您可能没有内置导航功能的完整浏览器。在这种情况下,如果用户点击一个 URL,你可能想使用一个Intent
来请求 Android 在一个完整的浏览器中加载那个页面。但是如果你在 HTML 中插入了一个“假”的 URL,代表了一些活动提供的内容的链接,你可以自己更新WebView
。
举个例子,让我们修改第一个浏览器演示,使它成为一个点击后显示当前时间的应用。从WebKit/Browser3
开始,这里是修改后的 Java:
`public class BrowserDemo3 extends Activity {
WebView browser;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
browser=(WebView)findViewById (R.id.webkit);
browser.setWebViewClient (new Callback ());
loadTime ();
}
void loadTime () {
String page="<a href="clock">"
+new Date ().toString ()
+"";
browser.loadData (page, "text/html", "UTF-8");
}
private class Callback extends WebViewClient {
public boolean shouldOverrideUrlLoading (WebView view, String url) {
loadTime ();
return(true);
}
}
}`
这里,我们在浏览器(loadTime()
)中加载一个简单的网页,其中包含当前时间,并制作成一个到/clock
URL 的超链接。我们还附加了一个WebViewClient
子类的实例,提供了我们的shouldOverrideUrlLoading()
实现。在这种情况下,不管 URL 是什么,我们只想通过loadTime()
重新加载WebView
。
运行该活动会产生如图 Figure 15–3 所示的结果。
图 15–3。 ??【browser demo 3 示例应用】??
选择该链接并单击 D-pad 中心按钮将“单击”该链接,从而使用新时间重建页面。
设置、首选项和选项(哦,天哪!)
有了你最喜欢的桌面网络浏览器,你就有了某种设置、偏好或选项窗口。在这个和工具栏控件之间,你可以调整和旋转浏览器的行为,从偏好的字体到 JavaScript 的行为。类似地,您可以通过调用小部件的getSettings()
方法返回的WebSettings
实例,调整您的WebView
小部件的设置。
在WebSettings
上有很多选项可以玩。大多数看起来相当深奥(例如setFantasyFontFamily()
)。然而,这里有一些你可能会发现更有用的:
通过setDefaultFontSize()
(使用磅值)或setTextZoom()
(使用常量表示相对大小,如LARGER
和SMALLEST
)控制字体大小
通过setJavaScriptEnabled()
(彻底禁用它)和setJavaScriptCanOpenWindowsAutomatically()
(仅仅阻止它打开弹出窗口)控制 JavaScript
通过setUserAgent()
控制 web 站点的呈现,这样您就可以提供自己的用户代理字符串,让 web 服务器认为您是一个桌面浏览器、另一个移动设备(例如,iPhone)或者其他什么
你改变的设置不是持久的,所以如果你允许你的用户决定设置,你应该把它们存储在某个地方(比如通过 Android 偏好引擎),而不是硬连接到你的应用中。
十六、应用菜单
像桌面应用和一些移动操作系统一样,Android 支持应用菜单的活动。大部分安卓手机都有弹出菜单的专用菜单键;其他设备提供了触发菜单出现的替代方法,如 Archos 5 Android 平板电脑使用的屏幕按钮。
此外,与许多 GUI 工具包一样,您可以为 Android 应用创建上下文菜单。在传统的 GUI 上,用户点击鼠标右键可能会触发上下文菜单。在移动设备上,当用户点击并按住特定的小部件时,通常会出现上下文菜单。例如,如果一个TextView
有一个上下文菜单,并且该设备是为基于手指的触摸输入而设计的,那么你可以用手指按下TextView
,按住一两秒钟,就会出现一个弹出菜单。
菜单的口味
Android 将前一节描述的两种类型的菜单称为选项菜单和上下文菜单。选项菜单是通过按下设备上的硬件菜单按钮来触发的,而上下文菜单是通过点击并按住显示菜单的小工具来启动的。
此外,选项菜单以两种模式之一运行:图标或展开。当用户第一次按下菜单按钮时,将出现图标模式,在屏幕底部的网格中以手指友好的大按钮形式显示前六个菜单选项。如果菜单有六个以上的选项,第六个按钮将标记为“更多”。点击更多选项将调出扩展模式,显示常规菜单中不可见的其余选项。该菜单是可滚动的,因此用户可以滚动到任何菜单选项。
菜单的选项
你需要实现onCreateOptionsMenu()
,而不是在onCreate()
期间构建活动的选项菜单,这是你连接 UI 其余部分的方式。这个回调接收一个Menu
的实例。
你应该做的第一件事是链接到超类(super.onCreateOptionsMenu(menu)
),这样 Android 框架就可以添加任何它认为必要的菜单选项。然后,您可以着手添加您自己的选项,如本节所述。
如果您需要在活动的使用过程中调整菜单(例如,禁用现在无效的菜单选项),只需保留在onCreateOptionsMenu()
中收到的Menu
实例。或者,您可以实现onPrepareOptionsMenu()
,它在每次请求显示菜单之前被调用。
假设您已经通过onCreateOptionsMenu()
收到了一个Menu
对象,您可以通过调用add()
来添加菜单选项。这种方法有多种形式,需要以下参数的某种组合:
一个组标识符(int
),它应该是NONE
,除非你正在创建一个特定的菜单选项组,用于setGroupCheckable()
(稍后描述)
一个选项标识符(也是一个int
),用于在选择菜单选项时在onOptionsItemSelected()
回调中标识该选项
一个订单标识符(另一个int
),用于指示如果菜单中有 Android 提供的选项和你自己的选项,那么这个菜单选项应该放在哪里;现在,就用NONE
菜单选项的文本,如String
或资源 ID
add()
系列方法都返回一个MenuItem
的实例,您可以在其中调整已经设置的任何菜单项设置(例如,菜单选项的文本)。
您还可以设置菜单选项的快捷键,这些快捷键是单字符助记符,当菜单可见时,它们会选择菜单项。Android 支持一组字母快捷键和一组数字快捷键。这些分别通过调用setAlphabeticShortcut()
和setNumericShortcut()
来单独设置。通过使用true
参数调用菜单上的setQwertyMode()
,菜单进入字母快捷方式。
选项和组标识符是用于解锁附加菜单功能的按键,如下所示:
使用选项标识符调用MenuItem#setCheckable()
,以控制菜单选项是否在标题旁边有一个双态复选框,当用户选择该菜单项时,复选框值被切换
用组标识调用Menu#setGroupCheckable()
,将一组菜单选项变成相互排斥的单选按钮,这样在任何时候组中只能有一项处于选中状态
您可以通过调用addSubMenu()
,提供与addMenu()
相同的参数来创建弹出子菜单。Android 最终会调用onCreatePanelMenu()
,传递给它子菜单的选择标识符,以及另一个代表子菜单本身的Menu
实例。与onCreateOptionsMenu()
一样,您应该向上链接到超类,然后将菜单选项添加到子菜单中。一个限制是不能无限嵌套子菜单,一个菜单可以有子菜单,但是子菜单不能有子菜单。
最后,您甚至可以将菜单项推到操作栏中,这使您的用户更容易发现您的选项,更重要的是,更好地利用平板电脑和更大设备上的所有可用屏幕空间。当我们关注动作栏本身时,我们将在第二十七章中更深入地探讨这个功能。
如果用户选择了一个菜单,那么您的活动将会通过onOptionsItemSelected()
回调得到一个菜单被选中的通知。您将获得与所选菜单选项相对应的MenuItem
对象。一个典型的模式是对菜单 ID ( item.getItemId()
)进行switch()
,并采取适当的行为。请注意,无论选择的菜单项是在基本菜单还是子菜单中,都会使用onOptionsItemSelected()
。
上下文中的菜单
总的来说,上下文菜单和选项菜单使用相同的元素。两个主要的区别是你如何填充菜单和你如何被告知菜单选择。
首先,您需要指出活动中的哪个或哪些小部件有上下文菜单。为此,从活动中调用registerForContextMenu()
,提供需要上下文菜单的小部件View
。
接下来,您需要实现onCreateContextMenu()
,它通过您在registerForContextMenu()
中提供的View
传递。假设您的活动有多个菜单,您可以使用它来决定构建哪个菜单。
onCreateContextMenu()
方法获得了ContextMenu
本身、与上下文菜单相关联的View
以及一个ContextMenu.ContextMenuInfo
,它告诉您用户点击并按住了列表中的哪个项目,以防您想要基于该信息定制上下文菜单。例如,您可以根据项目的当前状态切换可检查的菜单选项。
值得注意的是,每次请求上下文菜单时都会调用onCreateContextMenu()
。与选项菜单(每个活动只构建一次)不同,上下文菜单在使用或取消后会被丢弃。因此,您不想保留提供的ContextMenu
对象;您只需根据用户的操作,根据需求重新构建菜单,以满足您的活动需求。
要找出何时选择了上下文菜单选项,请在活动上实现onContextItemSelected()
。注意,您只获得在这个回调中选择的MenuItem
实例。因此,如果您的活动有两个或更多的上下文菜单,您可能希望确保它们的所有选择都有唯一的菜单项标识符,这样您就可以在这个回调中区分它们。还有,你可以在MenuItem
上呼叫getMenuInfo()
来获得你在onCreateContextMenu()
收到的ContextMenu.ContextMenuInfo
。否则,这个回调的行为与前面部分描述的onOptionsItemSelected()
相同。
偷看一眼
在示例项目Menus/Menus
中,您会发现带有相关菜单的ListView
示例(List
)的修改版本。由于菜单不影响布局,XML 布局文件不需要更改,因此在此不再重印。然而,Java 代码有一些新的行为:
`packagecom.commonsware.android.menus;
importandroid.app.AlertDialog;
importandroid.app.ListActivity;
importandroid.content.DialogInterface;
importandroid.os.Bundle;
importandroid.view.ContextMenu;
importandroid.view.Menu;
importandroid.view.MenuItem;
importandroid.view.View;
importandroid.widget.AdapterView;
importandroid.widget.ArrayAdapter;
importandroid.widget.EditText;
importandroid.widget.ListView;
importandroid.widget.TextView;
importjava.util.ArrayList;
public class MenuDemo extends ListActivity {
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet", "consectetuer", "adipiscing", "elit",
"morbi", "vel", "ligula", "vitae", "arcu", "aliquet",
"mollis", "etiam", "vel", "erat", "placerat", "ante",
"porttitor", "sodales", "pellentesque", "augue", "purus"};
public static final int MENU_ADD = Menu.FIRST+1;
public static final int MENU_RESET = Menu.FIRST+2;
public static final int MENU_CAP = Menu.FIRST+3;
public static final int MENU_REMOVE = Menu.FIRST+4 ;
private ArrayList words=null;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
initAdapter ();
registerForContextMenu (getListView ());
}
@Override
public boolean onCreateOptionsMenu (Menu menu) {
menu
.add (Menu.NONE, MENU_ADD, Menu.NONE, "Add")
.setIcon (R.drawable.ic_menu_add);
menu
.add (Menu.NONE, MENU_RESET, Menu.NONE, "Reset")
.setIcon (R.drawable.ic_menu_refresh);
return(super.onCreateOptionsMenu (menu));
}
@Override
public void onCreateContextMenu (ContextMenu menu, View v,
ContextMenu.ContextMenuInfo menuInfo) {
menu.add (Menu.NONE, MENU_CAP, Menu.NONE, "Capitalize");
menu.add (Menu.NONE, MENU_REMOVE, Menu.NONE, "Remove");
}
@Override
public booleanon OptionsItemSelected (MenuItem item) {
switch (item.getItemId ()) {
case MENU_ADD:
add ();
return(true);
case MENU_RESET:
initAdapter ();
return(true);
}
return(super.onOptionsItemSelected (item));
}
@Override
public boolean onContextItemSelected (MenuItem item) {
AdapterView.AdapterContextMenuInfo info=
(AdapterView.AdapterContextMenuInfo)item.getMenuInfo ();
ArrayAdapter adapter=(ArrayAdapter)getListAdapter ();
switch (item.getItemId ()) {
case MENU_CAP:
String word=words.get (info.position);
word=word.toUpperCase ();
adapter.remove (words.get (info.position));
adapter.insert (word, info.position);
return(true);
case MENU_REMOVE:
adapter.remove (words.get (info.position));
return(true);
}
return(super.onContextItemSelected (item));
}
private void initAdapter () {
words=new ArrayList();
for (String s : items) {
words.add (s);
}
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_1, words));
}
private void add () {
final View addView=getLayoutInflater ().inflate (R.layout.add, null);
newAlertDialog.Builder (this)
.setTitle ("Add a Word")
.setView (addView)
.setPositiveButton ("OK",
new DialogInterface.OnClickListener () {
public void onClick (DialogInterface dialog,
int whichButton) {
ArrayAdapter adapter=(ArrayAdapter)getListAdapter ();
EditText title=(EditText)addView.findViewById (R.id.title);
adapter.add (title.getText ().toString ());
}
})
.setNegativeButton ("Cancel", null)
.show ();
}
}`
在onCreate()
中,我们将ListView
小部件注册为具有上下文菜单。我们还将加载适配器委托给一个initAdapter()
私有方法,该方法将数据从我们的静态String
数组中复制出来,并将其倒入一个ArrayList
,对ArrayAdapter
使用ArrayList
。我们这样做的原因是我们希望能够动态地改变列表的内容,如果我们使用一个ArrayList
而不是一个普通的String
数组,这就容易多了。
对于选项菜单,我们覆盖了onCreateOptionsMenu()
并添加了两个菜单项,一个向列表中添加新单词,另一个将单词重置为初始状态。这些菜单项的 id 在本地被定义为静态数据成员(MENU_ADD
和MENU_RESET
),它们还带有从 Android 开源项目中复制的图标。如果用户显示菜单,看起来如图图 16–1 所示。
图 16–1。 MenuDemo 示例应用及其选项菜单
我们还覆盖了onOptionsItemSelected()
,如果用户从菜单中做出选择,就会调用它。提供的MenuItem
有一个getItemId()
方法,应该映射到MENU_ADD
或MENU_RESET
。在MENU_ADD
的情况下,我们调用一个私有的add()
方法,该方法显示一个AlertDialog
,它的内容是一个自定义的View
,从res/layout/add.xml
开始膨胀:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <TextView android:text="Word:" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <EditText android:id="@+id/title" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginLeft="4dip" /> </LinearLayout>
这将产生一个类似于图 16–2 所示的对话框。
图 16–2。 同样的应用,显示出 的添加单词对话框
如果用户点击 OK 按钮,我们得到我们的ArrayAdapter
并在上面调用add()
,将输入的单词添加到列表的末尾。
如果用户选择了MENU_RESET
,我们再次调用initAdapter()
,建立一个新的ArrayAdapter
,并将其附加到我们的ListActivity
。
对于上下文菜单,我们覆盖了onCreateContextMenu()
。我们再一次用本地 id 定义了一对菜单项,MENU_CAP
(大写长点击的单词)和MENU_REMOVE
(删除单词)。因为上下文菜单没有图标,我们可以跳过这一部分。如果用户长按一个单词,就会得到如图图 16–3 所示的上下文菜单。
图 16–3。 同样的应用,显示出 的快捷菜单
我们也覆盖了onContextMenuSelected()
。由于这是一个ListView
的上下文菜单,我们的MenuItem
为我们提供了一些额外的信息——特别是,列表中哪个项目被长时间点击了。为此,我们在MenuItem
上调用getMenuInfo()
,并将结果转换为AdapterView.AdapterContextMenuInfo
。该对象又有一个位置数据成员,它是用户选择的单词在数组中的索引。从那里,我们按照要求,用我们的ArrayAdapter
来大写或删除这个单词。
更多通货膨胀
第十三章解释了如何通过 XML 文件描述View
并在运行时将它们“膨胀”成实际的View
对象。Android 还允许你通过 XML 文件描述菜单,并在需要菜单时放大菜单。这有助于将菜单结构从菜单处理逻辑的实现中分离出来,并为开发菜单创作工具提供了更简单的方法。
菜单 XML 结构
菜单 XML 放在项目树的res/menu/
中,与项目可能使用的其他类型的资源放在一起。与布局一样,项目中可以有几个菜单 XML 文件,每个文件都有自己的文件名和扩展名.xml
。
例如,在Menus/Inflation
示例项目中,这里有一个名为option.xml
的菜单:
`
-
-
`
请注意以下几点:
您必须以一个menu
根元素开始。
在一个menu
元素中有item
元素和group
元素,后者代表可以作为一个组操作的菜单项的集合。
通过添加一个menu
元素作为item
元素的子元素来指定子菜单,使用这个新的menu
元素来描述子菜单的内容。
如果您想检测一个项目何时被选择,或者从 Java 代码中引用一个项目或组,请确保应用一个android:id
,就像您使用View
布局 XML 一样。
菜单选项和 XML
在item
和group
元素中,可以指定各种选项,与Menu
或MenuItem
上的相应方法相匹配,如下所示:
标题 :菜单项的标题是通过item
元素上的android:title
属性提供的。这可以是文字字符串,也可以是对字符串资源的引用(例如,@string/foo
)。
图标 :菜单项可选有图标。要以引用可绘制资源的形式提供图标(例如,@drawable/eject
),请使用item
元素上的android:icon
属性。
顺序 :默认情况下,菜单项在菜单中的顺序由它们在菜单 XML 中出现的顺序决定。您可以通过在item
元素上指定android:orderInCategory
属性来改变这个顺序。这是一个基于0
的与当前类别相关的商品订单索引。有一个隐含的默认类别;组可以提供一个android:menuCategory
属性来为该组中的项目指定一个不同的类别。不过,一般来说,最简单的方法是将 XML 中的项目按照您希望它们出现的顺序排列。
Enabled :可以启用或禁用项目和组,在 XML 中通过item
或group
元素上的android:enabled
属性来控制。默认情况下,项目和组处于启用状态。禁用的项目和组出现在菜单中,但不能被选择。你可以通过MenuItem
上的setEnabled()
方法在运行时改变一个项目的状态,或者通过Menu
上的setGroupEnabled()
改变一个组的状态。
Visible :条目和组可以是可见的或不可见的,在 XML 中通过item
或group
元素上的android:visible
属性来控制。默认情况下,项目和组是可见的。不可见的项目和群组不会出现在菜单中。你可以通过MenuItem
上的setVisible()
方法在运行时改变一个项目的状态,或者通过Menu
上的setGroupVisible()
改变一个组的状态。
快捷方式 :项目可以有快捷方式——单个字母(android:alphabeticShortcut
)或数字(android:numericShortcut
),可以按下这些快捷方式来选择项目,而不必使用触摸屏、D-pad 或轨迹球来导航整个菜单。
膨胀菜单
实际上,一旦用 XML 定义了菜单,使用它就很容易了。只需创建一个MenuInflater
并告诉它膨胀你的菜单。
项目Menus/Inflation
是项目Menus/Menus
的克隆,菜单创建转换为使用菜单 XML 资源和MenuInflater
。选项菜单已转换为本节前面显示的 XML 以下是上下文菜单:
`
-
item android:id="@+id/remove"
android:title="Remove" />
`
Java 代码几乎是相同的,主要变化在于onCreateOptionsMenu()
和onCreateContextMenu()
的实现:
`@Override
public boolean onCreateOptionsMenu (Menu menu) {
new MenuInflater (this).inflate (R.menu.option, menu);
return(super.onCreateOptionsMenu (menu));
}
@Override
public void onCreateContextMenu (ContextMenu menu, View v,
ContextMenu.ContextMenuInfomenuInfo) {
new MenuInflater (this).inflate (R.menu.context, menu);
}`
在这里,我们看到MenuInflater
如何将菜单资源中指定的菜单项(如R.menu.option
)注入到提供的Menu
或ContextMenu
对象中。
我们还需要更改onOptionsItemSelected()
和onContextItemSelected()
以使用 XML 中指定的android:id
值:
`@Override
public boolean onOptionsItemSelected (MenuItem item) {
switch (item.getItemId ()) {
case R.id.add:
add ();
return(true);
case R.id.reset:
initAdapter ();
return(true);
}
return(super.onOptionsItemSelected (item));
}
@Override
public boolean onContextItemSelected (MenuItem item) {
AdapterView.AdapterContextMenuInfo info=
(AdapterView.AdapterContextMenuInfo)item.getMenuInfo ();
ArrayAdapter adapter=(ArrayAdapter)getListAdapter ();
switch (item.getItemId ()) {
caseR.id.cap:
String word=words.get (info.position);
word=word.toUpperCase ();
adapter.remove (words.get (info.position));
adapter.insert (word, info.position);
return(true);
case R.id.remove:
adapter.remove (words.get (info.position));
return(true);
}
return(super.onContextItemSelected (item));
}`
当巨型菜单在地球上行走
随着 Android 3.x 和 4.0 的推出,处理平板电脑和大显示器的新方法被引入并融入到平台的核心。特别是选项菜单,从菜单按钮触发变成了动作栏的下拉菜单。幸运的是,这是向后兼容的,所以您现有的菜单不需要改变来采用这种新的外观。我们将在第二十六章中讲述使用更大设备的整体含义,动作栏本身将在第二十七章的中讲述。
十七、显示弹出消息
有时候,你的活动(或者其他 Android 代码)需要大声说出来。
并不是每一次与 Android 用户的交互都是整洁的,都包含在由视图组成的片段或活动中。错误会突然出现。后台任务可能比预期花费更长时间。可能会发生一些不同步的情况,比如传入的消息。在这些和其他情况下,您可能需要在传统用户界面之外与用户进行交流。
当然,这并不是什么新鲜事。对话框形式的错误消息已经存在很长时间了。更微妙的指示器也存在,从任务托盘图标到跳跃的停靠图标到振动的手机。
Android 有很多系统可以让你在基于Activity
的用户界面之外提醒你的用户。一个是通知,它与意图和服务紧密相关,因此包含在第三十七章中。在这一章中,你将学习两种弹出消息的方式:祝酒和提醒。
举杯
一个Toast
是一个瞬时消息,意味着它在没有用户交互的情况下自己显示和消失。此外,它不会将焦点从当前活动的Activity
上移开,所以如果用户正忙于编写下一个伟大的编程指南,按键不会被消息“吃掉”。
因为Toast
是短暂的,你无法知道用户是否注意到了它。你得不到用户的确认,消息也不会停留很长时间来烦扰用户。因此,Toast
主要用于咨询消息,比如指示一个长期运行的后台任务已经完成,电池电量已经下降到低水平,等等。
制作一个Toast
相当容易。Toast
类提供了一个静态的makeText()
方法,该方法接受一个String
(或字符串资源 ID)并返回一个Toast
实例。makeText()
方法也需要Activity
(或其他Context
)加上一个持续时间。持续时间以LENGTH_SHORT
常量或LENGTH_LONG
常量的形式表示,以相对基础表示消息保持可见的时间。
如果你希望你的Toast
由其他的View
组成,而不是一段无聊的旧文本,只需通过构造函数创建一个新的Toast
实例(它需要一个Context
,然后调用setView()
来提供要使用的视图,调用setDuration()
来设置持续时间。
一旦您的Toast
被配置,调用它的show()
方法,消息将被显示。在本章的后面,你将会看到一个这样的例子。
警惕!警惕!
如果你喜欢更经典的对话框风格,你想要的是一个AlertDialog
。与任何其他模式对话框一样,会弹出一个AlertDialog
,获取焦点,并停留在那里直到被用户关闭。您可以将它用于严重错误、无法在基本活动 UI 中有效显示的验证消息,或者您确定用户需要立即看到消息的其他情况。
构造AlertDialog
最简单的方法是使用Builder
类。遵循真正的构建器风格,Builder
提供了一系列配置AlertDialog
的方法,每个方法返回Builder
以方便链接。最后,调用构建器上的show()
来显示对话框。
Builder
上常用的配置方法有以下几种:
setMessage()
:将对话框的“主体”设置为简单的文本消息,来自提供的String
或提供的字符串资源 ID
setTitle()
和setIcon()
:配置出现在对话框标题栏的文本和/或图标
setPositiveButton()
和setNegativeButton()
:指示哪个(些)按钮应该出现在对话框的底部,它们应该被放置在哪里(分别是左、中或右),它们的标题应该是什么,以及当按钮被点击时应该调用什么逻辑(除了关闭对话框之外)。
如果需要对AlertDialog
进行超出构建器允许范围的配置,不要调用show()
,而是调用create()
来获得部分构建的AlertDialog
实例,剩下的部分进行配置,然后在AlertDialog
本身上调用show()
的一种风格。一旦show()
被调用,对话框将出现并等待用户输入。
请注意,按下任何按钮都将关闭对话框,即使您已经为该按钮注册了侦听器。因此,如果你需要一个按钮来关闭对话框,给它一个标题和一个null
监听器。使用AlertDialog
时,没有选项可以让底部的按钮调用监听器,但不关闭对话框。
结账
要了解这些在实践中是如何工作的,请看一下包含以下布局的Messages/Message
:
<?xml version="1.0" encoding="utf-8"?> <Button xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/alert" android:text="Raise an alert" android:layout_width="fill_parent" android:layout_height="fill_parent" android:onClick="showAlert" />
以下是 Java 代码:
`public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
}
public void showAlert (View view) {
new AlertDialog.Builder (this)
.setTitle ("MessageDemo")
.setMessage ("Let's raise a toast!")
.setNeutralButton ("Here, here!", new DialogInterface.OnClickListener () {
public void onClick (DialogInterface dlg, int sumthin) {
Toast
.makeText (MessageDemo.this, "<clink, clink>",
Toast.LENGTH_SHORT)
.show ();
}
})
.show ();
}
}`
布局并不起眼——只有一个很大的Button
来显示AlertDialog
。然而,冰激凌三明治为AlertDialog(context, int)
形式的调用增加了两个新选项。这些选项通过THEME_DEVICE_DEFAULT_LIGHT
和THEME_DEVICE_DEFAULT_DARK
值支持设备范围的“亮”和“暗”背景报警。这些选项有助于在整个 Android 设备上推广无缝体验的概念。
当你点击Button
时,在显示对话框之前,我们使用一个生成器(new Builder(this)
)来设置标题(setTitle("MessageDemo")
)、消息(setMessage("Let's raise a toast!")
)和中性按钮(setNeutralButton("Here, here!", new OnClickListener() ...
)。当按钮被点击时,OnClickListener
回调触发Toast
类来制作一个基于文本的 toast ( makeText(this, "<clink, clink>", LENGTH_SHORT)
),然后我们将其命名为show()
。结果是一个典型的对话框,如图 Figure 17–1 所示。
图 17–1。 ??【message demo】示例应用,点击后引发一个警告按钮
当您通过按钮关闭对话框时,它会升起烤面包片,如 Figure 17–2 所示。
图 17–2。 同样的应用,点击制作敬酒按钮后
十八、处理活动生命周期事件
众所周知,Android 设备基本上都是手机。因此,有些活动比其他活动更重要——对用户来说,接电话可能比玩数独更重要。而且,因为它是一部手机,它的内存可能比你现在的台式机或笔记本要少。
由于手机内存有限,你的活动可能会被终止,因为其他活动正在进行,系统需要你的活动的内存。可以把它想象成生命循环的机器人版——你的活动结束了,其他人可能会活下来,以此类推。在您认为活动已经完成之前,甚至在用户认为活动已经完成之前,您都不能假设活动将会运行。这是一个例子,也许是最重要的例子,说明了活动的生命周期将如何影响您自己的应用逻辑。
本章涵盖了构成活动生命周期的各种状态和回调,以及如何恰当地挂钩它们。
薛定谔的活动
一般来说,活动在任何时间点都处于四种状态之一:
活动 :活动由用户启动,正在运行,在前台。这是你习惯于考虑的活动运作方式。
暂停 :活动由用户启动,正在运行,并且是可见的,但是通知或其他东西覆盖了屏幕的一部分。在此期间,用户可以看到您的活动,但可能无法与之互动。示例包括提示用户接受来电,或者警告用户电池电量低或电量极低。
停止 :该活动由用户启动,正在运行,但被其他已经启动或切换到的活动隐藏。您的应用不能直接向用户呈现任何有意义的内容,但是可以通过通知的方式进行通信。
Dead :要么该活动从未开始(例如,在电话复位之后),要么该活动被终止,可能是由于缺少可用存储器。
生命、死亡和你的活动
Android 使用本节描述的方法调用您的活动,因为活动在上一节列出的四种状态之间转换。一些转换可能会导致多次调用您的活动,有时 Android 会在不调用应用的情况下将其杀死。这整个领域是相当模糊的,可能会发生变化,所以在决定哪些事件值得关注,哪些可以安全忽略时,请密切关注 Android 官方文档以及本节。
请注意,对于所有这些方法,您应该向上链接并调用该方法的超类版本,否则 Android 可能会引发异常。
onCreate()和 onDestroy()
在所有的例子中,我们已经在所有的Activity
子类中实现了onCreate()
。在三种情况下会调用此方法:
当活动第一次启动时(例如,从系统重启开始),将使用null
参数调用onCreate()
。
如果活动一直在运行,然后过一段时间被终止,onCreate()
将被调用,来自onSaveInstanceState()
的Bundle
作为参数(如下一节所述)。
如果 activity 一直在运行,并且您已经将 activity 设置为基于不同的设备状态(例如,横向与纵向)拥有不同的资源,那么您的 activity 将被重新创建,并调用onCreate()
。第二十三章中介绍了如何使用资源。
这里是您初始化 UI 和设置任何需要一次性完成的事情的地方,不管活动是如何使用的。
在生命周期的另一端,当活动关闭时,可能会调用onDestroy()
,这可能是因为名为finish()
的活动(它“结束”了该活动),也可能是因为 Android 需要 RAM 并且过早地关闭了该活动。请注意,如果对 RAM 的需求很紧急(例如,一个来电),可能不会调用onDestroy()
,但是活动仍然会被关闭。因此,onDestroy()
主要是为了干净地释放你在onCreate()
获得的资源(如果有的话)。
在处理包含视图的活动时要小心,该视图由来自数据库(如 SQLite)的适配器填充。谨慎的做法是在数据库和/或适配器对象上调用close()
,但是也要记住,如果您的关闭是突然的,那么您不能依赖在onDestroy()
中调用这些对象。我们将在第三十二章中进一步讨论这个问题。
onStart()、onRestart()和 onStop()
一个活动可以出现在前台,因为它是第一次被启动,或者因为它在被隐藏(例如,被另一个活动或被一个呼入电话)之后被带回到前台。在这两种情况下都会调用onStart()
方法。
在活动已经停止并且现在重新开始的情况下,调用onRestart()
方法。
相反,当活动将要停止时,调用onStop()
。
onPause()和 onResume()
在您的活动进入前台之前调用onResume()
方法,无论是在最初启动之后、从停止状态重新启动之后,还是在弹出对话框(例如,来电)被清除之后。这是一个很好的地方,可以根据用户上次查看您的活动后发生的事情来刷新 UI。例如,如果您正在轮询某个服务的某些信息的更改(例如,提要的新条目),onResume()
是刷新当前视图以及(如果适用)启动后台线程来更新视图(例如,通过Handler
)的好时机。
相反,任何从您的活动中窃取用户的事情——通常是激活另一个活动——都会导致您的onPause()
方法被调用。在这里,您应该撤销您在onResume()
中所做的任何事情,比如停止后台线程、释放您可能已经获得的任何独占访问资源(例如,相机)等等。
一旦onPause()
被调用,Android 保留在任何时候终止你的活动进程的权利。因此,您不应该依赖于接收任何进一步的事件。
国家的恩惠
大多数情况下,前面提到的方法是用于处理应用级的事情(例如,在onCreate()
中把你的 UI 的最后部分连接在一起,或者在onPause()
中关闭后台线程)。
然而,Android 的很大一部分目标是拥有无缝的铜绿。活动可能根据内存需求来来去去,但理想情况下,用户不会意识到这种情况正在发生。例如,如果一个用户正在使用一个计算器,午休后又回到那个计算器,他应该会看到他在午休前正在处理的数字,除非他采取了一些措施关闭计算器(例如,按下返回按钮退出)。
为了完成所有这些工作,活动需要能够保存它们的应用实例状态,并且要快速、廉价地保存。由于活动可能在任何时候被终止,活动可能需要比您预期的更频繁地保存它们的状态。然后,当活动重新启动时,活动应该恢复到以前的状态,这样它就可以将活动恢复到以前的状态。可以把它想象成建立一个书签,这样当用户返回到该书签时,您可以将应用恢复到用户离开时的状态。
保存实例状态由onSaveInstanceState()
处理。这提供了一个Bundle
,活动可以将它们需要的任何数据(例如,计算器显示屏上显示的数字)注入其中。这个方法的实现需要很快,所以不要太花哨——只需将数据放入Bundle
中,然后退出该方法。
该实例状态在两个地方再次提供给您:在onCreate()
和onRestoreInstanceState()
中。当您希望将状态数据重新应用到活动时,这是您的选择——任一回调都是合理的选择。
onSaveInstanceState()
的内置实现将保存部件子集的可能可变状态。例如,它会将文本保存在EditText
中,但不会保存Button
是启用还是禁用的状态。只要小部件通过它们的android:id
属性被唯一识别,这就可以工作。
因此,如果您实现了onSaveInstanceState()
,您可以向上链接并利用继承的实现,或者不向上链接并覆盖继承的实现。类似地,有些活动可能根本不需要onSaveInstanceState()
来实现,因为内置的活动会处理所有需要的事情。
十九、处理旋转
一些 Android 设备提供滑出式键盘,可以触发屏幕从纵向旋转到横向。其他设备使用加速度计来确定屏幕何时旋转。因此,有理由假设从纵向到横向的切换可能是您的应用的用户想要做的事情。
正如本章所描述的,Android 有很多方法可以让你处理屏幕旋转,这样你的应用就可以正确地处理任何一个方向。但是请记住,这些工具只能帮助您检测和管理旋转过程——您仍然必须确保您的布局和片段在每个方向上都看起来不错。
毁灭的哲学
默认情况下,当设备配置发生可能影响资源选择的变化时,Android 将在下次查看时销毁并重新创建任何正在运行或暂停的活动。各种不同的配置更改都会发生这种情况,包括:
旋转屏幕(即方向改变)
在具有滑动键盘的设备上扩展或隐藏物理键盘
将设备放入汽车或桌面坞站,或从坞站中移除
更改区域设置,从而更改首选语言
屏幕旋转是最容易出错的变化,因为方向的变化会导致应用加载一组不同的资源(例如布局)。
这里的关键是,Android 的默认行为破坏并重新创建任何正在运行或暂停的活动,这可能是最适合您的大多数活动的行为。不过,您确实可以控制这件事,并且可以定制您的活动如何响应方向变化或类似的配置切换。
都一样,只是不同
由于默认情况下,Android 会在循环中销毁并重新创建您的活动,因此您可能只需要连接到相同的onSaveInstanceState()
,如果您的活动因任何其他原因被销毁(例如,内存不足或我们在第十八章中讨论的其他原因)。在您的活动中实现该方法,并在提供的Bundle
中填入足够的信息,让您回到当前状态。然后,在onCreate()
(或者onRestoreInstanceState()
,如果你喜欢的话),从Bundle
中挑选数据,用它来恢复你的活动。
为了证明这一点,我们来看一下Rotation/RotationOne
项目。本章中的这个和其他示例项目使用一对main.xml
布局,一个在res/layout/
中用于纵向模式,一个在res/layout-land/
中用于横向模式。以下是纵向布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/pick" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="Pick" android:enabled="true" android:onClick="pickContact" /> <Button android:id="@+id/view" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="View" android:enabled="false" android:onClick="viewContact" /> </LinearLayout>
这里是类似的景观布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/pick" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="Pick" android:enabled="true" android:onClick="pickContact"
/> <Button android:id="@+id/view" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="View" android:enabled="false" android:onClick="viewContact" /> </LinearLayout>
基本上,这两种布局都包含一对按钮,每个按钮占据半个屏幕。在纵向模式下,按钮是堆叠的;在横向模式下,它们是并排的。
如果您只是创建一个项目,放入这两个布局,然后编译它,应用看起来工作得很好——旋转(模拟器中的 Ctrl+F12)将导致布局改变。虽然按钮没有状态,但是如果你正在使用其他小部件(例如EditText
),你甚至会发现 Android 为你保留了一些小部件的状态(例如在EditText
中输入的文本)。
Android 不能自动帮助你的是小部件之外的任何东西。
选择并查看联系人
该应用允许用户选择一个联系人,然后通过单独的按钮查看该联系人。仅当用户通过挑选按钮挑选联系人后,查看按钮才被启用。让我们仔细看看这一壮举是如何完成的。
当用户点击 Pick 按钮时,我们调用startActivityForResult()
。这是startActivity()
的一个变体,设计用于返回某种结果的活动——用户选择的文件、联系人或其他。相对来说,很少有活动是这样安排的,所以你不能指望打电话给startActivityForResult()
并从你选择的任何活动中得到答案。
在这种情况下,我们要选择一个联系人。Android 中有一个ACTION_PICKIntent
动作就是为这种场景设计的。一个ACTION_PICKIntent
向 Android 表示我们要挑选…某样东西。那个“某物”是由我们放入Intent
的Uri
决定的。
在我们的例子中,我们可以使用一个ACTION_PICKIntent
作为某些系统定义的Uri
值,让用户从设备的联系人列表中选择一个联系人。特别是在 Android 2.0 及更高版本上,我们可以使用android.provider.ContactsContract.Contacts.CONTENT_URI
来实现这个目的:
`public void pickContact (View v) {
Intent i=new Intent (Intent.ACTION_PICK,
Contacts.CONTENT_URI);
startActivityForResult (i, PICK_REQUEST);
}`
对于 Android 1.6 和更早的版本,我们可以使用一个单独的android.provider.Contacts.CONTENT_URI
。
startActivityForResult()
的第二个参数是一个标识号,帮助我们将这个对startActivityForResult()
的调用与我们可能进行的任何其他调用区分开来。用ACTION_PICKIntent
呼叫Contacts.CONTENT_URI
的startActivityForResult()
将会调出一个由 Android 提供的联系人选择活动。
当用户点击一个联系人时,picker 活动结束(例如,通过finish()
),并且控制返回到我们的活动。此时,我们的活动用onActivityResult()
调用。Android 为我们提供了三条信息:
我们提供给startActivityForResult()
的标识号,因此我们可以将这个结果与其原始请求进行匹配
结果状态RESULT_OK
或RESULT_CANCELED
,指示用户是否做出了肯定的选择或放弃了选取器(例如,通过按下后退按钮)
对于RESULT_OK
响应,代表结果数据本身的Intent
您调用的活动需要记录Intent
中的详细内容。在Contacts.CONTENT_URI
的ACTION_PICKIntent
的情况下,返回的Intent
有它自己的Uri
(通过getData()
)来代表选择的联系人。在RotationOne
示例中,我们将它放在活动的数据成员中,并启用 View 按钮:
@Override protected void **onActivityResult**(int requestCode, int resultCode, Intent data) { if (requestCode==PICK_REQUEST) { if (resultCode==RESULT_OK) { contact=data**.getData**(); viewButton**.setEnabled**(true); } } }
如果用户点击现在启用的视图按钮,我们在联系人的Uri
上创建一个ACTION_VIEWIntent
,并在那个Intent
上调用startActivity()
:
public void **viewContact**(View v) { **startActivity**(new **Intent**(Intent.ACTION_VIEW, contact)); }
这将弹出一个 Android 提供的活动来查看该联系人的详细信息。
保存您的状态
假设我们已经使用了startActivityForResult()
来选择一个联系人,现在我们需要在屏幕方向改变时保持这个联系人。在RotationOne
示例中,我们通过onSaveInstanceState()
来实现:
`package com.commonsware.android.rotation.one;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.ContactsContract.Contacts;
import android.view.View;
import android.widget.Button;
import android.util.Log;
public class RotationOneDemo extends Activity {
static final int PICK_REQUEST=1337;
Button viewButton=null;
Uri contact=null;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
viewButton=(Button)findViewById (R.id.view);
restoreMe (savedInstanceState);
viewButton.setEnabled (contact!=null);
}
@Override
protected void onActivityResult (int requestCode, int resultCode,
Intent data) {
if (requestCodePICK_REQUEST) {
if (resultCode RESULT_OK) {
contact=data.getData ();
viewButton.setEnabled (true);
}
}
}
public void pickContact (View v) {
Intent i=new Intent (Intent.ACTION_PICK,
Contacts.CONTENT_URI);
startActivityForResult (i, PICK_REQUEST);
}
public void viewContact (View v) {
startActivity (new Intent (Intent.ACTION_VIEW, contact));
}
@Override
protected void onSaveInstanceState (Bundle outState) {
super.onSaveInstanceState (outState);
if (contact!=null) {
outState.putString ("contact", contact.toString ());
}
}
private void restoreMe (Bundle state) {
contact=null;
if (state!=null) {
String contactUri=state.getString ("contact");
if (contactUri!=null) {
contact=Uri.parse (contactUri);
}
}
}
}`
总的来说,这看起来像一个正常的活动…因为它是。最初,“模型”——一个名为contact
的Uri
——是null
。它被设置为生成ACTION_PICK
子活动的结果。它的字符串表示保存在onSaveInstanceState()
中,并在restoreMe()
(从onCreate()
调用)中恢复。如果联系人不是null
,查看按钮被激活,可用于查看所选联系人。
从视觉上看,它看起来和你想象的差不多,如图图 19–1 和 19–2 所示。
图 19–1。 纵向模式下的 RotationOne 应用
图 19–2。 rotation one 应用,在风景模式下
这种实现的好处是,它处理许多系统事件,而不仅仅是旋转,比如由于内存不足而被 Android 关闭。
出于好玩,注释掉onCreate()
中的restoreMe()
调用,并尝试运行应用。当你旋转仿真器或设备时,你会看到应用“忘记”在一个方向选择的一个触点。
现在节省更多!
onSaveInstanceState()
的问题在于,你被限制在一个Bundle
之内。这是因为这个回调也用于整个进程可能被终止的情况(例如,内存不足),所以要保存的数据必须是可以序列化的,并且不依赖于正在运行的进程。
对于某些活动,这种限制不是问题。对其他人来说,就更烦了。以网上聊天为例。您无法在Bundle
中存储套接字,因此默认情况下,您必须断开与聊天服务器的连接,然后重新建立连接。这不仅可能会影响性能,还可能会影响聊天本身,例如在聊天日志中显示您正在断开连接和重新连接。
解决这个问题的一个方法是使用onRetainNonConfigurationInstance()
而不是onSaveInstanceState()
来表示“光”的变化,比如旋转。您的活动的onRetainNonConfigurationInstance()
回调可以返回一个Object
,稍后您可以通过getLastNonConfigurationInstance()
检索它。Object
可以是你想要的任何东西。通常,它是某种保存活动状态的“上下文”对象,如运行线程、打开套接字等。你的活动的onCreate()
可以调用getLastNonConfigurationInstance()
,如果你得到一个非null
的响应,你现在就有了你的套接字、线程等等。最大的限制是,您不希望在保存的上下文中放置任何可能引用将被换出的资源的内容,比如从资源中加载的Drawable
。
让我们看看Rotation/RotationTwo
示例项目,它使用这种方法来处理旋转。布局和视觉外观与Rotation/RotationOne
相同。略有不同的是 Java 代码:
`package com.commonsware.android.rotation.two;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.ContactsContract.Contacts;
import android.view.View;
import android.widget.Button;
import android.util.Log;
public class RotationTwoDemo extends Activity {
static final int PICK_REQUEST=1337;
Button viewButton=null;
Uri contact=null;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
viewButton=(Button)findViewById (R.id.view);
restoreMe ();
viewButton.setEnabled (contact!=null);
}
@Override
protected void onActivityResult (int requestCode, int resultCode,
Intent data) {
if (requestCodePICK_REQUEST) {
if (resultCode RESULT_OK) {
contact=data.getData ();
viewButton.setEnabled (true);
}
}
}
public void pickContact (View v) {
Intent i=new Intent (Intent.ACTION_PICK,
Contacts.CONTENT_URI);
startActivityForResult (i, PICK_REQUEST);
}
public void viewContact (View v) {
startActivity (new Intent (Intent.ACTION_VIEW, contact));
}
@Override
public Object onRetainNonConfigurationInstance () {
return(contact);
}
private void restoreMe () {
contact=null;
if (getLastNonConfigurationInstance ()!=null) {
contact=(Uri)getLastNonConfigurationInstance ();
}
}
}`
在这种情况下,我们覆盖了onRetainNonConfigurationInstance()
,返回联系人的实际Uri
,而不是它的字符串表示。反过来,restoreMe()
呼叫getLastNonConfigurationInstance()
,如果不是null
,我们将它作为联系人并启用查看按钮。
这里的优点是我们传递的是Uri
而不是字符串表示。在这种情况下,这不是一个很大的节省。但是我们的状态可能要复杂得多,包括线程、套接字和其他我们无法打包到Bundle
中的东西。
然而,即使是处理旋转的onRetainNonConfigurationInstance()
方法也可能对您的应用造成太大的干扰。例如,假设您正在创建一个实时游戏,例如第一人称射击游戏。当你的活动被破坏和重新创建时,你的用户所经历的“打嗝”可能足以让他们中枪,但他们可能不欣赏。虽然这在 T-Mobile G1 上不是什么问题,但由于旋转需要滑动打开键盘,因此不太可能在游戏中途完成,其他设备可能会仅根据加速计确定的设备位置进行旋转。对于这样的应用,还有第三种处理旋转的可能性,那就是告诉 Android 你将自己处理它们,而不需要框架的任何帮助。
DIY 旋转
要在没有 Android 帮助的情况下处理旋转,请执行以下操作:
在您的AndroidManifest.xml
文件中放入一个android:configChanges
条目,列出您想要自己处理的配置更改和让 Android 为您处理的配置更改。
在您的Activity
中实现onConfigurationChanged()
,当您在android:configChanges
中列出的配置变化之一发生时,将会调用它。
现在,对于您想要的任何配置更改,您可以绕过整个活动销毁过程,只需获得一个回调,让您知道更改。
要了解这一点,请转到Rotation/RotationThree
示例应用。同样,我们的布局是相同的,因此应用看起来与前两个示例相同。然而,Java 代码有很大的不同,因为我们不再关心保存我们的状态,而是更新我们的 UI 来处理布局。
但是首先,我们需要对我们的清单做一个小小的改动:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.rotation.three" android:versionCode="1" android:versionName="1.0.0"> <uses-sdk android:minSdkVersion="5" android:targetSdkVersion="6"/> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".RotationThreeDemo" android:label="@string/app_name" android:configChanges="keyboardHidden|orientation"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
这里,我们声明我们将自己处理keyboardHidden
和orientation
配置变更。这涵盖了任何原因的旋转,无论是滑动键盘还是物理旋转。请注意,这是在活动上设置的,而不是在应用上。如果你有几项活动,你将需要为每一项活动决定你希望使用本章概述的哪一种策略。
此外,我们需要向我们的LinearLayout
容器添加一个android:id
,如下所示:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/container" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/pick" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="Pick" android:enabled="true" android:onClick="pickContact" /> <Button android:id="@+id/view" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="View" android:enabled="false" android:onClick="viewContact" /> </LinearLayout>
这个项目的 Java 代码如下所示:
package com.commonsware.android.rotation.three;
`import android.app.Activity;
import android.content.Intent;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.provider.ContactsContract.Contacts;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
public class RotationThreeDemo extends Activity {
static final int PICK_REQUEST=1337;
Button viewButton=null;
Uri contact=null;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
viewButton=(Button)findViewById (R.id.view);
viewButton.setEnabled (contact!=null);
}
@Override
protected void onActivityResult (int requestCode, int resultCode,
Intent data) {
if (requestCodePICK_REQUEST) {
if (resultCode RESULT_OK) {
contact=data.getData ();
viewButton.setEnabled (true);
}
}
}
public void pickContact (View v) {
Intent i=new Intent (Intent.ACTION_PICK,
Contacts.CONTENT_URI);
startActivityForResult (i, PICK_REQUEST);
}
public void viewContact (View v) {
startActivity (new Intent (Intent.ACTION_VIEW, contact));
}
public void onConfigurationChanged (Configuration newConfig) {
super.onConfigurationChanged (newConfig);
LinearLayout container=(LinearLayout)findViewById (R.id.container);
if (newConfig.orientation==Configuration.ORIENTATION_LANDSCAPE) {
container.setOrientation (LinearLayout.HORIZONTAL);
}
else {
container.setOrientation (LinearLayout.VERTICAL);
}
}
}`
我们的onConfigurationChanged()
需要更新 UI 来反映方向的变化。在这里,我们找到我们的LinearLayout
,并告诉它改变方向,以匹配设备的方向。Configuration
对象上的orientation
字段将告诉我们设备是如何定向的。
…但是谷歌不推荐这个
你可能认为onConfigurationChanged()
和android:configChanges
是处理旋转的最终解决方案。毕竟,随着旧活动被破坏,我们不再需要担心将数据杂乱地传递给新活动。onConfigurationChanged()
的做法非常性感。
但是,谷歌并不推荐。
主要担心的是忘记资源。使用onConfigurationChanged()
方法,您必须确保由于这种配置变化而可能发生变化的每个资源都得到更新。这包括字符串、布局、可绘制性、菜单、动画、偏好、尺寸、颜色和所有其他内容。如果你不能确保所有的东西都被完全更新,你的应用将会有一系列的小错误。
允许 Android 破坏和重新创建您的活动保证您将获得适当的资源。您需要做的就是安排将适当的数据从旧活动传递到新活动。
onConfigurationChanged()
方法仅适用于用户会直接受到破坏-创建循环影响的情况。例如,想象一个正在播放视频流的视频播放器应用。销毁并重新创建活动必然会导致应用必须重新连接到流,并在此过程中丢失缓冲数据。如果意外的移动导致设备改变方向并中断他们的视频播放,用户会感到沮丧。在这种情况下,由于用户会察觉到破坏-创建循环的问题,onConfigurationChanged()
是一个合适的选择。
强行提出问题
有些活动根本不是为了改变方向。例如,游戏、相机预览、视频播放器等可能仅在横向方向有意义。虽然大多数活动应该允许用户在任何期望的方向上工作,但是对于只有一个方向有意义的活动,您可以控制它。
要阻止 Android 旋转您的活动,您需要做的就是将android:screenOrientation = "portrait"
(或"landscape"
,随您喜欢)添加到您的AndroidManifest.xml
文件中,如下所示(来自Rotation/RotationFour
示例项目):
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.rotation.four" android:versionCode="1"
android:versionName="1.0.0"> <uses-sdk android:minSdkVersion="5" android:targetSdkVersion="6"/> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".RotationFourDemo" android:screenOrientation= "portrait" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
由于这是在每个活动的基础上应用的,您将需要决定您的哪些活动可能需要打开它。
此时,无论您做什么,您的活动都被锁定在您指定的方向。图 19–3 和 19–4 显示了与前三节相同的活动,但是使用了前面的清单,并且模拟器设置为纵向和横向。请注意,UI 没有移动一点,而是保持在纵向模式。
图 19–3。 在纵向模式下旋转四个应用
图 19–4。 ??【rotation four】应用,在风景模式下
请注意,Android 仍然会破坏和重新创建您的活动,即使您将方向设置为特定值,如下所示。如果您希望避免这种情况,您还需要在清单中设置android:configChanges
,如本章前面所述。或者,您仍然可以使用onSaveInstanceState()
或onRetainNonConfigurationInstance()
来保存活动的可变状态。
理解这一切
正如本章开头所提到的,带有滑出式键盘的设备(如 T-Mobile G1、摩托罗拉 DROID/Milestone 等。)在键盘暴露或隐藏时改变屏幕方向,而其他设备基于加速度计改变屏幕方向。如果你有一个基于加速度计改变方向的活动,即使这个设备有一个滑出式键盘,只需将android:screenOrientation = "sensor"
添加到AndroidManifest.xml
文件中,如下所示(来自Rotation/RotationFive
示例项目):
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.rotation.five" android:versionCode="1" android:versionName="1.0.0">
<uses-sdk android:minSdkVersion="5" android:targetSdkVersion="6"/> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".RotationFiveDemo" android:screenOrientation="sensor" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
在这种情况下,传感器告诉 Android 您希望加速度计控制屏幕方向,因此设备方向的物理移动控制了屏幕方向。
Android 2.3 为android:screenOrientation
增加了许多其他可能的值:
reverseLandscape
和reversePortrait
:分别表示您希望屏幕处于横向或纵向,但与正常的横向和纵向相比,屏幕上下颠倒
sensorLandscape
和sensorPortrait
:分别表示您希望屏幕锁定在横向或纵向,但传感器可用于确定哪一侧“向上”
fullSensor
:允许传感器将屏幕置于四个可能的方向(纵向、反向纵向、横向、反向横向),而sensor
只能在纵向和横向之间切换
更高版本的 Android 增加了更多的可能性:
behind
:匹配此活动背后的方向
user
:采用用户手机范围内的定向行为偏好(这显然依赖于使用提供全局设置选项的设备)
您的偏好和选项通过使用片段得到进一步扩展,这将在第二十八章中的专门章节中讨论。
二十、处理线程
用户喜欢简洁的应用。用户不喜欢感觉迟钝的应用。让你的应用让用户感觉爽快的方法是使用 Android 内置的标准线程功能。本章将带你了解 Android 中线程管理的相关问题,以及一些保持 UI 简洁和响应的选项。
主应用线程
你可能会认为,当你在一个TextView
上调用setText()
时,屏幕会立即更新你提供的文本。事情不是这样的。相反,所有修改基于小部件的 UI 的事情都要经过一个消息队列。对setText()
的调用不更新屏幕;他们只是在队列中弹出一条消息,告诉操作系统更新屏幕。操作系统将这些消息从队列中弹出,并执行消息要求的操作。
队列由一个线程处理,分别称为主应用线程 和 UI 线程 。只要该线程能够继续处理消息,屏幕就会更新,用户输入就会被处理,等等。
然而,主应用线程也用于对活动的几乎所有回调。你的onCreate()
、onClick()
、onListItemClick()
以及类似的方法都是在主应用线程上调用的。当您的代码在这些方法中执行时,Android 不会处理队列中的消息,这意味着屏幕不会更新,用户输入不会被处理,等等。
这当然是不好的。事实上,糟糕的是,如果你在主应用线程上花费超过几秒钟的时间,Android 可能会显示可怕的“应用没有响应”(ANR)错误,你的活动可能会被终止。因此,您希望确保您在主应用线程上的所有工作快速进行。这意味着任何缓慢的事情都应该在后台线程中完成,以免占用主应用线程。这包括以下活动:
互联网访问,如向 web 服务发送数据或下载图像
重要的文件操作,因为闪存存储有时会非常慢
任何复杂的计算
幸运的是,Android 支持使用来自 Java 的标准Thread
类的线程,以及所有你能想到的包装器和控制结构,比如java.util.concurrent
类包。
然而,有一个很大的限制:您不能从后台线程修改 UI。您只能从主应用线程修改 UI。因此,您需要将长期运行的工作转移到后台线程中,但是这些线程需要做一些事情来安排使用主应用线程更新 UI。Android 提供了大量的工具来做到这一点,这些工具是本章的主要焦点。
使用进度条取得进展
如果您打算派生后台线程来代表用户工作,您应该考虑让用户知道工作正在进行。如果用户实际上在等待后台工作完成,这一点尤其正确。
让用户了解进度的典型方法是某种形式的进度条,就像你在许多桌面操作系统中将一堆文件从一个地方复制到另一个地方时看到的那样。Android 通过ProgressBar
小部件支持这一点。
一个ProgressBar
跟踪进度,定义为一个整数,0
表示没有取得任何进展。您可以通过setMax()
定义范围的最大值——该值表示进度已完成。默认情况下,ProgressBar
以进度0
开始,尽管您可以通过setProgress()
从其他位置开始。如果你希望你的进度条不确定,使用setIndeterminate()
并将其设置为true
。
在您的 Java 代码中,您可以积极地设置已经取得的进展量(通过setProgress()
)或者从当前量增加进展量(通过incrementProgressBy()
)。你可以通过getProgress()
了解进展情况。
还有其他显示进度的方法——ProgressDialog
,活动标题栏中的进度指示器,等等——但是ProgressBar
是一个很好的起点。
通过处理程序
制作 Android 友好的后台线程最灵活的方法是创建一个Handler
子类的实例。每个活动只需要一个Handler
对象,不需要手工注册。仅仅创建实例就足以将它注册到 Android 线程子系统。
您的后台线程可以与Handler
通信,它将在活动的 UI 线程上完成所有工作。这一点很重要,因为 UI 更改(比如更新小部件)应该只发生在活动的 UI 线程上。
与Handler
通信有两种选择:消息和Runnable
对象。
消息
要将一个Message
发送给一个Handler
,首先调用obtainMessage()
将Message
对象从池中取出。有几种风格的obtainMessage()
,允许您创建空的Message
对象或填充了消息标识符和参数的对象。您的Handler
处理需要越复杂,您就越有可能需要将数据放入Message
来帮助Handler
区分不同的事件。
然后,通过消息队列将Message
发送到Handler
,使用sendMessage...()
系列方法之一,如下所示:
sendMessage()
:立即将消息放入队列
sendMessageAtFrontOfQueue()
:立即将消息放入队列,并将其放在消息队列的前面(而不是后面,这是默认设置),因此您的消息优先于所有其他消息
sendMessageAtTime()
:在指定的时间将消息放入队列,根据系统正常运行时间以毫秒表示(SystemClock.uptimeMillis()
)
sendMessageDelayed()
:延迟一段时间后将消息放入队列,以毫秒表示
sendEmptyMessage()
:向队列发送一个空的Message
对象,如果您打算让它为空,就可以跳过obtainMessage()
步骤
为了处理这些消息,您的Handler
需要实现handleMessage()
,它将被出现在消息队列中的每条消息调用。在那里,Handler
可以根据需要更新 UI。然而,它仍然应该很快完成这项工作,因为其他 UI 工作会暂停,直到Handler
完成。
例如,让我们创建一个ProgressBar
并通过一个Handler
更新它。下面是来自Threads/Handler
示例项目的布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <ProgressBar android:id="@+id/progress" style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout>
除了正常设置宽度和高度之外,ProgressBar
还使用了style
属性。这种特殊的风格表明ProgressBar
应该被绘制成传统的水平条,显示已经完成的工作量。
这是 Java:
`package com.commonsware.android.threads;
import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.widget.ProgressBar;
import java.util.concurrent.atomic.AtomicBoolean;
public class HandlerDemo extends Activity {
ProgressBar bar;
Handler handler=new Handler () {
@Override
public void handleMessage (Message msg) {
bar.incrementProgressBy (5);
}
};
AtomicBoolean isRunning=new AtomicBoolean (false);
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
bar=(ProgressBar)findViewById (R.id.progress);
}
public void onStart () {
super.onStart ();
bar.setProgress (0);
Thread background=new Thread(new Runnable () {
public void run () {
try {
for (int i=0;i<20 && isRunning.get ();i++) {
Thread.sleep (1000);
handler.sendMessage(handler.obtainMessage ());
}
}
catch (Throwable t) {
// just end the background thread
}
}
});
isRunning.set (true);
background.start ();
}
public void onStop () {
super.onStop ();
isRunning.set (false);
}
}`
作为构建Activity
的一部分,我们用handleMessage()
的实现创建了一个Handler
的实例。基本上,对于收到的任何消息,我们通过5
点更新ProgressBar
,然后退出消息处理程序。
然后我们利用onStart()
和onStop()
。在onStart()
中,我们设置了一个后台线程。在真实的系统中,这个线程会做一些有意义的事情。在这里,我们只需休眠 1 秒钟,向Handler
发送一个Message
,并重复总共20
次。由于ProgressBar
的默认最大值是100
,这与ProgressBar
位置的 5 点增加相结合,将使该条在屏幕上清晰行进。您可以通过setMax()
调整最大值。例如,您可以将最大值设置为正在处理的数据库行数,并且每行更新一次。
注意,我们接着离开 onStart()
。这一点至关重要。在活动 UI 线程上调用了onStart()
方法,因此它可以更新小部件等。然而,这意味着我们需要离开onStart()
,既要让Handler
完成工作,又要告诉 Android 我们的活动没有停滞。
产生的活动只是一个水平进度条,如图 Figure 20–1 所示。
图 20–1。 handler demo 样本应用
请注意,虽然像这样的ProgressBar
示例显示了您的代码安排更新 UI 线程的进度,但对于这个特定的小部件,这不是必需的。至少从 Android 1.5 开始,ProgressBar
现在是 UI 线程安全的,因为你可以从任何线程更新它,它将处理在 UI 线程上执行实际 UI 更新的细节。
不可执行
如果您不想对Message
对象大惊小怪,您也可以将Runnable
对象传递给Handler
,它将在活动 UI 线程上运行这些Runnable
对象。Handler
提供了一组post...()
方法来传递Runnable
对象进行最终处理。
正如Handler
支持post()
和postDelayed()
将Runnable
对象添加到事件队列中一样,您可以在任何View
(即任何小部件或容器)上使用相同的方法。这稍微简化了您的代码,因为您可以跳过Handler
对象。
我的 UI 线程去哪里了?
有时,您可能不知道您当前是否正在应用的 UI 线程上执行。例如,如果您将一些代码打包在一个 JAR 中供其他人重用,您可能不知道您的代码是在 UI 线程上执行还是从后台线程执行。
为了帮助解决这个问题,Activity
提供了runOnUiThread()
。这类似于Handler
和View
上的post()
方法,如果你现在不在 UI 线程上,它将一个Runnable
排队在 UI 线程上运行。如果您已经在 UI 线程上,它会立即调用Runnable
。这给了你两全其美的好处:如果你在 UI 线程上,没有延迟;如果你不在,也很安全。
令人激动的感觉
Android 1.5 引入了后台操作的新思路:AsyncTask
。在一个(相当)方便的类中,Android 处理所有在 UI 线程和后台线程上工作的杂务。此外,Android 本身会分配和删除那个后台线程。而且,它保持了一个小的工作队列,进一步强调了“一劳永逸”的感觉。
理论
有一种说法在营销界很流行,“当一个人在五金店买 1/4 英寸的钻头时,他想要的不是 1/4 英寸的钻头,而是 1/4 英寸的孔。”五金店不能卖洞,所以他们卖退而求其次的东西:让打洞变得容易的设备(钻子和钻头)。
同样,一直纠结于后台线程管理的 Android 开发者,严格来说也不是想要后台线程。相反,他们希望工作在 UI 线程之外完成,这样用户就不会陷入等待,活动也不会出现可怕的 ANR 错误。虽然 Android 不能神奇地让工作不消耗 UI 线程时间,但它可以提供一些东西,使这种后台操作更容易、更透明。AsyncTask
就是这样一个例子。
要使用AsyncTask
,您必须执行以下操作:
创建AsyncTask
的子类,通常作为使用任务的私有内部类(例如,活动)
覆盖一个或多个AsyncTask
方法来完成后台工作,以及与需要在 UI 线程上完成的任务相关的任何工作(例如,更新进度)
需要时,创建一个AsyncTask
子类的实例并调用execute()
让它开始工作
你需要做的是
创建自己的后台线程
在适当时候终止后台线程
调用各种方法来安排在 UI 线程上完成的处理
AsyncTask、泛型和 Varargs
创建AsyncTask
的子类不像实现Runnable
接口那么简单。AsyncTask
使用泛型,因此需要指定三种数据类型:
处理任务所需的信息类型(例如,要下载的 URL)
在任务中传递以指示进度的信息类型
任务完成时传递给任务后代码的信息类型
更令人困惑的是,前两个数据类型实际上被用作 varargs,这意味着在您的AsyncTask
子类中使用了这些类型的数组。
当我们朝着一个例子前进时,这一点会变得更加清楚。
异步任务的各个阶段
你可以在AsyncTask
中忽略四种方法来实现你的目标。
为了使任务类有用,您必须覆盖的是doInBackground()
。这将由AsyncTask
在后台线程上调用。只要有必要,它就可以运行,以完成这个特定任务需要完成的任何工作。不过,请注意,任务是有限的;不建议对无限循环使用AsyncTask
。
doInBackground()
方法将接收一个 varargs 数组作为参数,该数组是上一节中列出的三种数据类型中的第一种——处理任务所需的数据。因此,如果您的任务是下载一组 URL,doInBackground()
将接收这些 URL 进行处理。doInBackground()
方法必须返回前一节中列出的第三种数据类型的值——后台工作的结果。
您可能希望覆盖onPreExecute()
。在后台线程执行doInBackground()
之前,从 UI 线程调用这个方法。在这里,您可以初始化一个ProgressBar
,或者指示后台工作正在开始。
此外,您可能希望覆盖onPostExecute()
。在doInBackground()
完成之后,从 UI 线程调用这个方法。它接收由doInBackground()
返回的值作为参数(例如,成功或失败标志)。在这里,您可以关闭ProgressBar
并利用后台完成的工作,比如更新列表的内容。
此外,您可能希望覆盖onProgressUpdate()
。如果doInBackground()
调用任务的publishProgress()
方法,传递给该方法的对象被提供给onProgressUpdate()
,但是在 UI 线程中。这样,onProgressUpdate()
可以提醒用户后台工作的进展,比如更新ProgressBar
或者继续播放动画。onProgressUpdate()
方法将从前面的列表中接收第二种数据类型的 varargs 由doInBackground()
通过publishProgress()
发布的数据。
一个示例任务
如前所述,实现一个AsyncTask
不像实现一个Runnable
那么容易。然而,一旦你过了泛型和 varargs 这一关,就不会太糟糕了。
例如,下面是一个来自Threads/Asyncer
示例项目的ListActivity
的实现,它使用了一个AsyncTask
:
`package com.commonsware.android.async;
import android.app.ListActivity;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.SystemClock;
import android.widget.ArrayAdapter;
import android.widget.Toast;
import java.util.ArrayList;`
`public class AsyncDemo extends ListActivity {
private static final String[] items={"lorem", "ipsum", "dolor",
"sit", "amet", "consectetuer",
"adipiscing", "elit", "morbi",
"vel", "ligula", "vitae",
"arcu", "aliquet", "mollis",
"etiam", "vel", "erat",
"placerat", "ante",
"porttitor", "sodales",
"pellentesque", "augue",
"purus"};
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_1,
new ArrayList ()));
new AddStringTask().execute ();
}
class AddStringTask extends AsyncTask<Void, String, Void> {
@Override
protected Void doInBackground (Void... unused) {
for (String item : items) {
publishProgress (item);
SystemClock.sleep (200);
}
return(null);
}
@Override
protected void onProgressUpdate (String... item) {
((ArrayAdapter)getListAdapter()).add (item[0]);
}
@Override
protected void onPostExecute (Void unused) {
Toast
.makeText (AsyncDemo.this, "Done!", Toast.LENGTH_SHORT)
.show ();
}
}
}`
这是本书中频繁使用的词汇列表的另一个变体。这一次,我们不是简单地将单词列表交给一个ArrayAdapter
,而是模拟使用我们的AsyncTask
实现AddStringTask
在后台创建这些单词。
让我们一段一段地检查这个项目的代码。
addstring task 声明
AddStringTask
声明如下:
class AddStringTask extends AsyncTask<Void, String, Void> {
这里,我们使用泛型来设置我们将在AddStringTask
中利用的特定数据类型:
在这种情况下,我们不需要任何配置信息,所以我们的第一个类型是Void
。
我们希望将我们的后台任务生成的每个字符串传递给onProgressUpdate()
,以允许我们将它添加到我们的列表中,所以我们的第二个类型是String
。
严格地说,我们没有任何结果(除了更新),所以我们的第三种类型是Void
。
doInBackground()方法
代码中的下一步是doInBackground()
方法:
`@Override
protected Void doInBackground (Void... unused) {
for (String item : items) {
publishProgress (item);
SystemClock.sleep (200);
}
return(null);
}`
在后台线程中调用doInBackground()
方法。因此,我们喜欢多久就多久。在一个生产应用中,我们可能会做一些类似于遍历一个 URL 列表并下载每个 URL 的事情。在这里,我们迭代我们的静态列表 lorem ipsum 单词,为每个单词调用publishProgress()
,然后休眠 200 毫秒来模拟正在完成的实际工作。
既然我们选择了没有配置信息,我们应该不需要参数来doInBackground()
。然而,与AsyncTask
的约定说我们必须接受第一个数据类型的 varargs,这就是为什么我们的方法参数是Void... unused
。
既然我们选择了没有结果,我们应该不需要返回任何东西。尽管如此,与AsyncTask
的契约说我们必须返回第三种数据类型的对象。由于数据类型是Void
,我们返回的对象是null
。
onProgressUpdate()方法
接下来是onProgressUpdate()
方法:
@Override protected void **onProgressUpdate**(String... item) { ((ArrayAdapter)getListAdapter()).**add**(item[0]); }
在 UI 线程上调用了onProgressUpdate()
方法,我们想做一些事情让用户知道我们在加载这些字符串方面取得了进展。在这种情况下,我们简单地将字符串添加到ArrayAdapter
中,因此它被追加到列表的末尾。
onProgressUpdate()
方法接收一个String...
varargs,因为这是我们的类声明中的第二个数据类型。因为我们每次调用publishProgress()
只传递一个字符串,所以我们只需要检查 varargs 数组中的第一个条目。
onPostExecute()方法
下一个方法是onPostExecute()
:
@Override protected void **onPostExecute**(Void unused) { Toast .**makeText**(AsyncDemo.this, "Done!", Toast.LENGTH_SHORT) .**show**(); }
在 UI 线程上调用了onPostExecute()
方法,我们想做一些事情来表示后台工作已经完成。在一个真实的系统中,可能会有一些ProgressBar
被解除或者一些动画被停止。在这里,我们简单地举一个Toast
。
既然我们选择了没有结果,我们应该不需要任何参数。与AsyncTask
的合同规定我们必须接受第三种数据类型的单个值。因为数据类型是Void
,我们方法参数是Void unused
。
活动
该活动如下:
new AddStringTask().**execute**();
要使用AddStringTask
,我们只需创建一个实例并在其上调用execute()
。这启动了一系列事件,最终导致后台线程完成其工作。
如果AddStringTask
需要配置参数,我们将不会使用Void
作为我们的第一个数据类型,构造函数将接受零个或多个已定义类型的参数。这些价值最终会传递给doInBackground()
。
结果
如果您构建、安装并运行这个项目,您将会看到列表在几秒钟内被实时填充,然后是一个表示完成的Toast
,如图 20–2 中的所示。
图 20–2。 async demo,中途加载单词列表
螺纹和旋转
活动在方向改变时经历的默认破坏-创建循环的一个问题来自后台线程。如果活动已经开始了一些后台工作——例如,通过一个AsyncTask
——然后活动被销毁并重新创建,那么AsyncTask
需要以某种方式知道这一点。否则,AsyncTask
很可能会将更新和最终结果发送给旧的活动,而新的活动对此一无所知。事实上,新活动可能会再次开始后台工作,浪费资源。
*处理这个问题的一种方法是通过接管配置更改来禁用销毁-创建循环,如前一节所述。另一个选择是进行更聪明的活动。您可以在Rotation/RotationAsync
示例项目中看到这样的例子。如下所示,这个项目使用了一个ProgressBar
,很像本章前面的Handler
演示。它还有一个TextView
来指示后台工作何时完成,最初是不可见的。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical"
android:layout_width="fill_parent" android:layout_height="fill_parent" > <ProgressBar android:id="@+id/progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <TextView android:id="@+id/completed" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Work completed!" android:visibility="invisible" /> </LinearLayout>
“业务逻辑”是让一个AsyncTask
在后台做一些(假的)工作,一路更新ProgressBar
,并在完成时使TextView
可见。更重要的是,如果屏幕被旋转,它需要以这样的方式正确地运行。这意味着:
我们不能“失去”我们的AsyncTask
,让它继续工作并更新错误的活动。
我们不能开始第二个AsyncTask
,从而加倍我们的工作量。
我们需要让 UI 正确地反映我们工作的进度或完成情况。
手工活动协会
前面,本章展示了作为Activity
类的常规内部类实现的AsyncTask
的使用。当你不关心旋转时,这很有效。例如,如果AsyncTask
不影响 UI——比如上传照片——旋转对你来说就不是问题。将AsyncTask
作为Activity
的内部类意味着您可以随时访问任何需要Context
的地方的活动。
然而,对于旋转场景,常规的内部类将很难工作。AsyncTask
会认为它知道应该与哪个Activity
一起工作,但实际上它会保持对旧活动的隐式引用,而不是在方向改变后。
所以,在RotationAsync
中,RotationAwareTask
类是一个静态内部类。这意味着RotationAwareTask
没有任何对任何RotationAsyncActivity
(旧的或新的)的隐式引用:
import android.app.Activity; import android.os.AsyncTask; import android.os.Bundle; import android.os.SystemClock; import android.util.Log; import android.view.View; import android.widget.ProgressBar;
`public class RotationAsync extends Activity {
private ProgressBar bar=null;
private RotationAwareTask task=null;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
bar=(ProgressBar)findViewById (R.id.progress);
task=(RotationAwareTask)getLastNonConfigurationInstance ();
if (task==null) {
task=new RotationAwareTask (this);
task.execute ();
}
else {
task.attach (this);
updateProgress(task.getProgress ());
if (task.getProgress ()>=100) {
markAsDone ();
}
}
}
@Override
public Object onRetainNonConfigurationInstance () {
task.detach ();
return(task);
}
void updateProgress (int progress) {
bar.setProgress (progress);
}
void markAsDone () {
findViewById(R.id.completed).setVisibility (View.VISIBLE);
}
static class RotationAwareTask extends AsyncTask<Void, Void, Void> {
RotationAsync activity=null;
int progress=0;
RotationAwareTask (RotationAsync activity) {
attach (activity);
}
@Override
protected Void doInBackground (Void... unused) {
for (int i=0;i<20;i++) {
SystemClock.sleep (500);
publishProgress ();
}`
` return(null);
}
@Override
protected void onProgressUpdate (Void... unused) {
if (activity==null) {
Log.w("RotationAsync", "onProgressUpdate() skipped – no activity");
}
else {
progress+=5;
activity.updateProgress (progress);
}
}
@Override
protected void onPostExecute (Void unused) {
if (activity==null) {
Log.w("RotationAsync", "onPostExecute() skipped – no activity");
}
else {
activity.markAsDone ();
}
}
void detach () {
activity=null;
}
void attach (RotationAsync activity) {
this.activity=activity;
}
int getProgress () {
return(progress);
}
}
}`
因为我们希望RotationAwareTask
更新当前的RotationAsyncActivity
,所以我们在创建任务时通过构造函数提供了那个Activity
。RotationAwareTask
也有attach()
和detach()
方法来改变任务所知道的Activity
,我们很快就会看到。
事件的流程
当RotationAsync
第一次启动时,它创建一个RotationAwareTask
类的新实例并执行它。在这一点上,任务有了对RotationAsyncActivity
的引用,并且可以做它的(假的)工作,告诉RotationAsync
在这个过程中更新进度。
现在,假设在doInBackground()
处理过程中,用户旋转屏幕。我们的Activity
将用onRetainNonConfigurationInstance()
来称呼。在这里,我们想做两件事:
由于这个Activity
实例正在被销毁,我们需要确保任务不再持有对它的引用。因此,我们调用detach()
,使任务将其RotationAsync
数据成员(activity
)设置为null
。
我们返回RotationAwareTask
对象,这样我们的新RotationAsync
实例就可以访问它。
最终,新的RotationAsync
实例将被创建。在onCreate()
中,我们试图通过getLastNonConfigurationInstance()
访问任何当前的RotationAwareTask
实例。如果那是null
,那么我们知道这是一个新创建的活动,因此我们创建了一个新任务。然而,如果getLastNonConfigurationInstance()
从旧的RotationAsync
实例中返回了任务对象,我们就保留它并更新我们的 UI 以反映当前已经取得的进展。我们还将新的RotationAsync
添加到RotationAwareTask
中,这样随着进一步的进展,任务可以通知适当的活动。
最终结果是我们的ProgressBar
平稳地从0
进展到100
,即使轮换正在进行。
为什么会这样
Android 中的大多数回调方法都是由主应用线程正在处理的消息队列中的消息驱动的。通常,只要主应用线程不忙,比如运行我们的代码,就会处理这个队列。然而,当配置发生变化时,比如屏幕旋转,这就不再成立了。在调用旧活动的onRetainNonConfigurationInstance()
实例和完成新活动的onCreate()
之间,消息队列保持不变。
所以,让我们假设,在onRetainNonConfigurationInstance()
活动和随后的onCreate()
之间,我们的AsyncTask
的后台工作完成了。这将触发onPostExecute()
被调用...最终。然而,由于onPostExecute()
实际上是从消息队列中的一条消息启动的,所以在我们的onCreate()
完成之前onPostExecute()
不会被调用。因此,我们的AsyncTask
可以在配置更改期间保持运行,只要我们做两件事:
在新活动实例的onCreate()
中,我们更新了AsyncTask
,让它与我们的新活动一起工作,而不是与旧活动一起工作。
我们不尝试使用来自doInBackground()
的活动。
现在,注意事项
背景线程,虽然使用 Android Handler
系统非常可能,但并不都是快乐和温暖的小狗。后台线程不仅增加了复杂性,而且在可用内存、CPU 和电池寿命方面也有实际成本。因此,你需要用你的后台线程考虑各种各样的场景,包括如下:
当后台线程运行时,用户可能会与您的活动的 UI 进行交互。如果后台线程正在做的工作被用户输入改变或无效,您需要将这一情况通知后台线程。Android 在java.util.concurrent
包中包含了许多类,可以帮助你安全地与后台线程通信。
当后台工作正在进行时,活动被取消的可能性。例如,在开始你的活动后,用户可能有一个电话进来,接着是一条短信,然后需要查找一个联系人——所有这些可能足以将你的活动踢出记忆。第十八章涵盖了 Android 将带你的活动经历的各种事件;挂钩到正确的线程,并确保在有机会时干净地关闭后台线程。
如果你浪费了大量的 CPU 时间和电池寿命却没有任何回报,用户可能会被激怒。从战术上来说,这意味着使用ProgressBar
或其他方式让用户知道有事情正在发生。从战略上来说,这意味着您仍然需要高效地工作——后台线程不是处理缓慢或无意义代码的灵丹妙药。
在后台处理过程中遇到错误的可能性。例如,如果您从互联网上收集信息,设备可能会失去连接。通过一个通知来提醒用户这个问题(在第三十七章中有介绍)并关闭后台线程可能是你最好的选择。*
二十一、创建意图过滤器
到目前为止,这本书的重点一直是用户从设备的启动器直接打开的活动。这是让您的活动开始运行并让用户可见的最明显的例子。而且,在许多情况下,这是用户开始使用您的应用的主要方式。
但是,请记住,Android 系统是基于许多松散耦合的组件的。你可能在桌面 GUI 中通过对话框、子窗口等完成的事情通常被认为是独立的活动。虽然一个活动是“特殊的”,因为它显示在启动器中,但是其他活动都需要被访问...不知何故。
“不知何故”是通过意图。
意图基本上是你传递给 Android 的一个信息,说“哟!我想做什么...呃...有事!耶!”“某事”的具体程度取决于具体情况——有时你确切地知道你想做什么(例如,打开你的一个其他活动),有时你不知道。
抽象地说,Android 是关于意图和那些意图的接收者的。所以,现在你已经精通了创建活动,让我们深入了解意图,这样我们就可以创建更复杂的应用,同时成为“好的 Android 公民”
你的意图是什么?
当蒂姆·伯纳斯·李爵士发明超文本传输协议(HTTP)时,他建立了一个动词加地址的 URL 形式的系统。该地址表示一种资源,如网页、图形或服务器端程序。动词指示应该做什么:GET 检索它,POST 将表单数据发送给它进行处理,等等。
意图是相似的,因为它们代表一个动作加上上下文。与 HTTP 动词和资源相比,Android 意图的上下文有更多的动作和组件,但概念仍然是相同的。正如 web 浏览器知道如何处理动词+URL 对一样,Android 知道如何找到处理给定意图的活动或其他应用逻辑。
件意图
意图的两个最重要的部分是动作和 Android 称为的数据 。这些几乎完全类似于 HTTP 动词和 URL:动作是动词,数据是一个Uri
,比如content://contacts/people/1
,表示联系人数据库中的一个联系人。动作是常量,比如ACTION_VIEW
(打开资源的查看器)、ACTION_EDIT
(编辑资源),或者ACTION_PICK
(选择一个可用的项目,给定一个代表集合的Uri
,比如content://contacts/people
。
如果你要创建一个意图,将ACTION_VIEW
和content://contacts/people/1
的内容Uri
结合起来,并将这个意图传递给 Android,Android 会知道找到并打开一个能够查看该资源的活动。
除了动作和数据Uri
之外,您还可以在意图(表示为Intent
对象)中放置其他标准,例如:
类别 :你的“主要”活动将在LAUNCHER
类别中,表明它应该出现在启动菜单上。其他活动可能属于DEFAULT
类别或ALTERNATIVE
类别。
MIME 类型 :如果你不知道一个集合Uri
,这表示你想要操作的资源的类型。
组件 :这是应该接收这个意图的活动的类。以这种方式使用组件消除了对意图的其他属性的需要。然而,它确实使意图更加脆弱,因为它假设了特定的实现。
额外信息 :这指的是你想要传递给接收者的其他信息的Bundle
,通常是接收者可能想要利用的信息。给定的接收者可以使用哪些信息取决于接收者,并且(希望)被很好地记录下来。
您将在 Android SDK 文档中找到Intent
类的标准动作和类别的列表。
意图路由
如前一节所述,如果您在意图中指定目标组件,Android 毫无疑问会将意图路由到哪里,并且它会启动指定的活动。如果目标意图在您的应用中,这可能是好的。绝对不建议将意图发送到其他应用。总的来说,组件名称被认为是应用的私有名称,可能会更改。模板和 MIME 类型是识别您希望第三方代码提供的服务的首选方式。
如果您没有指定目标组件,那么 Android 必须找出哪些活动(或其他接收者)有资格接收该意图。请注意复数 activities 的使用,因为广义的书面意图可能会分解为几个活动。那是...嗯...意图(原谅这个双关语),你会在本章后面看到。这种路由方法被称为隐式路由 。
基本上,有三个规则,对于给定的活动,所有这些规则都必须符合给定的意图:
活动必须支持指定的操作。
活动必须支持规定的 MIME 类型(如果提供)。
活动必须支持意向中指定的所有类别。
结果是,你要让你的意图足够具体,以找到正确的接收者,没有比这更具体。当我们在本章后面学习一些例子时,这一点会变得更加清楚。
陈述你的意图
所有希望通过意图得到通知的 Android 组件都必须声明意图过滤器,因此 Android 知道哪些意图应该发送给该组件。为此,您需要将intent-filter
元素添加到您的AndroidManifest.xml
文件中。
所有的示例项目都定义了意图过滤器,这要归功于 Android 应用构建脚本(android create project
或 IDE 等价物)。它们看起来像这样:
<?xml version="1.0"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.skeleton"> <application> <activity android:name=".Now" android:label="Now"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> </manifest>
注意activity
元素下的intent-filter
元素。在此,我们声明这一活动:
是该应用的主要活动
在LAUNCHER
类别中,这意味着它在 Android 主菜单中有一个图标
因为这个活动是应用的主要活动,Android 知道当有人从主菜单中选择应用时,它应该启动这个组件。
欢迎您在意向过滤器中加入多个行动或多个类别。这表明相关联的组件(例如,活动)处理多个不同种类的意图。
很有可能,您还希望让您的次要(非MAIN
)活动指定它们所处理的数据的 MIME 类型。然后,如果一个意图是针对那个 MIME 类型的——不管是直接的,还是通过引用那个类型的东西的Uri
间接的——Android 将知道组件处理这样的数据。
例如,您可以像这样声明一个活动:
<activity android:name=".TourViewActivity"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="vnd.android.cursor.item/vnd.commonsware.tour" /> </intent-filter> </activity>
该活动将由请求查看代表一条vnd.android.cursor.item/vnd.commonsware.tour
内容的Uri
的意图发起。该Intent
可能来自同一应用中的另一个活动(例如,该应用的MAIN
活动),或者来自另一个 Android 应用中的另一个活动,该活动碰巧知道该活动处理的Uri
。
窄接收器
在前面的例子中,意图过滤器是在活动上设置的。有时,将意图与活动捆绑在一起并不完全是您想要的,如以下情况:
一些系统事件可能会导致您想要触发服务中的某个事件,而不是某个活动。
一些事件可能需要在不同的情况下启动不同的活动,其中标准不仅仅基于意图本身,而是基于一些其他状态(例如,如果我们获得意图 X,并且数据库具有 Y,则启动活动 M;如果数据库没有 Y,则启动活动 N)。
对于这些情况,Android 提供了接收器,定义为实现BroadcastReceiver
接口的类。广播接收器是设计用来接收意图(具体地说,广播意图)并采取行动的一次性对象。
BroadcastReceiver
接口只有一个方法:onReceive()
。接收者实现那个方法,在那里他们做任何他们想要做的事情。要声明一个接收者,在您的AndroidManifest.xml
文件中添加一个receiver
元素:
<receiver android:name=".MyIntentReceiverClassName" />
接收器只在处理onReceive()
的时间内有效——一旦该方法返回,接收器实例就会被垃圾收集,不会被重用。这意味着接收者在他们能做的事情上有些限制,主要是为了避免任何涉及回调的事情。例如,它们不能绑定到服务,也不能打开对话框。
例外情况是,如果BroadcastReceiver
是在一些长期存在的组件上实现的,比如一个活动或服务。在这种情况下,接收方的寿命与其“宿主”的寿命一样长(例如,直到活动被冻结)。但是,在这种情况下,您不能通过AndroidManifest.xml
声明接收者。相反,你需要在你的Activity
的onResume()
回调中调用registerReceiver()
来声明对一个意向感兴趣,然后当你不再需要那些意向时,从你的Activity
的onPause()
中调用unregisterReceiver()
。
各种场合的意图
随着 Android 每个新版本的推出,Intent
类包含的动作数量稳步增长。随着冰淇淋三明治(ICS)4.0 版本的发布,Google 又增加了六个动作,并取消了三个不再需要的动作。Android SDK 文档涵盖了 ICS 中所有可用的 97 个意图动作,您可以在闲暇时阅读。以下是让你思考可能性的一些亮点:
ACTION_AIRPLANE_MODE_CHANGED
:设备已进入或退出飞行模式。
ACTION_CAMERA_BUTTON
:相机按钮被按下。
日期已经改变。这对于提醒列表、日历等应用来说很重要。
ACTION_HEADSET_PLUG
:耳机被连接或移除。这对于音乐播放类应用和类似的应用来说是相当重要的。
你可以开始看到可能性和复杂性。
暂停警告
使用Intent
对象传递任意消息有一个问题:它只有在接收者活动时才起作用。引用BroadcastReceiver
的文档:
如果在您的Activity.onResume()
实现中注册了一个接收者,您应该在Activity.onPause()
中注销它。(暂停时你不会收到意图,这将减少不必要的系统开销)。不要在Activity.onSaveInstanceState()
中取消注册,因为如果用户在历史堆栈中返回,将不会调用这个函数。
因此,您只能在以下情况下使用Intent
框架作为任意的消息总线:
你的接收器并不在乎是否因为没有激活而错过了信息。
您提供了一些方法来让接收者“抓住”它在不活动时错过的消息。
你的收货人在货单上登记了。
二十二、启动活动和子活动
Android UI 架构背后的理论是,开发人员应该将他们的应用分解成不同的活动。例如,日历应用可以具有用于查看日历、查看单个事件、编辑事件(包括添加新事件)、在同一屏幕上查看和编辑事件以进行更大显示等活动。这意味着您的一个活动有办法启动另一个活动。例如,如果用户从视图-日历活动中选择了一个事件,您可能希望显示该事件的视图-事件活动。这意味着您需要能够启动 view-event 活动并显示特定的事件(用户选择的事件)。
这可以进一步分为两种情况:
您知道要启动哪个活动,可能是因为它是您自己的应用中的另一个活动。
您有一个内容Uri
来做一些事情,并且您希望您的用户能够用它来做一些事情,但是您事先不知道选项是什么。
本章涵盖了第一种情况;第二个超出了本书的范围。
同级和下级
当你决定发起一项活动时,你需要回答的一个关键问题是:你的活动需要知道发起的活动何时结束吗?
例如,假设您想要生成一个活动来收集您正在连接的某个 web 服务的身份验证信息——为了使用 OAuth 服务,您可能需要使用 OpenID 进行身份验证。在这种情况下,您的主活动将需要知道身份验证何时完成,以便它可以开始使用 web 服务。
另一方面,想象一下 Android 中的电子邮件应用。当用户选择查看附件时,您和用户都不一定希望主活动知道用户何时完成了对附件的查看。
在第一个场景中,已启动的活动显然从属于启动活动。在这种情况下,您可能希望将子活动作为子活动启动,这意味着当子活动完成时,您的活动将得到通知。
在第二个场景中,启动的活动更像是您的活动的对等体,因此您可能希望像启动常规活动一样启动子活动。孩子做完了你的活动不会被通知,但是,话说回来,你的活动真的不需要知道。
启动它们
开始一项活动的两个要素是一个意图和你如何开始的选择。
制定一个意图
正如前一章所讨论的,intents 封装了对 Android 的请求,请求一些活动或其他接收者做一些事情。如果您想要启动的活动是您自己的,您可能会发现创建一个明确的意图是最简单的,命名您想要启动的组件。例如,在您的活动中,您可以创建如下意图:
new **Intent**(this, HelpActivity.class);
这规定了你要发射HelpActivity
。这个活动需要在您的AndroidManifest.xml
文件中命名,尽管不需要任何意图过滤器,因为您试图直接请求它。
或者,您可以为某个Uri
组织一个意图,请求一个特定的操作:
Uri uri=Uri.**parse**("geo:"+lat.**toString**()+","+lon.**toString**()); Intent i=new **Intent**(Intent.ACTION_VIEW, uri);
这里,假设您有类型为Double
的某个位置的纬度和经度(分别为lat
和lon
,您构建了一个geo
方案Uri
,并创建了一个请求查看这个Uri
( ACTION_VIEW
)的意图。
打电话
一旦你有了你的意图,你需要把它传递给 Android 并启动子活动。您有两个主要选项(以及一些更高级/更专业的变体):
最简单的选择是用Intent
调用startActivity()
。这将导致 Android 找到最匹配的活动,并将意图传递给它进行处理。当子活动完成时,您的活动不会得到通知。
您可以调用startActivityForResult()
,向其传递Intent
和一个数字(对于调用活动是唯一的)。Android 将找到最匹配的活动,并将意图传递给它进行处理。当子活动完成时,您的活动将通过onActivityResult()
回调得到通知。
在某些情况下,您可能希望或需要条件启动、批量启动等。的活动。像startActivities()
、startActivityFromFragment()
和startActivityIfNeeded()
这样的附加方法可以帮助处理这些情况。
如上所述,使用startActivityForResult()
,您可以实现onActivityResult()
回调,以便在子活动完成其工作时得到通知。回调接收提供给startActivityForResult()
的唯一编号,因此您可以确定哪个子活动已经完成。您还会得到以下内容:
一个结果代码,来自调用setResult()
的子活动。通常,这是RESULT_OK
或RESULT_CANCELED
,尽管您可以创建自己的返回代码(选择一个以RESULT_FIRST_USER
开头的数字)。
可选的String
包含一些结果数据,可能是一些内部或外部资源的 URL。例如,ACTION_PICK
意图通常通过这个数据字符串返回内容的选定位。
可选的Bundle
包含结果代码和数据字符串之外的附加信息。
为了演示如何启动一个 peer 活动,请看一下Activities/Launch
示例应用。XML 布局相当简单:两个纬度和经度字段,外加一个按钮。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TableLayout android:layout_width="fill_parent" android:layout_height="wrap_content" android:stretchColumns="1,2" > <TableRow> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingLeft="2dip" android:paddingRight="4dip" android:text="Location:" /> <EditText android:id="@+id/lat" android:layout_width="fill_parent" android:layout_height="wrap_content" android:cursorVisible="true" android:editable="true" android:singleLine="true"
android:layout_weight="1" /> <EditText android:id="@+id/lon" android:layout_width="fill_parent" android:layout_height="wrap_content" android:cursorVisible="true" android:editable="true" android:singleLine="true" android:layout_weight="1" /> </TableRow> </TableLayout> <Button android:id="@+id/map" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Show Me!" android:onClick="showMe" /> </LinearLayout>
按钮的showMe()
回调方法简单地获取纬度和经度,将它们倒入一个geo
方案Uri
,然后开始活动:
`packagecom.commonsware.android.activities;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
public class LaunchDemo extends Activity {
private EditText lat;
private EditText lon;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
lat=(EditText)findViewById (R.id.lat);
lon=(EditText)findViewById (R.id.lon);
}
public void showMe (View v) {
String _lat=lat.getText ().toString ();
String _lon=lon.getText ().toString ();
Uri uri=Uri.parse ("geo:"+_lat+","+_lon);
startActivity (new Intent(Intent.ACTION_VIEW, uri));
}
}`
我们保持了非常基本的活动,以便将重点放在处理地理意图的主题上。我们开始如图 22–1 所示。
图 22–1。 launch demo 示例应用,位置填写为
如果你填写一个位置(如纬度 38.8891,经度-77.0492)并点击按钮,得到的地图会更有趣,如图图 22–2 所示。请注意,这是内置的 Android 地图活动——我们没有创建自己的活动来显示该地图。
图 22–2。 launch demo 推出的地图,显示了 DC 的林肯纪念堂
在第四十章中,你将看到如何在自己的活动中创建地图,以防你需要更好地控制地图的显示方式。
注意: 这个geo:Intent
只能在安装了谷歌地图的设备或模拟器上运行,或者在安装了其他支持geo:
URL 的地图应用的设备上运行。
类似于标签式浏览
现代桌面网络浏览器的主要特征之一是选项卡式浏览,其中单个浏览器窗口可以显示跨越一系列选项卡的几个页面。在移动设备上,这可能没有太大意义,因为你失去了选项卡本身的屏幕空间。然而,在本书中,我们不会让感性这样的小事阻止我们,所以这一节使用TabActivity
和Intent
对象演示了一个选项卡式浏览器。
您可能还记得第十四章的“将它放在我的选项卡上”一节,一个选项卡可以有一个View
或一个Activity
作为它的内容。如果您想使用一个Activity
作为选项卡的内容,您提供一个Intent
,它将启动所需的Activity
;Android 的标签管理框架会将Activity
的用户界面注入到标签中。
你的本能可能是使用http: Uri
,就像我们在前面的例子中使用geo: Uri
一样:
Intent i=new **Intent**(Intent.ACTION_VIEW); i.**setData**(Uri.**parse**("http://commonsware.com"));
这样,您可以使用内置的浏览器应用,并获得它提供的所有功能。唉,这不管用。您不能在选项卡中主持其他应用的活动;出于安全原因,只允许您自己的活动。所以,我们从第十五章中掸掉我们的WebView
演示,并使用它们,重新包装成Activities/IntentTab
。
下面是主活动的源代码,它托管了TabView
:
`package com.commonsware.android.intenttab;
import android.app.Activity;
import android.app.TabActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.WebView;
import android.widget.TabHost;
public class IntentTabDemo extends TabActivity {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
TabHost host=getTabHost ();
Intent i=new Intent (this, CWBrowser.class);
i.putExtra (CWBrowser.URL, "http://commonsware.com ");
host.addTab (host.newTabSpec ("one")
.setIndicator ("CW")
.setContent (i));
i=new Intent (i);
i.putExtra (CWBrowser.URL, "http://www.android.com ");
host.addTab (host.newTabSpec ("two")
.setIndicator ("Android")
.setContent (i));
}
}`
如您所见,我们使用TabActivity
作为基类,因此我们不需要自己的布局 XML— TabActivity
为我们提供了它。我们所做的就是访问TabHost
并添加两个选项卡,每个选项卡指定一个直接引用另一个类的Intent
。在这种情况下,我们的两个选项卡将各自拥有一个CWBrowser
,通过一个额外的Intent
提供一个 URL 来加载。
CWBrowser
活动是对早期浏览器演示的简单修改:
`package com.commonsware.android.intenttab;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.webkit.WebView;
public class CWBrowser extends Activity {
public static final String URL="com.commonsware.android.intenttab.URL";
private WebView browser;
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
browser=new WebView (this);
setContentView (browser);
browser.loadUrl (getIntent ().getStringExtra (URL));
}
}`
他们只需在浏览器中加载不同的 URL:一个是 CommonsWare 主页,另一个是 Android 主页。
由此产生的用户界面显示了标签式浏览在 Android 上的样子,如图图 22–3 和图 22–4 所示。
图 22–3。 ??【IntentTabDemo 示例应用,显示第一个选项卡
图 22–4。 IntentTabDemo 示例应用,显示第二个选项卡
然而,这种方法相当浪费。创建一个活动有相当大的开销,您不需要仅仅填充TabHost
中的选项卡。特别是,它增加了你的应用所需的堆栈空间,堆栈空间的耗尽是 Android 中的一个重大问题,这将在后面的章节中描述。
二十三、使用资源
资源是保存在 Java 源代码之外的静态信息。你已经在本书的例子中经常看到一种类型的资源——布局。还有许多其他类型的资源,比如图像和字符串,您可以在 Android 应用中加以利用。
资源阵容
资源作为文件存储在 Android 项目布局中的res/
目录下。除了原始资源(res/raw/
),所有其他类型的资源都由 Android 打包系统或设备或仿真器上的 Android 系统为您解析。因此,例如,当您通过布局资源(res/layout/
)布局一个活动的 UI 时,您不必自己解析布局 XML,因为 Android 会为您处理。
除了布局资源(在第八章的中介绍),还有其他几种类型的资源可供您使用,包括:
图像(res/drawable-mdpi/
、res/drawable-ldpi
等)。),用于在用户界面中放置静态图标、图像、照片或其他图片
Raw ( res/raw/
),用于对您的应用有意义但对 Android 框架不一定有意义的任意文件
字符串、颜色、数组和维度(res/values/
),用于给这些类型的常量赋予符号名称,并使它们与代码的其余部分分开(例如,用于国际化和本地化)
XML ( res/xml/
),用于包含您自己的数据和结构的静态 XML 文件
弦论
将标签和其他文本放在应用的主要源代码之外通常被认为是一个非常好的主意。特别是,它有助于国际化和本地化,这将在本章后面的“不同人使用不同的笔画”一节中讨论。即使你不打算把你的字符串翻译成其他语言,如果所有的字符串都在一个地方而不是分散在你的源代码中,修改起来会更容易。
Android 支持常规的外部化字符串,以及字符串格式 ,其中字符串有动态插入信息的占位符。最重要的是,Android 支持简单的文本格式,称为风格的文本 ,因此你可以将你的文字加粗或斜体与普通文本混合在一起。
普通字符串
一般来说,对于普通字符串,您所需要的只是一个位于res/values
目录中的 XML 文件(通常命名为res/values/strings.xml
),带有一个resources
根元素,以及一个针对您希望编码为资源的每个字符串的子string
元素。string
元素采用了一个name
属性(该属性是该字符串的唯一名称)和一个包含该字符串文本的文本元素,如下例所示:
<resources> <string name="quick">The quick brown fox...</string> <string name="laughs">He who laughs last...</string> </resources>
唯一棘手的部分是字符串值是否包含引号("
)或撇号('
)。在这种情况下,您可能希望通过在这些值前面加一个反斜杠来对它们进行转义(例如,These are the times that try men\'s souls.
)。或者,如果它只是一个撇号,您可以用引号将值括起来(例如,"These are the times that try men's souls."
)。
然后,您可以从布局文件中引用该字符串(如@string/...
,其中省略号是唯一的名称,如@string/laughs
)。或者您可以通过使用字符串资源的资源 ID 调用getString()
从您的 Java 代码中获取字符串,该资源 ID 是以R.string.
为前缀的唯一名称(例如getString(R.string.quick)
)。
字符串格式
与 Java 语言的其他实现一样,Android 的 Dalvik 虚拟机支持字符串格式。这里,字符串包含占位符,表示在运行时将被变量信息替换的数据(例如,My name is %1$s
)。存储为资源的普通字符串可以用作字符串格式:
String strFormat=**getString**(R.string.my_name); String strResult=String.**format**(strFormat, "Tim"); ((TextView)**findViewById**(R.id.some_label)).**setText**(strResult);
还有一种getString()
的味道,那就是String.format()
在召唤你:
String strResult=**getString**(R.string.my_name, "Tim"); ((TextView)**findViewById**(R.id.some_label)).**setText**(strResult);
使用带索引的占位符版本— %1$s
而不仅仅是%s
是非常重要的。从策略上讲,字符串资源的翻译可能会导致您以不同于原始翻译的顺序应用变量数据,并且使用无索引占位符会将您锁定在特定的顺序。从战术上讲,你的项目将无法编译,因为现在 Android 构建工具拒绝无索引占位符。
样式化文本
如果您想要真正丰富的文本,您应该拥有包含 HTML 的原始资源,然后将这些资源注入一个 WebKit 小部件。然而,对于轻量级的 HTML 格式,使用诸如<b>
、<i>
和<u>
之类的行内元素,您可以在字符串资源中使用它们:
<resources> <string name="b">This has <b>bold</b> in it.</string> <string name="i">Whereas this has <i>italics</i>!</string> </resources>
您可以通过getText()
来访问它们,这给了您一个支持android.text.Spanned
接口的对象,因此应用了所有的格式:
((TextView)**findViewById**(R.id.another_label)) **.setText(getText**(R.string.b));
样式化的文本和格式
样式化的文本变得棘手的地方是样式化的字符串格式,因为String.format()
作用于String
对象,而不是带有格式指令的Spanned
对象。如果您真的想拥有样式化的字符串格式,以下是解决方法:
实体-转义字符串资源中的尖括号(如this is <b>%1$s</b>
)。
正常检索字符串资源,尽管此时不会对其进行样式化(例如,getString(R.string.funky_format)
)。
生成格式结果,确保转义您替换的任何字符串值,以防它们包含尖括号或&符号:String.**format(getString**(R.string.funky_format), TextUtils.**htmlEncode**(strName));
通过Html.fromHtml()
: someTextView.setText(Html **.fromHtml**(resultFromStringFormat));
将实体转义的 HTML 转换成Spanned
对象
要了解这一点,我们来看一下Resources/Strings
演示。以下是布局文件:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <LinearLayout android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <Button android:id="@+id/format" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/btn_name" android:onClick="applyFormat" /> <EditText android:id="@+id/name" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout> <TextView android:id="@+id/result" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout>
如您所见,它只是一个按钮、一个字段和一个标签。这个想法是让用户在字段中输入他们的名字,然后单击按钮,用包含他们名字的格式化消息更新标签。
布局文件中的Button
引用了一个字符串资源(@string/btn_name
),所以我们需要一个字符串资源文件(res/values/strings.xml
):
<?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">StringsDemo</string> <string name="btn_name">Name:</string> <string name="funky_format">My name is <b>%1$s</b></string> </resources>
app_name
资源由android create project
命令自动创建。btn_name
字符串是Button
的标题,而我们样式化的字符串格式在funky_format
中。
最后,为了将所有这些联系在一起,我们需要一些 Java:
`package com.commonsware.android.strings;
import android.app.Activity;
import android.os.Bundle;
import android.text.TextUtils;
import android.text.Html;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
public class StringsDemo extends Activity {
EditText name;
TextView result;
@Override
public void onCreate(Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
name=(EditText)findViewById (R.id.name);
result=(TextView)findViewById (R.id.result);
}
public void applyFormat (View v) {
String format=getString (R.string.funky_format);
String simpleResult=String.format (format,
TextUtils.htmlEncode (name.getText ().toString ()));
result.setText(Html.fromHtml (simpleResult));
}
}`
字符串资源操作可以在applyFormat()
中找到,点击按钮时调用。首先,我们通过getString()
获得我们的格式——为了效率,我们可以在onCreate()
时间完成。接下来,我们使用这种格式格式化字段中的值,返回一个String
,因为字符串资源是实体编码的 HTML。注意使用TextUtils.htmlEncode()
对输入的名字进行实体编码,以防有人决定使用“与”号或其他符号。最后,我们通过Html.fromHtml()
将简单的 HTML 转换成样式化的文本对象,并更新我们的标签。
当活动首次启动时,我们有一个空标签,如 Figure 23–1 所示。
图 23–1。 strings demo 示例应用,如同最初启动的
如果我们填写一个名称并点击按钮,我们会得到如图图 23–2 所示的结果。
图 23–2。 同样的申请,在填写一些英雄人物的名字后
拿到图了?
Android 支持 PNG、JPEG、BMP、WEBP 和 GIF 格式的图像。然而,官方不鼓励使用 GIF。PNG 是最常见的格式,因为它在 Android 的早期版本中更受欢迎,而且在网络上也越来越受欢迎。冰淇淋三明治新支持 WEBP。这是一种基于 VP8 技术的编解码器,谷歌在 2010 年收购了 On2 Technologies。对于相同的图像质量,WEBP(通常读作“weppy”)提供了比 JPEG 好大约 40%的压缩率。图像可以用在任何需要Drawable
的地方,比如ImageView
的图像和背景。
使用图像只需将图像文件放在res/drawable/
中,然后作为资源引用它们。在布局文件中,图像被引用为@drawable/...
,其中省略号是文件的基本名称(例如,对于res/drawable/foo.png
,资源名称是@drawable/foo
)。在 Java 中,当您需要一个图像资源 ID 时,使用R.drawable.
加上基本名称(例如R.drawable.foo
)。
因此,让我们更新前面的例子,使用按钮的图标代替字符串资源。这个可以找到Resources/Images
。我们稍微调整了布局文件,使用了一个ImageButton
并引用了一个名为@drawable/icon
的 drawable,该 drawable 引用了res/drawable
中一个基本名为icon
的图像文件。在这种情况下,我们使用 Nuvola 图标集中的 32×32 像素 PNG 文件。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <LinearLayout android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <ImageButton android:id="@+id/format" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/icon" android:onClick="applyFormat" /> <EditText android:id="@+id/name" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout> <TextView android:id="@+id/result" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout>
现在,我们的按钮有了想要的图标,如图 23–3 所示。
图 23–3。 ??【图片】Demo 示例应用
XML:资源之道
如果希望将静态 XML 打包到应用中,可以使用 XML 资源。只需将 XML 文件放在res/xml/
中,就可以通过Resources
对象上的getXml()
来访问它,为它提供一个资源 IDR.xml.
和 XML 文件的基本名称。例如,在一个活动中,对于一个 XML 文件words.xml
,您可以调用getResources().getXml(R.xml.words)
。这将返回一个在org.xmlpull.v1
Java 名称空间中找到的XmlPullParser
的实例。
XML 拉解析器是事件驱动的:您不断调用解析器上的next()
来获取下一个事件,可能是START_TAG
、END_TAG
、END_DOCUMENT
等等。在一个START_TAG
事件中,你可以访问标签的名称和属性;单个TEXT
事件表示作为该元素直接子元素的所有文本节点的连接。通过循环、测试和调用每个元素的逻辑,您可以解析文件。
为了看到这一点,让我们为示例项目Files/Static
重写 Java 代码,以使用 XML 资源。这个新项目Resources/XML
要求您将Static
中的words.xml
文件放在res/xml/
而不是res/raw/
中。布局保持不变,因此需要替换的只是 Java 源代码:
`package com.commonsware.android.resources;
import android.app.Activity;
import android.os.Bundle;
import android.app.ListActivity;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import java.io.InputStream;
import java.util.ArrayList;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
public class XMLResourceDemo extends ListActivity {
TextView selection;
ArrayList items=new ArrayList();
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
selection=(TextView)findViewById (R.id.selection);
try {
XmlPullParser xpp=getResources ().getXml (R.xml.words);
while (xpp.getEventType ()!=XmlPullParser.END_DOCUMENT) {
if (xpp.getEventType ()==XmlPullParser.START_TAG) {
if (xpp.getName ().equals ("word")) {
items.add (xpp.getAttributeValue (0));
}
}
xpp.next();
}
}
catch (Throwable t) {
Toast
.makeText (this, "Request failed: "+t.toString (), Toast.LENGTH_LONG)
.show ();
}
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_1,
items));
}
public void onListItemClick (ListView parent, View v, int position,
long id) {
selection.setText (items.get (position).toString ());
}
}`
现在,在我们的try...catch
块中,我们得到了我们的XmlPullParser
,并循环直到文档结束。如果当前事件是START_TAG
,元素的名称是word
( xpp.getName().equals("word")
),那么我们获得唯一的属性,并将其放入选择小部件的项目列表中。因为我们对 XML 文件有完全的控制权,所以假设只有一个属性是足够安全的。在其他情况下,如果您不确定 XML 是否被正确定义,您可能会考虑检查属性计数(getAttributeCount()
)和属性名称(getAttributeName()
),而不是假设0
-index 属性就是您所认为的那样。
结果看起来和以前一样,只是标题栏中的名称不同,如图 Figure 23–4 所示。
图 23–4。 XML resource demo 示例应用
杂项值
在res/values/
目录中,除了字符串资源,还可以放置一个或多个描述其他简单资源的 XML 文件,比如维度、颜色和数组。在前面的例子中,您已经看到了尺寸和颜色的使用,它们作为简单的字符串(例如,"10dip"
)作为参数传递给调用。您可以将它们设置为 Java static final 对象,并使用它们的符号名称,但是这只能在 Java 源代码中使用,而不能在布局 XML 文件中使用。通过将这些值放在资源 XML 文件中,您可以从 Java 和 layouts 中引用它们,并且将它们放在中心位置以便于编辑。
资源 XML 文件的根元素为resources
;其他一切都是这个根的孩子。
尺寸
Android 中有几个地方使用维度来描述距离,比如小部件的填充。您可以使用几种不同的测量单位:
in
和mm
分别为英寸和毫米。这些是基于屏幕的实际尺寸。
pt
为积分。在出版术语中,一个点是 1/72 英寸(同样,基于屏幕的实际物理尺寸)
dip
和sp
分别用于与设备无关的像素和与比例无关的像素。对于 160 dpi 分辨率的屏幕,一个像素等于一个dip
,比例缩放基于实际的屏幕像素密度。与比例无关的像素也考虑了用户偏好的字体大小。
要将一个维度编码为一个资源,添加一个dimen
元素,用一个name
属性表示该资源的唯一名称,并用一个子文本元素表示值:
<resources> <dimen name="thin">10px</dimen> <dimen name="fat">1in</dimen> </resources>
在布局中,可以将维度引用为@dimen/...
,其中省略号是资源的唯一名称的占位符(例如,前面示例中的thin
和fat
)。在 Java 中,通过以R.dimen.
为前缀的惟一名称来引用维度资源(例如Resources.getDimen(R.dimen.thin)
)。
颜色
Android 中的颜色是十六进制的 RGB 值,还可以选择指定一个 alpha 通道。您可以选择单字符十六进制值或双字符十六进制值,提供四种样式:
#RGB
#ARGB
#RRGGBB
#AARRGGBB
它们的工作方式与级联样式表(CSS)中的类似。
当然,您可以将这些 RGB 值作为字符串放在 Java 源代码或布局资源中。但是,如果您希望将它们转换成资源,您所需要做的就是将color
元素添加到资源文件中,用一个name
属性作为该颜色的唯一名称,以及一个包含 RGB 值本身的文本元素:
<resources> <color name="yellow_orange">#FFD555</color> <color name="forest_green">#005500</color> <color name="burnt_umber">#8A3324</color> </resources>
在布局中,您可以将颜色引用为@color/...
,将省略号替换为该颜色的唯一名称(例如burnt_umber
)。在 Java 中,通过以R.color.
为前缀的惟一名称来引用颜色资源(例如Resources.getColor(R.color.forest_green)
)。
数组
数组资源被设计用来保存简单字符串的列表,比如一个尊称列表(先生、夫人、女士、博士等)。).
在资源文件中,每个数组需要一个string-array
元素,其中一个name
属性代表您赋予数组的惟一名称。然后,添加一个或多个子item
元素,每个子元素都有一个文本元素,包含数组中该条目的值:
<?xml version="1.0" encoding="utf-8"?> <resources> <string-array name="cities"> <item>Philadelphia</item> <item>Pittsburgh</item> <item>Allentown/Bethlehem</item> <item>Erie</item> <item>Reading</item> <item>Scranton</item> <item>Lancaster</item> <item>Altoona</item> <item>Harrisburg</item> </string-array> <string-array name="airport_codes"> <item>PHL</item> <item>PIT</item> <item>ABE</item> <item>ERI</item> <item>RDG</item> <item>AVP</item> <item>LNS</item> <item>AOO</item> <item>MDT</item> </string-array> </resources>
从您的 Java 代码中,您可以使用Resources.getStringArray()
来获得列表中项目的String[]
。getStringArray()
的参数是数组的唯一名称,以R.array.
为前缀(例如Resources.getStringArray(R.array.honorifics)
)。
因人而异
一组资源可能不适合应用可能使用的所有情况。一个明显的领域是字符串资源和处理国际化(I18N)和本地化(L10N)。将所有字符串放在一种语言中很好——至少对开发人员来说是这样——但是只涵盖一种语言。
然而,这并不是资源可能需要不同的唯一场景。以下是其他内容:
屏幕方向 :屏幕是纵向还是横向?或者屏幕是方形的,因此没有方向?
屏幕尺寸 :屏幕有多少像素,这样你就可以相应地调整你的资源(例如,大图标还是小图标)?
触摸屏 :设备有触摸屏吗?如果是,触摸屏是设置为使用手写笔还是手指?
键盘 :用户有哪种键盘(QWERTY,数字,两者都没有),现在有还是作为一个选项?
其他输入 :设备是否有其他形式的输入,比如 D-pad 或点击轮?
Android 目前处理这一问题的方式是拥有多个资源目录,每个目录的标准都嵌入在其名称中。
例如,假设您希望同时支持英语和西班牙语的字符串。通常,对于单语言设置,您应该将字符串放在一个名为res/values/strings.xml
的文件中。为了同时支持英语和西班牙语,您将创建两个文件夹,res/values-en/
和res/values-es/
,其中连字符后的值是该语言的 ISO 639-1 双字母代码。你的英语琴弦会放在res/values-en/strings.xml
里,西班牙语琴弦会放在res/values-es/strings.xml
里。Android 会根据用户的设备设置选择合适的文件。
更好的方法是将某种语言作为默认语言,并将这些字符串放入res/values/strings.xml
中。然后,为您的翻译创建其他资源目录(例如,res/values-es/strings.xml
代表西班牙语)。Android 会尝试匹配特定语言的资源集;如果做不到这一点,它将退回到res/values/strings.xml
的违约状态。
看起来很简单,对吧?
当您需要对您的资源使用多个不同的标准时,事情就变得复杂了。例如,假设您想为以下设备开发:
HTC Nexus 1,拥有正常尺寸的高密度屏幕,没有硬件键盘
三星 Galaxy Tab,它有一个大尺寸、高密度的屏幕,没有硬件键盘
摩托罗拉的魅力,它有一个小尺寸、中等密度的屏幕和一个硬件键盘
您可能希望这些设备的布局有所不同,以利用不同的屏幕空间和不同的输入选项。具体来说,您可能需要以下内容:
每种大小、方向和键盘组合的不同布局
每种密度都有不同的图案
然而,一旦你进入这种情况,各种各样的规则就开始起作用了,比如下面这些:
配置选项(如-en
)有特定的优先顺序,它们必须以该顺序出现在目录名中。Android 文档概述了这些选项出现的具体顺序。就本例而言,屏幕尺寸比屏幕方向更重要,屏幕方向比屏幕密度更重要,屏幕密度比设备是否有键盘更重要。
每个目录的每个配置选项类别只能有一个值。
选项区分大小写。
因此,对于示例场景,理论上,我们需要以下目录,代表可能的组合:
res/layout-large-port-mdpi-qwerty
res/layout-large-port-mdpi-nokeys
res/layout-large-port-hdpi-qwerty
res/layout-large-port-hdpi-nokeys
res/layout-large-land-mdpi-qwerty
res/layout-large-land-mdpi-nokeys
res/layout-large-land-hdpi-qwerty
res/layout-large-land-hdpi-nokeys
res/layout-normal-port-mdpi-qwerty
res/layout-normal-port-mdpi-nokeys
res/layout-normal-port-finger-qwerty
res/layout-normal-port-hdpi-nokeys
res/layout-normal-land-mdpi-qwerty
res/layout-normal-land-mdpi-nokeys
res/layout-normal-land-hdpi-qwerty
res/layout-normal-land-hdpi-nokeys
res/drawable-large-port-mdpi-qwerty
res/drawable-large-port-mdpi-nokeys
res/drawable-large-port-hdpi-qwerty
res/drawable-large-port-hdpi-nokeys
res/drawable-large-land-mdpi-qwerty
res/drawable-large-land-mdpi-nokeys
res/drawable-large-land-hdpi-qwerty
res/drawable-large-land-hdpi-nokeys
res/drawable-normal-port-mdpi-qwerty
res/drawable-normal-port-mdpi-nokeys
res/drawable-normal-port-finger-qwerty
res/drawable-normal-port-hdpi-nokeys
res/drawable-normal-land-mdpi-qwerty
res/drawable-normal-land-mdpi-nokeys
res/drawable-normal-land-hdpi-qwerty
res/drawable-normal-land-hdpi-nokeys
别慌!我们将很快缩短此列表!
请注意,没有什么可以阻止您使用不带修饰的基本名称(res/layout
)的目录。事实上,这确实是一个好主意,以防 Android 运行时的未来版本引入您没有考虑到的其他配置选项——拥有默认布局可能会影响您的应用在新设备上的工作或失败。
正如承诺的那样,我们可以大幅削减所需目录的数量。我们通过解码 Android 用于确定一组候选目录中哪个是正确的资源目录的规则来做到这一点:
Android 会丢弃特别无效的目录。因此,举例来说,如果设备的屏幕尺寸是normal
,Android 会放弃-large
目录作为候选目录,因为它们需要其他尺寸。
Android 统计每个文件夹的匹配数,只关注匹配数最多的文件夹。
Android 按照选项的优先顺序进行;换句话说,它在目录名中是从左到右的。
此外,我们的 drawables 只随密度而变化,而我们的布局不会随密度而变化,因此我们可以通过只关注相关平台的差异来清除许多组合。
因此,我们可以只用以下配置滑行:
res/layout-large-land-qwerty
res/layout-large-qwerty
res/layout-large-land
res/layout-large
res/layout-normal-land-qwerty
res/layout-normal-qwerty
res/layout-normal-land
res/layout
res/drawable-hdpi
res/drawable
这里,我们利用了特定匹配优先于未指定值的事实。因此,带有 QWERTY 键盘的设备将选择目录中带有qwerty
的资源,而不是没有指定其键盘类型的资源。
我们可以进一步细化,仅涵盖我们针对的特定设备(例如,没有带qwerty
的large
设备):
res/layout-large-land
res/layout-large
res/layout-land-qwerty
res/layout-qwerty
res/layout-land
res/layout
res/drawable-hdpi
res/drawable
如果我们不在乎根据设备是否有硬件键盘而有不同的布局,我们可以删除两个-qwerty
资源集。
我们将在第二十五章中再次看到这些资源集,其中描述了如何支持多种屏幕尺寸。
RTL 语言:双向发展
Android 2.3 增加了对更多语言的支持,超过了之前版本的平台。因此,您现在有更多的机会在需要的地方本地化您的应用。
特别是,Android 2.3 增加了对从右向左(RTL)语言的支持,尤其是希伯来语和阿拉伯语。以前 Android 只支持从左到右水平书写的语言,比如英语。这意味着你可以为 RTL 语言创建本地化版本,但是首先你需要考虑你的 UI 是否能为 RTL 语言正常工作。例如:
你的TextView
小部件是否在左侧与其他小部件或容器对齐?如果是,这是适合您的 RTL 用户的配置吗?
当用户开始输入 RTL 文本时,你的EditText
窗口小部件会有什么问题吗,比如因为你没有适当地限制EditText
窗口小部件的宽度而导致不适当的滚动?
如果你在EditText
和输入法框架之外创建了自己的文本输入形式(例如,自定义屏幕虚拟键盘),它们会支持 RTL 语言吗?
二十四、定义和使用样式
有时,您会在布局元素中发现一些带有神秘样式属性的代码。例如,在关于线程的章节中,出现了以下ProgressBar
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <ProgressBarandroid:id="@+id/progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout>
神奇的style
属性将我们的ProgressBar
从一个普通的圆形变成了一个单杠。
本章简要探讨了样式的概念,包括如何创建样式以及如何将样式应用到自己的小部件中。
款式:DIY 干爽
样式的目的是封装一组您打算重复使用、有条件使用或与您的布局保持分离的属性。主要用例是“不要重复自己”(DRY)——如果你有一堆看起来一样的小部件,使用一个样式来使用“看起来一样”的单一定义,而不是从一个小部件复制到另一个小部件。
如果我们看一个例子,特别是Styles/NowStyled
示例项目,这一段会更有意义。这是我们在前面章节中检查过的同一个项目,它有一个全屏按钮,显示活动启动或按钮被按下的日期和时间。在这个例子中,我们想要改变按钮表面的文本外观,这将通过使用一个样式来实现。
这个项目中的res/layout/main.xml
文件与第二十章中的文件相同,但是增加了一个style
属性:
<?xml version="1.0" encoding="utf-8"?> <Button xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/button" android:text="" android:layout_width="fill_parent" android:layout_height="fill_parent" style="@style/bigred" />
注意: 因为style
属性是股票 XML 的一部分,因此不在android
名称空间中,它没有得到android:
前缀。
值@style/bigred
指向一个样式资源。样式资源是值资源,可以在项目中的res/values/
目录中找到,或者在其他资源集中找到(例如,res/values-v11/
用于仅在 API 级别 11 或更高级别上使用的值资源)。惯例是将样式资源保存在一个styles.xml
文件中,比如下面来自NowStyled
项目的:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="bigred"> <item name="android:textSize">30sp</item> <item name="android:textColor">#FFFF0000</item> </style> </resources>
元素提供了样式的名称,这是我们从布局中引用样式时使用的名称。<style>
元素的<item>
子元素表示应用于任何样式的属性值——在我们的例子中,是我们的Button
小部件。因此,我们的Button
将有一个相对较大的字体(android:textSize
设置为30sp
),并且它的文本将显示为红色(android:textColor
设置为#FFFF0000
)。
项目中的其他地方不需要做任何更改——清单、活动的 Java 代码等等都不需要调整。只需定义样式并将其应用于小部件,就会得到如图 Figure 24–1 所示的结果。
图 24–1。 Styles/now style 示例应用
风格的要素
应用样式时需要考虑四个问题:
你把样式属性放在哪里表示你想应用一个样式?
哪些属性可以通过样式来定义?
你如何继承以前定义的风格(你自己的或者来自 Android 的)?
样式定义中的属性可以有哪些值?
在哪里应用样式
style
属性可以应用于一个小部件,它只影响那个小部件。
style
属性也可以应用于一个容器,它只影响那个容器。但是,这样做不会自动设置其子级的样式。例如,假设res/layout/main.xml
看起来像这样:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent"
` style="@style/bigred"
`
尽管有style
属性,最终的 UI 不会有红色大字体的Button
文本。样式只影响容器,不影响容器的内容。
你也可以将一个样式应用到一个活动或一个应用中,在这种情况下,它被称为一个主题 ,这将在本章的后面介绍。
可用的属性
在设计小部件或容器的样式时,您可以在样式本身中应用该小部件或容器的任何属性。因此,如果它出现在 Android JavaDocs 的“XML 属性”或“继承的 XML 属性”部分,您可以将它放在一个样式中。
注意,Android 会忽略无效的样式。因此,如果我们如上所示将bigred
样式应用于LinearLayout
,一切都会运行良好,只是没有可见的结果。尽管LinearLayout
没有android:textSize
或android:textColor
属性,但不会出现编译时故障或运行时异常。
此外,布局指令,如android:layout_width
,可以放在一个样式中。
传承一种风格
您还可以通过在<style>
元素上指定一个parent
属性来表明您想要从另一个样式继承样式属性。例如,看看这个样式资源(你会在第二十八章中再次看到,其中涵盖了使用片段框架的 UI 设计):
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="activated" parent="android:Theme.Holo"> <item name="android:background">?android:attr/activatedBackgroundIndicator</item> </style> </resources>
在这里,我们表明我们希望从 Android 内部继承Theme.Holo
风格。因此,除了指定我们自己的所有属性定义,我们还指定我们想要来自Theme.Holo
的所有属性定义。
在许多情况下,这是不必要的。如果您没有指定父对象,那么您的属性定义将被混合到应用于小部件或容器的任何默认样式中。
可能的值
通常,你在样式中赋予属性的值将是某个常量,比如30sp
或#FFFF0000
。但是,有时您可能希望执行一点间接操作,从您继承的主题中应用一些其他属性值。在这种情况下,您需要使用有点神秘的?android:attr/
语法,以及一些相关的魔法咒语。
例如,让我们再来看看这个样式资源:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="activated" parent="android:Theme.Holo"> <item name="android:background">?android:attr/activatedBackgroundIndicator</item> </style> </resources>
这里,我们指出android:background
的值不是某个常数值,甚至不是对可提取资源的引用(例如@drawable/my_background
)。相反,我们从我们继承的主题中引用了一些其他属性的值——??。无论主题如何定义为activatedBackgroundIndicator
都是我们的背景。
有时这适用于整体风格。例如,让我们再来看看ProgressBar
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <ProgressBarandroid:id="@+id/progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" /> </LinearLayout>
这里,我们的样式属性——不是样式资源——指向主题提供的属性(progressBarStyleHorizontal
)。如果你翻翻 Android 的源代码,你会发现这被定义为一个样式资源,确切地说是@android:style/Widget.ProgressBar.Horizontal
。因此,我们对 Android 说,我们希望我们的ProgressBar
通过?android:attr/progressBarStyleHorizontal
的间接方式被命名为@android:style/Widget.ProgressBar.Horizontal
。
Android 风格系统的这一部分仍然很少被记录,即使是最新发布的 Android 4.0 冰淇淋三明治——整个继承主题是三个简短的段落。谷歌自己建议你看看列出各种风格的 Android 源代码,看看有什么是可能的。
这是继承风格变得重要的一个地方。在本节显示的第一个例子中,我们从Theme.Holo
继承,因为我们特别想要来自Theme.Holo
的 activatedBackgroundIndicator
值。该值可能不存在于其他样式中,或者它可能没有我们想要的值。
主题:任何其他名称的风格…
主题是通过<activity>
或<application>
元素上的android:theme
属性应用于活动或应用的样式。如果你正在应用的主题是你自己的,简单地引用它为@style/…
,就像你在一个小部件的style
属性中一样。但是,如果你应用的主题来自 Android,通常你会使用一个以@android:style/
为前缀的值,比如@android:style/Theme.Dialog
或@android:style/Theme.Light
。
在一个主题中,你的重点不是设计小部件,而是设计活动本身。比如下面是@android:style/Theme.NoTitleBar.Fullscreen
的定义:
`
`
它指定活动应该接管整个屏幕,移除 Android 1.x 和 2.x 设备上的状态栏(android:windowFullscreen
设置为true
),以及 Android 3.x 和 4.x 设备上的动作栏。它还指定内容覆盖图——环绕活动内容视图的布局——应该设置为 nothing ( android:windowContentOverlay
设置为@null
),具有移除标题栏的效果。
主题还可能指定应用于特定小部件的其他样式。例如,我们在根主题(Theme
)中看到以下内容:
<item name="progressBarStyleHorizontal">@android:style/Widget.ProgressBar .Horizontal</item>
这里,progressBarStyleHorizontal
是指向@android:style/ Widget.ProgressBar.Horizontal
的。这就是我们如何能够在我们的ProgressBar
小部件中引用?android:attr/progressBarStyleHorizontal
,并且我们可以创建我们自己的主题,重新定义progressBarStyleHorizontal
以指向一些其他的样式(例如,如果我们想要改变用于实际进度条图像本身的圆角矩形)。
二十五、处理多种屏幕尺寸
在 Android 1.0 发布后的第一年左右,所有生产的 Android 设备都有相同的屏幕分辨率(HVGA,320×480 像素)和尺寸(大约 3.5 英寸,或 9 厘米)。然而,从 2009 年末开始,设备开始出现各种不同的屏幕尺寸和分辨率,从微小的 QVGA (240×320)屏幕到更大的 WVGA (480×800)屏幕。2010 年末,平板电脑和谷歌电视设备出现,提供了更多的屏幕尺寸,随着蜂巢和冰淇淋三明治的发布,平板电脑和更大的屏幕尺寸爆炸式增长。
当然,用户会希望你的应用在所有这些屏幕上都能正常工作,也许会利用更大的屏幕尺寸来增加更大的价值。为此,Android 1.6 增加了新的功能,以帮助更好地支持这些不同的屏幕尺寸和分辨率,这些功能在后续的 Android 版本中得到了扩展。随着 Android 3.0 的发布,可选的片段系统作为一种处理不同屏幕尺寸的更强大——尽管更复杂——的方式被引入。Android 文档广泛介绍了使用传统方法和片段方法处理多种屏幕尺寸的机制。我们鼓励你在阅读本章(以及第二十八章)的同时阅读该文档,以充分理解如何最好地应对,或者利用多种屏幕尺寸。
这一章将处理更多的理论和抽象的设计思想,用一些章节讨论屏幕尺寸的选择和理论。然后,我们将深入探讨如何让一个相当简单的应用很好地处理多种屏幕尺寸。这一章将避免增加片段的复杂性,但是不要害怕:我们将回到第二十八章的主题和片段。
采取默认
让我们假设你一开始完全忽略了屏幕尺寸和分辨率的问题。会发生什么?
如果你的应用是为 Android 1.5 或更低版本编译的,Android 会认为你的应用在传统的屏幕尺寸和分辨率下看起来很好。Android 将自动执行以下操作:
如果你的应用安装在屏幕更大的设备上,Android 将在兼容模式下运行你的应用,根据实际屏幕大小缩放一切。因此,假设你有一个 24 像素的方形 PNG 文件,Android 在一个标准物理尺寸但具有 WVGA 分辨率的设备上安装并运行你的应用(所谓的高密度屏幕)。Android 可能会在显示 PNG 文件时将其缩放为 36 像素,因此它在屏幕上占据相同的可视空间。有利的一面是,Android 会自动处理这个问题;不利的一面是,位图缩放算法会使图像有点模糊。
如果你的应用安装在屏幕较小的设备上,Android 会阻止你的应用运行。因此,QVGA 设备,如 HTC Tattoo,将无法获得您的应用,即使它在 Android 市场上可用。
为了举例说明这如何影响你的应用,Figure 25–1 展示了在 HTC 纹身上看到的Containers/Table
示例应用,带有 QVGA 屏幕。
图 25–1。 通过兼容模式在 QVGA 中的表样
如果您的应用是为 Android 1.6 或更高版本编译的,Android 会假设您可以正确处理所有屏幕尺寸,因此不会在兼容模式下运行您的应用。考虑到后续版本的巨大改进,尤其是 Android 2.2、3.0 和 4.0,很少有开发者会将 1.6 之前的版本作为目标。这意味着你几乎总是会自己处理屏幕尺寸管理。在后面的部分中,您将看到如何对其进行定制。
整体合一
在 Android 中处理多种屏幕尺寸的最简单的方法是设计你的用户界面(UI ),使其自动根据屏幕尺寸进行缩放,而不需要任何特定尺寸的代码或资源。换句话说,“它只是工作。”
然而,这意味着你在 UI 中使用的一切都可以被 Android 优雅地缩放,一切都将适合,即使是在 QVGA 屏幕上。
以下部分提供了实现这种一体化解决方案的一些提示。
考虑规则,而不是立场
一些开发人员,也许是那些来自 UI 开发拖放学校的开发人员,首先考虑的是小部件的位置。他们认为他们希望特定的部件在特定的固定位置具有特定的固定大小。他们对 Android 布局管理器(容器)感到失望,并倾向于用他们习惯的方式来设计 ui。
这种方法很少能很好地工作,即使是在桌面上,这可以在不能很好地处理窗口大小调整的应用中看到。同样,这种方法也不适用于移动设备,尤其是 Android,因为它们的屏幕尺寸和分辨率差异很大。
不要想立场,要想规则。你需要教会 Android 关于小部件的大小和位置的“商业规则”,然后 Android 将根据设备屏幕在分辨率方面实际支持的内容来解释这些规则。
最简单的规则是android:layout_width
和android:layout_height
的fill_parent
和wrap_content
值。它们不指定具体的尺寸,而是适应可用的空间。
最容易指定规则的环境是RelativeLayout
。虽然表面上很复杂,RelativeLayout
做得很好,让你控制你的布局,同时仍然适应其他屏幕尺寸。例如,您可以执行以下操作:
明确地将窗口小部件锚定在屏幕的底部或右侧,而不是希望它们会因为其他布局而出现在那里
控制连接的小部件之间的距离(例如,字段的标签应该在字段的左侧),而不必依赖填充或边距
指定规则的最好方法是创建自己的布局类。例如,假设您正在创建一系列实现纸牌游戏的应用。您可能希望有一个布局类,它知道关于扑克牌的以下内容:它们如何重叠,面朝上还是面朝下,处理不同数量的牌应该有多大,等等。虽然你可以用一个RelativeLayout
来实现你想要的外观,但是实现一个PlayingCardLayout
或者一个HandOfCardsLayout
或者更明确地为你的应用定制的东西可能会更好。不幸的是,创建自定义布局类目前还没有被记录下来。
考虑物理尺寸
Android 提供了大量可用的尺寸测量单位。最流行的是 pixel ( px
),因为它很容易让人理解这个概念。毕竟,每个 Android 设备都有一个每个方向都有一定数量像素的屏幕。
但是,随着屏幕密度的变化,像素开始变得麻烦。随着给定屏幕尺寸中像素数量的增加,像素实际上会缩小。传统 Android 设备上的 32 像素图标可能对手指友好,但在高密度设备上(比如手机外形的 WVGA),32 像素对于手指来说可能有点小。
如果你有某种本质上可缩放的东西(例如,Button
),你可以考虑使用毫米(mm
)或英寸(in
)作为度量单位。无论屏幕分辨率还是屏幕尺寸,10 毫米就是 10 毫米。这样,您可以确保小部件的大小适合手指,而不管可能需要多少像素。
避免“真实”像素
在某些情况下,使用毫米表示尺寸没有意义。在这种情况下,您可能需要考虑使用其他度量单位,同时避免使用“真实”像素。
Android 提供了以密度无关像素(dip
)测量的尺寸。这些 1:1 映射到 160 dpi 屏幕(例如,经典的 HVGA Android 设备)的像素,并从那里缩放。例如,在 240 dpi 的设备(例如,手机大小的 WVGA 设备)上,该比率是 2:3,因此50dip
= 50px
在 160 dpi,而= 75px
在 240 dpi。用户使用dip
的好处是尺寸的实际大小保持不变,因此显然 160 dpi 的50dip
和 240 dpi 的50dip
没有区别。
Android 还提供按比例像素测量的尺寸(sp
)。理论上,缩放像素是根据用户选择的字体大小进行缩放的(System.Settings
中的FONT_SCALE
值)。
选择可伸缩的抽屉
传统位图——PNG、JPG、BMP 和 GIF——本质上不可扩展,Android 4.0 也不支持最新的图像格式——WEBP。如果你不是在兼容模式下运行,Android 甚至不会尝试根据屏幕分辨率和尺寸来缩放这些内容。无论你提供的位图大小是多少,即使这会使图像在某些屏幕上变得过大或过小。
解决这个问题的一个方法是尽量避免静态位图,使用九补丁位图和 XML 定义的 drawables(如GradientDrawable
)作为替代。九片位图是一种 PNG 文件,经过特殊编码,具有指示如何拉伸图像以占据更多空间的规则。XML 定义的 drawables 使用一种准 SVG XML 语言来定义形状、它们的笔画和填充等等。
量身定做,只为你(还有你,还有你,还有……)
有时,您会希望根据屏幕大小或密度拥有不同的外观或行为。Android 提供了一些技术,您可以使用这些技术根据应用运行的环境来切换资源或代码块。当这些技术与上一节描述的技术结合使用时,实现屏幕尺寸和密度独立是完全可能的,至少对于运行 Android 1.6 和更新版本的设备来说是如此。
增加<支撑屏>元素
主动支持不同屏幕尺寸的第一步是将<supports-screens>
元素添加到AndroidManifest.xml
文件中。这指定了应用明确支持和不支持的屏幕尺寸。它没有明确支持的那些将由自动兼容模式处理,如前所述。
下面是一个包含<supports-screens>
元素的清单:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.eu4you" android:versionCode="1" android:versionName="1.0"> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true" /> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".EU4You" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
android:smallScreens
、android:normalScreens
和android:largeScreens
属性是不言自明的:每一个属性都有一个布尔值,表明你的应用是明确支持那个尺寸的屏幕(true
)还是需要兼容模式帮助(false
)。Android 2.3 还为更大的平板电脑、电视等增加了android:xlargeScreens
(剧院,有人吗?).
android:anyDensity
属性表示您是否在计算中考虑了密度(true
)或不考虑密度(false
)。如果是false
,Android 将会把你的所有尺寸(例如4px
)都当作普通密度(160-dpi)的屏幕来处理。如果你的应用运行在更低或更高密度的屏幕上,Android 会相应地缩放你的尺寸。如果你指出android:anyDensity = "true"
,你就是在告诉 Android 不要这么做,让你承担使用密度无关单位的责任,比如dip
、mm
或in
。
资源和资源集
基于屏幕大小或密度切换不同事物的主要方法是创建资源集。通过创建特定于不同设备特性的资源集,您可以教会 Android 如何渲染每一个资源集,然后 Android 会自动在这些资源集中进行切换。
默认缩放
默认情况下,Android 会缩放所有可提取的资源。那些本质上可伸缩的,如前所述,将会很好地伸缩。普通位图使用普通的缩放算法进行缩放,这可能会也可能不会给你很好的结果。这也可能会降低应用的速度。为了避免这种情况,您需要设置单独的包含不可缩放位图的资源集。
基于密度的集合
如果您希望基于不同的屏幕密度拥有不同的布局、尺寸等,您可以使用-ldpi
、-mdpi
、-hdpi
和-xhdpi
资源集标签。例如,res/values-hdpi/dimens.xml
将包含高密度设备中使用的尺寸。
请注意,在使用这些屏幕密度资源集时,Android 1.5 (API level 3)中有一个 bug。尽管所有的 Android 1.5 设备都是中等密度,但 Android 1.5 可能会意外地选择其他密度。如果您打算支持 Android 1.5 并使用屏幕密度资源集,您需要克隆您的-mdpi
集的内容,克隆名为-mdpi-v3
。这个基于版本的集合将在本节稍后详细描述。
基于大小的集合
同样,如果你希望根据屏幕大小拥有不同的资源集,Android 提供了-small
、-normal
、-large
和-xlarge
资源集标签。创建res/layout-large-land/
将指示在横向大屏幕(如 WVGA)上使用的布局。
基于版本的集合
可能会有早期版本的 Android 被新的资源集标签弄糊涂的时候。为了帮助解决这个问题,您可以向您的资源集添加一个版本标签,格式为-vN
,其中N
是一个 API 级别。因此,res/drawable-large-v4/
表示这些 drawables 应该在 API 级别为 4 (Android 1.6)和更高的大屏幕上使用。
所以,如果你发现 Android 1.5 模拟器或设备正在抓取错误的资源集,可以考虑在它们的资源集名称中添加-v4
来过滤掉它们。
找到你的尺码
如果需要根据屏幕大小或密度在 Java 代码中采取不同的动作,有几种选择。
如果您的资源集中有一些与众不同的东西,您可以基于此“嗅”出来,并在代码中相应地进行分支。例如,正如你将在本章后面的代码示例中看到的,你可以在一些布局中有额外的小部件(例如,res/layout-large/main.xml
);简单地看看是否有一个额外的小部件存在,就可以知道你是否在运行一个大屏幕。
你也可以通过一个Configuration
对象找到你的屏幕尺寸等级,通常由一个Activity
通过getResources().getConfiguration()
获得。一个Configuration
对象有一个名为screenLayout
的公共字段,它是一个位掩码,指示应用运行的屏幕类型。您可以测试您的屏幕是小、正常还是大,或者是长(其中“长”表示 16:9 或类似的宽高比,而不是 4:3)。例如,我们在这里测试我们是否在大屏幕上运行:
if (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_LARGE) ==Configuration.SCREENLAYOUT_SIZE_LARGE) { // yes, we are large } else { // no, we are not }
类似地,您可以使用DisplayMetrics
类找出您的屏幕密度,或者您的屏幕尺寸中像素的确切数量。
没有什么比得上真实的东西
Android 模拟器将帮助你在不同尺寸的屏幕上测试你的应用。但是,这只能做到这一步,因为移动设备的 LCD 与台式机或笔记本电脑的 LCD 具有不同的特性,例如:
移动设备 LCD 的密度可能比开发机器的密度高得多。
鼠标允许比实际指尖更精确的触摸屏输入。
在可能的情况下,你将需要以新的和令人兴奋的方式使用模拟器,或者尝试使用具有不同屏幕分辨率的实际设备。
密度不同
摩托罗拉 DROID 有一个 240 dpi、3.7 英寸、480×854 像素的屏幕(FWVGA 显示器)。要模拟 DROID 屏幕,根据像素计算,需要占用 19 英寸、1280×1024 像素液晶显示器的三分之一,因为液晶显示器的密度比 DROID 低得多——约为 96 dpi。因此,当你为像 droid 这样的 FWVGA 显示器启动 Android 模拟器时,你会得到一个巨大的模拟器窗口。
对于在 FWVGA 环境中确定应用的整体外观来说,这仍然是非常好的。无论密度如何,窗口小部件仍将对齐,大小将具有相同的关系(例如,窗口小部件 A 可能是窗口小部件 B 的两倍高,并且无论密度如何都是如此),等等。
但是,请记住以下几点:
在 19 英寸的 LCD 上看起来尺寸合适的东西,在相同分辨率的移动设备屏幕上可能会太小。
在模拟器中,你可以用鼠标轻松点击的东西可能太小,用手指在物理上更小、更密集的屏幕上无法显示出来。
调整密度
默认情况下,仿真器以牺牲密度为代价来保持像素计数的准确性,这就是为什么您会得到真正大的仿真器窗口。不过,您可以选择让仿真器以牺牲像素数量为代价来保持密度的准确性。
最简单的方法是使用 Android 1.6 中引入的 Android AVD 管理器。这个工具的 Android 2.0 版本有一个启动选项对话框,当你通过开始按钮启动一个仿真器实例时会弹出这个对话框,如图 Figure 25–2 所示。
图 25–2。 ??【启动选项】对话框
默认情况下,“按实际大小显示”复选框是未选中的,Android 会正常打开模拟器窗口。您可以选中该复选框,然后提供两位缩放信息:
您希望模拟的设备的屏幕尺寸,以英寸为单位(例如,摩托罗拉 DROID 的屏幕尺寸为 3.7 英寸)
显示器的 dpi(单击?按钮打开计算器,帮助您确定您的 dpi 值)
这为您提供了一个仿真窗口,它更准确地描述了您的用户界面在物理设备上的外观,至少在大小方面是这样的。但是,由于仿真程序使用的像素比设备少得多,因此字体可能难以阅读,图像可能有块状等等。
无情地利用形势
到目前为止,我们已经关注了如何确保你的布局在其他尺寸的屏幕上看起来不错。对于比标准尺寸更小的屏幕(例如 QVGA),这也许是你所能希望达到的。
然而,一旦你进入更大的屏幕,另一种可能性就出现了:使用不同的布局来利用额外的屏幕空间。当物理屏幕尺寸较大时(例如,戴尔 Streak Android 平板电脑上的 5 英寸液晶显示器,或三星 Galaxy Tab 上的 7 英寸液晶显示器),这一点特别有用,而不是简单地在相同的物理空间中拥有更多像素。
以下部分描述了一些利用额外空间的方法。
用按钮代替菜单
选项菜单选择需要两个物理动作:按下菜单按钮,然后点击适当的菜单选项。上下文菜单选择也需要两个物理动作:长时间点击小部件,然后点击菜单选项。上下文菜单具有实际上不可见的额外问题;例如,用户可能没有意识到你的ListView
有一个上下文菜单。
你可以考虑增加你的用户界面来提供直接在屏幕上完成事情的方法,否则这些事情可能会隐藏在菜单上。这不仅减少了用户需要采取的步骤数量,而且使这些选项更加明显。
例如,假设您正在创建一个媒体播放器应用,并且希望提供手动播放列表管理。您有一个在ListView
中显示播放列表中的歌曲的活动。在选项菜单上,您可以选择添加,将设备上的歌曲添加到播放列表中。在ListView
的上下文菜单上,你有一个移除选项,加上上移和下移选项来重新排列列表中的歌曲。不过,对于大屏幕,您可能会考虑为这四个选项在 UI 中添加四个ImageButton
小部件,只有当通过 D-pad 或轨迹球选择一行时,上下文菜单中的三个小部件才会启用。在普通或小屏幕上,你会坚持只使用菜单。
用一个简单的活动替换标签
您可能在 UI 中引入了一个TabHost
来允许您在可用的屏幕空间中显示更多的小部件。只要您通过将小部件移动到一个单独的选项卡而节省的空间大于选项卡本身占用的空间,您就赢了。然而,拥有多个选项卡意味着需要更多的用户步骤来导航用户界面,特别是当用户需要频繁地在选项卡之间来回切换时。
如果你只有两个标签,考虑改变你的用户界面,提供一个大屏幕布局,去掉标签,把所有的小部件放在一个屏幕上(或者,等待第二十八章关于片段的讨论)。这使得用户无需一直切换标签就能看到所有内容。
如果你有三个或者更多的标签,你可能没有足够的屏幕空间来把这些标签的内容放在一个活动中。然而,你可以考虑对半分:让流行的小部件一直出现在活动中,让你的TabHost
在(大约)半个屏幕上处理剩下的部分。
整合多个活动
最强大的技术是使用更大的屏幕来彻底消除活动转换。例如,如果您有一个ListActivity
,单击一个项目会在一个单独的活动中显示该项目的详细信息,请考虑支持大屏幕布局,其中详细信息与ListView
在同一个活动中(例如,在横向布局中,ListView
在左边,在右边)。这消除了用户在查看另一组细节之前必须不断地按后退按钮来离开一组细节的情况。
您将在下一节中展示的示例代码中看到这种技术的应用。
例子:EU4You
为了研究如何使用前面几节中介绍的一些技术,让我们看一下ScreenSizes/EU4You
示例应用。这个应用有一个活动(EU4You
),其中包含一个ListView
,上面有欧盟成员的名单和他们各自的旗帜。点击其中一个国家,就会出现这个国家的移动维基百科页面。
在本书的源代码中,您会发现这个应用的四个版本。我们从一个不知道屏幕大小的应用开始,慢慢地添加更多与屏幕相关的功能。
第一刀
首先,这是我们的AndroidManifest.xml
文件,它看起来很像本章前面显示的那个:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.eu4you" android:versionCode="1" android:versionName="1.0"> <supports-screens android:xlargeScreens="true" android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true" /> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".EU4You" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
注意,我们已经包含了<supports-screens>
元素,这表明我们确实支持所有的屏幕尺寸。如果我们不指定我们支持某些屏幕尺寸,这将阻止 Android 的大部分自动缩放。
我们的主要布局与尺寸无关,因为它只是一个全屏ListView
:
<?xml version="1.0" encoding="utf-8"?> <ListView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" />
不过,我们的争吵最终将需要一些调整:
`
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="2dip"
android:minHeight="?android:attr/listPreferredItemHeight"
`
比如现在,我们的字体大小设置为20dip
,不会因屏幕大小或密度而变化。
我们的EU4You
活动有点冗长,主要是因为有很多欧盟成员,所以我们需要智能地显示行中的标志和文本:
`package com.commonsware.android.eu4you;
import android.app.ListActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
public class EU4You extends ListActivity {
static private ArrayList EU=new ArrayList();
static {
EU.add (new Country (R.string.austria, R.drawable.austria,
R.string.austria_url));
EU.add (new Country (R.string.belgium, R.drawable.belgium,
R.string.belgium_url));
EU.add (new Country (R.string.bulgaria, R.drawable.bulgaria,
R.string.bulgaria_url));
EU.add (new Country (R.string.cyprus, R.drawable.cyprus,
R.string.cyprus_url));
EU.add (new Country (R.string.czech_republic,
R.drawable.czech_republic,
R.string.czech_republic_url));
EU.add (new Country (R.string.denmark, R.drawable.denmark,
R.string.denmark_url));
EU.add (new Country (R.string.estonia, R.drawable.estonia,
R.string.estonia_url));
EU.add (new Country (R.string.finland, R.drawable.finland,
R.string.finland_url));
EU.add (new Country (R.string.france, R.drawable.france,
R.string.france_url));
EU.add (new Country (R.string.germany, R.drawable.germany,
R.string.germany_url));
EU.add (new Country (R.string.greece, R.drawable.greece,
R.string.greece_url));
EU.add (new Country (R.string.hungary, R.drawable.hungary,
R.string.hungary_url));
EU.add (new Country (R.string.ireland, R.drawable.ireland,
R.string.ireland_url));
EU.add (new Country (R.string.italy, R.drawable.italy,
R.string.italy_url));
EU.add (new Country (R.string.latvia, R.drawable.latvia,
R.string.latvia_url));
EU.add (new Country (R.string.lithuania, R.drawable.lithuania,
R.string.lithuania_url));
EU.add (new Country (R.string.luxembourg, R.drawable.luxembourg,
R.string.luxembourg_url));
EU.add (new Country (R.string.malta, R.drawable.malta,
R.string.malta_url));
EU.add (new Country (R.string.netherlands, R.drawable.netherlands,
R.string.netherlands_url));
EU.add (new Country (R.string.poland, R.drawable.poland,
R.string.poland_url));
EU.add (new Country (R.string.portugal, R.drawable.portugal,
R.string.portugal_url));
EU.add (new Country (R.string.romania, R.drawable.romania,
R.string.romania_url));
EU.add (new Country (R.string.slovakia, R.drawable.slovakia,
R.string.slovakia_url));
EU.add (new Country (R.string.slovenia, R.drawable.slovenia,
R.string.slovenia_url));
EU.add (new Country (R.string.spain, R.drawable.spain,
R.string.spain_url));
EU.add (new Country (R.string.sweden, R.drawable.sweden,
R.string.sweden_url));
EU.add (new Country (R.string.united_kingdom,
R.drawable.united_kingdom,
R.string.united_kingdom_url));
}
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
setListAdapter (new CountryAdapter ());
}
@Override
protected void onListItemClick (ListView l, View v,
int position, long id) {
startActivity (new Intent (Intent.ACTION_VIEW,
Uri.parse (getString (EU.get (position).url))));
}
static class Country {
int name;
int flag;
int url;
Country (int name, int flag, int url) {
this.name=name;
this.flag=flag;
this.url=url;
}
}
class CountryAdapter extends ArrayAdapter {
CountryAdapter () {
super(EU4You.this, R.layout.row, R.id.name, EU);
}
@Override
public View getView (int position, View convertView,
ViewGroup parent) {
CountryWrapper wrapper=null;
if (convertView==null) {
convertView=getLayoutInflater ().inflate (R.layout.row, null);
wrapper=new CountryWrapper (convertView);
convertView.setTag (wrapper);
}
else {
wrapper=(CountryWrapper)convertView.getTag ();
}
wrapper.populateFrom (getItem (position));
return(convertView);
}
}
class CountryWrapper {
private TextView name=null;
private ImageView flag=null;
private View row=null;
CountryWrapper (View row) {
this.row=row;
}
TextView getName () {
if (name==null) {
name=(TextView)row.findViewById (R.id.name);
}
return(name);
}
ImageView getFlag () {
if (flag==null) {
flag=(ImageView)row.findViewById (R.id.flag);
}
return(flag);
}
void populateFrom (Country nation) {
getName ().setText (nation.name);
getFlag ().setImageResource (nation.flag);
}
}
}`
图 25–3、25–4 和 25–5 分别显示了普通 HVGA 仿真器、WVGA 仿真器和 QVGA 屏幕中的活动。
图 25–3。 EU4You,原版,HVGA
图 25–4。 EU4You,原版,WVGA (800×480 像素)
图 25–5。 EU4You,原版,QVGA
固定字体
首先要解决的问题是字体大小。正如你所看到的,对于固定的 20 像素大小,字体的范围从大到小,取决于屏幕的大小和密度。对于 WVGA 屏幕,字体可能很难阅读。
我们可以将维度作为一个资源(res/values/dimens.xml
),并基于屏幕大小或密度拥有该资源的不同版本。然而,更简单的方法是只指定一个与密度无关的大小,如5mm
,如ScreenSizes/EU4You_2
项目所示:
`
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="2dip"
android:minHeight="?android:attr/listPreferredItemHeight"
`
图 25–6、25–7 和 25–8 分别显示了 HVGA、WVGA 和 QVGA 屏幕上的新活动。
图 25-6。 EU4You,5mm 字体版本,HVGA
图 25–7。 EU4You,5mm 字体版本,WVGA (800×480 像素)
图 25-8。 EU4You,5mm 字体版本,QVGA
现在我们的字体大小一致,足够匹配旗帜。
固定图标
那么,那些图标呢?它们的大小也应该不同,因为它们对于所有三个模拟器都是相同的。
但是,Android 会自动缩放位图资源,即使是在<supports-screens>
及其属性设置为true
的情况下。从好的方面来说,这意味着您可能不需要对这些位图做任何事情。然而,您依赖于一个设备来进行扩展,这无疑会消耗 CPU 时间(因此会延长电池寿命)。此外,与开发机器上的图形工具相比,设备使用的缩放算法可能不是最佳的。
ScreenSizes/EU4You_3
项目创建了res/drawable-ldpi
和res/drawable-hdpi
,分别放入更小和更大的旗帜。该项目还将res/drawable
更名为res/drawable-mdpi
。Android 将根据设备或模拟器的需要,使用合适的屏幕密度标志。
因为这种效果很微妙,不会在本书中很好地表现出来,所以没有提供截图。
利用空间
虽然该活动在纵向模式下在 WVGA 上看起来不错,但在横向模式下确实浪费了很多空间,如图 Figure 25–9 所示。
图 25–9。 EU4You,风景 WVGA (800×480 像素)
我们可以更好地利用这个空间,让维基百科的内容在大屏幕横向模式下直接出现在主活动上;这省去了生成单独的浏览器活动。
要做到这一点,我们首先必须将main.xml
布局克隆到包含WebView
小部件的res/layout-large-land
呈现中,如ScreenSizes/EU4You_4
所示:
`
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="fill_parent"
`
然后,我们需要调整我们的活动来寻找那个WebView
,如果找到就使用它,否则默认启动一个浏览器活动:
`@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
browser=(WebView)findViewById (R.id.browser);
setListAdapter (new CountryAdapter ());
}
@Override
protected void onListItemClick (ListView l, View v,
int position, long id) {
String url=getString (EU.get (position).url);
if (browser==null) {
startActivity (new Intent (Intent.ACTION_VIEW,
Uri.parse (url)));
}
else {
browser.loadUrl (url);
}
}`
这为我们提供了一个更加节省空间的活动版本,如图 Figure 25–10 所示。
图 25–10。 EU4You,风景 WVGA (800×480 像素),设置为正常密度,并显示嵌入式 WebView
如果用户点击维基百科页面上的一个链接,完整的浏览器就会打开,以便于浏览。我们可以重复这个练习,为大屏幕的活动添加更多的数据。
请注意,测试这个版本的活动,以查看这种行为,需要一些额外的模拟器工作。默认情况下,Android 将 WVGA 设备设置为高密度,这意味着 WVGA 在资源集方面并不算大,而是正常的。您将需要创建一个不同的模拟器 AVD,设置为正常(中等)密度,这将导致一个大的屏幕尺寸。
如果不是浏览器呢?
当然,EU4You
确实有点作弊。第二个活动是浏览器(或者嵌入式表单中的WebView
),而不是您自己创建的活动。如果第二个活动是您的某个活动,在一个布局中有许多小部件,并且您既想将它用作一个活动(对于较小的屏幕),又想将它嵌入到您的主活动 UI 中(对于较大的屏幕),那么事情会变得稍微复杂一些。
对于 Android 1.6 和更高版本,解决这个问题的最好方法是使用新的片段系统。虽然这是在 Android 3.0 中引入的,但 Android 兼容性库使片段在早期版本的 Android 中可用。片段的基本使用——包括另一个版本的EU4You
样本——将在第二十八章中介绍。
二十六、关注平板电脑和更大的用户界面
2011 年 2 月,Android 3.0 和一种用户界面范式问世,这种界面范式采用了比早期 Android 版本设计的传统手机大得多的屏幕。快进到 2011 年 10 月,Android 4.0 冰淇淋三明治(ICS)已经发布,将 Android 3.0 的平板专用蜂巢 UI 系统与主流 Android 代码库统一起来。拥抱平板电脑,以及更大的设备,如电视、影院显示器等,这是自第一代手机问世之前的 Android 0.9 以来,Android 最大的一次变化。
在设计和构建 Android 应用时是否考虑平板设备是您自己的偏好,但了解平台适应大格式的方式将允许您设计代码,以便在未来轻松适应平板电脑,最重要的是,处理 API 的核心原则,无论您对平板电脑的感觉如何,都必须解决这些原则。这一章更侧重于平板设备的 API 的现状以及它们在 Android 中的位置。
为什么选择平板电脑?
原则上,Android 最初以手机为中心的 UI 可以在平板电脑上运行。毕竟,一些平板电脑已经搭载了 Android 2.2 支持,如三星 Galaxy Tab 和中兴 V9。显然,这些制造商认为当时的 Android 对于他们的平板设备来说已经足够强大了。
也就是说,随着你进入更大的平板电脑(例如,10 英寸对角线屏幕的摩托罗拉 XOOM),旧的 Android 手机用户界面开始变得沉闷。虽然应用可以通过扩展来使用更大的屏幕,但是默认的扩展方式只是把所有东西都放大,这经常会导致大量的空间浪费。手机上的电子邮件客户端可能会专门显示收件箱中的电子邮件列表,而平板电脑上的电子邮件客户端实际上应该显示电子邮件列表和其他内容,例如所选电子邮件的内容。我们有这个房间,所以不妨使用它。
同样,对菜单的依赖在手机上是合理的,但在平板电脑上就没那么有意义了。我们有足够的空间在屏幕上展示更多的功能。将它们隐藏在菜单中会使它们不容易被用户发现,并且需要额外的点击才能访问。
因此,“现代”Android 旨在保留 Android 用户体验的精髓,同时允许应用(相对)优雅地利用可用空间。
用户看到的内容
平板电脑屏幕看起来与传统手机上的 Android 2.x 屏幕略有不同,如图 Figure 26–1 所示。
图 26–1。 Android 应用启动器,在模拟器上显示为平板电脑
有了这些额外的不动产,各种库存组件可以放置在更多不同的位置。在本例中,我们看到系统栏位于屏幕底部。系统栏的左端是返回、主页和最近任务的屏幕按钮(无需记住长按主页按钮即可达到相同的效果)。通知图标出现在系统栏的右侧,旁边还有时钟、信号和电池电量指示器(通知的概念将在第三十七章中介绍)。
未针对 Android 3.x/4.0 优化的应用的 UI 看起来大同小异,如图 Figure 26–2 所示。
图 26–2。 Android 3.0 上的 fancy lists/动态样本项目
唯一实质性的区别是系统栏左边的新图标,它将打开一个 Android 2.x 选项菜单,如果应用有的话。
针对平板电脑优化的应用看起来会有些不同,如图 Figure 26–3 所示。
图 26–3。 在安卓 4.0 上添加联系人
屏幕顶部是操作栏,占据了 Android 3.0 之前的应用使用菜单的空间。在图 26–3 中,完成选项作为菜单选项出现。其他需要注意的菜单行为是动作栏左端的<图标,如图 Figure 26–4 所示。
图 26–4。 <图标用于在动作层级中向上移动,在安卓 4.0 中显示
在这种情况下,点击< icon takes the user up in the hierarchy of actions in this application, going “up” from viewing a new contact to viewing the list of existing contacts, as shown in 图 26–5。
图 26–5。 安卓 4.0 显示的可用联系人名单
我们的用户界面已经走过了几个 Android 版本的生命周期。在 Android 2.x 中,联系人 UI 将有一个包含联系人列表的活动,以及一个查看该联系人详细信息的单独活动。在 Android 3.0 中,这些被合并成一个活动。在 Android 4.0 中,我们又回到了每次操作一个活动的模式。操作栏的右侧包括一个“查找联系人”搜索图标(放大镜)和一个添加新联系人的图标。与之相邻的是代表任何其他可用选项菜单项和上下文菜单项的图标。
处理剩余的设备
当然,世界上所有的 Android 手机并没有因为 Android 4.0 的发布而消失。我们的目标是让您从一个代码库创建一个同时支持手机和平板电脑的应用。
你的以手机为中心的应用在平板电脑上运行也很好,尽管你可能希望做一些事情来利用更大的屏幕尺寸,正如上一章所讨论的那样。如果您想采用冰激凌三明治 UI 的一般外观,您需要在清单的<uses-sdk>
元素中包含android:targetSdkVersion="14"
。如果你以前为 Honeycomb 开发过,并且习惯于使用android:hardwareAccelerated="true"
属性显式打开硬件加速,那么好消息是你不再需要在 Android 4.0 中显式设置这样的加速。硬件加速现在是默认的。从ScreenSizes/EU4You_5
示例项目的AndroidManifest.xml
文件中摘录的这段内容展示了 SDK 的变化:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.eu4you" android:versionCode="1" android:versionName="1.0"> <uses-permission android:name="android.permission.INTERNET" /> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true" /> <uses-sdk android:minSdkVersion="4" android:targetSdkVersion="14" /> <application android:label="@string/app_name" android:icon="@drawable/cw" <activity android:name=".EU4You" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
最终的应用在旧设备上运行良好,但是没有其他变化,我们在摩托罗拉 XOOM 上得到如图 Figure 26–6 所示的结果。
图 26–6。 ??【EU4You】样本应用,在摩托罗拉 XOOM 上运行
如果你想利用冰淇淋三明治的一些新特性,你还需要考虑向后兼容性,以确保你在应用中实现的东西能在新老版本的 Android 上成功工作。这个主题将在本书的后面部分讨论。
如果您有需要特定于版本的资源,比如样式,您可以使用-v*NN*
资源集后缀语法,其中 NN
表示您所针对的版本。例如,你可以有一个res/values/styles.xml
和一个res/values-v14/styles.xml
——后者将用于冰淇淋三明治,而前者将用于旧版本的 Android。但是首先,你需要探索所有你可以利用的平板 UI 特性,这是接下来几章的重点。
二十七、使用动作栏
让你的应用更好地融入最新最棒的 Android UI 的最简单的方法之一是启用动作栏,这在第二十六章中有介绍。让它变得“容易”的是,动作栏的大部分基本功能都是向后兼容的 Android 4.0 的设置不会导致应用在早期版本的 Android 上崩溃。
本章中显示的示例项目是Menus/ActionBar
,它扩展了上一章中显示的Menus/Inflation
项目。
启用动作栏
默认情况下,您的 Android 应用不会使用操作栏。事实上,它甚至不会显示在屏幕上。如果您希望动作栏出现在屏幕上,您需要在清单中的<uses-sdk>
元素中包含android:targetSdkVersion="11"
或更高版本,例如Menus/ActionBar
项目的清单:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.inflation"> <application android:label="@string/app_name" android:icon="@drawable/cw" android:hardwareAccelerated="true"> <activity android:name=".InflationDemo" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <uses-sdk android:minSdkVersion="4" android:targetSdkVersion="11" /> <supports-screens android:xlargeScreens="true" android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
这将使你的选项菜单出现在屏幕的右上角,在动作栏的菜单图标下,如第二十六章所示。此外,您的活动图标将出现在左上角,旁边是您的活动名称(来自清单中的android:label
属性)。
虽然这给了你基本的现代外观和感觉——包括冰淇淋三明治主题的小部件——但它并没有真正改变用户体验。
将菜单项提升到操作栏
与动作栏集成的下一步是将某些选项菜单项从选项菜单的一部分提升到总是在动作栏上可见。这使得它们更容易被找到,并在用户需要使用它们的时候节省了时间。
为此,在您的菜单 XML 资源中,您可以将android:showAsAction
属性添加到<item>
元素中。值ifRoom
意味着如果有空间,菜单项将出现在动作栏中,而值always
意味着菜单项将总是被放在动作栏中。在其他条件相同的情况下,ifRoom
是更好的选择,因为一旦蜂窝用户界面转移到手机上,它将更好地适应更小的屏幕。您也可以将其与withText
值(例如ifRoom|withText
)结合使用,使菜单项的标题出现在该项目的图标旁边(否则,只有图标出现在操作栏中)。
例如,Menus/ActionBar
项目的options.xml
菜单资源在前两个菜单项上有android:showAsAction
:
`
-
-
-
`
第二个菜单项 Reset 用于重置列表的内容,它是一个普通的“带文本”操作栏按钮。第一个菜单项 Add 做了一点不同的事情,我们将在本章的后面讨论。第三个菜单项 About 没有android:showAsAction
这一事实意味着它将保留在菜单中,即使动作栏中还有空间。
请注意,Java 代码没有改变——我们的InflationDemo
活动的onCreateOptionsMenu()
和onOptionsItemSelected()
不需要调整,因为菜单项仅通过菜单 XML 资源被提升到动作栏中。
响应标志
屏幕左上角的活动图标可点击。如果用户点击它,它会触发onOptionsItemSelected()
…但不是您自己定义的选项菜单项。而是使用了android.R.id.home
的神奇值。在Menus/ActionBar
项目中,我们将它连接到用户选择 About options 菜单项时调用的相同代码——显示一个Toast
:
`@Override
public boolean onOptionsItemSelected (MenuItem item) {
switch (item.getItemId ()) {
case R.id.add:
add ();
return(true);
case R.id.reset:
initAdapter ();
return(true);
case R.id.about:
case android.R.id.home:
Toast
.makeText (this,
"Action Bar Sample App",
Toast.LENGTH_LONG)
.show ();
return(true);
}
return(super.onOptionsItemSelected (item));
}`
然而,在一个包含多个活动的项目中,无论这意味着什么,点击图标都会将你带到应用的“主页”活动。
向操作栏添加自定义视图
除了简单地将选项菜单项转换为相当于工具栏按钮的内容之外,您还可以使用操作栏做更多的事情。您可以将自己的自定义用户界面添加到操作栏中。在Menus/ActionBar
的例子中,我们将在动作栏本身用一个添加字段替换添加菜单选项和结果对话框。
然而,如下所述,实现起来有点棘手。
定义布局
要在动作栏中放置自定义的东西,我们需要以布局 XML 文件的形式定义“自定义的东西”是什么。幸运的是,我们已经有了一个用于向列表添加单词的布局 XML 文件——它是当点击 Add options 菜单项时,Menus/Inflation
示例包装在自定义的AlertDialog
中的文件。最初的布局是这样的:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <TextView android:text="Word:" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <EditText android:id="@+id/title" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginLeft="4dip" /> </LinearLayout>
我们需要对这个布局做一些小的调整,以便将其用于操作栏:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <TextView android:text="Word:" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="@android:style/TextAppearance.Medium" /> <EditText android:id="@+id/title" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginLeft="4dip" android:width="160sp" android:inputType="text" android:imeActionId="1337" android:imeOptions="actionDone" /> </LinearLayout>
具体来说,我们做了以下小调整:
我们向表示添加标题的TextView
添加了一个android:textAppearance
属性。android:textAppearance
属性允许我们一次性定义字体类型、大小、颜色和粗细(如粗体)。我们特别使用了一个神奇的值@android:style/TextAppearance.Medium
,以便标题与我们提升到动作栏的另一个菜单项上的重置标签的样式相匹配。
我们为EditText
小部件指定了android:width="160sp"
,因为android:layout_width="fill_parent"
在动作栏中被忽略了——否则,我们将占用栏的其余部分。
我们在EditText
小部件上指定了android:inputType="text"
,这将我们限制为单行文本。
我们用EditText
小部件上的android:imeActionId
和android:imeOptions
来控制软键盘的动作按钮,所以当用户按下软键盘上的回车键时,我们得到控制权。
将布局放入菜单
接下来,如果我们运行的是最新版本的 Android,比如冰激凌三明治或蜂巢,我们需要教会 Android 使用这种布局来添加选项菜单项。为此,我们在<item>
元素上使用了android:actionLayout
属性,引用了我们的布局资源(@layout/add
),如本章前面所示。这个属性在早期版本的 Android 上会被忽略,所以可以安全使用。
如果我们什么也不做,我们将得到想要的 UI,如图 Figure 27–1 所示。
图 27–1。 菜单/ActionBar 示例应用
然而,尽管用户可以输入一些东西,我们没有办法知道他们输入了什么,什么时候输入的,等等。
控制用户输入
给定我们放在EditText
小部件上的软键盘设置,我们可以安排找出用户何时在软键盘或硬件键盘上按回车键。然而,要做到这一点,我们需要接触到EditText
小部件本身。你可能认为它是在onCreate()
中用户界面膨胀时添加的……但是你错了。
对于动作栏,onCreateOptionsMenu()
在onCreate()
之后被调用,作为设置 UI 的一部分。在经典版本的安卓系统中,onCreateOptionsMenu()
直到用户按下菜单键才会被调用。但是,由于一些选项菜单项可能会被提升到动作栏中,Android 现在会自动调用onCreateOptionsMenu()
。在我们扩大我们的options.xml
菜单资源后,EditText
将会存在。
然而,获得EditText
的最好方法是不要在活动中使用findViewById()
。相反,我们应该在与添加选项相关的MenuItem
上调用getActionView()
。这将返回视图层次结构的根,它是从我们在菜单资源的android:actionLayout
属性中定义的布局资源展开的。在这种情况下,那是来自res/layout/add.xml
的LinearLayout
,所以我们需要在它上面调用findViewById()
来获得EditText
:
`@Override
public boolean onCreateOptionsMenu (Menu menu) {
new MenuInflater (this).inflate (R.menu.option, menu);
EditText add=(EditText)menu
.findItem (R.id.add)
.getActionView ()
.findViewById (R.id.title);
add.setOnEditorActionListener (onSearch);
return(super.onCreateOptionsMenu (menu));
}`
然后,我们可以调用EditText
上的setOnEditorActionListener()
,注册一个OnEditorActionListener
对象,当用户在硬键盘或软键盘上按 Enter 键时,该对象将获得控制权:
`private TextView.OnEditorActionListener onSearch=
new TextView.OnEditorActionListener () {
public boolean onEditorAction (TextView v, int actionId,
KeyEvent event) {
if (eventnull || event.getAction () KeyEvent.ACTION_UP) {
addWord (v);
InputMethodManager imm=(InputMethodManager)getSystemService (INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow (v.getWindowToken (), 0);
}
return(true);
}
};`
这又调用了一个addWord()
方法,提供了EditText
,它通过ArrayAdapter
将单词添加到列表中:
`private void addWord (TextView title) {
ArrayAdapter adapter=(ArrayAdapter)getListAdapter ();
adapter.add (title.getText ().toString ());
}`
显示AlertDialog
的add()
方法也可以使用相同的addWord()
方法,尽管它不会在平板电脑上使用,因为添加菜单选项不再作为菜单选项存在:
`private void add () {
final View addView=getLayoutInflater ().inflate (R.layout.add, null);
new AlertDialog.Builder (this)
.setTitle ("Add a Word")
.setView (addView)
.setPositiveButton ("OK",
new DialogInterface.OnClickListener () {
public void onClick (DialogInterface dialog,
int whichButton) {
addWord ((TextView)addView.findViewById (R.id.title));
}
})
.setNegativeButton ("Cancel", null)
.show ();
}`
最终结果是,当用户在 Add 字段中键入内容并按下 Enter 键时,该单词将被添加到列表的底部。这比传统的电话 UI 节省了一些点击,因为用户不必打开选项菜单,不必点击选项菜单项,并且不必点击对话框上的按钮。
请注意,我们的OnEditorActionListener
不仅仅是将单词添加到列表中:它隐藏了软键盘。如前一章所述,它使用InputMethodManager
来实现这一点。
别忘了手机!
除了上一节中描述的自定义视图特性之外,本章中关于操作栏的所有内容都是自动向后兼容的。适用于冰淇淋三明治口味的 Android 版本的代码和资源将适用于未经修改的 Android 经典版本。
然而,如果你想使用自定义视图功能,你会遇到一个问题——getActionView()
方法是 API 级的新方法,在旧版本的 Android 上不可用。这意味着您将需要编译至少 API 级别 11(例如,设置您的 Eclipse 目标或 Ant default.properties
来引用android-11
)或更高,并且您将需要采取措施来避免在旧设备上调用getActionView()
。我们将在后面的章节中探讨如何实现这一壮举。
二十八、片段
2011 年 Android 开发者面临的最大变化可能是 Android 3.0 引入了片段系统,以及最近 Android 4.0 冰淇淋三明治将片段系统合并到主代码库。片段是一个可选层,可以放在活动和小部件之间,旨在帮助您重新配置活动,以支持大屏幕(例如平板电脑)和小屏幕(例如手机)。然而,片段系统也增加了额外的复杂性,这将需要 Android 开发者社区一些时间来适应。因此,使用片段的公共评论、博客帖子和示例应用就更少了,因为片段是在 Android 之后很久才被引入的。
本章涵盖了片段的基本用途,包括在 Android 3.0 之前的设备上支持片段。
引入片段
片段不是小部件,像Button
或EditText
。片段不是容器,像LinearLayout
或RelativeLayout
。片段不是活动。
相反,片段集合了小部件和容器。然后可以将片段放入活动中——有时一个活动有几个片段,有时每个活动有一个片段。原因是 Android 屏幕尺寸的变化。
片段解决的问题
平板电脑的屏幕比手机大。电视的屏幕比平板电脑大。利用额外的屏幕空间是有意义的,正如第二十五章中所描述的那样,该章解释了如何处理多种屏幕尺寸。在那一章中,我们分析了一个EU4You
示例应用,最终以一个活动结束,该活动将在一个不同的布局中为更大尺寸的屏幕加载,该布局具有一个嵌入的WebView
小部件。该活动将检测小部件的存在,并使用它来加载与选定国家相关的网页内容,而不是启动一个单独的浏览器活动或只包含一个WebView
的活动。
然而,第二十五章中概述的场景相当琐碎。想象一下,我们有一个包含 28 个小部件的TableLayout
,而不是一个WebView
。在更大尺寸的屏幕上,我们希望TableLayout
和相邻的ListView
在同一活动中;在较小的屏幕上,我们希望TableLayout
在一个单独的活动中,因为没有足够的空间。为了使用早期的 Android 技术做到这一点,我们需要复制两个活动中所有的TableLayout
处理逻辑,创建一个活动基类并希望两个活动都可以继承它,或者将TableLayout
和它的内容变成一个定制的ViewGroup
…或者做其他事情。这仅仅是针对一个 这样的场景——在一个更大的应用中乘以许多活动,复杂性就会增加。
片段溶液
片段减少了复杂性,但没有消除。
对于片段,可以在多个活动中使用的用户界面的每个离散块(基于屏幕大小)都放在一个片段中。根据屏幕大小,正在讨论的活动决定了谁得到片段。
在EU4You
的例子中,我们有两个片段。一个片段代表国家列表。另一个片段表示该国家的详细信息(在我们的例子中,是一个WebView
)。在大屏幕设备上,我们希望两个片段都在一个活动中,而在小屏幕设备上,我们将这些片段放在两个独立的活动中。这为大屏幕用户提供了与上一个版本的EU4You
相同的好处:用更少的点击获得更多信息。然而,我们用片段演示的技术将更具可伸缩性,能够处理比简单的EU4You
的WebView
或【非】场景更复杂的 UI 模式。
在这种情况下,我们的整个 UI 都在片段中。那是不必要的。片段是一种选择加入的技术——你只需要它们用于你的 UI 中可能出现在不同场景的不同活动中的部分。事实上,您的活动根本不会改变(比如说,一个帮助屏幕)可能不会使用任何片段。
片段还为我们提供了其他一些额外的功能,包括:
基于用户交互动态添加片段的能力 :例如,Gmail 应用最初显示用户邮件文件夹的ListFragment
。轻按一个文件夹会在屏幕上添加第二个ListFragment
,显示该文件夹中的对话。点击一个对话会在屏幕上添加第三个Fragment
,显示该对话中的信息。
动态片段在屏幕上来回移动的动画功能 :例如,当用户在 Gmail 中点击一个对话时,文件夹ListFragment
从屏幕上滑向左边,对话ListFragment
向左滑动并缩小以占据更少的空间,而消息Fragment
从右边滑入。
动态片段的自动后退按钮管理 :例如,当用户在查看消息Fragment
时按下后退,则Fragment
滑向右侧,对话ListFragment
向右滑动并扩展以填充更多屏幕,文件夹ListFragment
从左侧滑回。所有这些都不必由开发人员来管理——只需通过FragmentTransaction
添加动态片段,就可以让 Android 自动处理后退按钮,包括反转所有动画。
向选项菜单添加选项的能力,因此也向动作栏 添加选项:调用片段的onCreate()
中的setHasOptionsMenu()
来注册对此感兴趣,然后像在活动中一样覆盖片段中的onCreateOptionsMenu()
和onOptionsItemSelected()
。片段还可以注册小部件来拥有上下文菜单,并像处理活动一样处理这些上下文菜单。
向动作栏添加标签的能力 :动作栏可以有标签,取代了TabHost
,其中每个标签的内容都是一个片段。类似地,动作栏可以有一个导航模式,用一个Spinner
在模式之间切换,其中每个模式由一个片段表示。
如果你可以使用任何运行 Honeycomb 或 Ice Cream Sandwich 的最新设备,启动 Gmail 应用来查看所有片段的功能。
Android 兼容性库
如果片段只适用于 Android 3.0 和更高版本,我们将回到起点,因为今天并不是所有的 Android 设备都运行 Android 3.0 和更高版本。
幸运的是,情况并非如此,因为 Google 已经发布了 Android 兼容性库(ACL),它可以通过 Android SDK 和 AVD 管理器获得(在这里,您可以安装其他 SDK 支持文件,创建和启动您的仿真器 AVD,等等)。ACL 允许您访问从 Android 1.6 开始的 Android 版本上的片段系统。因为绝大多数 Android 设备运行的是 1.6 或更高版本,这允许您在保持向后兼容性的同时开始使用片段。随着时间的推移,对于希望使用该库的应用,该库可能会添加其他特性来帮助实现向后兼容性。
本章中的材料着重于在使用分段时使用 ACL。一般来说,对片段使用 ACL 几乎等同于直接使用原生 Android 3.0 片段类。
由于 ACL 仅支持 Android 1.6 的版本,Android 1.5 设备将无法使用基于片段的应用。这在目前的 Android 设备中只占很小的一部分——在撰写本文时大约是 1%。
创建片段类
建立基于片段的应用的第一步是为每个片段创建片段类。正如您从Activity
(或子类)继承活动一样,您从Fragment
(或子类)继承片段。
在这里,我们将检查Fragments/EU4You_6
示例项目和它定义的片段。
注: 本书的约定将是使用“片段”作为通用名词,Fragment
指代实际的Fragment
类。
一般片段
除了从Fragment
继承之外,片段唯一需要的就是覆盖onCreateView()
。这将作为把片段放在屏幕上的一部分被调用。您需要返回一个代表片段主体的View
。最有可能的是,您将通过一个 XML 布局文件来创建您的片段的 UI,并且onCreateView()
将扩展该片段布局文件。
例如,下面是来自EU4You_6
的DetailsFragment
,它将围绕我们的WebView
显示给定国家的网页内容:
`import android.support.v4.app.Fragment;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;
public class DetailsFragment extends Fragment {
@Override
public View onCreateView (LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return(inflater.inflate (R.layout.details_fragment, container, false));
}
public void loadUrl(String url) {
((WebView)(getView ().findViewById (R.id.browser))).loadUrl(url);
}
}`
注意,我们不是从android.app.Fragment
继承,而是从android.support.v4.app.Fragment
继承。后者是来自 ACL 的Fragment
实现,因此它可以跨 Android 版本使用。
onCreateView()
实现扩展了一个布局,碰巧其中有一个WebView
:
<?xml version="1.0" encoding="utf-8"?> <WebView
xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/browser" android:layout_width="fill_parent" android:layout_height="fill_parent" />
它还公开了一个loadUrl()
方法,宿主活动使用它来告诉片段是时候显示一些 web 内容了,并提供 URL 来做同样的事情。DetailsFragment
中loadUrl()
的实现使用getView()
检索onCreateView()
中创建的View
,找到其中的WebView
,将loadUrl()
调用委托给WebView
。
在Fragment
上有无数其他可用的生命周期方法。更重要的包括活动的标准onCreate()
、onStart()
、onResume()
、onPause()
、onStop()
和onDestroy()
方法的镜子。由于片段是带有小部件的片段,它将实现更多以前可能驻留在这些方法的活动中的业务逻辑。例如,在onPause()
或onStop()
中,由于用户可能不会返回到您的应用,您可能希望将任何未保存的编辑保存到某个临时存储中。在DetailsFragment
的例子中,这里没有真正合格的东西,所以那些生命周期方法被单独留下。
列表片段
一个肯定会流行的Fragment
子类是ListFragment
。这将一个ListView
封装在一个Fragment
中,旨在简化国家、邮件文件夹、邮件对话等列表的设置。类似于一个ListActivity
,你所需要做的就是用你选择和配置的ListAdapter
调用setListAdapter()
,并且当用户点击列表中的一行时覆盖onListItemClick()
来响应。
在EU4You_6
中,我们有一个代表可用国家列表的CountriesFragment
。它初始化onActivityCreated()
中的ListAdapter
,这个函数在onCreate()
结束保存片段的活动后被调用:
`@Override
public void onActivityCreated (Bundle state) {
super
.onActivityCreated(state);
setListAdapter (new CountryAdapter ());
if (state!=null) {
int position=state.getInt (STATE_CHECKED, -1);
if (position>-1) {
getListView ().setItemChecked (position, true);
}
}
}`
处理提供给onCreate()
的Bundle
的代码将在本章稍后解释。
CountryAdapter
与之前的EU4You
样本几乎相同,除了在Fragment
上没有getLayoutInflater()
方法,所以我们必须在LayoutInflater
上使用静态from()
方法,并通过getActivity()
提供我们的活动:
`class CountryAdapter extends ArrayAdapter {
CountryAdapter () {
super(getActivity (), R.layout.row, R.id.name, EU);
}
@Override
public View getView (int position, View convertView,
ViewGroup parent) {
CountryWrapper wrapper=null;
if (convertView==null) {
convertView=LayoutInflater
.from(getActivity ())
.inflate (R.layout.row, null);
wrapper=new CountryWrapper (convertView);
convertView.setTag(wrapper);
}
else {
wrapper=(CountryWrapper)convertView.getTag ();
}
wrapper.populateFrom (getItem (position));
return(convertView);
}
}`
同样,CountryWrapper
与之前的EU4You
样品没有任何不同:
`static class CountryWrapper {
private TextView name=null;
private ImageView flag=null;
private View row=null;
CountryWrapper (View row) {
this.row=row;
name=(TextView)row.findViewById (R.id.name);
flag=(ImageView)row.findViewById (R.id.flag);
}
TextView getName () {
return(name);
}
ImageView getFlag () {
return(flag);
}
void populateFrom (Country nation) {
getName().setText (nation.name);
getFlag().setImageResource (nation.flag);
}
}`
国家列表也是一样的:
static { EU**.add**(new **Country**(R.string.austria, R.drawable.austria, R.string.austria_url)); EU**.add**(new **Country**(R.string.belgium, R.drawable.belgium, R.string.belgium_url)); EU**.add**(new **Country**(R.string.bulgaria, R.drawable.bulgaria, R.string.bulgaria_url)); EU0**.add**(new **Country**(R.string.cyprus, R.drawable.cyprus, R.string.cyprus_url)); EU**.add**(new **Country**(R.string.czech_republic, R.drawable.czech_republic, R.string.czech_republic_url)); EU**.add**(new **Country**(R.string.denmark, R.drawable.denmark, R.string.denmark_url)); EU**.add**(new **Country**(R.string.estonia, R.drawable.estonia, R.string.estonia_url)); EU**.add**(new **Country**(R.string.finland, R.drawable.finland, R.string.finland_url)); EU**.add**(new **Country**(R.string.france, R.drawable.france, R.string.france_url)); EU**.add**(new **Country**(R.string.germany, R.drawable.germany, R.string.germany_url)); EU**.add**(new **Country**(R.string.greece, R.drawable.greece, R.string.greece_url)); EU**.add**(new **Country**(R.string.hungary, R.drawable.hungary, R.string.hungary_url)); EU**.add**(new **Country**(R.string.ireland, R.drawable.ireland, R.string.ireland_url)); EU**.add**(new **Country**(R.string.italy, R.drawable.italy, R.string.italy_url)); EU**.add**(new **Country**(R.string.latvia, R.drawable.latvia, R.string.latvia_url)); EU**.add**(new **Country**(R.string.lithuania, R.drawable.lithuania, R.string.lithuania_url)); EU**.add**(new **Country**(R.string.luxembourg, R.drawable.luxembourg, R.string.luxembourg_url)); EU**.add**(new **Country**(R.string.malta, R.drawable.malta, R.string.malta_url)); EU**.add**(new **Country**(R.string.netherlands, R.drawable.netherlands, R.string.netherlands_url)); EU**.add**(new **Country**(R.string.poland, R.drawable.poland, R.string.poland_url)); EU**.add**(new **Country**(R.string.portugal, R.drawable.portugal, R.string.portugal_url)); EU**.add**(new **Country**(R.string.romania, R.drawable.romania, R.string.romania_url)); EU**.add**(new **Country**(R.string.slovakia, R.drawable.slovakia, R.string.slovakia_url)); EU**.add**(new **Country**(R.string.slovenia, R.drawable.slovenia, R.string.slovenia_url)); EU**.add**(new **Country**(R.string.spain, R.drawable.spain, R.string.spain_url));
EU**.add**(new **Country**(R.string.sweden, R.drawable.sweden, R.string.sweden_url)); EU**.add**(new **Country**(R.string.united_kingdom, R.drawable.united_kingdom, R.string.united_kingdom_url)); }
…正如Country
的定义一样,来自一个单独的公共类:
`public class Country {
int name;
int flag;
int url;
Country (int name, int flag, int url) {
this.name=name;
this.flag=flag;
this.url=url;
}
}`
持续突出显示
当你使用像 Gmail 这样基于片段的应用时,有一件事会让你眼前一亮。当您点击列表中的一行,并且在同一活动中显示(或更新)另一个片段时,您点击的行会保持高亮显示。这与传统的使用ListView
背道而驰,在传统的使用中,列表选择器只有在使用 D-pad、轨迹球或类似的定点设备时才会出现。目的是向用户显示相邻片段的上下文。
实际的实现与您预期的不同。这些ListView
小部件实际上实现了CHOICE_MODE_SINGLE
,通常使用行右侧的RadioButton
来呈现。然而,在ListFragment
中,单选ListFragment
的典型样式是通过一个“激活的”背景。
在EU4You_6
中,这是通过我们的**Country**Adapter
使用的行布局(res/layout/row.xml
)来处理的:
`
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="2dip"
android:minHeight="?android:attr/listPreferredItemHeight"
style="@style/activated"
<TextView android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|right"
android:textSize="5mm"
/>
`
注意style
属性,它指向一个activated
样式。这被EU4You_6
定义为本地风格,而不是操作系统提供的风格。事实上,它必须有两个样式的实现,因为“激活”的概念是 Android 3.0 的新功能,不能在以前的 Android 版本中使用。
因此,EU4You_6
有一个向后兼容的空样式的res/values/styles.xml
:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="activated"> </style> </resources>
它还有res/values-v11/styles.xml
。-v11
资源集后缀意味着这将只在 API Level 11 (Android 3.0)及更高版本上使用。这里,风格继承了标准的 Android 全息主题,并使用标准的激活背景色:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="activated" parent="android:Theme.Holo"> <item name="android:background">?android:attr/activatedBackgroundIndicator</item> </style> </resources>
在CountriesFragment
中,活动将通过enablePersistentSelection()
方法让我们知道CountriesFragment
是否出现在DetailsFragment
旁边——因此需要单选模式:
public void **enablePersistentSelection**() { **getListView**()**.setChoiceMode**(ListView.CHOICE_MODE_SINGLE); }
同样,在onListItemClick()
,CountriesFragment
“检查”用户点击的行,从而启用持久高亮:
`@Override
public void onListItemClick (ListView l, View v, int position,
long id) {
l.setItemChecked (position, true);
if (listener!=null) {
listener.onCountrySelected (EU.get (position));
}
}`
listener
对象和对on**Country**Selected()
的调用将在本章后面解释。
其他片段基类
ACL 还有另外一个子类Fragment
: DialogFragment
。这用于帮助协调模态Dialog
和基于片段的 UI。
Android 3.0 本身还有两个子类Fragment
,在本文撰写之时,ACL 中还没有:
PreferenceFragment
:用于新型蜂巢式PreferenceActivity
(包含在第三十一章中)
WebViewFragment
:一个Fragment
缠绕着一个WebView
片段、布局、活动和多种屏幕尺寸
拥有一些片段类及其伴随的布局当然很好,但是我们需要将它们与活动联系起来,并让它们出现在屏幕上。在这个过程中,我们必须考虑如何处理多种屏幕尺寸,就像我们对先前版本的EU4You
示例采用的WebView
or-browser 方法一样。
在 Android 3.0 及更高版本中,任何活动都可以托管一个片段。但是,对于 ACL,需要从FragmentActivity
继承才能使用片段。ACL 的这种限制肯定会带来挑战,特别是如果您打算将地图放入片段中,这是我们将在本书后面讨论的主题。其他活动基类造成的问题更少——例如,ListActivity
将由ListFragment
代替。
片段可以通过两种方式添加到活动中:
您可以通过活动布局中的<fragment>
元素来定义它们。这些片段是固定的,并且将在该活动实例的生存期内一直存在。
您可以通过FragmentManager
和FragmentTransaction
即时添加它们。这为您提供了更多的灵活性,但也增加了复杂性。这种技术不在本书讨论范围之内。
处理多种屏幕尺寸的一个很大的限制是,对于任何配置更改,布局都需要有相同的起始片段。因此,活动的小屏幕版本和大屏幕版本可以有不同的片段组合,但是相同屏幕大小的纵向布局和横向布局必须定义相同的片段。否则,当屏幕旋转时,Android 就会出现问题,例如,试图使用不存在的片段。
我们还需要解决片段和活动之间的通信。活动定义了它们持有的片段,因此它们通常知道哪些类实现了这些片段,并可以直接调用这些片段上的方法。然而,这些片段只知道它们是由某个活动托管的,而这个活动可能因情况而异。因此,典型的模式是使用接口进行片段到活动的通信:
定义一个方法的接口,该片段将在它的活动(或者由该活动提供的一些其他对象)上调用这些方法。
当创建片段时,活动通过片段上的一些 setter 方法提供该接口的实现。
片段根据需要使用该接口实现。
当我们完成EU4You_6
活动和它们相应的布局时,我们会看到所有这些。
在早期版本的EU4You
项目中,我们只有一个活动,也叫做EU4You
。在EU4You_6
,我们有两项活动:
EU4You
:在所有屏幕尺寸下显示CountriesFragment
的手柄,以及在更大屏幕上显示DetailsFragment
DetailsActivity
:在较小的屏幕上显示DetailsFragment
虽然我们可以让EU4You
在更小的屏幕上启动浏览器活动,而不是让DetailsActivity
托管一个只有WebView
的DetailsFragment
,但后一种方法对于更多基于片段的应用来说更现实。
优优
首先,我们来看看EU4You
活动的各个部分。
布局
对于普通屏幕设备,我们只想显示CountriesFragment
。这是通过具有适当的<fragment>
元素的res/layout/main.xml
来实现的:
<?xml version="1.0" encoding="utf-8"?> <fragment xmlns:android="http://schemas.android.com/apk/res/android" class="com.commonsware.android.eu4you.CountriesFragment" android:id="@+id/countries" android:layout_width="fill_parent" android:layout_height="fill_parent" />
属性表明哪个 Java 类实现了这个片段。否则,这种布局是不起眼的。
请注意,片段不会像活动那样在清单文件中列出。
另一种布局
对于大屏幕设备,在横向模式下,我们希望将CountriesFragment
和DetailsFragment
并排显示。这样,用户可以点击一个国家并查看详细信息,而无需在活动之间来回切换。这也使我们能够更好地利用屏幕空间。
然而,有一个问题。如果我们想在我们的布局文件中预定义这两个片段,我们必须对使用相同的片段对 横向和纵向模式——尽管我们不想在纵向模式下使用EU4You
中的DetailsFragment
(将列表垂直堆叠在WebView
上看起来很奇怪,最好的情况也是如此)。作为一种变通方法,我们将对两个方向使用相同的布局文件,然后在 Java 代码中进行调整。另一个解决问题的方法是让布局文件只有CountriesFragment
并使用FragmentManager
和一个FragmentTransaction
来添加到DetailsFragment
中。不过,在这里,我们将使用其他技巧。
因此,在res/layout-large/
(不是res/layout-large-land/
)中,我们有这样的布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="fill_parent"> <fragment class="com.commonsware.android.eu4you.CountriesFragment" android:id="@+id/countries" android:layout_weight="30" android:layout_width="0px" android:layout_height="fill_parent" /> <fragment class="com.commonsware.android.eu4you.DetailsFragment" android:id="@+id/details" android:layout_weight="70" android:layout_width="0px" android:layout_height="fill_parent" /> </LinearLayout>
注意,我们负责片段的定位,所以这里我们使用一个水平的LinearLayout
来包围两个<fragment>
元素。
监听器接口
当用户在CountriesFragment
中选择一个国家 时,我们希望让我们的包含活动知道这一点。在这种情况下,碰巧唯一会主办CountriesFragment
的活动是EU4You
。然而,也许将来不会是这样。因此,我们应该通过一个监听器接口将从CountriesFragment
到它的主机活动的通信抽象出来。
因此,EU4You_6
项目有一个**Country**Listener
接口:
`package com.commonsware.android.eu4you;
public interface Country Listener {
void onCountrySelected (Country c);
}`
CountriesFragment
持有由托管活动提供的**Country**Listener
的实例:
public void **setCountryListener**(CountryListener listener) { this.listener=listener; }
并且,当用户点击一个国家 并触发onListItemClick()
时,CountriesFragment
调用接口上的on**Country**Selected()
方法:
`@Override
public void onListItemClick (ListView l, View v, int position,
long id) {
l.setItemChecked (position, true);
if (listener!=null) {
listener.onCountrySelected (EU.get (position));
}
}`
活动
这个EU4You
活动时间不长,虽然有点棘手:
`package com.commonsware.android.eu4you;
import android.content.Intent;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentActivity;
import android.view.View;
public class EU4You extends FragmentActivity implements Country Listener {
private boolean detailsInline=false;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
CountriesFragment countries
=(CountriesFragment)getSupportFragmentManager ()
.findFragmentById (R.id.countries);
countries.setCountryListener (this);
Fragment f=getSupportFragmentManager ().findFragmentById (R.id.details);
detailsInline=(f!=null &&
(getResources ().getConfiguration ().orientation==
Configuration.ORIENTATION_LANDSCAPE));
if (detailsInline) {
countries.enablePersistentSelection ();
}
else if (f!=null) {
f.getView ().setVisibility (View.GONE);
}
}
@Override
public void onCountrySelected (Country c) {
String url=getString (c.url);
if (detailsInline) {
((DetailsFragment)getSupportFragmentManager ()
.findFragmentById (R.id.details))
.loadUrl (url);
}
else {
Intent i=new Intent (this, DetailsActivity.class);
i.putExtra (DetailsActivity.EXTRA_URL, url);
startActivity (i);
}
}
}`
我们在onCreate()
的任务是连接我们的片段。片段本身是由我们对setContentView()
的调用创建的,膨胀了我们的布局和其中定义的片段。然而,除此之外,EU4You
还做了以下事情:
找到CountriesFragment
并将自己注册为**Country**Listener
,因为EU4You
实现了那个接口。
如果存在,查找DetailsFragment
。如果它存在,并且我们处于横向模式,我们告诉CountriesFragment
启用持续高亮,以提醒用户右侧正在加载什么细节。如果它存在,并且我们处于纵向模式,我们实际上不想要DetailsFragment
,但是需要它与布局模式一致,所以我们将片段的内容标记为GONE
。如果DetailsFragment
不存在,我们不必做任何特别的事情。
像findFragmentById()
这样的呼叫的FragmentManager
是通过getFragmentManager()
完成的。然而,ACL 定义了一个单独的getSupportFragmentManager()
,以确保您正在使用 ACL 的FragmentManager
实现,并在更广泛的 Android 版本上工作。
另外,由于EU4You
实现了**Country**Listener
接口,所以它必须实现on**Country**Selected()
。在这里,EU4You
指出我们是否应该路由到DetailsFragment
的内嵌版本。如果应该,那么on**Country**Selected()
将**Country**
传递给DetailsFragment
,因此它加载那个国家 的网页。否则,我们启动DetailsActivity
,额外提供 URL。
详细活动
DetailsActivity
将用于DetailsFragment
未在EU4You
活动中显示的情况,包括以下情况:
当设备具有正常屏幕尺寸,因此在布局中没有DetailsFragment
时
当设备具有纵向尺寸的大屏幕,因此EU4You
隐藏了它自己的DetailsFragment
布局
布局中只有我们的<fragment>
元素,因为没有其他东西可以显示:
<?xml version="1.0" encoding="utf-8"?> <fragment xmlns:android="http://schemas.android.com/apk/res/android" class="com.commonsware.android.eu4you.DetailsFragment" android:id="@+id/details" android:layout_width="fill_parent" android:layout_height="fill_parent" />
活动
DetailsActivity
简单地将来自Intent
extra 的 URL 传递给DetailsFragment
,告诉它要显示什么网页内容:
`package com.commonsware.android.eu4you;
import android.support.v4.app.FragmentActivity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
public class DetailsActivity extends FragmentActivity {
public static final String EXTRA_URL="com.commonsware.android.eu4you.EXTRA_URL";
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.details);
DetailsFragment details
=(DetailsFragment)getSupportFragmentManager ()
.findFragmentById (R.id.details);
details.loadUrl (getIntent ().getStringExtra (EXTRA_URL));
}
}`
片段和配置变化
在第十九章中,我们回顾了活动如何处理配置变化,比如屏幕旋转。这如何转化为一个片段的世界?
和往常一样,有好消息,也有其他消息。
好消息是片段有可以覆盖的onSaveInstanceState()
方法,行为很像它们的活动对应物。然后Bundle
在很多地方都可以买到,比如onCreate()
和onActivityCreated()
,尽管没有专门的onRestoreInstanceState()
。
另一个消息是,不仅片段缺少onRetainNonConfigurationInstance()
,而且 ACL 的FragmentActivity
不允许您扩展onRetainNonConfigurationInstance()
,因为那是内部使用的。使用片段的直接 Android 实现的应用不会遇到这个问题。这个限制是很大的,开发人员社区仍然在共同寻找克服这个限制的方法。
为片段设计
片段的总体设计方法倾向于在片段中包含业务逻辑,活动充当片段间导航的编排层,以及片段所不具备的功能(例如,onRetainNonConfigurationInstance()
)。例如,Gmail 应用最初可能在每个活动(例如,文件夹的活动、对话列表的活动、单个对话的活动)中实现其大部分业务逻辑。如今,该应用可能是围绕将业务逻辑委托给片段而构建的,活动只是根据可用的屏幕大小来选择显示哪些片段。
自从 fragments 在 2011 年初首次亮相以来,这已经导致了现有应用的大量重组。例如,ListActivity
可能已经从onListItemClick()
发起了另一个活动。第一次重构会让片段的onListItemClick()
发起一个活动。但是,该片段不知道用户请求的内容是否应该在另一个活动中显示——它可能会转到当前活动中的另一个片段。因此,该片段不应该盲目地调用startActivity()
,而是应该调用其容器活动上的方法(或者,更有可能是由该活动实现的侦听器接口),告诉它点击事件,并让它决定正确的操作过程。
二十九、处理平台变更
自最初发布以来,Android 一直在快速发展,并将在未来几年继续发展。或许,随着时间的推移,变化的速度会下降一些。然而,就目前而言,你应该假设每 6 到 12 个月就会有重要的 Android 发布,并且可能的 Android 硬件阵容会不断变化。因此,虽然现在 Android 的重点是手机和平板电脑,但很快你就会看到 Android 上网本、Android 电视、Android 媒体播放器等等。
许多这些变化对您现有的代码几乎没有影响。但是,有些应用至少需要新一轮的测试,并且可能会根据测试结果对这些应用进行更改。
本章涵盖了随着 Android 的发展,未来可能会给你带来麻烦的几个问题,并提供了一些处理这些问题的建议。
让你兴奋的事情
Android 将 改变,不仅仅是在谷歌引入的方面,还包括设备制造商如何为他们自己的硬件调整 Android。如果您没有做好准备,本节将指出这些变化会影响您的应用的几个地方。
查看层次结构
Android 不是为处理任意复杂的视图层次而设计的。在这里,视图层次结构 意味着容器包含容器,容器包含容器包含小部件。在后面的章节中描述的hierarchyviewer
程序很好地描述了这样的视图层次结构。
Android 总是限制视图层次的深度。然而,在 Android 1.5 中,这个限制被降低了,所以一些在 Android 1.1 上运行良好的应用在新的 Android 中将会崩溃。当然,这对于开发人员来说是令人沮丧的,他们从来没有意识到视图层次深度的问题,然后被这种变化所困扰。
从中吸取的教训如下:
保持你的视图层次浅。一旦进入两位数深度,就越有可能耗尽堆栈空间。
如果您遇到一个StackOverflowException
,并且堆栈跟踪看起来像是在绘制小部件的中间,那么您的视图层次结构可能太复杂了。
改变资源
Android 核心团队可能会随着 Android 升级而改变资源,这些可能会对您的应用产生意想不到的影响。例如,在 Android 1.5 中,Android 团队改变了股票Button
背景,以允许更小的按钮。然而,隐式依赖于前一个更大的最小尺寸的应用最终会崩溃,需要一些 UI 调整。
类似地,应用可以重用 Android 内部的公共资源,比如图标。虽然这样做可以节省一些存储空间,但是这些资源中有许多是公共的,不被视为 SDK 的一部分。例如,硬件制造商可能会更改图标以适应一些可选的 UI 外观和感觉。依赖现有的总是看起来像他们做的有点危险。将这些资源从 Android 开源项目复制到您自己的代码库中会更好。
处理 API 变更
Android 核心团队在保持 API 稳定方面做得很好,当他们改变 API 时,支持一个弃用模型。在 Android 中,当一个特性被弃用 时,这并不意味着这个特性正在消失,只是不鼓励继续使用它。当然,每次新的 Android 更新都会发布新的 API。通过 API 差异报告,API 的变更在每个版本中都有很好的文档记录。
不幸的是,Android Market——Android 应用的主要发布渠道——只允许你为每个应用上传一个 Android 包(APK)文件。因此,你需要一个 APK 文件来处理尽可能多的 Android 版本。很多时候,你的代码会“正常工作”,不需要修改。但是,其他时候,您将需要进行调整,特别是如果您希望在新版本上支持新的 API,同时又不破坏旧版本。让我们研究一些处理这些情况的技术。
最小、最大、目标和构建版本
Android 不遗余力地帮助你处理这样一个事实,即在任何时间点,市场上会有许多 Android 操作系统版本。不幸的是,Android 提供的工具给了我们一组有些混乱的重叠概念,比如目标和 SDK 版本。本节试图澄清这些概念。
目标与 SDK 版本与操作系统版本
目标的概念是在本书开头介绍的。定义 avd 时使用目标来确定这些 avd 支持什么类型的设备。创建新项目时也会使用目标,主要是为了确定将使用哪个版本的 SDK 构建工具来构建您的项目。
目标将 API 级别与该目标是否包括谷歌 API(例如,谷歌地图支持)的指示符相结合。
API 级别是表示 Android API 版本的整数。每个对 Android API 进行修改的 Android OS 版本都会触发一个新的 API 级别。以下是 API 级别:
3
:安卓 1.5r1,1.5r2,1.5r3
4
:安卓 1.6r1 和 1.6r2
5
: Android 2.0
6
: Android 2.0.1
7
: Android 2.1.x
8
: Android 2.2.x
9
:安卓 2.3,2.3.1,2.3.2
Android 2.3.3 和 2.3.4
11
: Android 3.0.x
12
: Android 3.1.x
13
: Android 3.2
14
: Android 4.0
谷歌维护着一个网页,根据对 Android Market 的请求,概述了目前使用的 Android 版本。
最低 SDK 版本
在您的AndroidManifest.xml
文件中,您应该添加一个<uses-sdk>
元素。该元素描述了您的应用如何与各种 SDK 版本相关联。
<uses-sdk>
中最关键的属性是android:minSdkVersion
。这表明您的应用支持的最低 API 级别。运行与较低 API 级别相关联的 Android 操作系统版本的设备将无法安装您的应用。如果您选择通过该分销商发布,您的应用甚至可能不会出现在 Android Market 列表中的那些设备上。
如果你跳过这个属性,Android 假设你的应用可以在所有的 Android API 版本上工作。那可能是真的,但是如果你没有测试过,就这样假设是相当危险的。因此,将android:minSdkVersion
设置为您正在测试并且愿意支持的最低级别。
目标 SDK 版本
另一个<uses-sdk>
属性是android:targetSdkVersion
。这代表了您主要开发的 Android API 的版本。任何运行新版操作系统的 Android 设备都可以选择应用一些兼容性设置,这将有助于像你这样针对旧 API 的应用在新版操作系统上运行。
大多数情况下,您应该将它设置为当前的 Android API 版本,即您发布应用时的版本。
特别是对于冰激凌三明治,您需要指定一个目标14
或15
来获得新的外观和感觉。
最高 SDK 版本
第三个<uses-sdk>
属性是android:maxSdkVersion
。任何运行比该 API 等级所指示的更新的 Android 操作系统的 Android 设备将被禁止运行您的应用。
从好的方面来说,这确保了您的应用不会在您没有测试过的 API 级别上使用,特别是如果您将它设置为截至您发布之日的当前 Android API 版本。
然而,请记住,您的应用将被过滤出这些新设备的 Android 市场。随着时间的推移,如果您不发布具有更高 SDK 最高版本的更新,这将限制您的应用的范围。
Android 核心团队建议您不要使用这个选项,而是依靠 Android 固有的向后兼容性——特别是利用您的android:targetSdkVersion
值——来允许您的应用继续在新的 Android OS 版本上运行。
检测版本
如果您只是想基于版本在代码中采用不同的分支,最简单的方法就是检查android.os.Build.VERSION.SDK_INT
。这个公共静态整数值将反映您在创建 avd 和在清单中指定 API 级别时使用的相同 API 级别。因此,你可以将这个值与android.os.Build.VERSION_CODES.DONUT
进行比较,看看你运行的是 Android 1.6 还是更新版本。
包装 API
只要您尝试使用的 API 存在于您支持的所有 Android 版本中,只需分支就足够了。当 API 发生变化时,事情就会变得麻烦,比如当方法有新参数、新方法甚至新类时。您需要不管 Android 版本如何都可以工作的代码,同时还允许您利用新的可用 API。
挑战在于,如果你试图加载虚拟机代码,而这些代码引用了设备运行的 Android 版本中不存在的类、方法等,那么你的应用将会因VerifyError
而崩溃。你需要针对包含你正在尝试使用的最新 API 的 Android 版本编译 ——你不能将代码加载到一个旧的 Android 设备上。
请注意,这里的关键词是“加载该代码”您不一定会因为应用中存在一个使用比现有 API 更新的类而遇到问题。只有当你执行的代码触发 Android 将那个类加载到你的运行进程中,你才会遇到VerifyError
。
记住这一点,有三个主要的技巧来处理这种情况,在下面的章节中概述。
检测类别
也许你需要做的就是禁用你的应用中的一些功能,这些功能会导致在给定的设备上不可能发生的事情。例如,假设您有一个使用片段特性的活动。您无法在 3.0 之前的设备上成功启动该活动。停止该活动可能只是禁用一个菜单选项或Button
之类的事情。
要查看某个类(比方说,ListFragment
)是否对您可用,可以调用Class.forName()
。这将返回一个代表所请求的类的Class
对象,或者抛出一个Exception
,如果它不可用的话。您可以使用异常处理程序来禁用 UI 路径,这将导致您的应用尝试启动使用不可用类的活动。
反思
如果您需要对旧版本 Android 上不存在的类进行有限的访问,您可以使用一点反射。
例如,在关于旋转的章节中,我们使用了一系列允许用户选择联系人的示例应用。这依赖于一个ACTION_PICKIntent
,使用特定的Uri
作为联系人的内容提供者。在这些示例中,我们特别使用了ContactsContract
,这是 Android 2.0 及更高版本中提供的修订版联系人 API。这意味着这些项目无法在旧版本的 Android 上运行。
然而,我们真正需要的是这个神奇的Uri
值。如果我们能设计出一种方法,在不引起问题的情况下,为旧版本的 Android 获得正确的Uri
,以及为新版本的 Android 获得正确的Uri
,我们就能更好地向后兼容。
幸运的是,通过一些反射,这很容易做到:
`static {
intsdk=new Integer (Build.VERSION.SDK).intValue ();
if (sdk>=5) {
try {
Class clazz=Class.forName ("android.provider.ContactsContract$Contacts");
CONTENT_URI=(Uri)clazz.getField ("CONTENT_URI").get (clazz);
}
catch (Throwable t) {
Log.e("PickDemo", "Exception when determining CONTENT_URI", t);
}
}
else {
CONTENT_URI=android.provider.Contacts.People.CONTENT_URI;
}
}`
在这里,我们通过查看Build.VERSION.SDK
来检查设备的 API 级别(我们可以使用Build.VERSION.SDK_INT
,但这是在 Android 1.6 之前添加的——这里显示的代码也适用于 Android 1.5)。如果我们在 Android 2.0 (API 级别5
)或更高,我们使用Class.forName()
来获得新的ContactsContract.Contacts
类,然后使用反射来获得该类的CONTENT_URI
静态数据成员。如果我们在旧版本的 Android 上,我们简单地使用旧的Contacts.People
类发布的Uri
。
因为我们没有在代码中直接引用ContactsContract.Contacts
,所以我们可以安全地执行它,即使是在旧版本的 Android 上。
条件类加载
反思是有用的,但对任何复杂的事物来说都是痛苦的。而且,它比直接调用代码要慢。
因此,最强大的技术是简单地组织您的代码,使您拥有使用较新 API 的常规类,但是您不在较旧的设备上加载这些类。我们将在本书的后面部分研究这种技术。
图案为冰淇淋三明治和蜂窝
随着 Honeycomb (Android 3.0)和现在的冰激凌三明治(Android 4.0)的出现,支持多个 Android 版本现在是一个重大挑战。在许多情况下,支持不同 UI 所需的 UI 更改需要您采取措施来确保您的应用仍然可以在旧版本的 Android 上成功运行。本节概述了处理向后兼容性领域的一些模式。
操作栏
正如在第二十七章中提到的,动作栏的许多基本特性将以向后兼容的方式工作。例如,指示选项菜单项可以显示在动作栏中只需要菜单资源 XML 中的一个属性,这个属性在旧版本的 Android 中将被忽略。支持 Honeycomb 的设备会将该项目放在操作栏中,而运行早期 Android 版本的设备不会。
然而,并不是动作栏的所有功能都是向后兼容的。在第二十七章的中的Menus/ActionBar
示例应用中,我们添加了一个自定义的View
到动作栏,允许人们在不处理菜单和对话框的情况下添加单词到我们的列表中。然而,这需要一些只在 API 级别11
(Android 3.0)和更高级别的代码。更高级的动作栏功能——超出了本书的范围——也有类似的需求。
您需要安排只在运行 API 级别11
或更高的设备上使用那些动作栏方法。本章前面概述的条件类加载就是这样一种技术,也是在Menus/ActionBarBC
示例应用中使用的技术。让我们来看看这是如何工作的。
检查 API 级别
我们最初的onCreateOptionsMenu()
是这样的:
`@Override
public boolean onCreateOptionsMenu (Menu menu) {
new MenuInflater (this).inflate (R.menu.option, menu);
EditText add=(EditText)menu
.findItem (R.id.add)
.getActionView ()
.findViewById (R.id.title);
add.setOnEditorActionListener (onSearch);
return(super.onCreateOptionsMenu (menu));
}`
这很好,但是它将只在 API 级别11
和更高的级别上起作用,因为getActionView()
只从那个 API 级别开始存在。因此,在没有获得VerifyError
的情况下,我们无法在旧版本的 Android 上运行这段代码,甚至无法加载这个类。
新版本的onCreateOptionsMenu()
隐藏了违规代码,并检查 API 级别:
`@Override
public boolean onCreateOptionsMenu (Menu menu) {
new MenuInflater (this).inflate (R.menu.option, menu);
EditText add=null;
if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.HONEYCOMB) {
View v=ICSHCHelper.getAddActionView (menu);
if (v!=null) {
add=(EditText)v.findViewById (R.id.title);
}
}
if (add!=null) {
add.setOnEditorActionListener (onSearch);
}
return(super.onCreateOptionsMenu (menu));
}`
我们只隐藏检索理论上放在动作栏中的View
的代码。如果我们在一个旧版本的 Android 上,HONEYCOMB
检查将失败,我们将以一个nullView
结束,所以我们跳过将OnEditorActionListener
添加到那个View
内的EditText
。
这还有另一个好处:如果 Android 设备运行 API 级别11
或更高,但没有空间容纳我们的自定义 APIView
,它也能工作。Android 平板电脑将有一个动作栏和足够的空间,但未来支持蜂窝的手机可能会有一个动作栏,但缺乏足够的空间。在这种情况下,手机会保留添加选项菜单项,我们仍然会以一个nullView
结束。这段代码处理这种情况;原始代码没有。
隔离冰淇淋三明治/蜂巢代码
我们的 Honeycomb 特定代码保存在一个单独的ICSHCHelper
类中(ICS 用于冰激凌三明治,HC 用于 Honeycomb),该类仅用于 API 级别11
(或更高)的设备:
`packagecom.commonsware.android.inflation;
importandroid.view.Menu;
importandroid.view.View;
classICSHCHelper {
static View getAddActionView (Menu menu) {
return(menu.findItem(R.id.add).getActionView ());
}
}`
ICSHCHelper
有一个单独的getAddActionView()
静态方法,如果有添加动作栏条目的话,这个静态方法会找到它的View
。
因为我们不试图在这个类上执行任何代码,除了在HONEYCOMB
检查中,在旧版本的 Android 上有这个类是安全的。Menus/ActionBarHC
应用可以在 Android 1.6 及更高版本上运行。
编写平板电脑专用应用
理想情况下,您的 Android 应用可以在所有形式的设备上运行:手机、平板电脑等等。然而,你可能想创建一个不能在手机上使用的应用。理想情况下,你应该让你的应用远离小屏幕设备,这样用户才不会失望。
要做到这一点,你可以利用这样一个事实,即 Android 将扩大应用,但不会缩小应用。换句话说,如果你指定你的应用不支持一些更大的屏幕尺寸(例如,android:xlargeScreens="false"
出现在你的AndroidManifest.xml
文件的<supports-screens>
元素中),Android 仍然允许你的应用在这样的屏幕上运行,并采取措施帮助你的应用在额外的屏幕空间上运行。但是,如果您指定您的应用不支持一些较小的屏幕尺寸(例如,android:smallScreens="false"
出现在您的<supports-screens>
元素中),Android 将不会运行您的应用,您将被过滤出此类设备的 Android 市场。
因此,如果您的应用只能在大屏幕设备上运行良好,请使用如下的<supports-screens>
元素:
<supports-screens android:xlargeScreens="true" android:largeScreens="true" android:normalScreens="false" android:smallScreens="false" android:anyDensity="true"/>
三十、访问文件
虽然 Android 通过偏好和数据库提供结构化存储,但有时一个简单的文件就足够了。Android 提供了两种访问文件的模式:一种是应用预打包的文件,另一种是应用在设备上创建的文件。
你和你骑的马
假设您有一些静态数据希望随应用一起提供,比如拼写检查器的单词列表。最简单的部署方法是将文件放在res/raw
目录中,这样它将作为打包过程的一部分作为原始资源放在 Android 应用 APK 文件中。
要访问这个文件,您需要给自己弄一个Resources
对象。从一个活动中,这就像调用getResources()
一样简单。一个Resources
对象提供openRawResource()
来获取你指定文件的一个InputStream
。openRawResource()
期望打包的文件有一个整数标识符,而不是路径。这就像通过findViewById()
访问小部件一样;例如,如果将一个名为words.xml
的文件放在res/raw
中,这个标识符在 Java 中可以作为R.raw.words
来访问。
因为您只能获得一个InputStream
,所以您无法修改这个文件。因此,它实际上只对静态参考数据有用。此外,由于它在用户安装应用包的更新版本之前不会改变,所以要么引用数据必须在可预见的将来有效,要么您必须提供一些更新数据的方法。最简单的处理方法是使用引用数据来引导一些其他可修改的存储形式(例如,数据库),但这会导致存储中数据的两个副本。另一种方法是保持参考数据不变,并将修改保存在文件或数据库中,然后在需要完整的信息时将它们合并在一起。例如,如果您的应用提供了一个 URL 文件,您可以拥有另一个文件来跟踪用户添加的 URL 或引用用户删除的 URL。
在Files/Static
示例项目中,您会发现对前面的列表框示例进行了修改,这次使用了静态 XML 文件,而不是 Java 中的硬连线数组。布局是相同的:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <TextView android:id="@+id/selection" android:layout_width="fill_parent" android:layout_height="wrap_content" /> <ListView android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" android:drawSelectorOnTop="false" /> </LinearLayout>
除了这个 XML 文件之外,您还需要一个 XML 文件,其中包含要在列表中显示的单词:
<words> <word value="lorem" /> <word value="ipsum" /> <word value="dolor" /> <word value="sit" /> <word value="amet" /> <word value="consectetuer" /> <word value="adipiscing" /> <word value="elit" /> <word value="morbi" /> <word value="vel" /> <word value="ligula" /> <word value="vitae" /> <word value="arcu" /> <word value="aliquet" /> <word value="mollis" /> <word value="etiam" /> <word value="vel" /> <word value="erat" /> <word value="placerat" /> <word value="ante" /> <word value="porttitor" /> <word value="sodales" /> <word value="pellentesque" /> <word value="augue" /> <word value="purus" /> </words>
虽然这种 XML 结构并不完全是空间效率的模型,但对于演示来说已经足够了。
Java 代码现在必须读入 XML 文件,解析出单词,并将它们放在列表可以获取地方:
public class StaticFileDemo extends ListActivity { TextView selection;
`ArrayList items=new ArrayList();
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
selection=(TextView)findViewById (R.id.selection);
try {
InputStream in=getResources ().openRawResource (R.raw.words);
DocumentBuilder builder=DocumentBuilderFactory
.newInstance ()
.newDocumentBuilder ();
Document doc=builder.parse (in, null);
NodeList words=doc.getElementsByTagName ("word");
for (inti=0;i<words.getLength ();i++) {
items.add (((Element)words.item (i)).getAttribute ("value"));
}
in.close() ;
}
catch (Throwable t) {
Toast
.makeText (this, "Exception: "+t.toString (), Toast.LENGTH_LONG)
.show ();
}
setListAdapter (new ArrayAdapter(this,
android.R.layout.simple_list_item_1,
items));
}
public void onListItemClick (ListView parent, View v, int position,
long id) {
selection.setText (items.get (position).toString ());
}
}`
注意: 我们对openRawResource()
的调用引用了前面描述的R.raw.words
。从冰激凌三明治开始,更具体地说,SDK 和 ADT 版本 14 和 15,Google 已经禁止以这种方式引用一些资源字段,允许库项目只编译一次,然后跨应用重用。通常,这不值得一提。然而,在 Eclipse 中,随 SDK 14 发布的 ADT 插件错误地将我们的用法标记为错误,试图在switch
语句中使用R.raw.words
。在这个问题解决之前,您需要从命令行构建或者调整您的 ADT 插件级别。
分歧主要在onCreate()
内部。我们为 XML 文件(getResources().openRawResource(R.raw.words)
)获取一个InputStream
,然后使用内置的 XML 解析逻辑将文件解析成一个 DOM Document
,挑选出单词元素,然后将值属性注入一个ArrayList
供ArrayAdapter
使用。
产生的活动看起来和以前一样,如 Figure 30–1 所示,因为单词列表是相同的,只是重新定位了。
图 30–1。 static file demo 示例应用
当然,还有更简单的方法将 XML 文件作为预打包文件提供给你,比如使用 XML 资源。这在第三十一章中有所涉及。然而,虽然这个例子使用了 XML,但是这个文件也可以是一个简单的每行一个单词的列表,或者是 Android 资源系统本身不处理的其他格式。
读取“n 个 writin”
读写您自己的特定于应用的数据文件几乎与您在桌面 Java 应用中所做的一样。关键是在活动或其他上下文中使用 openFileInput()和 openFileOutput()来分别获得 InputStream 和 OutputStream。从这一点来看,它与常规 Java I/O 逻辑没有太大区别:
根据需要包装那些流,比如通过使用一个InputStreamReader
或OutputStreamWriter
用于基于文本的 I/O。
读取或写入数据。
完成后,使用close()
释放流。
如果两个应用都试图通过openFileInput()
读取一个notes.txt
文件,那么每个应用都将访问自己的文件版本。如果您需要从许多地方访问一个文件,您可能希望创建一个内容提供者,这将在下一章中描述。
注意openFileInput()
和openFileOutput()
不接受文件路径(如path/to/file.txt
),只接受简单的文件名。
下面是世界上最简单的文本编辑器的布局,摘自Files/ReadWrite
示例应用:
<?xml version="1.0" encoding="utf-8"?> <EditTextxmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/editor" android:layout_width="fill_parent" android:layout_height="fill_parent" android:singleLine="false" android:gravity="top" />
我们这里只有一个很大的文本编辑小工具…这很无聊。
Java 只是稍微复杂一点:
`package com.commonsware.android.readwrite;
importandroid.app.Activity;
importandroid.os.Bundle;
importandroid.view.View;
importandroid.widget.Button;
importandroid.widget.EditText;
importandroid.widget.Toast;
importjava.io.BufferedReader;
importjava.io.File;
importjava.io.InputStream;
importjava.io.InputStreamReader;
importjava.io.OutputStream;
importjava.io.OutputStreamWriter;
public class ReadWriteFileDemo extends Activity {
private final static String NOTES="notes.txt";
privateEditText editor;
@Override
public void onCreate(Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
editor=(EditText)findViewById (R.id.editor);
}
public void onResume () {
super.onResume ();
try {
IputStream in=openFileInput (NOTES);
if (in!=null) {
InputStreamReader tmp=new InputStreamReader (in);
BufferedReader reader=new BufferedReader (tmp);
String str;
StringBuilderbuf=new StringBuilder ();
while ((str = reader.readLine ()) != null) {
buf.append (str+"\n");
}
in.close ();
editor.setText (buf.toString ());
}
}
catch (java.io.FileNotFoundException e) {
// that's OK, we probably haven't created it yet
}
catch (Throwable t) {
Toast
.makeText (this, "Exception: "+t.toString (), Toast.LENGTH_LONG)
.show ();
}
}
public void onPause () {
super.onPause ();
try {
OutputStreamWriter out=
new OutputStreamWriter (openFileOutput (NOTES, 0));
out.write (editor.getText ().toString());
out.close();
}
catch (Throwable t) {
Toast
.makeText (this, "Exception: "+t.toString(), Toast.LENGTH_LONG)
.show ();
}
}
}`
首先,我们挂钩到onResume()
,这样我们就可以控制我们的编辑器什么时候复活,从新启动还是被冻结。我们使用openFileInput()
读入notes.txt
,并将内容注入文本编辑器。如果没有找到文件,我们假设这是第一次运行活动(或者文件通过其他方式被删除),我们只是让编辑器为空。
接下来,我们挂钩到onPause()
,这样当我们的活动被另一个活动隐藏或者被关闭时,我们就可以获得控制权,比如通过设备的 Back 按钮。这里,我们使用openFileOutput()
打开notes.txt
,将文本编辑器的内容注入其中。
最终结果是我们有了一个持久的记事本,如图图 30–2 和 30–3 所示。任何输入的内容都会保留,直到被删除,直到我们的活动被关闭(例如,通过后退按钮),手机被关闭,或者类似的情况。
图 30–2。 read write file demo 示例应用,如同最初启动的
图 30–3。 同样的应用,输入一些文字后
使用应用本地文件的另一种方法是使用getFilesDir()
。这将返回一个File
对象,该对象指向板载闪存中应用可以存储文件的位置。这个目录是openFileInput()
和openFileOutput()
工作的地方。然而,虽然openFileInput()
和openFileOutput()
不支持子目录,但如果需要,可以使用getFilesDir()
中的File
来创建和导航子目录。
默认情况下,只有您的应用可以访问存储在此处的文件。设备上的其他应用无权读取该空间,更不用说写入了。然而,请记住,一些用户“root”他们的 Android 手机,获得超级用户权限。这些用户将能够读写他们想要的任何文件。因此,请不要认为应用本地文件对感兴趣的用户是安全的。
外部存储:巨大的经济空间
除了应用本地存储,您还可以访问外部存储。这可能是可移动媒体卡的形式,如 SD 卡或 microSD 卡,或者是附加的板载闪存,用作“外部存储”。
有利的一面是,外部存储往往比板载存储有更多的可用空间。机载存储可能相当有限;例如,最初的 T-Mobile G1 (HTC Dream)所有应用的总容量为 70MB。虽然新手机提供了更多的板载空间,但外部存储通常至少为 2GB,最大可达 32GB。
不利的一面是,如果愿意,所有应用都可以读写外部存储,因此这些文件不是很安全。此外,外部存储可以作为 USB 大容量存储设备安装在主机上,当它在此模式下使用时,Android 应用无法访问它。因此,在任何给定的时刻,外部存储器上的文件可能对您可用,也可能不可用。
往哪里写
如果您的应用有太大的文件,不能冒险放在应用本地文件区域,那么您可以使用getExternalFilesDir()
,它可用于任何活动或其他Context
。这为您提供了一个File
对象,它指向在外部存储器上自动创建的目录,对于您的应用是唯一的。虽然不能抵御其他应用,但它有一个很大的优势:当您的应用被卸载时,这些文件会被自动删除,就像应用本地文件区域中的文件一样。
如果你有更多属于用户而不是你的应用的文件(例如,相机拍摄的照片、下载的 MP3 文件等。),更好的解决方案是使用Environment
类上可用的getExternalStoragePublicDirectory()
。这为您提供了一个File
对象,该对象基于您传递给getExternalStoragePublicDirectory()
的类型,指向为特定类型的文件留出的目录。例如,你可以分别请求DIRECTORY_MOVIES
、DIRECTORY_MUSIC
或DIRECTORY_PICTURES
来存储 MP4、MP3 或 JPEG 文件。卸载应用时,这些文件将被留下。
您还会在Environment
上找到一个getExternalStorageDirectory()
方法,指向外部存储的根目录。这不再是首选方法,之前描述的方法有助于更好地组织用户的文件。然而,如果你支持旧的 Android 设备,你可能需要使用getExternalStorageDirectory()
,仅仅是因为新的选项可能对你不可用。
什么时候写
从 Android 1.6 开始,您还需要持有权限才能使用外部存储(例如,WRITE_EXTERNAL_STORAGE
)。权限的概念将在后面的章节中介绍。
此外,如果用户将外部存储器作为 USB 存储设备安装,它可能会被占用。您可以使用getExternalStorageState()
(在Environment
上的一个静态方法)来确定外部存储器目前是否可用。
StrictMode:避免 Janky 代码
如果用户觉得你的应用响应迅速,他们就更可能喜欢你的应用。所谓“反应灵敏”,我们的意思是它对用户的操作做出快速而准确的反应,比如点击和滑动。
相反,如果用户认为你的 UI“笨拙”——对他们的请求反应迟钝——他们就不太可能对你的应用满意。例如,也许你的列表不能像用户希望的那样平滑滚动,或者点击一个按钮不能立即得到他们想要的结果。
虽然线程和AsyncTask
之类的东西会有所帮助,但是在哪里应用它们并不总是显而易见的。使用 Traceview 或类似的 Android 工具进行全面的性能分析当然是可能的。然而,开发人员在主应用线程上做的一些标准的事情,有时很偶然,往往会导致缓慢:
用于板载存储和外部存储(例如 SD 卡)的闪存 I/O
网络输入输出
然而,即使在这里,也可能看不出您是在主应用线程上执行这些操作。当操作实际上是由您简单调用的 Android 代码完成时,情况尤其如此。
这就是StrictMode
的用武之地。它的任务是帮助您确定您在主应用线程上做什么事情可能会导致不愉快的用户体验。
设置严格模式
制定一套政策。目前有两类策略:虚拟机策略和线程策略。VM 策略代表了与您的整个应用相关的糟糕的编码实践,特别是泄漏 SQLite Cursor
对象和 kin。线程策略表示在主应用线程上执行时不好的事情,特别是闪存 I/O 和网络 I/O。
每个策略都规定了StrictMode
应该注意什么(例如,闪存读取可以,但闪存写入不行)以及当您违反规则时StrictMode
应该如何反应,例如
将消息记录到 LogCat
显示一个对话框
崩溃你的应用(说真的!)
最简单的方法是从第一个活动的onCreate()
调用StrictMode
上的静态enableDefaults()
方法。这将设置正常操作,通过简单地记录到 LogCat 来报告所有违规。但是,如果您愿意,您可以通过Builder
对象设置自己的定制策略。
了解 StrictMode 的实际应用
Threads/ReadWriteStrict
示例应用是本章前面显示的Files/ReadWrite
示例应用的翻版。它所添加的只是一个定制的StrictMode
线程策略:
StrictMode**.setThreadPolicy**(new StrictMode.ThreadPolicy**.Builder**() **.detectAll**() **.penaltyLog**() **.build**());
如果您运行该应用,用户将看不到任何区别。但是,您将在 LogCat 中看到一条调试级别的日志消息,其中包含以下堆栈跟踪:
12-28 17:19:40.009: DEBUG/StrictMode(480): StrictMode policy violation; ~duration=169 ms: android.os.StrictMode$StrictModeDiskReadViolation: policy=23 violation=2 12-28 17:19:40.009: DEBUG/StrictMode(480): at android.os.StrictMode$AndroidBlockGuardPolicy.onReadFromDisk(StrictMode.java:745) 12-28 17:19:40.009: DEBUG/StrictMode(480): at dalvik.system.BlockGuard$WrappedFileSystem.open(BlockGuard.java:228) 12-28 17:19:40.009: DEBUG/StrictMode(480): at android.app.ContextImpl.openFileOutput(ContextImpl.java:410) 12-28 17:19:40.009: DEBUG/StrictMode(480): at android.content.ContextWrapper.openFileOutput(ContextWrapper.java:158) 12-28 17:19:40.009: DEBUG/StrictMode(480): at com.commonsware.android.readwrite.ReadWriteFileDemo.onPause(ReadWriteFileDemo.java:82) …
这里,StrictMode
警告我们,我们试图在主应用线程(我们设置了StrictMode
策略的线程)上进行闪存写入。理想情况下,我们会重写这个项目,以使用一个AsyncTask
或写出数据的东西。
请只开发!
不要在生产代码中使用StrictMode
。它是为构建、测试和调试应用而设计的。它不是为野外使用而设计的。
为了解决这个问题,你可以
在准备生产构建时,只需注释掉或删除StrictMode
安装代码
需要时,使用某种生产标志跳过StrictMode
设置代码
有条件的严格
StrictMode
仅适用于 Android 2.3 及更高版本。因此,如果我们的代码中有它,即使是在开发模式中,当我们尝试在旧的模拟器或设备上测试时,它可能会干扰。正如我们在前面的章节中看到的,有一些技术可以解决这个问题,但是使用反射来配置StrictMode
会非常痛苦。
因此,正确的方法是简单地组织您的代码,使您拥有使用较新 API 的常规类,但是您不要在较旧的设备上加载这些类。APIVersions/ReadWriteStrict
项目演示了这一点,允许应用在可用的地方使用 Android 2.3 的StrictMode
,在不可用的地方跳过它。
当我们在本节前面检查StrictMode
时,我们在示例活动的onCreate()
方法中配置了StrictMode
。这是可行的,但是只在 Android 2.3 和更新的版本上。
为了让它在旧版本的 Android 上工作,我们使用了StrictWrapper
:
`packagecom.commonsware.android.readwrite;
importandroid.os.Build;
abstract class StrictWrapper {
static private StrictWrapper INSTANCE=null;
static public void init() {
if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.GINGERBREAD) {
INSTANCE=new StrictForRealz ();
}
else {
INSTANCE=new NotAllThatStrict ();
}
}
static class NotAllThatStrict extends StrictWrapper {
// no methods needed
}
}`
这个看起来很奇怪的类封装了我们处理StrictMode
的“做我们或者不做我们”的逻辑。它包含一个init()
方法,当被调用时,检查应用运行在哪个版本的 Android 上,并基于它创建一个StrictWrapper
子类的单例实例——对于 Android 2.3 和更高版本为StrictForRealz
,对于旧版本的 Android 为NotAllThatStrict
。后一个类是StrictWrapper
的静态内部类,什么都不做,反映出 Android 较新版本中没有StrictMode
。
StrictForRealz
包含了StrictMode
的初始化逻辑:
`packagecom.commonsware.android.readwrite;
importandroid.os.StrictMode;
classStrictForRealz extends StrictWrapper {
StrictForRealz () {
StrictMode.setThreadPolicy (new StrictMode.ThreadPolicy.Builder ()
.detectAll ()
.penaltyLog ()
.build ());
}
}`
并且,我们活动的onCreate()
方法调用StrictWrapper
上的init()
,以触发创建适当的对象:
`@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
StrictWrapper.init ();
editor=(EditText)findViewById (R.id.editor);
}`
当活动第一次启动时,StrictWrapper
和StrictForRealz
都没有被加载到流程中。一旦我们到达onCreate()
中的init()
语句,Android 就会将StrictWrapper
加载到这个过程中,但是这是安全的,因为它不会引用任何可能不存在的类。只有当我们安全地使用受支持的 Android 版本时,StrictWrapper
上的init()
方法才会执行涉及StrictForRealz
的语句。因此,StrictForRealz
只有在我们使用较新的 Android 版本时才会被加载到进程中,所以我们在StrictForRealz
中使用StrictMode
不会触发VerifyError
。
在这里,我们所需要的只是一点初始化。singleton 模式用于演示如果您愿意,您可以公开一个依赖于版本的 API 实现。简单地将 API 定义为抽象类(StrictWrapper
)上的抽象方法,并在具体子类(StrictForRealz
、NotAllThatStrict
)上拥有这些抽象方法的依赖于版本的具体实现。
Linux 文件系统:同步,你赢了
Android 建立在 Linux 内核之上,使用 Linux 文件系统来保存文件。传统上,Android 使用 YAFFS(另一种闪存文件系统),优化用于低功耗设备,将数据存储到闪存中。今天,许多设备仍在使用 YAFFS。
YAFFS 有一个大问题:一次只有一个进程可以写入文件系统。YAFFS 不提供文件级锁定,而是提供分区级锁定。这可能会成为一个瓶颈,特别是当 Android 设备的功能越来越强大,并且开始想要同时做更多的事情时,就像他们的台式机和笔记本兄弟一样。
Android 开始向 ext4 发展,ext 4 是另一个针对台式机/笔记本的 Linux 文件系统。您的应用不会直接感觉到差异。然而,ext4 做了相当多的缓冲,它会给没有考虑这种缓冲的应用带来问题。2008 年和 2009 年,当 ext4 开始流行时,Linux 应用开发人员一头扎进了这个领域。作为一个 Android 开发者,你现在需要考虑一下…你自己的文件存储。
如果你使用的是 SQLite 或者SharedPreferences
,就不需要担心这个问题。Android(和 SQLite,如果你正在使用它)为你处理所有的缓冲问题。但是,如果您编写自己的文件,您可能希望在将数据刷新到磁盘时考虑一个额外的步骤。具体来说,您需要触发一个名为fsync()
的 Linux 系统调用,它告诉文件系统确保所有缓冲区都被写入磁盘。
如果你在同步模式下使用java.io.RandomAccessFile
,这一步也会为你处理,所以你不需要担心。然而,Java 开发人员倾向于使用FileOutputStream
,它不会触发fsync()
,即使您在流上调用close()
。相反,你在FileOutputStream
上调用getFD().sync()
来触发fsync()
。注意,这可能很耗时,所以只要可行,磁盘写应该在主应用线程之外完成,比如通过AsyncTask
。
三十一、使用偏好设置
Android 有许多不同的方式供您存储数据,供您的活动长期使用。最容易使用的是首选项系统,这是本章的重点。
Android 允许活动和应用以键/值对(类似于Map
)的形式保存首选项,这些首选项将在活动调用之间保留。顾名思义,首选项的主要目的是让您能够存储用户指定的配置细节,比如用户在您的提要阅读器中查看的最后一个提要、默认情况下在列表中使用的排序顺序等等。当然,您可以在首选项中存储任何您喜欢的内容,只要它是由一个String
键控的,并且有一个原始值(boolean
、String
等)。)
首选项可以针对单个活动,也可以在应用中的所有活动之间共享。其他组件(如服务)也可以使用共享偏好设置。
得到你想要的
要访问首选项,有三个 API 可供选择:
getPreferences()
在您的Activity
中,访问特定活动的偏好设置
从您的Activity
(或其他应用Context
)中,访问应用级偏好设置
getDefaultSharedPreferences()
,在PreferenceManager
上,获取与 Android 的整体偏好设置框架协同工作的共享偏好设置
前两个方法采用安全模式参数—正确的选择是MODE_PRIVATE
,这样其他应用就不能访问该文件。getSharedPreferences()
方法还接受一组首选项的名称。getPreferences()
有效地调用getSharedPreferences()
,将活动的类名作为首选项集名。getDefaultSharedPreferences()
方法将Context
作为首选项(例如,您的Activity
)。
所有这些方法都返回一个SharedPreferences
的实例,它提供了一系列的 getters 来访问命名的首选项,返回一个合适类型的结果(例如,getBoolean()
返回一个布尔首选项)。getters 还采用默认值,如果在指定的键下没有设置首选项,则返回该值。
除非你有很好的理由不这样做,否则最好使用第三个选项——getDefaultSharedPreferences()
——因为这将为你提供默认情况下与PreferenceActivity
一起工作的SharedPreferences
对象,这将在本章后面描述。
陈述你的偏好
给定适当的SharedPreferences
对象,您可以使用edit()
来获得首选项的编辑器。这个对象有一组设置器,这些设置器镜像父SharedPreferences
对象上的获取器。它还有以下方法:
remove()
:删除单个命名的首选项
clear()
:删除所有首选项
commit()
:保存您通过编辑器所做的更改
commit()
方法很重要,因为如果您通过编辑器修改首选项,并且未能commit()
更改,那么一旦编辑器超出范围,这些更改就会消失。请注意,Android 2.3 有一个apply()
方法,它的工作方式类似于commit()
,但运行速度更快。
相反,由于 preferences 对象支持实时更改,如果应用的一部分(比如一个活动)修改了共享的首选项,应用的另一部分(比如一个服务)将可以立即访问更改后的值。
引入偏好片段和偏好活动
您可以运行自己的活动来收集用户的偏好。总的来说,这是个坏主意。相反,根据您的目标 Android 版本,使用首选 XML 资源和一个PreferenceFragment
或一个PreferenceActivity
。为什么呢?对 Android 开发人员的一个常见抱怨是他们缺乏纪律性,不遵循平台固有的任何标准或惯例。对于其他操作系统,设备制造商可能会阻止你分发违反其人机界面准则的应用。对于 Android 来说,情况并非如此——但这并不意味着你可以为所欲为。如果有标准或惯例,请遵循它,这样用户会对你的应用和他们的设备感觉更舒服。在 Android 3.0 或更高版本中使用PreferenceFragment
,或者在早期版本中使用PreferenceActivity
来收集偏好就是这样一种惯例。Android 3.0 和 4.0 对PreferenceActivity
的行为进行了改进,因此我们将首先介绍使用偏好设置的新方式,然后说明原始模型——这对于许多现有的 Android 1.x 和 2.x 设备非常有用,您的代码可能需要与这些设备兼容。
片段偏好
Android 3.0 和更高版本引入了新的和改进的PreferenceScreen
和PreferenceActivity
。这使得偏好选择在大屏幕上看起来很棒,提供了大量设置的快速访问,如图 Figure 31–1 所示。
图 31–1。 ??【preference activity】使用片段
不利的一面是,新系统不是 Android 兼容性库的一部分,因此不能直接用于 3.0 之前的 Android 版本。也就是说,有可能找到一个向后兼容的解决方案,尽管如果您有很多偏好,这可能需要一些努力。
偏好新的和改进的方式
在 Android 的前蜂巢版本中,一个PreferenceActivity
子类从资源文件中加载首选项,以指示屏幕上应该显示什么。在 Honeycomb 和 Ice Cream Sandwich 中,一个PreferenceActivity
子类从资源文件中加载首选项标题 ,以指示屏幕上应该显示什么。
首选项标题
从视觉上看,首选项标题不是首选项类别(在一组首选项上放置一个标题)。相反,首选项标题是首选项的主要群集。表头列在左侧,所选表头的首选项显示在右侧,如图 Figure 31–1 所示。冰激凌三明治或蜂巢PreferenceActivity
调用loadHeadersFromResource()
,指向另一个描述偏好头的 XML 资源。例如,下面是来自Prefs/Fragments
样本项目的res/xml/preference_headers.xml
:
<preference-headers xmlns:android="http://schemas.android.com/apk/res/android"> <header android:fragment="com.commonsware.android.preffrags.StockPreferenceFragment" android:title="Original" android:summary="The original set from the other examples"> <extra android:name="resource" android:value="preferences" /> </header> <header android:fragment="com.commonsware.android.preffrags.StockPreferenceFragment" android:title="Other Stuff" android:summary="Well, we needed to show two sets here…"> <extra android:name="resource" android:value="preferences2" /> </header> </preference-headers>
每个<header>
元素表示将描述属于标题的首选项的PreferenceFragment
子类。此外,<header>
元素描述了标题和摘要,以及一个可选的图标(android:icon
属性)。一个<header>
元素也可能有一个或多个<extra>
子元素,提供一个PreferenceFragment
可以用来配置的额外数据的键/值对。在前面的例子中,每个<header>
元素都有一个<extra>
元素,它定义了一个 XML 资源的名称,该资源将保存该头的首选项。
因此,PreferenceActivity
是一个非常短的结构:
`package com.commonsware.android.preffrags;
import android.os.Bundle;
import android.preference.PreferenceActivity;
import java.util.List;
public class EditPreferences extends PreferenceActivity {
@Override
public void onBuildHeaders (List
target) {
loadHeadersFromResource (R.xml.preference_headers, target);
}
}`
您覆盖了一个onLoadHeaders()
方法,并在那里调用loadHeadersFromResource()
。
PreferenceFragment 和 StockPreferenceFragment
如前所述,首选项头指向PreferenceFragment
的子类。PreferenceFragment
的工作是做PreferenceActivity
在旧版本的 Android 中所做的事情(我们将很快介绍)——调用addPreferencesFromResource()
来定义当相关联的标题在左边被点击时将在右边显示的偏好。
PreferenceFragment
的奇怪之处在于它需要子类。考虑到绝大多数这样的片段会简单地在单个资源上调用addPreferencesFromResource()
一次,将它内置到 Android 中似乎是合乎逻辑的,允许PreferenceFragment
的子类用于更复杂的情况。然而,目前还不支持。官方的 Android 示例会让你为每个 preference 头创建一个PreferenceFragment
子类,这似乎很浪费。
另一种方法是使用StockPreferenceFragment
,一个在Prefs/Fragments
项目中实现的PreferenceFragment
子类,但是可以在任何地方使用。它假设您已经向<header>
添加了一个<extra>
来标识要加载的首选 XML 资源的名称,并加载它。不需要额外的子类。这就是上一节中显示的两个头如何指向单个StockPreferenceFragment
实现的原因。
并不特别长,但它确实使用了一个技巧:
`package com.commonsware.android.preffrags;
import android.os.Bundle;
import android.preference.PreferenceFragment;
public class StockPreferenceFragment extends PreferenceFragment {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
int res=getActivity ()
.getResources ()
.getIdentifier (getArguments ().getString ("resource"),
"xml",
getActivity ().getPackageName ());
addPreferencesFromResource (res);
}
}`
为了得到额外的内容,PreferenceFragment
可以调用getArguments()
,后者返回一个Bundle
。在我们的例子中,我们可以通过getArguments().getString("resource")
获得resources
额外价值。问题是,这是一个String
,不是资源 ID。为了调用addPreferencesFromResource()
,我们需要只知道名称的首选项的资源 ID。
窍门就是用getIdentifier()
。在给定三条信息的情况下,Resources
对象上的getIdentifier()
方法(通过调用Activity
上的getResources()
获得)将使用反射来查找资源 ID:
资源的名称(在这种情况下,是参数中的值)
资源的类型(在本例中为xml
)
这个 ID 应该驻留的包(通常是您自己的包,通过调用Activity
上的getPackageName()
获得)
因此,StockPreferenceFragment
使用getIdentifier()
将额外的resource
转换成资源 ID,然后与addPreferencesFromResource()
一起使用。
注意getIdentifier()
并不是特别快,因为它使用了反射。不要在一个紧循环中,在一个Adapter
的getView()
中,或者在任何可能会被调用数千次的地方使用它。
避免嵌套的 PreferenceScreen 元素
在前蜂窝 Android 中,如果你有很多偏好,你可以考虑把它们变成嵌套的PreferenceScreen
元素。最好将它们分成单独的首选项标题。部分原因是为了提供更好的用户体验——用户可以直接看到和访问各种标题,而不是不得不费力地通过你的首选项来找到导致嵌套的PreferenceScreen
的标题。部分原因也是因为嵌套的PreferenceScreen
UI 没有采用当代的 Android 外观和感觉(例如,没有嵌套的首选项标题),所以会有视觉冲突。
标题或偏好的意图
如果您需要收集一些超出标准首选项处理能力的首选项,您有一些选择。
一种选择是创建自定义的Preference
。扩展DialogPreference
来创建自己的Preference
实现并不特别困难。然而,它确实把你限制在一个对话框中。
另一种选择是将一个<intent>
元素指定为一个<header>
元素的子元素。当用户点击这个标题时,您指定的Intent
与startActivity()
一起使用,为您提供了一个自己收集偏好 UI 无法处理的东西的活动的入口。例如,您可以使用下面的<header>
:
`
`
然后,只要您有一个带有指定您想要的动作(com.commonsware.android.MY_CUSTOM_ACTION
)的<intent-filter>
的活动,当用户点击相关的标题时,该活动将得到控制。
增加向后兼容性
当然,本节描述的所有内容仅适用于 Android 3.0 至 4.0 及更高版本。其他数以百万计的安卓设备呢?它们是剁碎的肝脏吗?不。一方面,切碎的肝脏有众所周知的不好的细胞接收。然而,他们将不得不退回到最初的方法。由于旧版本的 Android 不能加载引用来自新版本 Android 的其他类或方法的类,最简单的方法是有两个PreferenceActivity
类,一个新的,一个旧的。
例如,Prefs/FragmentsBC
示例项目包含了来自Prefs/Fragments
的所有代码,并做了一些修改。首先,针对冰淇淋三明治和蜂巢的EditPreferences
类的特定版本被重命名为EditPreferencesNew
。基于我们最初的 prefragment 实现,添加了另一个EditPreferences
类:
`package com.commonsware.android.preffrags;
import android.os.Bundle;
import android.preference.PreferenceActivity;
public class EditPreferences extends PreferenceActivity {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
addPreferencesFromResource (R.xml.preferences);
addPreferencesFromResource (R.xml.preferences2);
}
}`
这里,我们利用了这样一个事实,即可以多次调用addPreferencesFromResource()
来简单地将我们的两个 preference 头的 preferences 值链接在一起。此外,打开我们的PreferenceActivity
的选项菜单选项会根据我们的Build.VERSION.SDK_INT
值选择正确的选项:
` @Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case EDIT_ID:
if (Build.VERSION.SDK_INT<Build.VERSION_CODES.HONEYCOMB) {
startActivity(new
Intent(this, EditPreferences.class));
}
else {
startActivity(new
Intent(this, EditPreferencesNew.class));
}
return(true);
}
return(super.onOptionsItemSelected(item));
}`
因此,我们只在已知安全的情况下使用EditPreferencesNew
类。否则,我们使用旧的。
偏好处理的旧模型
在旧版本的 Android 3 . x 之前,首选项框架和PreferenceActivity
的关键是另一种 XML 数据结构。您可以在项目的res/xml/
目录中存储的 XML 文件中描述您的应用的首选项。考虑到这一点,Android 可以呈现一个令人愉快的用户界面来操作这些偏好,然后存储在你从getDefaultSharedPreferences()
返回的SharedPreferences
中。即使您计划将 Android 3.0 和更高版本作为目标,下面的例子对您也很有用,因为它们展示了基本的首选项元素(如复选框和输入字段)是如何工作的——这些基本元素在新旧方法中都是通用的。
以下是Prefs/Simple
首选项示例项目的首选项 XML:
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <CheckBoxPreference android:key="checkbox" android:title="Checkbox Preference" android:summary="Check it on, check it off" /> <RingtonePreference android:key="ringtone" android:title="Ringtone Preference" android:showDefault="true" android:showSilent="true" android:summary="Pick a tone, any tone" /> </PreferenceScreen>
首选项 XML 的根是一个PreferenceScreen
元素。毫不奇怪,您可以在PreferenceScreen
元素中拥有的一些东西是偏好定义。这些是Preference
的子类,如CheckBoxPreference
或RingtonePreference
,如前面的 XML 所示。正如你所料,这些分别允许你选择一个复选框或者选择一个铃声。在RingtonePreference
的情况下,你有允许用户选择系统默认铃声或者选择静音作为铃声的选项。
让用户发表意见
假设您已经设置了首选项 XML,那么您可以使用一个近乎内置的活动来允许您的用户设置他们的首选项。该活动“几乎是内置的”,因为您只需将其子类化并指向您的首选 XML,然后将该活动与应用的其余部分挂钩。
例如,下面是Prefs/Simple
项目的EditPreferences
活动:
`package com.commonsware.android.simple;
import android.app.Activity;
import android.os.Bundle;
import android.preference.PreferenceActivity;
public class EditPreferences extends PreferenceActivity {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
addPreferencesFromResource (R.xml.preferences);
}
}`
如你所见,这里没有太多的到 可看。您需要做的就是调用addPreferencesFromResource()
并指定包含您的首选项的 XML 资源。
您还需要将此作为活动添加到您的AndroidManifest.xml
文件中:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.simple"> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name=".SimplePrefsDemo" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> <activity android:name=".EditPreferences" android:label="@string/app_name"> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
您需要安排调用活动,比如从菜单选项中调用。以下摘自SimplePrefsDemo
:
` public boolean onCreateOptionsMenu (Menu menu) {
menu.add (Menu.NONE, EDIT_ID, Menu.NONE, "Edit Prefs")
.setIcon (R.drawable.misc)
.setAlphabeticShortcut ('e');
return(super.onCreateOptionsMenu (menu));
}
@Override
public boolean onOptionsItemSelected (MenuItem item) {
switch (item.getItemId ()) {
case EDIT_ID:
startActivity (new Intent(this, EditPreferences.class));
return(true);
}
return(super.onOptionsItemSelected (item));
}`
这就是所需要的全部,除了 preferences XML 之外,真的没有那么多代码。你的努力得到的是一个 Android 提供的偏好 UI,如图 Figure 31–2 所示。
图 31–2。 简单项目的首选项 UI
该复选框可以直接选中或取消选中。要更改铃声首选项,只需选择首选项列表中的条目,弹出选择对话框,如 Figure 31–3 所示。
图 31–3。 选择铃声偏好
注意,PreferenceActivity
上没有明确的保存或提交按钮或菜单——更改会自动保存。
除了具有上述菜单之外,SimplePrefsDemo
活动还通过TableLayout
显示当前偏好:
`
<TableLayout
xmlns:android="http://schemas.android.com/apk/res/android "
android:layout_width="fill_parent"
android:layout_height="fill_parent"
`
该表的字段位于onCreate()
:
`public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
checkbox=(TextView)findViewById (R.id.checkbox);
ringtone=(TextView)findViewById (R.id.ringtone);
}`
这些字段在每个onResume()
更新:
`public void onResume () {
super.onResume ();
SharedPreferences prefs=PreferenceManager
.getDefaultSharedPreferences (this);
checkbox.setText (new Boolean (prefs
.getBoolean ("checkbox", false))
.toString ());
ringtone.setText(prefs.getString ("ringtone", ""));
}`
这意味着这些字段将在打开活动时和离开首选项活动后更新(例如,通过后退按钮),如 Figure 31–4 所示。
图 31–4。 简单项目的保存偏好列表
加入一点点 o’结构
如果你有很多用户需要设置的偏好,把它们都放在一个大列表里可能会很麻烦。Android 的偏好用户界面给了你一些方法来给你的偏好设置加上一点结构,包括类别和屏幕。
类别是通过首选项 XML 中的一个PreferenceCategory
元素添加的,用于将相关的首选项组合在一起。您可以将一些PreferenceCategory
元素放在PreferenceScreen
中,然后将您的偏好放在它们适当的类别中,而不是将您的偏好都作为根PreferenceScreen
的子元素。从视觉上看,这在偏好组之间添加了一个带有类别标题的分隔线。
如果你有很多很多的偏好——不方便用户滚动浏览——你也可以通过引入PreferenceScreen
元素把它们放在单独的“屏幕”上。是的,即 元素。
任何PreferenceScreen
的孩子都去自己的屏幕。如果嵌套了PreferenceScreen
元素,父屏幕会将屏幕显示为占位符条目,点击该条目会弹出子屏幕。
例如,在Prefs/Structured
示例项目中,有一个包含PreferenceCategory
和嵌套的PreferenceScreen
元素的首选 XML 文件:
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <PreferenceCategory android:title="Simple Preferences"> <CheckBoxPreference
android:key="checkbox" android:title="Checkbox Preference" android:summary="Check it on, check it off" /> <RingtonePreference android:key="ringtone" android:title="Ringtone Preference" android:showDefault="true" android:showSilent="true" android:summary="Pick a tone, any tone" /> </PreferenceCategory> <PreferenceCategory android:title="Detail Screens"> <PreferenceScreen android:key="detail" android:title="Detail Screen" android:summary="Additional preferences held in another page"> <CheckBoxPreference android:key="checkbox2" android:title="Another Checkbox" android:summary="On. Off. It really doesn't matter." /> </PreferenceScreen> </PreferenceCategory> </PreferenceScreen>
当您在您的PreferenceActivity
实现中使用这个首选 XML 时,结果是一个元素的分类列表,如图 Figure 31–5 所示。
图 31–5。 结构化项目的首选项 UI,显示类别和一个屏幕占位符
如果点击详细信息屏幕条目,您将进入儿童偏好屏幕,如图 Figure 31–6 所示。
图 31–6。 结构化项目偏好 UI 的子偏好画面
你喜欢的弹出窗口类型
当然,并不是所有的偏好都是复选框和铃声。对于其他的,比如输入框和列表,Android 使用弹出对话框。用户不直接在首选项 UI 活动中输入他们的首选项,而是点击一个首选项,填写一个值,然后点击 OK 以提交更改。
从结构上来说,在 preference XML 中,字段和列表与其他 preference 类型没有太大的不同,如来自Prefs/Dialogs
示例项目的 preference XML 所示:
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <PreferenceCategory android:title="Simple Preferences"> <CheckBoxPreference android:key="checkbox" android:title="Checkbox Preference" android:summary="Check it on, check it off" /> <RingtonePreference android:key="ringtone" android:title="Ringtone Preference" android:showDefault="true" android:showSilent="true" android:summary="Pick a tone, any tone"
/> </PreferenceCategory> <PreferenceCategory android:title="Detail Screens"> <PreferenceScreen android:key="detail" android:title="Detail Screen" android:summary="Additional preferences held in another page"> <CheckBoxPreference android:key="checkbox2" android:title="Another Checkbox" android:summary="On. Off. It really doesn't matter." /> </PreferenceScreen> </PreferenceCategory> <PreferenceCategory android:title="Other Preferences"> <EditTextPreference android:key="text" android:title="Text Entry Dialog" android:summary="Click to pop up a field for entry" android:dialogTitle="Enter something useful" /> <ListPreference android:key="list" android:title="Selection Dialog" android:summary="Click to pop up a list to choose from" android:entries="@array/cities" android:entryValues="@array/airport_codes" android:dialogTitle="Choose a Pennsylvania city" /> </PreferenceCategory> </PreferenceScreen>
使用字段(EditTextPreference
),除了您在首选项上添加的标题和摘要之外,您还可以为对话框提供标题。
使用 list ( ListPreference
),您可以提供一个对话框标题和两个字符串数组资源:一个用于显示名称,一个用于值。这些需要有相同的顺序和相同的元素数量,因为所选显示名称的索引决定了哪个值作为首选项存储在SharedPreferences
中。例如,以下是前面示例中显示的ListPreference
使用的数组:
<?xml version="1.0" encoding="utf-8"?> <resources> <string-array name="cities"> <item>Philadelphia</item> <item>Pittsburgh</item> <item>Allentown/Bethlehem</item> <item>Erie</item> <item>Reading</item> <item>Scranton</item> <item>Lancaster</item> <item>Altoona</item> <item>Harrisburg</item> </string-array> <string-array name="airport_codes"> <item>PHL</item> <item>PIT</item>
<item>ABE</item> <item>ERI</item> <item>RDG</item> <item>AVP</item> <item>LNS</item> <item>AOO</item> <item>MDT</item> </string-array> </resources>
当您调出首选项 UI 时,您从另一个类别开始,该类别包含另一对首选项条目,如 Figure 31–7 所示。
图 31–7。 对话框项目偏好 UI 的偏好屏幕
点击文本输入对话框,弹出一个文本输入对话框——在这种情况下,填入先前的首选项,如 Figure 31–8 所示。
图 31–8。 编辑文本偏好
点击选择对话框弹出一个选择对话框,显示一个数组的显示名称,如 Figure 31–9 所示。
图 31–9。 编辑列表偏好
三十二、管理和访问本地数据库
SQLite 是一个非常受欢迎的嵌入式数据库,因为它结合了一个干净的 SQL 接口,内存占用非常小,速度也不错。而且是公共领域,大家都可以用。许多公司(例如 Adobe、Apple、Google、Sun 和 Symbian)和开源项目(例如 Mozilla、PHP 和 Python)都提供 SQLite 产品。
对于 Android,SQLite 是“嵌入”Android 运行时的,因此每个 Android 应用都可以创建 SQLite 数据库。由于 SQLite 使用一个 SQL 接口,对于有其他基于 SQL 的数据库经验的人来说,使用起来相当简单。然而,它的原生 API 是用 C 语言编写的,尽管它可以使用诸如 JDBC 这样的 Java 库,但无论如何,对于像电话这样内存有限的设备来说,JDBC 的开销可能太大了。因此,Android 程序员需要学习不同的 API。好消息是这并不困难。
本章将介绍在 Android 环境下使用 SQLite 的基础知识。这绝不是对整个 SQLite 的全面介绍。如果你想了解更多关于 SQLite 的知识,以及如何在 Android 以外的环境中使用它,一本好书是格兰特·艾伦(你现在的作者)和迈克尔·欧文斯(2010 年出版)写的《SQLite 权威指南,第二版》。这还涵盖了其他补充主题,如 SQLite 数据库的安全性等。
本章展示的大部分示例代码来自于Database/Constants
应用。这个应用展示了一个物理常数的列表,名字和值是从 Android 的SensorManager
中挑选出来的,如图图 32–1 所示。
图 32–1。 常量示例应用,如同最初启动的
你可以弹出一个菜单添加一个新的常量,弹出一个对话框,填写常量的名称和值,如图 Figure 32–2 所示。
图 32–2。 常量示例应用的添加常量对话框
常量随后被添加到列表中。长时间点击一个现有的常量将会弹出一个带有删除选项的上下文菜单,在确认后,将会删除该常量。
当然,所有这些都存储在 SQLite 数据库中。
快速 SQLite 入门
SQLite,顾名思义,使用 SQL 的一种方言进行数据操作查询(SELECT
、INSERT
等),数据定义(CREATE TABLE
等)。SQLite 有一些地方偏离了 SQL-92 和 SQL-99 标准,这与大多数关系数据库没有什么不同。好消息是 SQLite 非常节省空间,Android 运行时可以包含所有的 SQLite,而不是一些任意的子集来缩减它的大小。
SQLite 和其他关系数据库的最大区别是数据类型。虽然您可以在一个CREATE TABLE
语句中指定列的数据类型,并且 SQLite 将使用这些数据类型作为提示,但也就到此为止了。你可以把任何你想要的数据放在任何你想要的列中。在一个INTEGER
列中放一个字符串?当然,没问题!反之亦然?那也行!SQLite 将此称为清单类型 ,如文档中所述:
在清单类型中,数据类型是值本身的属性,而不是存储值的列的属性。因此,SQLite 允许用户将任何数据类型的任何值存储到任何列中,而不管该列声明的类型。
从头开始
Android 不会自动向您提供任何数据库。如果您想使用 SQLite,您需要创建自己的数据库,然后用您自己的表、索引和数据填充它。
要创建并打开一个数据库,最好的选择是创建一个SQLiteOpenHelper
的子类。这个类根据您的应用的需要,按照您的规范包装了创建和升级数据库的逻辑。您的SQLiteOpenHelper
子类将需要三个方法:
构造函数,链接到SQLiteOpenHelper
构造函数。这需要Context
(例如一个Activity
)、数据库的名称、一个可选的游标工厂(通常只需传递null
)和一个表示您正在使用的数据库模式版本的整数。
onCreate()
,它传递给您一个SQLiteDatabase
对象,您可以根据需要用表和初始数据填充它。
onUpgrade()
,它传递给你一个SQLiteDatabase
对象和新旧版本号,这样你就可以知道如何最好地将数据库从旧模式转换到新模式。如果您不关心现有的数据或数据库,最简单的方法是丢弃旧表并创建新表,尽管这种方法最不友好。更好的方法是使用适当的CREATE
或ALTER TABLE
语句来升级你的模式(尽管一定要检查使用ALTER TABLE
的条件,这将在本章后面讨论)。
例如,这里有一个来自Database/Constants
的DatabaseHelper
类,它在onCreate()
中创建了一个表并添加了一些行,在onUpgrade()
中通过删除现有的表并执行onCreate()
来欺骗:
`packagecom.commonsware.android.constants;
importandroid.content.ContentValues;
importandroid.content.Context;
importandroid.database.Cursor;
importandroid.database.SQLException;
importandroid.database.sqlite.SQLiteOpenHelper;
importandroid.database.sqlite.SQLiteDatabase;
importandroid.hardware.SensorManager;
public class DatabaseHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME="db";
static final String TITLE="title";
static final String VALUE="value";
public DatabaseHelper (Context context) {
super(context, DATABASE_NAME, null, 1);
}
@Override
public void onCreate (SQLiteDatabasedb) {
db.execSQL ("create table constants (_id integer primary key autoincrement, title
text, value real);");
ContentValues cv=new ContentValues ();
cv.put (TITLE, "Gravity, Death Star I");
cv.put (VALUE, SensorManager.GRAVITY_DEATH_STAR_I);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Earth");
cv.put (VALUE, SensorManager.GRAVITY_EARTH);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Jupiter");
cv.put (VALUE, SensorManager.GRAVITY_JUPITER);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Mars");
cv.put (VALUE, SensorManager.GRAVITY_MARS);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Mercury");
cv.put (VALUE, SensorManager.GRAVITY_MERCURY);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Moon");
cv.put (VALUE, SensorManager.GRAVITY_MOON);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Neptune");
cv.put (VALUE, SensorManager.GRAVITY_NEPTUNE);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Pluto");
cv.put (VALUE, SensorManager.GRAVITY_PLUTO);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Saturn");
cv.put (VALUE, SensorManager.GRAVITY_SATURN);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Sun");
cv.put (VALUE, SensorManager.GRAVITY_SUN);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, The Island");
cv.put (VALUE, SensorManager.GRAVITY_THE_ISLAND);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Uranus");
cv.put (VALUE, SensorManager.GRAVITY_URANUS);
db.insert ("constants", TITLE, cv);
cv.put (TITLE, "Gravity, Venus");
cv.put (VALUE, SensorManager.GRAVITY_VENUS);
db.insert ("constants", TITLE, cv);
}
@Override
public void onUpgrade (SQLiteDatabasedb, intoldVersion, intnewVersion) {
android.util.Log.w ("Constants", "Upgrading database, which will destroy all
old data");
db.execSQL ("drop table if exists constants");
onCreate (db);
}
}`
在这一章的后面,我们将仔细看看onCreate()
在做什么——就execSQL()
和insert()
调用而言。
要使用您的SQLiteOpenHelper
子类,创建并保持它的一个实例。然后,当你需要一个SQLiteDatabase
对象进行查询或数据修改时,根据你是否要改变它的内容,询问你的SQLiteOpenHelper
到getReadableDatabase()
或getWriteableDatabase()
。例如,我们的ConstantsBrowser
活动在onCreate()
中打开数据库,作为查询的一部分:
constantsCursor=db **.getReadableDatabase**() **.rawQuery**("select _id, title, value "+ "from constants order by title", null);
当您完成数据库时(例如,您的活动被关闭),只需在您的SQLiteOpenHelper
上调用close()
来释放您的连接。
为了使onUpgrade()
正常工作,您的数据库模式的版本号必须随着您的前进而增加。一个典型的模式是从1
开始,然后从那里开始。
如果您觉得有必要,您可以选择在SQLiteOpenHelper
中覆盖另外两种方法:
当有人打开这个数据库时,你可以覆盖它来获得控制权。通常,这不是必需的。
在 Android 3.0 中引入,如果代码请求的模式比当前数据库中的旧,将调用这个方法。这是onUpgrade()
的逆。如果您的版本号不同,将调用这两种方法中的一种。因为通常你会继续更新,你通常可以跳过onDowngrade()
。
摆桌子
为了创建表和索引,您需要在您的SQLiteDatabase
上调用execSQL()
,提供您希望应用于数据库的数据定义语言(DDL)语句。除非出现数据库错误,否则该方法不返回任何内容。
例如,您可以调用execSQL()
来创建constants
表,如DatabaseHelperonCreate()
方法所示:
db.**execSQL**("create table constants (_id integer primary key autoincrement, title text, value real);");
这将创建一个名为constants
的表,其中一个名为_id
的主键列是一个自动递增的整数(也就是说,当您插入行时,SQLite 将为您赋值),外加两个数据列:title
(文本)和value
(一个浮点数,或者在 SQLite 术语中是实数 )。SQLite 会自动为您的主键列创建一个索引。您可以通过一些CREATE INDEX
语句在这里添加其他索引。
最有可能的情况是,当您第一次创建数据库时,或者当数据库需要升级以适应应用的新版本时,您将创建表和索引。如果您决定将预配置的 SQLite 数据库与您的应用打包在一起,这可能是一个例外,我们将在本章的后面探讨这个选项。如果不改变表模式,可能永远也不会删除表或索引,但如果这样做了,只需根据需要使用execSQL()
调用DROP INDEX
和DROP TABLE
语句。
制作数据
假设您有一个数据库和一个或多个表,您可能想在其中放一些数据。有两种主要方法可以做到这一点:
使用execSQL()
,就像创建表格一样。execSQL()
方法适用于任何不返回结果的 SQL,因此它可以很好地处理INSERT
、UPDATE
、DELETE
等等。
在SQLiteDatabase
对象上使用insert()
、update()
和delete()
方法,这消除了执行基本操作所需的大量 SQL 语法。
例如,在这里我们将一个新行insert()
到我们的constants
表中:
`private void processAdd (DialogWrapper wrapper) {
ContentValues values=new ContentValues (2);
values.put (DatabaseHelper.TITLE, wrapper.getTitle ());
values.put (DatabaseHelper.VALUE, wrapper.getValue ());
db.getWritableDatabase ().insert ("constants", DatabaseHelper.TITLE, values);
constantsCursor.requery ();
}`
这些方法利用了ContentValues
对象,这些对象实现了一个Map
风格的接口,尽管这个接口有额外的方法来处理 SQLite 类型。例如,除了通过键检索值的get()
,还有getAsInteger()
、getAsString()
等等。
insert()
方法接受表的名称、作为“null column hack”的一列的名称,以及一个带有您希望放入该行的初始值的ContentValues
。空列 hack 针对的是ContentValues
实例为空的情况——在由insert()
生成的 SQL INSERT
语句中,被命名为空列 hack 的列将被显式赋值NULL
。这是必需的,因为 SQLite 对 SQL INSERT
语句的支持有些奇怪。
update()
方法接受表的名称,一个代表要使用的列和替换值的ContentValues
,一个可选的WHERE
子句,以及一个填充到WHERE
子句中的可选参数列表,以替换任何嵌入的问号(?
)。因为update()
只替换具有固定值的列,而不是基于其他信息计算的列,所以您可能需要使用execSQL()
来完成一些任务。WHERE
子句和参数列表的工作方式类似于其他 SQL APIs 中的位置 SQL 参数。
delete()
方法的工作方式类似于update()
,接受表的名称、可选的WHERE
子句和相应的参数来填充到WHERE
子句中。例如,这里我们从我们的constants
表中delete()
一行,给出它的 _ID:
private void **processDelete**(long rowId) { String[] args={String.**valueOf**(rowId)};
db.**getWritableDatabase**().**delete**("constants", "_ID=?", args); constantsCursor.**requery**(); }
恶有恶报
与INSERT
、UPDATE
和DELETE
一样,使用SELECT
从 SQLite 数据库中检索数据有两个主要选项:
使用rawQuery()
直接调用SELECT
语句
使用query()
从其组成部分构建一个查询
使事情更加混乱的是SQLiteQueryBuilder
类以及游标和游标工厂的问题。让我们一次只看一片。
原始查询
最简单的解决方案,至少就 API 而言,是rawQuery()
。只需用您的 SQL SELECT
语句调用它。SELECT
语句可以包含位置参数;这些数组构成了你给rawQuery()
的第二个参数。如果您的查询不包含位置参数,那么这个参数就是null
。所以,我们以这个结尾:
constantsCursor=db **.getReadableDatabase**() **.rawQuery**("SELECT _ID, title, value "+ "FROM constants ORDER BY title", null);
返回值是一个Cursor
,这是大多数数据库 API 在处理数据库查询结果集时使用的通用结构。您的Cursor
包含了对结果进行迭代的方法(在“使用游标”一节中会简短讨论)。
如果您的查询已经“嵌入”到您的应用中,这是一种非常简单的使用方法。然而,如果查询的某些部分是动态的,超出了位置参数所能处理的范围,那么事情就变得复杂了。例如,如果在编译时不知道需要检索的列集,那么将列名连接成逗号分隔的列表可能会很烦人...这就是query()
的用武之地。
常规查询
query()
方法获取SELECT
语句的离散片段,并根据它们构建查询。按照它们作为query()
的参数出现的顺序,这些部分如下:要查询的表的名称
要检索的列的列表
WHERE
子句,可选地包括位置参数
替换那些位置参数的值列表
GROUP BY
条款,如果有的话
HAVING
条款,如果有的话
ORDER BY
条款,如果有的话
这些可以在不需要的时候null
(当然表名除外):
String[] columns={"ID", "inventory"}; String[] parms={"snicklefritz"}; Cursor result=db.**query**("widgets", columns, "name=?", parms, null, null, null);
query()
方法的一个大缺点就在第一个要点中:只能查询一个表,隐式或显式连接表超出了该方法的范围。
使用光标
无论您如何执行查询,都会返回一个Cursor
。这是数据库游标的 Android/SQLite 版本,这是许多数据库系统中使用的概念。使用光标,您可以执行以下操作:
通过getCount()
找出结果集中有多少行(尽管要注意,以这种方式计算行数隐含地检索结果集中的所有数据)
通过moveToFirst()
、moveToNext()
和isAfterLast()
迭代这些行
通过getColumnNames()
找出列名,通过getColumnIndex()
将其转换成列号,并通过getString()
、getInt()
等方法获得给定列的当前行的值
通过requery()
重新执行创建光标的查询
通过close()
释放光标的资源
例如,这里我们迭代一个widgets
表条目:
`Cursor result=
db.rawQuery ("select id, name, inventory from widgets", null);
while (!result.moveToNext ()) {
int id=result.getInt (0);
String name=result.getString (1);
int inventory=result.getInt (2);
// do something useful with these
}
result.close ();`
您还可以将Cursor
包装在SimpleCursorAdapter
或其他实现中,然后将结果适配器传递给ListView
或其他选择小部件。但是请注意,如果您要使用CursorAdapter
或它的子类(比如SimpleCursorAdapter
),您的查询的结果集必须 包含一个名为_ID
的整数列,该列对于结果集是惟一的。这个“id”值然后被提供给诸如onListItemClick()
之类的方法,以识别用户在AdapterView
中点击了哪个项目。
例如,在检索到排序后的常量列表后,我们只用几行代码就将它们放入ConstantsBrowser
活动的ListView
中:
ListAdapter adapter=new **SimpleCursorAdapter**(this, R.layout.row, constantsCursor, new String[] {DatabaseHelper.TITLE, DatabaseHelper.VALUE}, new int[] {R.id.title, R.id.value});
自定义光标适配器
您可能还记得在前面的章节中,您可以覆盖ArrayAdapter
中的getView()
,为如何显示行提供更多的自定义控制。然而,CursorAdapter
及其子类有一个默认的实现getView()
,它检查提供的View
来回收。如果是null
,getView()
调用newView()
,再调用bindView()
。如果不是null
,getView()
只是调用bindView()
。如果您正在扩展CursorAdapter
——用于显示数据库或内容供应器查询的结果——您应该覆盖newView()
和bindView()
而不是getView()
。
这样做的目的是删除您在getView()
中的if()
测试,并将该测试的每个分支放在一个独立的方法中,类似于下面的:
`public View newView (Context context, Cursor cursor,
ViewGroup parent) {
LayoutInflaterinflater=getLayoutInflater ();
View row=inflater.inflate (R.layout.row, null);
ViewWrapper wrapper=new ViewWrapper (row);
row.setTag (wrapper);
return(row);
}
public void bindView (View row, Context context, Cursor cursor) {
ViewWrapper wrapper=(ViewWrapper)row.getTag ();
// actual logic to populate row from Cursor goes here
}`
制作自己的光标
有些情况下,你可能想使用自己的Cursor
子类,而不是 Android 提供的标准实现。在这些情况下,您可以使用queryWithFactory()
和rawQueryWithFactory()
,它们将一个SQLiteDatabase.CursorFactory
实例作为参数。如您所料,工厂负责通过其newCursor()
实现创建新的光标。
找到并实现对该工具的有效使用是留给您的一个练习。简单地说,在普通的 Android 开发中,您不需要创建太多自己的光标类。
SQLite 和 Android 版本
随着两者新版本的不断发布,Android 包含的底层 SQLite 库也在不断发展。Android 的最初版本附带了 SQLite 3.5.9。Android 2.2 Froyo 将 SQLite 库更新至 3.6.22。这是一次相对较小的升级,处理了一些 bug 修复之类的问题。Android 3.0 蜂巢再次将 SQLite 库升级到了 3.7.4,而这仍然是与 Android 4.0 冰淇淋三明治一起使用的版本。虽然您可以将此次升级视为另一个修复 bug 并提供增量改进的点版本,但是 SQLite 的 3.7 版本包含了一组关于并发性、日志记录和锁定的非常激进的增强特性。
您可能永远不需要担心这些变化,特别是当您的应用可能是唯一一个并发访问 SQLite 数据库的应用时。然而,引入了一些微妙之处。
首先,对于使用 SQLite 3.7 版和更高版本创建的新数据库,SQLite 数据库格式的主要内部版本号会增加,旧数据库可以升级到这种新格式。如果您计划将自己的 SQLite 数据库打包成应用的一部分(而不是通过onCreate()
创建),您应该考虑您将支持哪些旧设备和 Android 版本,并确保您使用旧的 SQLite 数据库格式。它仍然可以被 SQLite 3.7.4 读取和操作,无需任何形式的升级。
第二,SQLite 的一些新特性显然只有在以后的版本中才提供。这将主要影响您使用rawQuery()
执行的一些更高级的查询,比如使用 SQL 标准外键创建命令。
闪光灯:听起来比实际速度快
您的数据库将存储在闪存中,通常是设备的板载闪存。从闪存中读取数据相对较快。虽然内存不是特别快,但没有移动硬盘磁头的寻道时间,就像使用磁介质一样,所以对 SQLite 数据库执行查询往往会很快。
向闪存写入数据完全是另一回事。有时,这可能发生得相当快,大约几毫秒。不过,有时候,即使是写入少量数据,也可能需要数百毫秒的时间。此外,闪存越满越慢,所以用户看到的速度变化更大。
最终结果是,你应该认真考虑在主应用线程之外做所有的数据库写操作,比如通过一个AsyncTask
,正如第二十章中所描述的。这样,数据库写操作不会降低用户界面的速度。
在有些情况下,写入基于闪存的存储可能是一个冒险的举动。当电池电量低时,相信写入闪存将在电池耗尽之前完成,这对于作为开发人员的您来说可能有点过于信任了。同样,依靠在设备的电源循环期间写入闪存的能力也不是一个好办法。在这些情况下,您可以在您的应用中添加一个Intent
接收器来监视ACTION_BATTERY_CHANGED
广播,然后检查所提供的数据,看看电池发生了什么,它当前的充电水平,等等。
请注意,模拟器的行为有所不同,因为它通常使用硬盘上的文件来存储数据,而不是闪存。虽然对于 CPU 和 GPU 操作,仿真器往往比硬件慢得多,但对于将数据写入闪存,仿真器往往会快得多。因此,仅仅因为您没有看到由于模拟器中的数据库 I/O 而导致的任何 UI 变慢,就不要假设当您的代码在真实的 Android 设备上运行时也会如此。
船啊哟!
许多应用都带有现成的数据库,以支持从方便的参考列表到完整的离线缓存的各种使用方式。您可以将在项目中其他地方创建的数据库合并到已编译的应用中。
首先,将 SQLite 数据库文件包含在项目的assets/
文件夹中。要在代码中使用捆绑的数据库,可以将它的位置和文件名传递给openDatabase()
方法。调用openDatabase()
可以将完整路径和文件名作为第一个参数。实际上,该完整路径和文件名是通过连接以下内容构建的:
用于引用所有数据库素材的路径,/data/data/your.application.package/databases/
然后是所需的数据库文件名;例如your-db-name
数据,数据,无处不在
如果您习惯于为其他数据库进行开发,那么除了数据库的 API 之外,您可能还习惯于使用工具来检查和操作数据库的内容。有了 Android 的模拟器,你有两个主要的选择。
首先,模拟器应该捆绑在sqlite3
控制台程序中,并通过adb shell
命令使其可用。一旦你进入模拟器的外壳,只需执行sqlite3
,提供你的数据库文件的路径。您的数据库文件可以在以下位置找到:
/data/data/your.app.package/databases/your-db-name
这里,your.app.package
是应用的 Java 包(例如,com.commonsware.android
),your-db-name
是提供给createDatabase()
的数据库的名称。
sqlite3
程序工作正常,如果您习惯于使用控制台界面浏览您的表格,欢迎您使用它。如果您喜欢稍微友好一点的东西,您总是可以将 SQLite 数据库从设备复制到您的开发机器上,然后使用一个支持 SQLite 的客户端程序来进行操作。但是,请注意,您正在处理数据库的副本;如果您希望您的更改返回到设备,您需要将数据库传输回来。
要从设备复制数据库,您可以使用adb pull
命令(或者您的 IDE 中的等效命令,或者 Dalvik Debug Monitor 服务中的文件管理器),该命令将设备上数据库的路径和本地目的地作为参数。要在设备上存储修改过的数据库,使用adb push
,它将数据库的本地路径和设备上的目的地作为参数。
最易访问的 SQLite 客户端之一是火狐的 SQLite 管理器扩展,如图 Figure 32–3 所示,因为它可以跨所有平台工作。
图 32–3。 SQLite 管理器火狐扩展
您可以在 SQLite 网站上找到其他客户端工具。
三十三、利用 Java 库
Java 拥有和其他现代编程语言一样多的第三方库,甚至更多。这些第三方库是您可以包含在服务器或桌面 Java 应用中的无数 JARs 这些是 Java SDKs 本身不提供的。
在 Android 的情况下,Dalvik 虚拟机(VM)的核心并不完全是 Java,它在 SDK 中提供的内容也不完全与任何传统的 Java SDK 相同。也就是说,许多 Java 第三方库提供了 Android 本身缺乏的功能,因此,如果您能让它们与 Android 风格的 Java 一起工作,它们可能对您的项目有用。
本章解释了利用这些库需要什么,并描述了 Android 对任意第三方代码支持的限制。
蚂蚁和罐子
将第三方代码集成到项目中有两种选择:使用源代码或使用预打包的 jar。
如果您选择使用源代码,您需要做的就是将它复制到您自己的源代码树中(在您的项目中的src/
下),这样它就可以与您现有的代码并排放置,然后让编译器发挥它的魔力。
如果您选择使用一个现有的 JAR,也许您没有它的源代码,您将需要教您的构建链如何使用这个 JAR。首先,将 JAR 放在 Android 项目的libs/
目录中。然后,如果您使用的是 IDE,您可能需要将 JAR 添加到您的构建路径中(Ant 将自动选择在libs/
中找到的所有 JAR)。这对于 Eclipse 来说是必不可少的,您需要在 Java 构建路径页面的 Libraries 选项卡下放置一个对 jar 的引用。
仅此而已。向 Android 应用添加第三方代码相当容易。然而,让它真正工作 可能要复杂一些。
外部界限
并非所有可用的 Java 代码都能很好地与 Android 兼容。有许多因素需要考虑,包括:
期望的平台 API:代码是否假设了一个比 Android 所基于的 JVM 更新的 JVM?或者,代码是否假设 Java 2 Platform,Standard Edition (J2SE)附带了 Java APIs,而 Android 没有,比如 Swing?
大小 :现有的设计用于桌面或服务器的 Java 代码不需要太关心磁盘上的大小,或者在某种程度上,甚至不需要太关心内存中的大小。当然,安卓在这两方面都有所欠缺。使用第三方 Java 代码,尤其是预打包成 jar 的时候,可能会增加应用的规模。
性能 :Java 代码是否实际上假设了一个比你在许多 Android 设备上发现的更强大的 CPU?仅仅因为一台台式机可以运行它没有问题,并不意味着你的普通手机会处理得很好。
接口 :Java 代码是否假设了一个控制台接口?或者它是一个纯粹的 API,你可以用它来包装你自己的接口?
操作系统 :Java 代码是否假设某些控制台程序的存在?Java 代码是否认为它可以使用 Windows DLL?
语言版本 :JAR 是用老版本的 Java (1.4.2 或更老)编译的吗?这个 JAR 是用不同于 Sun 官方的编译器编译的吗(比如 GCJ)?
依赖性 :Java 代码是否依赖于其他可能也有这些问题的第三方 jar?Java 代码是否依赖于内置在 Android 中的第三方库(例如来自 http://json.org
的 JSON 库),但是期望这些库的不同版本?
解决这些问题的一个技巧是使用开源 Java 代码,并实际处理这些代码,使其对 Android 更加友好。例如,如果您只使用了第三方库的 10 %,也许值得重新编译项目的子集,使之成为您所需要的,或者至少从 JAR 中删除不必要的类。前一种方法更安全,因为您可以获得编译器的帮助,以确保您不会丢弃一些重要的代码,尽管这样做可能更繁琐。
跟随脚本
与其他移动设备操作系统不同,Android 对您可以在其上运行什么没有限制,只要您可以使用 Dalvik VM 用 Java 来完成。这包括将你自己的脚本语言整合到你的应用中,这在其他设备上是被明确禁止的。
一种可能的 Java 脚本语言是 BeanShell ( www.beanshell.org/
)。BeanShell 为您提供了与 Java 兼容的语法,带有隐式类型,不需要编译。
要添加 BeanShell 脚本,您需要将 BeanShell 解释器的 JAR 文件放在您的libs/
目录中。不幸的是,可以从 BeanShell 网站下载的 2.0b4 JAR 不能与 Android 0.9 和更新的 SDK 一起开箱即用,这可能是由于构建它所用的编译器。相反,您可能应该从 Apache Subversion 检查源代码并执行ant jarcore
来构建它,然后将结果 JAR(在 BeanShell 的dist/
目录中)复制到您自己项目的libs/
中。或者,在Java/AndShell
项目中使用本书源代码附带的 BeanShell JAR。
由此看来,在 Android 上使用 BeanShell 与在任何其他 Java 环境中使用 BeanShell 没有什么不同:
创建 BeanShell Interpreter
类的一个实例。
通过Interpreter#set()
设置脚本使用的任何全局变量。
调用Interpreter#eval()
运行脚本,并可选地获取最后一条语句的结果。
例如,下面是世界上最小的 BeanShell IDE 的 XML 布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/eval" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="Go!" android:onClick="go" /> <EditText android:id="@+id/script" android:layout_width="fill_parent" android:layout_height="fill_parent" android:singleLine="false" android:gravity="top" /> </LinearLayout>
结合以下活动实施:
`packagecom.commonsware.android.andshell;
importandroid.app.Activity;
importandroid.app.AlertDialog;
importandroid.os.Bundle;
importandroid.view.View;
importandroid.widget.EditText;
importandroid.widget.Toast;
importbsh.Interpreter;
public class MainActivity extends Activity {
private Interpreter i=new Interpreter ();
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
}
public void go (View v) {
EditText script=(EditText)findViewById (R.id.script);
String src=script.getText ().toString ();
try {
i.set ("context", MainActivity.this);
i.eval (src);
}
catch (bsh.EvalError e) {
AlertDialog.Builder builder=
newAlertDialog.Builder (MainActivity.this);
builder
.setTitle ("Exception!")
.setMessage(e.toString ())
.setPositiveButton ("OK", null)
.show ();
}
}
}`
编译并运行它(包括前面提到的合并 BeanShell JAR),并将其安装在模拟器上。启动它,你会得到一个简单的 IDE,它有一个大的文本区域来存放你的脚本和一个大的 Go!按钮执行,如图 Figure 33–1 所示。
图 33–1。 AndShell 豆壳 IDE
`importandroid.widget.Toast;
Toast.makeText (context, "Hello, world!", Toast.LENGTH_LONG).show ();`
注意在制作Toast
时使用context
来指代活动。这是活动引用回自身的全局设置。只要set()
调用和脚本代码使用相同的名称,您可以随意命名这个全局变量。
点击开始!按钮,您将得到如图图 33–2 所示的结果。
图 33–2。 AndShell BeanShell IDE,执行一些代码
现在,一些警告:
不是所有的脚本语言都能工作。例如,那些实现自己形式的实时(JIT)编译,动态生成 Java 字节码的人,可能需要扩充来生成 Dalvik VM 字节码,而不是那些用于普通 Java 实现的字节码。从解析过的脚本执行的更简单的语言,调用 Java 反射 API 回调编译过的类,可能会工作得更好。尽管如此,如果它依赖于 Dalvik 中不存在的传统 Java API 中的某些功能,也不是该语言的每一个特性都可以工作。例如,BeanShell 或附加 jar 中可能隐藏了一些在今天的 Android 上无法运行的东西。
没有 JIT 的脚本语言必然会比编译的 Dalvik 应用慢。较慢可能意味着用户体验迟缓。更慢无疑意味着同样的工作量消耗更多的电池寿命。因此,在 BeanShell 中构建一个完整的 Android 应用,仅仅因为你觉得它更容易编程,可能会导致你的用户不高兴。
暴露整个 Java API 的脚本语言,比如 BeanShell,可以做底层 Android 安全模型允许的任何事情。因此,如果您的应用拥有READ_CONTACTS
权限,那么您的应用运行的任何 BeanShell 脚本都应该拥有相同的权限。
最后,但肯定不是最不重要的,语言解释器 jar 往往…很大。本例中使用的 BeanShell JAR 有 200KB。考虑到它所做的事情,这并不荒谬,但它会使使用 BeanShell 的应用下载起来更大,占用设备上更多的空间,等等。
审阅剧本
由于本章介绍了 Android 中的脚本,您可能有兴趣知道除了在项目中直接嵌入 BeanShell 之外,您还有其他选择。
已经用其他基于 JVM 的编程语言进行了一些实验,比如 JRuby 和 Jython。目前,他们对 Android 的支持并不总是 100%顺利,但正在不断取得进展。例如,那些对 Android 上的 JRuby 感兴趣的人应该在 http://ruboto.org
调查 Ruboto 开源项目。
此外,在 http://code.google.com/p/android-scripting/
上描述的 Android 脚本层(SL4A)允许您使用 BeanShell 之外的各种脚本语言编写脚本,例如:
珠
计算机编程语言
JRuby
左上臂
JavaScript(通过 Rhino 实现,Rhino 是用 Java 编写的 Mozilla JavaScript 解释器)
服务器端编程语言(Professional Hypertext Preprocessor 的缩写)
这些脚本不是完全成熟的应用,尽管 SL4A 团队正在努力让你将它们转换成带有基本 ui 的 APK 文件。对于在设备上开发,SL4A 是一个不错的选择。SL4A 开发的著名项目包括 Nexus One 传感器测井有效载荷。如果你对 SL4A 的进一步阅读和开发感兴趣,这方面的一本好书是 Paul Ferrill 写的Pro Android Python with SL4A (a press,2011)。
三十四、通过互联网交流
人们的预期是,大多数(如果不是全部的话)Android 设备都将内置互联网接入。那可能是 Wi-Fi、蜂窝数据服务(EDGE、3G、4G 等。),或者可能完全是别的什么东西。不管怎样,大多数人——或者至少是那些有数据套餐或 Wi-Fi 接入的人——将能够通过他们的安卓手机上网。
毫不奇怪,Android 平台为开发者提供了多种方式来利用这种互联网接入。有些提供高级访问,比如集成的 WebKit 浏览器组件。如果你愿意,你可以直接使用原始套接字。在这两者之间,您可以利用 API——设备上的和来自第三方 jar 的——来访问特定的协议:HTTP、XMPP、SMTP 等等。
本书的重点是更高层次的访问形式:WebKit 组件,在第十五章中讨论,以及本章中讨论的互联网访问 API。作为忙碌的编码人员,我们应该尽可能重用现有的组件,而不是使用我们自己的在线协议。
休息和放松
Android 没有内置的 SOAP 或 XML-RPC 客户端 API。但是,它内置了 Apache HttpClient 库。您可以在这个库的上面放置一个 SOAP/XML-RPC 层,或者直接使用它来访问 REST 风格的 web 服务。出于本书的目的,REST 风格的 web 服务被认为是对普通 URL 的简单 HTTP 请求,包括所有 HTTP 动词,以及格式化的有效负载(XML、JSON 等)。)作为回应。
在 HttpClient 网站( http://hc.apache.org/
)上可以找到更多的教程、常见问题解答和指南。在这里,我们将涵盖基本的,而检查天气。
通过 Apache HttpClient 进行 HTTP 操作
毫不奇怪,使用 HttpClient 的第一步是创建一个HttpClient
对象。客户端对象代表您处理所有 HTTP 请求。因为HttpClient
是一个接口,你需要实例化这个接口的一些实现,比如DefaultHttpClient
。
这些请求被打包成HttpRequest
实例,每个不同的 HTTP 动词有不同的HttpRequest
实现(例如,HTTP GET
请求有HttpGet
)。您创建一个HttpRequest
实现实例,填写要检索的 URL 和其他配置数据(例如,如果您正在通过HttpPost
执行 HTTP POST
,则填写表单值),然后将该方法传递给客户端,以通过execute()
实际发出 HTTP 请求。
此时发生的事情可以简单,也可以复杂。您可以返回一个HttpResponse
对象,带有一个响应代码(例如,200
表示 OK)、HTTP 头等等。或者,您可以使用一种将ResponseHandler<String>
作为参数的execute()
,最终结果是execute()
只返回响应体的String
表示。实际上,这不是推荐的方法,因为您真的应该检查 HTTP 响应代码中的错误。然而,对于琐碎的应用,如书籍示例,ResponseHandler<String>
方法工作得很好。
例如,让我们看看Internet/Weather
示例项目。这实现了一个从国家气象局检索您当前位置的天气数据的活动。(请注意,这可能仅适用于美国的地理位置。)这些数据被转换成一个 HTML 页面,并被注入到一个WebKit
小部件中进行显示。使用ListView
重新构建这个演示是留给读者的一个练习。此外,由于这个示例相对较长,我们在本章中将只展示相关的 Java 代码片段,尽管您可以从 CommonsWare 网站下载完整的源代码。
为了让这个更有趣一点,我们使用 Android 定位服务来计算我们在哪里……算是吧。在第三十九章中提供了如何工作的全部细节。
在onResume()
方法中,我们开启位置更新,因此我们将被告知我们现在的位置以及我们何时移动了一个相当大的距离(10 公里)。当位置可用时——无论是在开始还是基于移动——我们通过我们的updateForecast()
方法检索国家气象局数据:
`private void updateForecast (Location loc) {
String url=String.format (format, loc.getLatitude (),
loc.getLongitude ());
HttpGet getMethod=new HttpGet (url);
try {
ResponseHandler responseHandler=new BasicResponseHandler ();
String responseBody=client.execute(getMethod,
responseHandler);
buildForecasts (responseBody);
String page=generatePage ();
browser.loadDataWithBaseURL (null, page, "text/html",
"UTF-8", null);
}
catch (Throwable t) {
android.util.Log.e("WeatherDemo", "Exception fetching data", t);
Toast
.makeText (this, "Request failed: "+t.toString (), Toast.LENGTH_LONG)
.show ();
}
}`
updateForecast()
方法将从位置更新过程中获得的一个Location
作为参数。现在,您需要知道的是,Location
提供了getLatitude()
和getLongitude()
方法,分别返回设备位置的纬度和经度。
我们将国家气象局 XML 的 URL 保存在一个字符串资源中,并在运行时注入纬度和经度。给定在onCreate()
中创建的HttpClient
对象,我们用定制的 URL 填充一个HttpGet
,然后执行该方法。给定从 REST 服务得到的 XML,我们构建预测 HTML 页面,如下所述,并将其注入到WebKit
小部件中。如果HttpClient
出现异常,我们将该错误作为Toast
提供。
注意,我们还关闭了onDestroy()
中的HttpClient
对象。
解析响应
您得到的响应将使用某种系统进行格式化——HTML、XML、JSON 或其他。当然,挑选出你需要的信息并利用它做一些有用的事情是你自己的事情。在WeatherDemo
的例子中,我们需要提取预测时间、温度和图标(指示天空状况和降雨量),并从中生成一个 HTML 页面。
Android 包括以下解析器:
三个 XML 解析器:传统的 W3C DOM ( org.w3c.dom
)、SAX 解析器(org.xml.sax
)和 XML 拉解析器(在第二十三章中讨论)
JSON 解析器(org.json
)
也欢迎您尽可能使用第三方 Java 代码来处理其他格式,比如用于提要阅读器的专用 RSS/Atom 解析器。第三方 Java 代码的使用在第三十三章中讨论。
对于WeatherDemo
,我们在buildForecasts()
方法中使用 W3C DOM 解析器:
void **buildForecasts**(String raw) throws Exception { DocumentBuilder builder=DocumentBuilderFactory .**new**Instance() .**newDocumentBuilder**(); Document doc=builder.**parse**(new InputSource(new **StringReader**(raw)));
` NodeList times=doc.getElementsByTagName ("start-valid-time");
for (int i=0;i<times.getLength ();i++) {
Element time=(Element)times.item (i);
Forecast forecast=new Forecast ();
forecasts.add (forecast);
forecast.setTime (time.getFirstChild ().getNodeValue ());
}
NodeList temps=doc.getElementsByTagName ("value");
for (int i=0;i<temps.getLength ();i++) {
Element temp=(Element)temps.item (i);
Forecast forecast=forecasts.get (i);
forecast.setTemp (new Integer(temp.getFirstChild().getNodeValue ()));
}
NodeList icons=doc.getElementsByTagName ("icon-link");
for (int i=0;i<icons.getLength ();i++) {
Element icon=(Element)icons.item (i);
Forecast forecast=forecasts.get (i);
forecast.setIcon (icon.getFirstChild ().getNodeValue ());
}
}`
国家气象局的 XML 格式结构奇特,严重依赖于列表中的顺序位置,而不是 RSS 或 Atom 等格式中更面向对象的风格。也就是说,我们可以采取一些自由措施,稍微简化解析,利用我们想要的元素(start-valid-time
表示预测时间,value
表示温度,icon-link
表示图标 URL)在文档中都是唯一的这一事实。
HTML 以InputStream
的形式出现,并被送入 DOM 解析器。从那里,我们扫描start-valid-time
元素,并使用这些开始时间填充一组Forecast
模型。然后,我们找到温度value
元素和icon-link
URL,并将它们填充到Forecast
对象中。
反过来,generatePage()
方法用预测创建了一个基本的 HTML 表:
`String generatePage () {
StringBuilder bufResult=new StringBuilder ("
");
bufResult.append ("
<th width="50%">Time"+
"");
for (Forecast forecast : forecasts) {
bufResult.append ("
<td align="center">");
bufResult.append (forecast.getTime ());
bufResult.append ("<td align="center">");
bufResult.append (forecast.getTemp ());
bufResult.append ("");
}
bufResult.append ("
Temperature Forecast
<img src="");
bufResult.append (forecast.getIcon ());
bufResult.append ("">
");
return(bufResult.toString ());
}`
结果类似于 Figure 34–1。
图 34–1。 weather demo 示例应用
注意: 如果您使用模拟器,您必须在 Eclipse 中设置您的位置。用Window
Open Perspective
Other
DDMS
打开 DDMS 透视图。在Devices
Name
面板中选择您的模拟器,然后使用经度和纬度框在模拟器控制面板中设置模拟器的位置。准备好后,点按“发送”。每次启动应用时,您都需要这样做。
货色要考虑
如果您需要使用 SSL,请记住默认的HttpClient
设置不包括 SSL 支持。大多数情况下,这是因为您需要决定如何处理 SSL 证书表示:您是否盲目地接受所有证书,即使是自签名或过期的证书?还是想问用户是不是真的要用一些奇怪的证书?
类似地,默认情况下,HttpClient
是为单线程使用而设计的。如果您将在多线程可能成为问题的其他地方使用HttpClient
,您可以很容易地设置HttpClient
来支持多线程。
对于这类主题,您最好查看 HttpClient 网站以获得文档和支持。
雄激素 http 客户端
从 Android 2.2 (API level 8)开始,你可以使用android.net.http
包中的AndroidHttpClient
类。这是一个HttpClient
接口的实现,类似于DefaultHttpClient
。然而,它预先配置了 Android 核心团队认为对平台有意义的设置。
您将获得以下好处:
SSL 管理
一种指定用户代理字符串的直接方法,该字符串在您调用静态newInstance()
方法以获得AndroidHttpClient
的实例时提供
用于处理通过 GZIP 压缩的材料、解析 HTTP 头中的日期等的实用方法
你失去的是自动 cookie 存储。常规的DefaultHttpClient
将在内存中缓存 cookies,并在需要它们的后续请求中使用它们。AndroidHttpClient
不会。有一些方法可以解决这个问题,通过使用一个HttpContext
对象,正如在AndroidHttpClient
文档中所描述的。
另外,AndroidHttpClient
阻止您在主应用线程上使用它——请求只能在后台线程上发出。这是一个特性,即使有些人可能认为这是一个 bug。
因为这个类只在 Android 2.2 和更高版本中可用,所以在你只支持 API level 8 或更高版本之前,对它做太多可能没有意义。
利用互联网感知的 Android 组件
只要有可能,就使用内置的 Android 组件来处理你的互联网接入。这类组件将经过相当严格的测试,更有可能很好地处理边缘情况,例如处理 Wi-Fi 上移动到接入点范围之外并故障转移到移动数据连接(如 3G)的用户。
例如,WebView
小工具(在第十五章中介绍)和MapView
小工具(在第四十章中介绍)都可以为您处理互联网接入。虽然您仍然需要INTERNET
权限,但是您不必自己执行 HTTP 请求或类似的操作。
本节概述了利用内置互联网功能的一些其他方法。
下载文件
Android 2.3 引入了一个DownloadManager
,旨在处理下载较大文件的许多复杂问题,例如:
确定用户是使用 Wi-Fi 还是移动数据,以及根据哪种情况,是否应该进行下载
当先前使用 Wi-Fi 的用户移出接入点范围并故障转移到移动数据时的处理
确保设备在下载过程中保持唤醒状态
与你自己编写全部内容相比,它本身并不复杂。然而,它确实带来了一些挑战。在本节中,我们将检查使用了DownloadManager
的Internet/Download
示例项目。
权限
要使用DownloadManager
,您需要持有INTERNET
权限。根据您选择下载文件的位置,您可能还需要WRITE_EXTERNAL_STORAGE
权限。
然而,在撰写本文时,如果您没有足够的权限,您可能会得到一个错误,抱怨您缺少ACCESS_ALL_DOWNLOADS
。这似乎是DownloadManager
实现中的一个错误。应该是抱怨缺少INTERNET
或者WRITE_EXTERNAL_STORAGE
,或者两者都缺。你不需要持有ACCESS_ALL_DOWNLOADS
许可,在 Android 3.0 中甚至没有记录。
例如,下面是Internet/Download
应用的清单:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.download" android:versionCode="1" android:versionName="1.0"> <!-- <uses-permission android:name="android.permission.ACCESS_ALL_DOWNLOADS" /> --> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name="DownloadDemo" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
注意: 对于本例,您需要确保您的仿真器配置了 SD 卡。打开 Android SDK 和 AVD 管理器并选择您的仿真器,然后单击编辑。然后,您可以设置仿真器用于存储的 SD 卡的大小。如果您调整现有 SD 卡映像的大小,请注意 AVD 将删除您现有的 SD 卡映像,因此您应该首先备份您希望保留的任何有价值的内容。
布局
我们的示例应用有一个简单的布局,由三个按钮组成:
一个开始下载
一个用于查询下载的状态
一个用于显示系统提供的包含下载文件列表的活动
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:id="@+id/start" android:text="Start Download" android:layout_width="fill_parent" android:layout_height="0dip" android:layout_weight="1" android:onClick="startDownload" /> <Button android:id="@+id/query" android:text="Query Status" android:layout_width="fill_parent" android:layout_height="0dip" android:layout_weight="1" android:onClick="queryStatus" android:enabled="false" /> <Button android:text="View Log" android:layout_width="fill_parent" android:layout_height="0dip"
android:layout_weight="1" android:onClick="viewLog" /> </LinearLayout>
请求下载
为了开始下载,我们首先需要访问DownloadManager
。这是一项系统服务。我们可以在任何活动上调用getSystemService()
(或其他Context
),向它提供我们想要的系统服务的标识符,并接收回系统服务对象。然而,由于getSystemService()
支持大范围的这些对象,我们需要将它转换成我们所请求的服务的适当类型。
举例来说,这是从DownloadDemo
活动的onCreate()
开始的一行,在这里我们得到了DownloadManager
:
mgr=(DownloadManager)**getSystemService**(DOWNLOAD_SERVICE);
这些管理器中的大多数都没有close()
、release()
或goAwayPlease()
类型的方法——我们可以使用它们,让垃圾收集来清理它们。
给定DownloadManager
,我们现在可以调用一个enqueue()
方法来请求下载。名称是相关的——不要假设您的下载会立即开始,尽管它经常会开始。enqueue()
方法将一个DownloadManager.Request
对象作为参数。Request
对象使用构建器模式,因为大多数方法返回Request
本身,所以我们可以用更少的输入将一系列调用链接在一起。
例如,我们布局中最顶端的按钮被绑定到DownloadDemo
中的startDownload()
方法,如下所示:
`public void startDownload (View v) {
Uri uri=Uri.parse ("http://commonsware.com/misc/test.mp4 ");
Environment
.getExternalStoragePublicDirectory (Environment.DIRECTORY_DOWNLOADS)
.mkdirs();
lastDownload=
mgr.enqueue (new DownloadManager.Request (uri)
.setAllowedNetworkTypes (DownloadManager.Request.NETWORK_WIFI |
DownloadManager.Request.NETWORK_MOBILE)
.setAllowedOverRoaming (false)
.setTitle ("Demo")
.setDescription ("Something useful. No, really.")
.setDestinationInExternalPublicDir (Environment.DIRECTORY_DOWNLOADS,
"test.mp4"));
v.setEnabled (false);
findViewById (R.id.query).setEnabled (true);
}`
我们正在下载一个样本 MP4 文件,我们想把它下载到外部存储区。为了实现后者,我们在Environment
上使用了getExternalStoragePublicDirectory()
,这给了我们一个适合存储某类内容的目录。在这种情况下,我们将把下载存储在Environment.DIRECTORY_DOWNLOADS
中,尽管我们也可以选择Environment.DIRECTORY_MOVIES
,因为我们正在下载一个视频剪辑。注意,getExternalStoragePublicDirectory()
返回的File
对象可能指向一个尚未创建的目录,这就是为什么我们对它调用mkdirs()
,以确保该目录存在。
然后我们创建DownloadManager.Request
对象,具有以下属性:
由于提供给Request
构造函数的Uri
,我们正在下载我们想要的特定 URL。
我们愿意使用移动数据或 Wi-Fi 进行下载(setAllowedNetworkTypes()
),但我们不希望下载产生漫游费(setAllowedOverRoaming()
)。
我们希望在外部存储器(setDestinationInExternalPublicDir()
)的下载区域中以test.mp4
的名称下载文件。
我们还提供了一个名称(setTitle()
)和描述(setDescription()
),它们被用作此次下载的通知抽屉条目的一部分。当用户在下载过程中向下滑动抽屉时,就会看到这些内容。
enqueue()
方法返回这次下载的 ID,我们保留它用于查询下载状态。
跟踪下载状态
如果用户点击查询状态按钮,我们希望了解下载进度的详细信息。为此,我们可以在DownloadManager
上调用query()
。query()
方法接受一个DownloadManager.Query
对象,描述我们感兴趣的下载内容。在我们的例子中,当用户请求下载时,我们使用从enqueue()
方法获得的值:
`public void queryStatus (View v) {
Cursor c=mgr.query (new DownloadManager.Query ().setFilterById (lastDownload));
if (c==null) {
Toast.makeText (this, "Download not found!", Toast.LENGTH_LONG).show ();
}
else {
c.moveToFirst ();
Log.d (getClass ().getName (), "COLUMN_ID: "+
c.getLong (c.getColumnIndex (DownloadManager.COLUMN_ID)));
Log.d (getClass ().getName (), "COLUMN_BYTES_DOWNLOADED_SO_FAR: "+
c.getLong (c.getColumnIndex (DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)));
Log.d (getClass ().getName (), "COLUMN_LAST_MODIFIED_TIMESTAMP: "+
c.getLong (c.getColumnIndex (DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)));
Log.d (getClass ().getName (), "COLUMN_LOCAL_URI: "+
c.getString (c.getColumnIndex (DownloadManager.COLUMN_LOCAL_URI)));
Log.d (getClass ().getName (), "COLUMN_STATUS: "+
c.getInt (c.getColumnIndex (DownloadManager.COLUMN_STATUS)));
Log.d (getClass ().getName (), "COLUMN_REASON: "+
c.getInt (c.getColumnIndex (DownloadManager.COLUMN_REASON)));
Toast.makeText (this, statusMessage (c), Toast.LENGTH_LONG).show ();
}
}`
query()
方法返回一个Cursor
,包含一系列表示下载细节的列。在DownloadManager
类中有一系列常量概述了什么是可能的。在我们的例子中,我们检索(并转储到 LogCat)以下内容:
下载的 ID(COLUMN_ID
)
迄今为止已经下载的数据量(COLUMN_BYTES_DOWNLOADED_SO_FAR
)
下载的最后修改时间戳是什么(COLUMN_LAST_MODIFIED_TIMESTAMP
)
文件保存到本地的位置(COLUMN_LOCAL_URI
)
实际状态是什么(COLUMN_STATUS
)
那种状态的原因是什么(COLUMN_REASON
)
有许多可能的状态代码(例如,STATUS_FAILED
、STATUS_SUCCESSFUL
和STATUS_RUNNING
)。有些,像STATUS_FAILED
,可能有一个附带的原因提供更多的细节。
用户看到的内容
用户在启动应用时,会看到我们的三个按钮,如图 Figure 34–2 所示。
图 34–2。 下载演示示例应用,如同最初启动的
在下载过程中,点击第一个按钮会禁用该按钮,状态栏中会出现一个下载图标(尽管由于 Android 的图标和 Android 的状态栏之间的对比度很差,有点难以看清),如 Figure 34–3 所示。
图 34–3。 下载演示示例应用,执行下载
向下滑动通知抽屉以ProgressBar
窗口小部件的形式向用户显示下载进度,如图 Figure 34–4 所示。
图 34–4。 通知抽屉,在下载期间使用 DownloadManager
点击通知抽屉中的条目将控制返回到我们的原始活动,用户会看到一个Toast
,如 Figure 34–5 所示。
图 34–5。 download demo 示例应用,来到前台后发出通知
如果用户在下载过程中点击中间按钮,会出现一个Toast
,表示下载正在进行中,如图图 34–6 所示。
图 34–6。 下载演示示例应用,显示下载中的状态
其他详细信息也转储到 LogCat,可通过 DDMS 或adb logcat
查看:
12-10 08:45:01.289: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_ID: 12 12-10 08:45:01.289: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_BYTES_DOWNLOADED_SO_FAR: 615400 12-10 08:45:01.289: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_LAST_MODIFIED_TIMESTAMP: 1291988696232 12-10 08:45:01.289: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_LOCAL_URI: file:///mnt/sdcard/Download/test.mp4 12-10 08:45:01.299: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_STATUS: 2 12-10 08:45:01.299: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_REASON: 0
下载完成后,点击中间的按钮将表明下载已经完成,关于下载的最终信息将发送到 LogCat:
12-10 08:49:27.360: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_ID: 12 12-10 08:49:27.360: DEBUG/com.commonsware.android.download.DownloadDemo(372):
COLUMN_BYTES_DOWNLOADED_SO_FAR: 6219229 12-10 08:49:27.370: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_LAST_MODIFIED_TIMESTAMP: 1291988713409 12-10 08:49:27.370: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_LOCAL_URI: file:///mnt/sdcard/Download/test.mp4 12-10 08:49:27.370: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_STATUS: 8 12-10 08:49:27.370: DEBUG/com.commonsware.android.download.DownloadDemo(372): COLUMN_REASON: 0
点击底部按钮,显示所有下载的活动,包括成功和失败,如 Figure 34–7 所示。
图 34–7。 下载屏幕,显示 DownloadManager 下载的所有内容
当然,文件是下载的。在 Android 2.3 中,在模拟器中,我们选择的位置映射到/mnt/sdcard/Downloads/test.mp4
。
限制
DownloadManager
适用于 HTTP URLs,但不适用于 HTTPS(SSL)URL。这是不幸的,因为越来越多的网站正在全面转向 SSL 加密,以应对各种安全挑战。希望在未来,DownloadManager
在这里会有更多的选择。
如果您显示所有下载的列表,并且您的下载也在其中,那么确保某个活动(可能是您的某个活动)能够响应该下载的 MIME 类型上的ACTION_VIEW Intent
是一个非常好的主意。否则,当用户点击列表中的条目时,他们会得到一个Toast
,表明没有可用的内容来查看下载。这可能会让用户感到困惑。或者,在您的请求上使用setVisibleInDownloadsUi()
,传入false
,从列表中取消它。
继续逃离简基密码
规则很简单:不要从主应用线程访问互联网。始终使用带有HttpClient
、HttpUrlConnection
的后台线程,或者您希望使用的任何其他互联网访问 API。
如果您试图在主应用线程上访问互联网,前面章节中介绍的StrictMode
将会警告您。如果您试图在主应用线程上发出 web 请求,AndroidHttpClient
将会崩溃。然而,这些功能仅在较新版本的 Android 中可用。也就是说,有很多方法可以在你的应用中使用StrictMode
,但是只能在使用条件类加载的新版 Android 中使用——这种技术在本书前面已经介绍过了。
三十五、服务:理论
如前所述,Android 服务适用于长期运行的流程,即使与任何活动分离,这些流程也可能需要保持运行。例如,即使播放器活动被垃圾收集,也可以播放音乐;轮询互联网上的 RSS/Atom 提要更新;即使聊天客户端由于来电而失去焦点,也可以保持在线聊天连接。
当手动启动(通过 API 调用)或者当某个活动试图通过进程间通信(IPC)连接到服务时,就会创建服务。服务将一直存在,直到明确关闭,或者直到 Android 迫切需要 RAM 并过早地破坏它们。然而,长时间运行是有成本的,所以服务需要小心,不要使用太多的 CPU 或让无线电在太多的时间里处于活动状态,以免服务导致设备的电池过快耗尽。
本章概述了创建和消费服务背后的基本理论。下一章将介绍一些特定的服务模式,这些模式可能非常符合您的特定需求。因此,本章只有有限的代码示例,而下一章提供了几个代码示例。
为什么是服务?
对于许多不需要直接访问活动用户界面的功能来说,服务是一把“瑞士军刀”,例如:
执行即使用户离开应用的活动也需要继续的操作,例如长时间下载(例如,从 Android Market 下载应用)或播放音乐(例如,Android 音乐应用)
执行需要存在的操作,而不管活动来来去去,例如维护聊天连接以支持聊天应用
向远程 API 提供本地 API,例如可能由 web 服务提供的 API
在没有用户干预的情况下执行定期工作,类似于 cron 作业或 Windows 计划任务
甚至像主屏幕应用小部件这样的东西也经常涉及一项服务,以帮助长时间运行的工作。
许多应用不需要任何服务。很少有应用需要一个以上。然而,服务是 Android 开发人员工具箱中的强大工具,任何合格的 Android 开发人员都应该熟悉它们的功能。
建立服务
创建服务实现与构建活动有许多共同的特征。您从 Android 提供的基类继承,覆盖一些生命周期方法,并通过清单将服务挂接到系统中。
服务等级
正如你的应用中的活动扩展了Activity
或者 Android 提供的Activity
子类,你的应用中的服务扩展了Service
或者 Android 提供的Service
子类。最常见的Service
子类是IntentService
,主要用于命令模式。也就是说,许多服务只是简单地扩展了Service
。
生命周期方法
正如活动有onCreate()
、onResume()
、onPause()
和类似的方法一样,Service
实现也有自己的生命周期方法,如下所示:
onCreate()
:与活动一样,在创建服务流程时通过任何方式调用
onStartCommand()
:每次通过startService()
向服务发送命令时调用
onBind()
:每当客户端通过bindService()
绑定到服务时调用
onDestroy()
:服务关闭时调用
与活动一样,服务在onCreate()
中初始化它们需要的任何东西,并在onDestroy()
中清理这些项目。和活动一样,如果 Android 终止了整个应用进程,比如紧急 RAM 回收,服务的onDestroy()
方法可能不会被调用。
我们之前提供的关于活动因内存不足而突然终止的警告同样适用于服务。然而,Android 4.0 冰淇淋三明治引入了一种新方法onTrimMemory()
,允许系统更好地处理低内存情况,特别是服务,让它们有机会释放未使用或不需要的资源,然后不得不求助于onDestroy()
(下一章将详细介绍)。
onStartCommand()
和onBind()
生命周期方法将基于您与客户端通信的选择来实现,这将在本章稍后解释。
舱单录入
最后,您需要将服务添加到您的AndroidManifest.xml
文件中,以便它被识别为可用的服务。这只是添加一个<service
>元素作为application
元素的子元素,提供android:name
来引用您的服务类。所以在下面的清单中,你会看到android:name="Downloader"
。
如果你想要求那些希望启动或绑定到服务的人的一些权限,添加一个android:permission
属性来命名你正在授权的权限——更多细节参见第三十八章。
例如,下面的清单显示了<service>
元素:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.commonsware.android.downloader" android:versionCode="1" android:versionName="1.0"> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <application android:label="@string/app_name" android:icon="@drawable/cw"> <activity android:name="DownloaderDemo" android:label="@string/app_name"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> <serviceandroid:name="Downloader"/> </application> <supports-screens android:largeScreens="true" android:normalScreens="true" android:smallScreens="true" android:anyDensity="true"/> </manifest>
与服务通信
服务的客户端——通常是活动,但不是必须的——有两种主要的方式向服务发送请求或信息。一种方法是发送命令,这不会创建与服务的持久连接。另一种方法是绑定到服务,建立一个双向的通信通道,只要客户端需要,这个通道就会一直存在。
使用 startService()发送命令
使用服务最简单的方法是调用startService()
。startService()
方法接受一个Intent
参数,就像startActivity()
一样。事实上,提供给startService()
的Intent
具有与startActivity()
相同的两部分作用:
确定要与之通信的服务
以Intent
extras 的形式提供参数,告诉服务它应该做什么
对于一个本地服务(本书的重点),最简单的Intent
形式是标识实现Intent
的类(例如,new Intent(this, MyService.class);
)。
对startService()
的调用是异步的,所以客户端不会阻塞。如果服务还没有运行,它将被创建,并通过调用onStartCommand()
生命周期方法来接收Intent
。在onStartCommand()
中,服务可以做任何它需要做的事情,但是因为onStartCommand()
是在主应用线程上被调用的,所以它应该很快完成它的工作。任何可能需要一段时间的事情都应该委托给后台线程。
onStartCommand()
方法可以返回几个值中的一个,主要是向 Android 指示如果服务的进程在运行时被终止会发生什么。最有可能的返回值如下:
START_STICKY
:服务应该被移回启动状态(就像onStartCommand()
被调用一样),但是Intent
不应该被重新交付给onStartCommand()
START_REDELIVER_INTENT
:应该通过调用onStartCommand()
来重启服务,提供与这次交付的相同的Intent
START_NOT_STICKY
:服务应该保持停止状态,直到被应用代码明确启动
默认情况下,调用startService()
不仅发送命令,还告诉 Android 保持服务运行,直到有东西告诉它停止。停止服务的一种方法是调用stopService()
,提供与startService()
使用的相同的Intent
,或者至少是等效的(例如,标识相同的类)。此时,服务将停止并被销毁。注意,stopService()
没有使用任何类型的引用计数,所以对startService()
的三次调用将导致一个服务运行,这个服务将被对stopService()
的调用停止。
停止服务的另一种可能性是让服务自己调用stopSelf()
。如果您使用startService()
让一个服务开始运行并在后台线程上做一些工作,然后让服务在后台工作完成时自行停止,您可能会这样做。
与 bindService()绑定
绑定允许服务向绑定到它的活动(或其他服务)公开 API。当一个活动(或其他客户端)绑定到一个服务时,它主要是请求能够通过服务的“绑定器”访问由该服务公开的公共 API,正如服务的onBind()
方法所返回的那样。当这样做时,活动还可以通过BIND_AUTO_CREATE
标志指示 Android 自动启动服务,如果它还没有运行的话。
服务的绑定器通常是Binder
的一个子类,你可以在上面放置任何你想向客户公开的方法。对于本地服务,您可以拥有任意多的方法,无论方法签名是什么(参数、返回类型等等)。)那你要的。该服务返回onBind()
中Binder
子类的一个实例。
客户端调用bindService()
,提供标识服务的Intent
、表示绑定客户端的ServiceConnection
对象和可选的BIND_AUTO_CREATE
标志。与startService()
一样,bindService()
是异步的。在用onServiceConnected()
调用ServiceConnection
对象之前,客户端不会知道任何关于绑定状态的信息。这不仅表明绑定已经建立,而且对于本地服务,它提供了服务通过onBind()
返回的Binder
对象。此时,客户机可以使用Binder
请求服务代表它完成工作。注意,如果服务还没有运行,并且您提供了BIND_AUTO_CREATE
,那么在绑定到客户端之前,服务将首先被创建。如果跳过BIND_AUTO_CREATE
,则bindService()
将返回false
,表明没有要绑定的现有服务。
最终,客户端将需要调用unbindService()
,以表明它不再需要与服务通信。例如,一个活动可能在其onCreate()
方法中调用bindService()
,然后在其onDestroy()
方法中调用unbindService()
。对unbindService()
的调用最终会触发对ServiceConnection
对象调用onServiceDisconnected()
——此时,客户端不再能够安全地使用Binder
对象。
如果该服务没有其他绑定客户端,Android 也会关闭该服务,释放其内存。因此,我们不需要自己调用stopService()
——如果需要,Android 会处理它,作为解除绑定的副作用。Android 4.0 还为bindService()
引入了一个额外的可能参数,叫做BIND_ALLOW_OOM_MANAGEMENT
。熟悉 OOM 的人都知道它是 out of memory 的缩写,许多操作系统使用“OOM 杀手”来选择进程进行销毁,以避免完全耗尽内存。使用BIND_ALLOW_OOM_MANAGEMENT
绑定到服务表明您认为您的应用及其绑定的服务是非关键的,允许在出现低内存问题时更积极地考虑杀死它和停止相关的服务。
如果客户端是一个活动,那么需要采取两个重要步骤来确保绑定在配置更改(如屏幕旋转)后仍然有效:
不要在活动本身上调用bindService()
,而是在ApplicationContext
(通过getApplicationContext()
获得)上调用bindService()
。
确保ServiceConnection
从活动的旧实例转移到新实例,可能是通过onRetainNonConfigurationInstance()
。
这允许绑定在活动实例之间持续。
从服务中交流
当然,上一节中列出的方法只适用于调用服务的客户机。反过来也是经常需要的,因此服务可以让活动或某些东西知道异步事件。
回调/监听器对象
活动或其他服务客户端可以向服务提供某种回调或侦听器对象,然后服务可以在需要时调用这些对象。要实现这一点,您需要执行以下操作:
为监听器对象定义一个 Java 接口。
给服务一个公共 API 来注册和收回监听器。
让服务在适当的时候使用这些侦听器,通知那些注册了侦听器的人一些事件。
让活动注册并根据需要收回侦听器。
让活动以某种适当的方式响应基于侦听器的事件。
最大的问题是确保活动完成后收回侦听器。侦听器对象通常明确地(通过数据成员)或隐含地(通过实现为内部类)知道它们的活动。如果服务持有失效的监听器对象,相应的活动将在内存中逗留,即使 Android 不再使用这些活动。这代表了一个大的内存泄漏。您可能希望使用WeakReference
s、SoftReference
s 或类似的构造来确保如果一个活动被销毁,它向您的服务注册的任何侦听器都不会将该活动保存在内存中。
广播意图
第二十一章中第一次提到的另一种方法是让服务发送一个广播Intent
,这个广播可以被活动接收到...假设活动仍然存在并且没有暂停。该服务可以调用sendBroadcast()
,提供一个Intent
来识别广播,设计为由BroadcastReceiver
接收。如果在清单中注册了BroadcastReceiver
,这可以是特定于组件的广播(例如new Intent(this, MyReceiver.class)
)。或者,它可以基于某个动作字符串,甚至可能是一个为第三方应用监听而记录和设计的字符串。
反过来,活动可以通过registerReceiver()
注册一个BroadcastReceiver
,尽管这种方法只适用于指定某些动作的Intent
对象,而不适用于标识特定组件的对象。但是,当活动的BroadcastReceiver
接收到广播时,它可以通知用户或者更新自己。
待定结果
您的活动可以调用createPendingResult()
。这将返回一个PendingIntent
,一个表示一个Intent
的对象,以及要在那个Intent
上执行的相应动作(例如,用它来启动一个活动)。在这种情况下,PendingIntent
将导致一个结果被交付到您的活动的实现onActivityResult()
,就好像另一个活动被startActivityForResult()
调用,然后被调用setResult()
发送回一个结果。
由于一个PendingIntent
是Parcelable
,因此可以放入一个额外的Intent
,您的活动可以将这个PendingIntent
传递给服务。反过来,服务可以在PendingIntent
上调用几种send()
方法中的一种,通知活动(通过onActivityResult()
)一个事件,甚至可能提供表示该事件的数据(以Intent
的形式)。
信使
还有一种可能是使用一个Messenger
对象。一个Messenger
向一个活动的Handler
发送消息。在一个单独的活动中,Handler
可以用来给自己发送消息,如第二十章中的所示。然而,在组件之间——比如在活动和服务之间——您将需要一个Messenger
作为桥梁。
与PendingIntent
一样,Messenger
是Parcelable
,因此可以放入一个额外的Intent
。调用startService()
或bindService()
的活动会在Intent
上附加一个Messenger
作为额外的。服务将从Intent
中获得Messenger
。当需要提醒某个事件的活动时,该服务将执行以下操作:
调用Message.obtain()
来获得一个空的Message
对象。
根据需要用服务希望传递给活动的任何数据填充那个Message
对象。
在Messenger
上调用send()
,提供Message
作为参数。
然后,Handler
将通过主应用线程上的handleMessage()
接收消息,从而能够更新 UI 或做任何必要的事情。
通知
另一种方法是让服务让用户直接知道已经完成的工作。要做到这一点,服务可以引发一个Notification
——在状态栏中放置一个图标,并可选地摇动、发出蜂鸣声或给出一些其他信号。这项技术在第三十七章中有所介绍。
三十六、基本服务模式
既然您已经看到了组成服务及其客户端的各个部分,那么让我们来研究几个使用服务的场景以及如何实现这些场景。
下载器
如果您选择从 Android Market 下载一些东西,在下载开始后,您可以自由地完全退出 Market 应用。这并不会取消下载——尽管屏幕上没有显示任何 Android Market 活动,下载和安装仍会运行至完成。
在您的应用中可能会有类似的场景。也许您希望用户能够下载购买的电子书、下载游戏地图、从“投件箱”文件共享服务下载文件,或者下载其他类型的资料,并且您希望允许他们在后台下载时退出应用。
Android 2.3 引入了DownloadManager
(包含在第三十四章中),它可以为你处理那个功能。然而,至少到 2011 年,你可能需要在旧版本的 Android 上使用这种功能。因此,本节将介绍一个下载器,您可以将它集成到您的应用中,以支持早期版本的 Android。本节评审的样本项目为Services/Downloader
。
设计
这种情况非常适合使用命令模式和IntentService
。IntentService
有一个后台线程,所以下载需要多长时间都可以。一个IntentService
会在工作完成后自动关闭,所以服务不会停滞,你也不需要担心自己会关闭它。您的活动可以简单地通过startService()
向IntentService
发送一个命令,告诉它开始工作。
不可否认,当您想让活动知道下载何时完成时,事情变得有点棘手。这个例子将展示如何使用Messenger
来达到这个目的。
服务实现
下面是这个IntentService
的实现,命名为Downloader
:
`package com.commonsware.android.downloader;
import android.app.Activity;
import android.app.IntentService;
import android.content.Intent;
import android.os.Bundle;
import android.os.Environment;
import android.os.Message;
import android.os.Messenger;
import android.util.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicResponseHandler;
import org.apache.http.impl.client.DefaultHttpClient;
public class Downloader extends IntentService {
public static final String
EXTRA_MESSENGER="com.commonsware.android.downloader.EXTRA_MESSENGER";
private HttpClient client=null;
public Downloader () {
super("Downloader");
}
@Override
public void onCreate () {
super.onCreate ();
client=new DefaultHttpClient ();
}
@Override
public void onDestroy () {
super.onDestroy ();
client.getConnectionManager ().shutdown ();
}
@Override
public void onHandleIntent (Intent i) {
HttpGet getMethod=new HttpGet(i.getData ().toString ());
int result=Activity.RESULT_CANCELED;
try {
ResponseHandler<byte[]> responseHandler=new ByteArrayResponseHandler ();
byte[] responseBody=client.execute (getMethod, responseHandler);
File output=new File (Environment.getExternalStorageDirectory (),
i.getData ().getLastPathSegment ());
if (output.exists ()) {
output.delete ();
}
FileOutputStream fos=new FileOutputStream (output.getPath ());
fos.write (responseBody);
fos.close ();
result=Activity.RESULT_OK;
}
catch (IOException e2) {
Log.e(getClass ().getName (), "Exception in download", e2);
}
Bundle extras=i.getExtras ();
if (extras!=null) {
Messenger messenger=(Messenger)extras.get (EXTRA_MESSENGER);
Message msg=Message.obtain ();
msg.arg1=result;
try {
messenger.send (msg);
}
catch (android.os.RemoteException e1) {
Log.w (getClass ().getName (), "Exception sending message", e1);
}
}
}
}`
在onCreate()
中,我们获得一个DefaultHttpClient
对象,如第三十四章所述。在onDestroy()
,我们关闭了客户端。这样,如果按顺序调用几个下载请求,我们可以使用单个DefaultHttpClient
对象。只有在所有排队的工作完成后,IntentService
才会关闭。
大部分工作在onHandleIntent()
中完成,每次调用startService()
时,在后台线程上的IntentService
中调用。对于Intent
,我们通过调用所提供的Intent
上的getData()
来获取要下载的文件的 URL。实际上下载文件使用了DefaultHttpClient
对象和HttpGet
对象。然而,由于文件可能是二进制的(例如 MP3)而不是文本,我们不能使用BasicResponseHandler
。相反,我们使用一个ByteArrayResponseHandler
,它是从BasicResponseHandler
的源代码中克隆的自定义ResponseHandler
,但是它返回一个byte[]
而不是一个String
:
`package com.commonsware.android.downloader;
import java.io.IOException;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.HttpResponseException;
import org.apache.http.util.EntityUtils;
public class ByteArrayResponseHandler implements ResponseHandler<byte[]> {
public byte[] handleResponse (final HttpResponse response)
throws IOException, HttpResponseException {
StatusLine statusLine=response.getStatusLine ();
if (statusLine.getStatusCode ()>=300) {
throw new HttpResponseException (statusLine.getStatusCode (),
statusLine.getReasonPhrase ());
}
HttpEntity entity=response.getEntity ();
if (entity==null) {
return(null);
}
return(EntityUtils.toByteArray (entity));
}
}`
一旦文件被下载到外部存储器,我们需要提醒活动工作已经完成。如果活动对这类消息感兴趣,它会将一个Messenger
对象作为EXTRA_MESSENGER
附加到Intent
上。Downloader
获取Messenger
,创建一个空的Message
对象,并将结果代码放入Message
的arg1
字段。然后,它将Message
发送给活动。如果活动在此之前被销毁,发送消息的请求将失败,并显示一个RemoteObjectException
。
由于这是一个IntentService
,如果没有更多工作等待完成,它将在onHandleIntent()
完成时自动关闭。
使用服务
演示Downloader
用法的活动有一个简单的 UI,由一个大按钮组成:
<?xml version="1.0" encoding="utf-8"?> <Button xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/button" android:layout_width="fill_parent" android:layout_height="fill_parent" android:text="Do the Download" android:onClick="doTheDownload" />
该 UI 通常在onCreate()
中初始化:
`@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
b=(Button)findViewById (R.id.button);
}`
当用户点击按钮时,调用doTheDownload()
禁用按钮(防止意外重复下载)并调用startService()
:
`public void doTheDownload (View v) {
b.setEnabled (false);
Intent i=new Intent (this, Downloader.class);
i.setData (Uri.parse ("http://commonsware.com/Android/excerpt.pdf "));
i.putExtra (Downloader.EXTRA_MESSENGER, new Messenger (handler));
startService (i);
}`
在这里,我们忽略的Intent
有要下载的文件的 URL(在这种情况下,是指向 PDF 的 URL),外加额外的EXTRA_MESSENGER
中的Messenger
。该Messenger
是通过活动的Handler
的附件创建的:
`private Handler handler=new Handler () {
@Override
public void handleMessage (Message msg) {
b.setEnabled (true);
Toast
.makeText (DownloaderDemo.this, "Download complete!",
Toast.LENGTH_LONG)
.show ();
}
};`
如果下载完成时活动仍在,则Handler
启用按钮并显示一个Toast
让用户知道下载已完成。请注意,该活动忽略了服务提供的结果代码,尽管原则上它可以在成功和失败的情况下做一些不同的事情。
音乐播放器
Android 中的大多数音频播放器应用——音乐、有声读物或其他——都不需要用户留在播放器应用中来保持它的运行。相反,用户可以继续用他们的设备做其他事情,在背景中播放音频。这在许多方面与上一节中的下载场景相似。然而,在这种情况下,用户 是控制工作(播放音频)何时结束的人。
本节评审的样本项目为Services/FakePlayer
。
设计
我们将再次使用startService()
,因为我们希望服务即使在启动它的活动被销毁后也能运行。然而,这一次我们将使用常规的而不是IntentService
。一个IntentService
被设计用来工作和停止自己,然而在这种情况下,我们希望用户能够停止音乐播放。
由于音乐播放超出了本书的范围,该服务将简单地剔除那些特定的操作。
服务实现
下面是这个Service
的实现,命名为PlayerService
:
`package com.commonsware.android.fakeplayer;
import android.app.Service;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
public class PlayerService extends Service {
public static final String EXTRA_PLAYLIST="EXTRA_PLAYLIST";
public static final String EXTRA_SHUFFLE="EXTRA_SHUFFLE";
private boolean isPlaying=false;
@Override
public int onStartCommand (Intent intent, int flags, int startId) {
String playlist=intent.getStringExtra (EXTRA_PLAYLIST);
boolean useShuffle=intent.getBooleanExtra (EXTRA_SHUFFLE, false);
play (playlist, useShuffle);
return(START_NOT_STICKY);
}
@Override
public void onDestroy () {
stop ();
}
@Override
public IBinder onBind (Intent intent) {
return(null);
}
private void play (String playlist, boolean useShuffle) {
if (!isPlaying) {
Log.w (getClass ().getName (), "Got to play()!");
isPlaying=true;
}
}
private void stop () {
if (isPlaying) {
Log.w (getClass ().getName (), "Got to stop()!");
isPlaying=false;
}
}
}`
在这种情况下,我们真的不需要onCreate()
的任何东西,所以我们跳过生命周期方法。另一方面,我们必须实现onBind()
,因为那是Service
子类的必需方法。IntentService
为我们实现了onBind()
,这就是为什么Downloader
示例不需要它。
当客户端调用startService()
时,在PlayerService
中调用onStartCommand()
。在这里,我们得到了Intent
,并挑选出一些额外的东西来告诉我们回放什么(EXTRA_PLAYLIST
)和其他配置细节(例如EXTRA_SHUFFLE
)。onStartCommand()
调用play()
,它简单地标记我们的播放器正在播放,并向 LogCat 记录一条消息——一个真正的音乐播放器会使用MediaPlayer
开始播放播放列表中的第一首歌曲。onStartCommand()
返回START_NOT_STICKY
,表示如果 Android 必须终止该服务(例如,由于内存不足),则在条件改善后不应重启。
onDestroy()
通过调用stop()
方法停止播放音乐(理论上是这样的)。同样,这只是向 LogCat 记录一条消息,并更新我们内部的“我们正在玩”标志。
在讨论通知的第三十七章中,我们将再次讨论这个示例并讨论startForeground()
的使用,它使用户更容易回到音乐播放器,并让 Android 知道该服务正在提供部分前台体验,因此不应被关闭。
使用服务
演示PlayerService
用法的FakePlayer
活动的 UI 比上一个示例中的 UI 更复杂,它由两个大按钮组成:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <Button android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="Start the Player" android:onClick="startPlayer" /> <Button android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_weight="1" android:text="Stop the Player" android:onClick="stopPlayer" /> </LinearLayout>
活动本身并不复杂:
`package com.commonsware.android.fakeplayer;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
public class FakePlayer extends Activity {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
}
public void startPlayer (View v) {
Intent i=new Intent (this, PlayerService.class);
i.putExtra (PlayerService.EXTRA_PLAYLIST, "main");
i.putExtra (PlayerService.EXTRA_SHUFFLE, true);
startService (i);
}
public void stopPlayer (View v) {
stopService (new Intent (this, PlayerService.class));
}
}`
onCreate()
方法只是加载 UI。startPlayer()
方法用假值为EXTRA_PLAYLIST
和EXTRA_SHUFFLE
构造一个Intent
,然后调用startService()
。单击顶部按钮后,您将在 LogCat 中看到相应的消息。类似地,stopPlayer()
调用stopService()
,触发第二个 LogCat 消息。值得注意的是,您不需要在这些按钮点击之间保持活动运行—您可以通过按 BACK 退出活动,稍后再回来停止服务。
Web 服务接口
如果您打算使用 REST 风格的 web 服务,您可能希望为该服务创建一个 Java 客户端 API。这允许您隔离关于 web 服务的细节(URL、授权凭证等。)放在一个地方,使应用的其余部分只使用发布的 API。如果客户端 API 可能涉及状态,比如会话 ID 或缓存的结果,您可能希望使用服务来实现客户端 API。在这种情况下,最自然的服务形式是发布一个Binder
,这样客户就可以调用一个“真正的”API,服务将它转换成 HTTP 请求。
在这种情况下,我们希望为国家气象局的预测 web 服务创建一个客户端 Java API,这样我们就可以获得给定纬度和经度(针对美国的地理位置)的天气预测(时间戳、预计温度和预计降雨量)。您可能还记得,我们在第三十四章中研究了这个 web 服务。
本节评审的样本项目为Services/WeatherAPI
。
设计
要使用绑定模式,我们需要从“binder”对象中公开一个 API。由于天气预报以一种非常糟糕的 XML 结构到达,我们将让绑定器负责解析 XML。因此,绑定器将有一个getForecast()
方法来获取一个Forecast
对象的ArrayList
,每个Forecast
代表一个时间戳/温度/降雨量三元组。
同样,为了提供要检索的预测名单的纬度和经度,我们将使用一个Location
对象,它将从 GPS 获得。这部分样品将在第三十九章中更详细地描述。
因为 web 服务调用可能需要一段时间,所以在主应用线程上这样做是不安全的。在这个示例中,我们将让服务使用一个AsyncTask
来调用我们的天气 API,因此活动很大程度上可以忽略线程问题。
轮换挑战
第二十章指出了活动中涉及方向变更(或其他配置变更)和后台线程的问题。给出的解决方案是将onRetainNonConfigurationInstance()
与静态内部类AsyncTask
实现一起使用,并手动将其与新的配置更改后的活动相关联。
绑定模式也会出现同样的问题,这也是绑定难以使用的原因之一。如果我们从一个活动绑定到一个服务,在方向改变后,这个绑定不会神奇地传递到新的活动实例。相反,我们需要做两件事:
绑定到服务不是通过使用活动作为Context
,而是通过使用getApplicationContext()
,因为Context
是一个将在我们的流程的生命周期中存在的服务
作为配置更改的一部分,将表示此绑定的ServiceConnection
从旧的活动实例传递到新的活动实例
为了完成第二个壮举,我们将需要使用我们在第二十章中使用的相同的onRetainNonConfigurationInstance()
技巧。
服务实现
我们的服务端逻辑分为三个类,Forecast
、WeatherBinder
和WeatherService
,外加一个接口,WeatherListener
。
天气预报
Forecast
类仅仅封装了三段预测数据——时间戳、温度和指示预期降雨量的图标(如果有):
`package com.commonsware.android.weather;
class Forecast {
String time="";
Integer temp=null;
String iconUrl="";
String getTime () {
return(time);
}
void setTime (String time) {
this.time=time.substring (0,16).replace ('T', ' ');
}
Integer getTemp () {
return(temp);
}
void setTemp (Integer temp) {
this.temp=temp;
}
String getIcon () {
return(iconUrl);
}
void setIcon (String iconUrl) {
this.iconUrl=iconUrl;
}
}`
界面
因为我们将在服务的后台线程上获取实际的天气预报,所以我们有一个小小的 API 挑战——对我们的绑定器的调用是同步的。因此,我们不能有一个返回我们预测的getForecast()
方法。相反,我们需要为服务提供一些方法来将预测返回给我们的活动。在这种情况下,我们将传入一个侦听器对象(WeatherListener
),服务将在预测就绪时使用该对象:
`package com.commonsware.android.weather;
import java.util.ArrayList;
public interface WeatherListener {
void updateForecast (ArrayList forecast);
void handleError (Exception e);
}`
活页夹
WeatherBinder
扩展了Binder
,这是本地绑定模式的一个要求。除此之外,API 由我们决定。
因此,我们公开了三种方法:
onCreate()
:当WeatherBinder
被设置时被调用,这样我们可以得到一个DefaultHttpClient
对象用于 web 服务
onDestroy()
:当不再需要WeatherBinder
时被调用,这样我们可以关闭那个DefaultHttpClient
对象
getForecast()
:我们的活动使用的主要公共 API,当给定一个Location
时,启动后台工作来创建我们的Forecast
对象的ArrayList
`package com.commonsware.android.weather;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.location.Location;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Bundle;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicResponseHandler;
import org.apache.http.impl.client.DefaultHttpClient;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
public class WeatherBinder extends Binder {
private String forecast=null;
private HttpClient client=null;
private String format=null;
void onCreate (Context ctxt) {
client=new DefaultHttpClient ();
format=ctxt.getString (R.string.url);
}
void onDestroy () {
client.getConnectionManager ().shutdown ();
}
void getForecast (Location loc, WeatherListener listener) {
new FetchForecastTask (listener).execute (loc);
}
private ArrayList buildForecasts (String raw) throws Exception {
ArrayList forecasts=new ArrayList();
DocumentBuilder builder=DocumentBuilderFactory
.newInstance ()
.newDocumentBuilder ();
Document doc=builder.parse (new InputSource (new StringReader (raw)));
NodeList times=doc.getElementsByTagName ("start-valid-time");
for (int i=0;i<times.getLength ();i++) {
Element time=(Element)times.item (i);
Forecast forecast=new Forecast ();
forecasts.add (forecast);
forecast.setTime (time.getFirstChild().getNodeValue ());
}
NodeList temps=doc.getElementsByTagName ("value");
for (int i=0;i<temps.getLength ();i++) {
Element temp=(Element)temps.item (i);
Forecast forecast=forecasts.get (i);
forecast.setTemp (new Integer (temp.getFirstChild ().getNodeValue ()));
}
NodeList icons=doc.getElementsByTagName ("icon-link");
for (int i=0;i<icons.getLength ();i++) {
Element icon=(Element)icons.item (i);
Forecast forecast=forecasts.get (i);
forecast.setIcon (icon.getFirstChild ().getNodeValue ());
}
return(forecasts);
}
class FetchForecastTask extends AsyncTask<Location, Void, ArrayList> {
Exception e=null;
WeatherListener listener=null;
FetchForecastTask (WeatherListener listener) {
this.listener=listener;
}
@Override
protected ArrayListdoInBackground (Location... locs) {
ArrayList result=null;
try {
Location loc=locs[0];
String url=String.format (format, loc.getLatitude (),
loc.getLongitude ());
HttpGet getMethod=new HttpGet (url);
ResponseHandler responseHandler=new BasicResponseHandler ();
String responseBody=client.execute (getMethod, responseHandler);
result=buildForecasts (responseBody);
}
catch (Exception e) {
this.e=e;
}
return(result);
}
@Override
protected void onPostExecute (ArrayList forecast) {
if (listener!=null) {
if (forecast!=null) {
listener.updateForecast (forecast);
}
if (e!=null) {
listener.handleError (e);
}
}
}
}
}`
大部分代码仅仅是使用DefaultHttpClient
和HttpGet
对象执行 web 服务请求,并使用 DOM 解析器将 XML 转换成Forecast
对象。然而,这被包装在一个FetchForecastTask
中,一个AsyncTask
,它将在后台线程上执行 HTTP 操作和解析。在onPostExecute()
中,任务调用我们的WeatherListener
,要么提供预测(updateForecast()
),要么移交一个被提出的Exception
(handleError()
)。
服务
WeatherService
相当短,业务逻辑委托给WeatherBinder
:
`package com.commonsware.android.weather;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
import java.util.ArrayList;
public class WeatherService extends Service {
private final WeatherBinder binder=new WeatherBinder ();
@Override
public void onCreate () {
super.onCreate ();
binder.onCreate (this);
}
@Override
public IBinder onBind (Intent intent) {
return(binder);
}
@Override
public void onDestroy () {
super.onDestroy ();
binder.onDestroy ();
}
}`
我们的onCreate()
和onDestroy()
方法委托给了WeatherBinder
,而onBind()
返回了WeatherBinder
本身。
使用服务
从表面上看,WeatherDemo
活动应该很简单:
绑定到onCreate()
中的服务
安排获取 GPS 定位,以Location
对象的形式
当出现修正时,使用WeatherBinder
获得预测,将其转换成 HTML,并显示在WebView
中
解除与onDestroy()
中服务的绑定
然而,我们决定使用绑定模式并让活动处理后台线程,这意味着需要做的工作比那些要点更多。
首先,这里是完整的WeatherDemo
实现:
`package com.commonsware.android.weather;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.DeadObjectException;
import android.os.RemoteException;
import android.os.IBinder;
import android.util.Log;
import android.webkit.WebView;
import java.util.ArrayList;
public class WeatherDemo extends Activity {
private WebView browser;
private LocationManager mgr=null;
private State state=null;
private boolean isConfigurationChanging=false;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
browser=(WebView)findViewById (R.id.webkit);
state=(State)getLastNonConfigurationInstance ();
if (state==null) {
state=new State ();
getApplicationContext ()
.bindService (new Intent (this, WeatherService.class),
state.svcConn, BIND_AUTO_CREATE);
}
else if (state.lastForecast!=null) {
showForecast ();
}
state.attach (this);
mgr=(LocationManager)getSystemService (LOCATION_SERVICE);
mgr.requestLocationUpdates (LocationManager.GPS_PROVIDER,
3600000, 1000, onLocationChange);
}
@Override
public void onDestroy () {
super.onDestroy ();
if (mgr!=null) {
mgr.removeUpdates (onLocationChange);
}
if (!isConfigurationChanging) {
getApplicationContext ().unbindService (state.svcConn);
}
}
@Override
public Object onRetainNonConfigurationInstance () {
isConfigurationChanging=true;
return(state);
}
private void goBlooey (Throwable t) {
AlertDialog.Builder builder=new AlertDialog.Builder (this);
builder
.setTitle ("Exception!")
.setMessage (t.toString ())
.setPositiveButton ("OK", null)
.show ();
}
static String generatePage (ArrayList forecasts) {
StringBuilder bufResult=new StringBuilder ("
");
bufResult.append ("
<th width="50%">Time"+
"");
for (Forecast forecast : forecasts) {
bufResult.append ("
<td align="center">");
bufResult.append (forecast.getTime ());
bufResult.append ("<td align="center">");
bufResult.append (forecast.getTemp ());
bufResult.append ("");
}
bufResult.append ("
Temperature Forecast
<img src="");
bufResult.append (forecast.getIcon ());
bufResult.append ("">
");
return(bufResult.toString ());
}
void showForecast () {
browser.loadDataWithBaseURL (null, state.lastForecast,
"text/html", "UTF-8", null);
}
LocationListener onLocationChange=new LocationListener () {
public void onLocationChanged (Location location) {
if (state.weather!=null) {
state.weather.getForecast (location, state);
}
else {
Log.w (getClass ().getName (), "Unable to fetch forecast – no WeatherBinder");
}
}
public void onProviderDisabled (String provider) {
// required for interface, not used
}
public void onProviderEnabled (String provider) {
// required for interface, not used
}
public void onStatusChanged (String provider, int status,
Bundle extras) {
// required for interface, not used
}
};
static class State implements WeatherListener {
WeatherBinder weather=null;
WeatherDemo activity=null;
String lastForecast=null;
void attach (WeatherDemo activity) {
this.activity=activity;
}
public void updateForecast (ArrayList forecast) {
lastForecast=generatePage (forecast);
activity.showForecast ();
}
public void handleError (Exception e) {
activity.goBlooey (e);
}
ServiceConnection svcConn=new ServiceConnection () {
public void onServiceConnected (ComponentName className,
IBinder rawBinder) {
weather=(WeatherBinder)rawBinder;
}
public void onServiceDisconnected (ComponentName className) {
weather=null;
}
};
}
}`
现在,让我们看看服务连接和后台线程的亮点。
管理国家
我们需要确保我们的ServiceConnection
可以在配置变更的活动实例之间传递。因此,我们有一个State
静态内部类来保存它,加上另外两个信息:与状态相关联的Activity
,以及一个显示我们检索到的上一次预测的String
:
`static class State implements WeatherListener {
WeatherBinder weather=null;
WeatherDemo activity=null;
String lastForecast=null;
void attach (WeatherDemo activity) {
this.activity=activity;
}
public void updateForecast (ArrayList forecast) {
lastForecast=generatePage (forecast);
activity.showForecast ();
}
public void handleError (Exception e) {
activity.goBlooey (e);
}
ServiceConnection svcConn=new ServiceConnection () {
public void onServiceConnected (ComponentName className,
IBinder rawBinder) {
weather=(WeatherBinder)rawBinder;
}
public void onServiceDisconnected (ComponentName className) {
weather=null;
}
};
}`
lastForecastString
允许我们在配置更改后重新显示生成的 HTML。否则,当用户旋转屏幕时,我们将丢失我们的预测(仅保存在旧实例的WebView
中),并且将不得不检索一个新的或者等待 GPS 定位。
我们从onRetainNonConfigurationInstance()
返回这个State
对象:
`@Override
public Object onRetainNonConfigurationInstance () {
isConfigurationChanging=true;
return(state);
}`
在onCreate()
中,如果没有非配置实例,我们创建一个新的State
并绑定到服务,因为我们目前没有服务连接。另一方面,如果onCreate()
从getLastNonConfigurationInstance()
得到一个State
,它简单地保持那个状态并在WebView
重新加载我们的预测。在任一情况下,onCreate()
向State
表明新的活动实例是当前的活动实例:
`@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
browser=(WebView)findViewById (R.id.webkit);
state=(State)getLastNonConfigurationInstance ();
if (state==null) {
state=new State ();
getApplicationContext ()
.bindService (new Intent (this, WeatherService.class),
state.svcConn, BIND_AUTO_CREATE);
}
else if (state.lastForecast!=null) {
showForecast ();
}
state.attach (this);
mgr=(LocationManager)getSystemService (LOCATION_SERVICE);
mgr.requestLocationUpdates (LocationManager.GPS_PROVIDER,
3600000, 1000, onLocationChange);
}`
根据天气预报的主观重要性,您可能会认为您的应用及其服务不一定是设备上运行的最重要的东西。回想一下,前一章强调了bindService()
的BIND_ALLOW_OOM_MANAGEMENT
参数,这是 Android 4.0 的新参数。天气预报可能没有保持电话通话那么重要,所以我们可以选择修改前面的onCreate()
方法中的bindService()
调用,以便在内存不足的情况下自愿回收 OOM 内存(并销毁进程):
bindService(new **Intent**(this, WeatherService.class), state.svcConn, BIND_AUTO_CREATE | BIND_ALLOW_OOM_MANAGEMENT );
这并不影响我们其余的逻辑。
是时候解开束缚了
当调用onCreate()
时,如果服务没有通过getLastNonConfigurationInstance()
接收到State
(在这种情况下,我们已经被绑定了),我们就绑定到服务。这就引出了一个问题:我们什么时候解除与服务的绑定?
我们希望在活动被销毁时解除绑定,但如果活动由于配置更改而被销毁,则不需要解除绑定。
不幸的是,从onDestroy()
开始就没有内置的方法来做出决定。有一个isFinishing()
方法,我们可以调用一个Activity
,如果活动永久结束,它将返回true
,否则返回false
。对于配置更改,它会返回false
,但如果活动被销毁以释放 RAM,并且用户可以通过 Back 按钮返回,它也会返回false
。
这就是为什么onRetainNonConfigurationInstance()
将WeatherDemo
中的一个isConfigurationChanging
标志翻转为true
。那面旗最初是false
。然后,我们检查该标志,看是否应该从服务中解除绑定:
`@Override
public void onDestroy () {
super.onDestroy();
if (mgr!=null) {
mgr.removeUpdates (onLocationChange);
}
if (!isConfigurationChanging) {
getApplicationContext ().unbindService (state.svcConn);
}
}`
三十七、通过通知提醒用户
弹出消息、托盘图标及其相关的“气泡”消息、跳跃的停靠图标……毫无疑问,你已经习惯于程序试图引起你的注意,有时是有充分理由的。除了来电,你的手机可能还会发出啁啾声:低电量、闹钟、约会通知、收到短信和电子邮件等等。
毫不奇怪,Android 有一个完整的框架来处理这类事情,统称为通知 ,如本章所述。
通知配置
在后台运行的服务需要一种方式来让用户知道发生了感兴趣的事情,比如何时收到了电子邮件。此外,该服务可能需要某种方式将用户引导到他们可以对事件采取行动的活动,比如阅读接收到的消息。为此,Android 提供了状态栏图标、闪光灯和其他指示器,统称为通知。
您当前的手机可能已经有这样的图标,以指示电池寿命、信号强度、蓝牙是否启用等。使用 Android,应用可以添加自己的状态栏图标,只在需要的时候才会出现(例如,有消息到达)。
在 Android 中,你可以通过系统服务NotificationManager
发出通知。要使用它,您需要通过getSystemService(NOTIFICATION_SERVICE)
从活动中获取服务对象。NotificationManager
给你三个方法:一个养一个Notification
( notify()
,两个去掉一个现有的Notification
( cancel()
、cancelAll()
)。
notify()
方法接受一个Notification
,这是一个数据结构,说明你的纠缠应该采取什么形式。以下部分描述了该对象的功能。
硬件通知
您可以通过将lights
设置为true
来闪烁设备上的 led,还可以指定颜色(作为ledARGB
中的#ARGB
值)和灯应该闪烁的模式(通过ledOnMS
和ledOffMS
提供灯的关闭/打开持续时间,单位为毫秒)。然而,请注意,Android 设备将尽最大努力满足您的颜色请求,这意味着不同的设备可能会给你不同的颜色,或者可能根本无法控制颜色。例如,摩托罗拉 CLIQ 只有一个白色的 LED,所以你可以要求任何你想要的颜色,你会得到白色。请注意,您需要将Notification.FLAG_SHOW_LIGHTS
值或(|
)到Notification
对象上的公共flags
域中,以使 LED 闪烁工作。
你可以使用一个Uri
来播放一段内容,这段内容可能是由ContentManager
( sound
)保存的。请将此视为您的应用的铃声。
您可以通过long[]
控制振动设备,指示振动(vibrate
)的开/关模式(毫秒)。你可以默认这样做,或者当情况需要比铃声更微妙的通知时,你可以让用户选择这个选项。不过,要使用这个,你需要请求VIBRATE
许可(许可在第三十八章的中讨论)。
默认情况下,所有这些选项都会出现一次(例如,一次 LED 闪烁或一次声音回放)。如果您想让它们一直持续到Notification
被取消,您需要在您的Notification
中设置flags
公共字段来包含FLAG_INSISTENT
。
除了手动指定硬件选项,您还可以使用Notification
中的defaults
字段,将其设置为DEFAULT_LIGHTS
、DEFAULT_SOUND
、DEFAULT_VIBRATE
或DEFAULT_ALL
,这将使用所有硬件选项的平台默认值。
图标
虽然闪烁的灯光、声音和振动旨在让人们看到设备,但图标旨在让他们采取下一步行动,并告诉他们什么是如此重要。
要为一个Notification
设置一个图标,您需要设置两个公共字段:icon
,在这里您提供一个代表图标的Drawable
资源的标识符,以及contentIntent
,在这里您提供一个当图标被点击时将被引发的PendingIntent
。一个PendingIntent
是一个常规Intent
的包装器,它允许Intent
稍后被另一个进程调用,启动一个活动或其他什么。通常,一个Notification
将触发一个活动,在这种情况下,您将通过静态的getActivity()
方法创建PendingIntent
,并给它一个Intent
来标识您的一个活动。也就是说,你可以通过使用PendingIntent
的getBroadcast()
版本,让Notification
发送广播Intent
。Android 4.0 扩展了PendingIntent
可用的 send()
方法的种类,提供了大多数可以想象的情况。
您也可以提供一个文本格式回复,当图标被放在状态栏上时出现(tickerText
)。
如果您想要所有这三个,更简单的方法是调用setLatestEventInfo()
,它将所有这三个封装在一个调用中。
您还可以在您的Notification
的number
公共字段中设置一个值。这将导致你提供的数字被画在一角的icon
的上方。例如,这用于显示未读电子邮件的数量,这样您就不需要有一堆不同的图标,每个图标代表未读邮件的可能数量。默认情况下,number
字段将被忽略且不使用。
请注意,在 Android 2.3 中,Notification
图标的大小发生了变化。在那个版本之前,25 像素的正方形是理想的尺寸。现在,更矩形的每密度图标是首选:
24 像素正方形(在 24 像素宽、38 像素高的边界框内),用于高密度和超高密度屏幕
适用于中等密度屏幕的 16 像素正方形(在 16×25 像素边框内)
用于低密度屏幕的 12 像素正方形(在 12×19 像素边界框内)
遵循这些规则的应用需要为新图标使用特定的资源集:
res/drawable-xhdpi-v9/
:超高密度 Android 2.3 及以后版本
res/drawable-hdpi-v9/
:针对高密度 Android 2.3 及以后版本
res/drawable-mdpi-v9/
:适用于中密度 Android 2.3 及以后版本
res/drawable-ldpi-v9/
:低密度 Android 2.3 及以后版本
res/drawable/
:用于 Android 2.2 及更早版本的图标
关于包括状态栏图标在内的所有图标指南的更多细节可以在 Android 开发者文档中找到。
通知在行动
现在让我们看一下Notifications/Notify1
示例项目,特别是NotifyDemo
类:
`packagecom.commonsware.android.notify;
importandroid.app.Activity;
importandroid.app.Notification;
importandroid.app.NotificationManager;
importandroid.app.PendingIntent;
importandroid.content.Intent;
importandroid.os.Bundle;
importandroid.view.View;
public class NotifyDemo extends Activity {
private static final int NOTIFY_ME_ID=1337;
privateint count=0;
private NotificationManager mgr=null;
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
mgr=(NotificationManager)getSystemService (NOTIFICATION_SERVICE);
}
public void notifyMe (View v) {
Notification note=new Notification (R.drawable.stat_notify_chat,
"Status message!",
System.currentTimeMillis ());
PendingIntenti=PendingIntent.getActivity (this, 0,
newIntent (this, NotifyMessage.class),
0);
note.setLatestEventInfo (this, "Notification Title",
"This is the notification message", i);
note.number=++count;
note.vibrate=new long[] {500L, 200L, 200L, 500L};
note.flags|=Notification.FLAG_AUTO_CANCEL;
mgr.notify (NOTIFY_ME_ID, note);
}
public void clearNotification (View v) {
mgr.cancel(NOTIFY_ME_ID);
}
}`
如图 Figure 37–1 所示,该活动有两个大按钮,一个用于在 5 秒钟延迟后启动通知,另一个用于取消通知(如果它处于活动状态)。
图 37–1。 ??【notify demo】活动主视图
在notifyMe()
中,创建通知分七个步骤完成:
创建一个带有我们的图标的Notification
对象,一个当通知出现时在状态栏上闪烁的消息,以及与该事件相关的时间。
创建一个PendingIntent
,它将触发另一个活动(NotifyMessage
)的显示。
使用setLatestEventInfo()
来指定,当通知被点击时,我们将显示某个标题和消息,如果被点击,我们将启动PendingIntent
。
更新与通知相关的号码。
指定振动模式:500 毫秒开启,200 毫秒关闭,200 毫秒开启,500 毫秒关闭。
将FLAG_AUTO_CANCEL
包含在Notification
对象的flags
字段中。
告诉NotificationManager
(在onCreate()
中获得)显示通知。
因此,如果我们点击顶部的按钮,我们的图标将会出现在状态栏中,同时会简短显示我们的状态信息,如图 Figure 37–2 所示。
图 37–2。 我们的通知出现在状态栏上,上面有我们的状态消息
状态信息消失后,图标的右下角会叠加我们的编号(最初为 1),如图 Figure 37–3 所示。例如,您可以用它来表示未读邮件的数量。
图 37–3。 我们的通知加上了叠加号
如果您向下拖动图标,状态栏下方会出现一个抽屉。将该抽屉一直拖到屏幕底部,以显示未完成的通知,包括我们自己的通知,如 Figure 37–4 所示。
图 37–4。 的通知抽屉,完全展开,里面有我们的通知
如果您单击抽屉中的通知条目,您将被带到一个显示消息的小活动。在实际的应用中,这个活动会根据发生的事件做一些有用的事情(例如,将用户带到新到达的邮件消息)。
单击取消按钮、单击抽屉中的清除按钮或单击抽屉中的通知条目将从状态栏中移除图标。后者之所以发生,是因为我们在Notification
中包含了FLAG_AUTO_CANCEL
,这表明在抽屉条目上点击一下应该会取消Notification
本身。Android 4.0 的用户还可以选择“滑动清除”,他们可以简单地将单个通知滑动到屏幕的任意一侧,以消除或处理它们。这有助于用户处理多个活动通知,并希望在处理完一个特定通知后返回到剩余的通知。
待在前台
通知还有另一个用途:保留选定的服务。
服务不会永远存在。Android 可能会在紧急情况下终止应用的进程来释放内存,或者只是因为它似乎已经在内存中逗留了太久。理想情况下,您设计您的服务来处理它们可能不会无限期运行的事实。
然而,如果一些服务神秘消失,用户将会错过它们。例如,Android 自带的默认音乐播放器应用使用一个服务来播放音乐。这样,用户可以一边听音乐,一边继续将手机用于其他目的。只有当用户按下音乐播放器活动中的停止按钮时,服务才会停止。如果该服务意外关闭,用户会想知道出了什么问题。
像这样的服务可以宣称自己是前台 的一部分。这将导致它们的优先级上升,并使它们不太可能被挤出内存。代价是服务必须维护一个Notification
,所以用户知道这个服务要求前台的一部分。而且,理想情况下,Notification
应该提供一个简单的路径返回到某个用户可以停止服务的活动。
要做到这一点,在您的服务的onCreate()
(或者在服务生命周期中任何有意义的地方),调用startForeground()
。这需要一个Notification
和一个本地唯一的整数,就像NotificationManager
上的notify()
方法一样。它使Notification
出现,并将服务移动到前台优先级。稍后,您可以调用stopForeground()
返回正常优先级。
注意,这个方法是在 Android 2.0 (API level 5)中添加的。有一个更早的方法setForeground()
,在 Android 的早期版本中执行类似的功能。
fakeplicael,Redux
前一章介绍了服务模式,给出了一个假的音乐播放器,用一个Activity
( FakePlayer
)和一个Service
( PlayerService
)实现。实际上,PlayerService
是播放音乐的地方,所以即使FakePlayer
活动没有打开,音乐也可以播放。
然而,Android 可能不认为PlayerService
是用户体验的一部分,因为服务通常很少直接与用户互动。这意味着 Android 可能会以限制 CPU 使用的方式运行PlayerService
(不一定是坏的),如果它认为自己已经运行了太长时间(可能是坏的),可能会选择关闭服务。
答案是用startForeground()
和stopForeground()
。当我们用play()
方法开始播放音乐时,我们可以调用startForeground()
:
`private void play(String playlist, booleanuseShuffle) {
if (!isPlaying) {
Log.w (getClass ().getName (), "Got to play()!");
isPlaying=true;
Notification note=new Notification (R.drawable.stat_notify_chat,
"Can you hear the music?",
System.currentTimeMillis ());
Intent i=new Intent (this, FakePlayer.class);
i.setFlags (Intent.FLAG_ACTIVITY_CLEAR_TOP|
Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pi=PendingIntent.getActivity (this, 0,
i, 0);
note.setLatestEventInfo (this, "Fake Player",
"Now Playing: "Ummmm, Nothing"",
pi);
note.flags|=Notification.FLAG_NO_CLEAR;
startForeground(1337, note);
}
}`
有利的一面是,如果需要的话,我们的服务将会有更多的 CPU 可用,并且被 Android 杀死的可能性会小得多。用户将在状态栏中看到一个图标。如果他们滑下通知抽屉并点击我们的Notification
条目,他们将被带回到FakePlayer
——现有的实例,如果有的话,或者一个新的实例,否则,由于我们的Intent
标志(Intent.FLAG_ACTIVITY_CLEAR_TOP| Intent.FLAG_ACTIVITY_SINGLE_TOP
)。对于一个音乐播放器来说,这种 UI 模式可以让用户在需要的时候快速返回停止播放音乐。
停止音乐,通过我们的stop()
方法,将调用stopForeground()
:
private void **stop**() { if (isPlaying) { Log.**w**(**getClass**().**getName**(), "Got to stop()!"); isPlaying=false; **stopForeground(true);** } }
传递给stopForeground()
的true
值告诉 Android 删除Notification
,这将是这种模式的典型方法。
冰淇淋三明治和蜂巢中的通知
Android 3.0 中引入的蜂巢 UI,以及它在 Android 4.0 中的继任者冰激凌三明治,都支持通知,就像之前所有版本的 Android 一样。然而,由于平板电脑的比喻及其额外的屏幕空间,用户体验略有不同。
figure 37–5 显示了未修改的Notifications/Notify1
项目,就像在平板电脑大小的模拟器中看到的一样。
图 37–5。 在安卓 3.0 平板大小的模拟器上看到的 notify 1
除了新样式的状态栏和超大按钮之外,这与你在蜂窝电话之前看到的没有什么不同。
如果我们单击顶部的按钮,我们的Notification
会出现,这次是在右下角,带有图标和滚动条文本,如图 Figure 37–6 所示。
图 37–6。 ??【notify 1】增加了一个通知
请注意,如果用户点击滚动条,就会触发我们的PendingIntent
,就像他们点击了电话上的通知抽屉条目一样。
当滚动条被移除时,我们的图标依然存在...无编号,如图图 37–7 所示。
图 37–7。 Notify1 带有一个无编号的通知图标
如果用户点击该图标,附近会出现一个通知抽屉样式的弹出窗口,如 Figure 37–8 所示。
图 37–8。 通知 1,通知内容出现
点击图标或文本触发PendingIntent
,点击右边的×取消此Notification
。
三十八、请求和要求权限
在 20 世纪 90 年代末,一波病毒通过互联网传播,通过电子邮件传递,使用从微软 Outlook 中收集的联系信息。病毒会将自身的副本通过电子邮件发送给每个有电子邮件地址的 Outlook 联系人。这是可能的,因为当时 Outlook 没有采取任何措施来保护使用 Outlook API 的程序的数据,因为该 API 是为普通开发人员而不是病毒作者设计的。
如今,许多保存联系人数据的应用通过要求用户明确授予其他程序访问联系人信息的权限来保护这些数据。这些权限可以根据具体情况授予,也可以在安装时一次性授予。
Android 也不例外,因为它要求应用读取或写入联系人数据的权限。Android 的许可系统不仅对联系人数据有用,而且对 Android 框架提供的内容供应器和服务也有用。
作为一名 Android 开发人员,您经常需要确保您的应用有适当的权限来处理其他应用的数据。如果您允许其他 Android 组件使用您的数据或服务,您也可以选择要求其他应用使用您的数据或服务的权限。本章涵盖了如何实现这两个目标。
妈妈,我可以吗?
请求使用其他应用的数据或服务需要将uses-permission
元素添加到您的AndroidManifest.xml
文件中。您的清单可能有零个或多个uses-permission
元素,都是根manifest
元素的直接子元素。
uses-permission
元素接受一个属性android:name
,它是您的应用需要的权限的名称:
<uses-permission android:name="android.permission.ACCESS_LOCATION" />
所有股票系统权限都以android.permission
开头,并在 Android SDK 文档中列出Manifest.permission
。第三方应用可能有自己的权限,希望他们已经为您记录了这些权限。以下是一些更有用的权限:
INTERNET
,如果您的应用希望通过任何方式访问互联网,从原始 Java 套接字通过WebView
小部件
WRITE_EXTERNAL_STORAGE
,用于将数据写入 SD 卡(或设备指定的任何外部存储器)
NFC
,用于在较新的设备上通过近场通信(NFC)无线电执行 I/O
ACCESS_COARSE_LOCATION
和ACCESS_FINE_LOCATION
,用于确定设备的位置
CALL_PHONE
,允许应用直接拨打电话,无需用户干预
权限在安装应用时得到确认。系统将提示用户确认您的应用是否可以执行权限所要求的操作。因此,要求尽可能少的权限并证明你所要求的权限是合理的是很重要的,这样用户就不会因为你要求太多不必要的权限而选择跳过安装你的应用。通过 USB 加载应用时,例如在开发过程中,此提示不会出现。
如果你没有想要的许可,并试图做一些需要它的事情,你应该得到一个SecurityException
通知你缺少许可。请注意,只有当您忘记请求权限时,您才会在权限检查中失败——您的应用不可能运行,并且而不是 已经被授予您所请求的权限。
立定!谁
硬币的另一面是保护您自己的应用。如果您的应用主要是活动,安全可能只是一个“出站”的东西,在那里您请求使用其他应用的资源的权利。另一方面,如果您将内容供应器或服务放在应用中,您将希望实现“入站”安全性来控制哪些应用可以对数据做什么。
请注意,这里的问题不是其他应用是否会弄乱您的数据,而是用户信息的隐私或可能会产生费用的服务的使用。这就是内置 Android 应用的股票权限所关注的地方:你是否可以阅读或修改联系人,发送短信,等等。如果你的应用不存储被认为是隐私的信息,安全性就不是问题。另一方面,如果您的应用存储私人数据,如医疗信息,安全性就更加重要了。
使用权限保护您自己的应用的第一步是再次在AndroidManifest.xml
文件中声明所述权限。在这种情况下,您添加了permission
元素,而不是uses-permission
。同样,您可以拥有零个或更多的permission
元素,全部作为根manifest
元素的直接子元素。
声明权限比使用权限稍微复杂一些。您需要提供三条信息:
权限的符号名 :为了防止您的权限与其他应用的权限冲突,您应该使用应用的 Java 名称空间作为前缀。
权限标签 :选择用户能够理解的简短内容。
对权限的描述 :选择用户能理解的稍微长一点的内容。
下面是一个例子:
<permission android:name="vnd.tlagency.sekrits.SEE_SEKRITS" android:label="@string/see_sekrits_label" android:description="@string/see_sekrits_description" />
这不会强制权限。相反,它表明这是一个可能的权限;当安全违规发生时,您的应用仍然必须标记它们。
您的应用有两种方法来实施权限,规定在什么地方和什么情况下需要权限。更简单的方法是在清单中指出哪里需要权限。更困难的选择是在代码中强制权限。接下来将讨论这两种选择。
通过清单实施权限
活动、服务和接收者都可以声明一个名为android:permission
的属性,其值是访问这些项目所需的权限的名称:
<activity android:name=".SekritApp" android:label="Top Sekrit" **android:permission="vnd.tlagency.sekrits.SEE_SEKRITS">** <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>
只有已经请求您指定许可的应用才能访问安全组件。在这种情况下,“访问”的含义如下:
未经许可,不能开始活动。
未经许可,不能启动、停止服务或将服务绑定到活动。
意图接收者忽略通过sendBroadcast()
发送的消息,除非发送者允许。
在其他地方实施权限
在您的代码中,您有两种额外的方法来实施权限。
首先,您的服务可以通过checkCallingPermission()
在每次调用的基础上检查权限。根据调用者是否拥有您指定的权限,这将返回PERMISSION_GRANTED
或PERMISSION_DENIED
。例如,如果您的服务实现了单独的读写方法,您可以通过检查这些方法是否具有您需要的 Java 权限,来要求代码中有单独的读写权限。
第二,调用sendBroadcast()
时可以包含一个权限。这意味着合格的广播接收机必须持有该许可;没有许可的人没有资格得到它。我们将在本书的其他地方更详细地研究sendBroadcast()
。
我可以看看你的文件吗?
编译时不会自动发现权限;所有权限失败都发生在运行时。因此,记录公共 API 所需的权限非常重要,包括内容提供者、服务和从其他活动启动的活动。否则,试图与您的应用交互的程序员将不得不通过反复试验来找出权限规则。
此外,您应该预料到您的应用的用户将被提示确认您的应用说它需要的任何权限。因此,您需要为您的用户记录他们应该期望什么,以免他们被设备提出的问题弄糊涂,选择不安装或使用您的应用。您可能希望为此使用字符串资源,这样您就可以像国际化应用中所有其他消息和提示一样国际化您的权限细节。你还应该注意,你的用户会在手机或平板电脑屏幕上阅读你的许可信息,所以你的理由要简明扼要。
旧应用中的新权限
有时,Android 引入了新的权限来管理以前不需要权限的行为。WRITE_EXTERNAL_STORAGE
就是一个例子。最初,应用可以在没有任何许可的情况下写入外部存储。Android 1.6 引入了WRITE_EXTERNAL_STORAGE
,这是你写外存之前必须的。然而,在 Android 1.6 之前编写的应用不可能请求这种权限,因为当时并不存在这种权限。打破这些应用似乎是进步的沉重代价。
Android 所做的是在支持早期 SDK 版本的应用的某些权限中祖父。特别是,如果您的清单中有<uses-sdkandroid:minSdkVersion="3">
,表示您支持 Android 1.5,您的应用将自动请求WRITE_EXTERNAL_STORAGE
和READ_PHONE_STATE
,即使您没有明确请求这些权限。在 Android 1.5 设备上安装您的应用的人会看到这些请求。
随着手机采用新功能以及用户对底层功能态度的成熟,引入的新权限数量也在增加,这就需要更细粒度的方法来管理访问。Android 4.0 中的新权限包括以下内容:
ADD_VOICEMAIL
:允许应用将语音邮件注入系统
BIND_TEXT_SERVICE
:您编写的任何从TextService
派生的服务都需要
BIND_VPN_SERVICE
:您编写的任何从VpnService
派生的服务都需要
READ_PROFILE
:提供读取用户个人资料的权限
WRITE_PROFILE
:允许应用更新或更改用户的个人资料
最终,当你放弃对旧版本权限的支持(例如,切换到<uses-sdkandroid:minSdkVersion="4">
)时,Android 将不再自动请求那些权限。因此,如果你的代码真的需要那些权限,你将需要自己申请。
*### 权限:预先或根本没有
Android 中的权限系统并不是特别灵活。值得注意的是,你必须事先申请你可能需要的所有权限,用户必须全部同意或者放弃安装你的应用。
这意味着您不能执行以下操作:
创建可选的权限,用户可以对这些权限说“不,谢谢”,应用可以对这些权限做出动态反应
安装后请求新的权限,这意味着即使只是一些很少使用的功能需要权限,你也必须请求
因此,在确定应用的功能列表时,牢记权限非常重要。你要求的每一个额外的许可都是一个过滤器,会让你失去一部分潜在的观众。某些组合——比如INTERNET
和READ_CONTACTS
——会有更强的效果,因为用户可能会担心这种组合会产生什么效果。您需要自己决定通过提供该功能来吸引更多用户是否值得为该功能的运行要求权限。*
三十九、访问基于位置的服务
当前移动设备上的一个流行功能是 GPS 功能,因此该设备可以告诉您在任何时间点的位置。虽然 GPS 服务最常见的用途是用于绘制地图和获取方向,但如果您知道自己的位置,还可以用它做其他事情。例如,您可以设置一个基于物理位置的动态聊天应用,这样用户就可以与离他们最近的人聊天。或者,你可以自动对 Twitter 或类似服务的帖子进行地理标记。
GPS 不是移动设备识别您位置的唯一方式。替代品包括以下几种:
相当于 GPS 的欧洲版本,称为 Galileo,在撰写本文时仍在开发中
手机信号塔三角测量,根据附近手机信号塔的信号强度来确定你的位置
靠近已知地理位置的公共 Wi-Fi 热点
Android 设备可能拥有一项或多项这些服务。作为开发人员,您可以向设备询问您的位置,以及哪些供应器可用的详细信息。您甚至可以在模拟器中模拟您的位置,用于测试启用位置功能的应用。
位置提供者:他们知道你藏在哪里
Android 设备可以通过几种不同的方式来确定你的位置。有些会比其他的更准确。有些可能是免费的,而有些可能需要付费。有些可能不仅仅能告诉你你现在的位置,比如你的海拔高度或者你现在的速度。
Android 将所有这些抽象成了一组LocationProvider
对象。您的 Android 环境将有零个或多个LocationProvider
实例,每个实例用于设备上可用的不同定位服务。供应器不仅知道你的位置,还知道他们自己在准确性、成本等方面的特点。
作为一名开发人员,您将使用一个保存有LocationProvider
集合的LocationManager
,来计算哪个LocationProvider
适合您的特定环境。您还需要应用中的权限,否则各种位置 API 将会由于安全违规而失败。根据您希望使用的位置供应器,您可能需要ACCESS_COARSE_LOCATION
、ACCESS_FINE_LOCATION
,或者两者都需要(参见第三十八章)。
发现自我
对于定位服务来说,最明显的事情就是找出你现在的位置。为此,您首先需要获得一个LocationManager
,因此从您的活动或服务中调用getSystemService(LOCATION_SERVICE)
,并将其转换为一个LocationManager
。下一步是获得您想要使用的LocationProvider
的名称。这里,您有两个主要选项:
请用户选择一个供应器
基于一组标准查找最匹配的供应器
如果你想让用户选择一个提供者,调用LocationManager
上的getProviders()
会给你一个提供者的List
,然后你可以把它呈现给用户选择。
如果您想根据一组标准找到最匹配的提供者,创建并填充一个Criteria
对象,说明您想从LocationProvider
中得到什么的细节。以下是一些可用于指定标准的方法:
setAltitudeRequired()
:表示是否需要当前高度
setAccuracy()
:设置位置的最低精度等级,单位为米
setCostAllowed()
:控制供应器是否必须是免费的或者可以代表设备用户产生费用
给定一个已填充的Criteria
对象,在你的LocationManager
上调用getBestProvider()
,Android 将筛选标准并给你最佳答案。请注意,并非您的所有标准都能得到满足;如果没有匹配,除了货币成本标准之外的所有标准都可以放宽。
您也可以使用一个LocationProvider
名称(例如GPS_PROVIDER
)进行硬连接,也许只是为了测试的目的。
一旦你知道了LocationProvider
的名字,你就可以呼叫getLastKnownPosition()
来找出你最近在哪里。然而,除非有其他原因导致期望的供应器收集定位(例如,除非 GPS 无线电打开),getLastKnownPosition()
将返回null
,指示没有已知位置。另一方面,getLastKnownPosition()
不会产生金钱或电力成本,因为提供者不需要被激活来获取价值。
这些方法返回一个Location
对象,它可以以 Java double
的形式给出设备的纬度和经度,单位为度。如果特定位置供应器提供其他数据,您也可以获得:
对于海拔高度,hasAltitude()
将告诉您是否有海拔高度值,getAltitude()
将以米为单位返回海拔高度。
对于方位(即罗盘式的方向),hasBearing()
将告诉您是否有可用的方位,getBearing()
将返回正北以东的度数。
对于速度,hasSpeed()
会告诉你速度是否已知,getSpeed()
会返回以米每秒为单位的速度。
不过,从LocationProvider
获取Location
的一个更可能的方法是注册更新,如下一节所述。
在移动中
不是所有的位置供应器都必须立即响应。例如,全球定位系统需要激活无线电,并从卫星获得定位,然后才能确定位置。这就是为什么 Android 不提供getMeMyCurrentLocationNow()
方法。再加上你的用户可能不希望他们的移动反映在你的应用中,你最好注册位置更新,并以此作为获取当前位置的手段。
Internet/Weather
和Service/WeatherAPI
示例应用展示了如何注册更新——在您的LocationManager
实例上调用requestLocationUpdates()
。这个方法有四个参数:
您希望使用的位置提供者的名称
在我们可能获得位置更新之前,应该过去多长时间(以毫秒为单位)
在我们获得位置更新之前,设备必须移动多远(以米为单位)
一个LocationListener
,它将被通知关键的位置相关事件,如下例所示:
`LocationListener onLocationChange=new LocationListener () {
public void onLocationChanged (Location location) {
if (state.weather!=null) {
state.weather.getForecast (location, state);
}
else {
Log.w(getClass ().getName (), "Unable to fetch forecast – no WeatherBinder");
}
}
public void onProviderDisabled (String provider) {
// required for interface, not used
}
public void onProviderEnabled (String provider) {
// required for interface, not used
}
public void onStatusChanged (String provider, int status,
Bundle extras) {
// required for interface, not used
}
}`
这里,我们所做的就是用提供给onLocationChanged()
回调方法的Location
触发一个FetchForecastTask
。
请记住,时间参数只是从功耗角度帮助控制 Android 的一个指南。你可能会得到比这更多的位置更新。要获得最大数量的位置更新,请为时间和距离约束提供0
。
当您不再需要更新时,用您注册的LocationListener
呼叫removeUpdates()
。如果你没有做到这一点,你的应用将继续接收位置更新,即使在所有的活动和类似的关闭,这也将阻止 Android 回收你的应用的内存。
还有另一个版本的requestLocationUpdates()
采用了一个PendingIntent
而不是一个LocationListener
。如果您想在您的位置发生变化时得到通知,即使您的代码没有运行,这也很有用。例如,如果您正在记录移动,您可以使用一个PendingIntent
来触发一个BroadcastReceiver
( getBroadcast()
)并让BroadcastReceiver
将条目添加到日志中。这样,只有当位置改变时,代码才在内存中,所以当设备不移动时,不会占用系统资源。
我们到了吗?我们到了吗?
有时候,你对你现在在哪里不感兴趣,甚至对你什么时候搬家不感兴趣,而是想知道你什么时候到达你要去的地方。这可能是一个最终目的地,也可能是到达一组方向上的下一步,所以你可以给用户下一个指令。
为了实现这一点,LocationManager
提供了addProximityAlert()
。这记录了一个PendingIntent
,当设备到达某个位置的某个距离内时,它将被触发。addProximityAlert()
方法采用以下参数:
感兴趣位置的纬度和经度。
一个半径,指定您应该离Intent
升起的位置有多近。
注册的持续时间,以毫秒为单位。过了这段时间,注册自动失效。值-1
表示注册持续到您通过removeProximityAlert()
手动删除它。
当设备在由位置和半径表示的目标区域内时要升高的PendingIntent
。
请注意,这并不保证您真的会收到一个Intent
。定位服务可能会中断,或者在接近警报激活期间,设备可能不在目标区域内。例如,如果位置偏离一点,并且半径过小,设备可能只会绕过目标区域的边缘,或者它可能会快速通过目标区域,以至于在此期间设备的位置没有被采样。
您可以安排一个活动或接收者来响应您注册的Intent
接近警报。当Intent
到来时,你做什么取决于你自己。例如,您可以设置一个通知(例如,振动设备),将信息记录到内容供应器,或将消息发布到网站。请注意,无论何时对头寸进行采样并且您在目标区域内,您都会收到Intent
,而不仅仅是在进入区域时。因此,根据目标区域的大小和设备移动的速度,您可能会得到几次Intent
,也许是相当多次。
测试...测试...
Android 模拟器无法通过 GPS 定位,通过手机信号塔对你的位置进行三角测量,或者通过附近的 Wi-Fi 信号识别你的位置。因此,如果您想要模拟一个移动的设备,您将需要一些向模拟器提供模拟位置数据的方法。
不管出于什么原因,随着 Android 自身的发展,这一特定领域已经发生了重大变化。过去,您可以在应用中提供模拟位置数据,这对于演示非常方便。唉,这些选项在 Android 1.0 中都被删除了。
提供模拟位置数据的一个选项是 Dalvik 调试监控服务(DDMS)。这是一个独立于仿真器的外部程序,它可以以几种不同的格式为仿真器提供单个位置点或要遍历的完整路径。在您的清单文件ACCESS_MOCK_LOCATION
中包含一个特定的权限,以允许访问数据。您还需要确保在模拟器的开发人员设置下启用了“允许模拟位置”选项。第二章中的“步骤 6:设置仪器”部分解释了如何访问这些设置。DDMS 本身在第四十三章中有更详细的描述。
四十、使用 MapView 和 MapActivity 进行制图
谷歌最受欢迎的服务之一——当然是在搜索之后——是谷歌地图,它使你能够绘制从最近的比萨饼店的位置到从纽约市到旧金山的方向的所有信息(只有 2571 英里,或公制的 4135 公里),包括街景和卫星图像。
毫不奇怪,大多数 Android 设备都集成了谷歌地图。对于那些这样做的人,有一个直接从主 Android 启动器向用户提供的映射活动。作为开发人员,与您更相关的是MapView
和MapActivity
,它们允许您将地图集成到自己的应用中。你不仅可以显示地图,控制缩放级别,允许人们四处平移,还可以结合 Android 的基于位置的服务来显示设备的位置和去向。
幸运的是,将基本的地图功能集成到您的 Android 项目中相当容易。再努力一点,你就可以集成更复杂的地图功能。
术语,非爱称
将谷歌地图集成到第三方应用中需要同意一套相当长的法律条款。这些条款包括你可能会觉得不愉快的条款。
如果您正在考虑谷歌地图,请仔细阅读这些条款,以确定您的预期用途是否会与任何条款相冲突。如果有任何潜在的冲突,强烈建议您寻求专业的法律顾问。
此外,请留意基于其他地图数据源的其他地图选项,比如 OpenStreetMap ( [www.openstreetmap.org/](http://www.openstreetmap.org/)
)。
打桩
从 Android 1.5 开始,谷歌地图并不是 Android SDK 的一部分。相反,它是 Google APIs 插件的一部分,是股票 SDK 的扩展。Android 附加系统为其他子系统提供挂钩,这些子系统可能是某些设备的一部分,但不是其他设备的一部分。
注意: 由于谷歌地图不是 Android 开源项目的一部分,一些设备由于许可问题而缺少谷歌地图。例如,在撰写本文时,Archos 5 Android 平板电脑没有谷歌地图。
总的来说,谷歌地图是一个附加软件的事实不会影响你的日常开发。但是,请记住以下几点:
您需要创建具有适当目标的项目,以确保 Google Maps APIs 可用。
为了测试您的 Google Maps 集成,您还需要一个使用适当目标的 AVD。
这一切的关键
如果您下载这本书的源代码,编译Maps/NooYawk
项目,将其安装在您的模拟器中,并运行它,您可能会看到一个带有网格和几个图钉的屏幕,但没有实际的地图。那是因为源代码中的 API 键对于你的开发机器是无效的。相反,您将需要生成您自己的 API 密钥,以供您的应用使用。这也适用于您自己从头开始创建的任何启用地图的项目。
可以在 Android 网站上找到用于开发和生产的生成 API 密钥的完整说明。为了简洁起见,让我们把重点放在让NooYawk
在您的仿真器中运行这个狭窄的例子上。为此,需要执行以下步骤:
请访问 API 密钥注册页面并查看服务条款。
重读这些服务条款,并确保你真的想同意他们。
找到用于对调试模式应用进行签名的证书的 MD5 摘要(在下面的列表中有详细描述)。
在 API 密钥注册页面上,粘贴 MD5 签名并提交表单。
在生成的页面上,复制 API 键并将其作为值粘贴到您的MapView
-using 布局中。
最棘手的部分是找到用于签署调试模式应用的证书的 MD5 签名。大部分的复杂性仅仅在于理解这个概念。
所有 Android 应用都使用证书生成的数字签名进行签名。当您设置 SDK 时,系统会自动为您提供一个调试证书,并且有一个单独的过程来创建用于生产应用的自签名证书。这个签名过程涉及到 Java keytool
和jarsigner
实用程序的使用。为了得到你的 API 密匙,你只需要担心keytool
。
要获取调试证书的 MD5 摘要,如果您使用的是 Mac OS X 或 Linux,请使用以下命令:
keytool -list -alias androiddebugkey -keystore ~/.android/debug.keystore -storepass android -keypass android
在其他开发平台上,比如 Windows,您需要用您的平台和用户帐户的位置替换-keystore
开关的值(其中<user>
是您的帐户名称):
在 Windows XP 上,使用C:\Documents and Settings\<user>\.android\debug.keystore
。
在 Windows Vista 或 Windows 7 上,使用C:\Users\<user>\.android\debug.keystore
。
输出的第二行包含您的 MD5 摘要,是由冒号分隔的一系列十六进制数字对。
光秃秃的骨头
要将地图放入您的应用,您需要创建自己的子类MapActivity
。就像ListActivity
总结了由ListView
主导的活动背后的一些智慧一样,MapActivity
处理了设置由MapView
主导的活动的一些细微差别。A MapView
只能被 a MapActivity
使用,不能被任何其他类型的Activity
使用。
在MapActivity
子类的布局中,您需要添加一个名为com.google.android.maps.MapView
的元素。这是拼写小部件类名的“手写”方式,包括完整的包名和类名。这是必要的,因为MapView
不在android.widget
名称空间中。你可以给MapView
小部件任何你想要的android:id
属性值,并处理所有的布局细节,让它和你的其他小部件一起正确呈现。
但是,您需要具备以下物品:
android:apiKey
,您的谷歌地图 API 密钥
如果您希望用户能够点击并平移您的地图
例如,在Maps/NooYawk
示例应用中,主要布局如下:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent"> <com.google.android.maps.MapView android:id="@+id/map" android:layout_width="fill_parent" android:layout_height="fill_parent" android:apiKey="00yHj0k7_7vxbuQ9zwyXI4bNMJrAjYrJ9KKHgbQ" android:clickable="true" /> </RelativeLayout>
此外,在您的AndroidManifest.xml
文件中还需要一些额外的东西:
INTERNET
和ACCESS_FINE_LOCATION
权限(后者与MyLocationOverlay
类一起使用,将在本章后面描述)
在您的<application>
中,一个带有android:name = "com.google.android.maps"
的<uses-library>
元素,表示您正在使用一个可选的 Android APIs
以下是NooYawk
的AndroidManifest.xml
文件:
`
<manifest xmlns:android="http://schemas.android.com/apk/res/android "
package="com.commonsware.android.maps">
<supports-screens android:largeScreens="true" android:normalScreens="true"
android:smallScreens="true" android:anyDensity="true"/>
`
除了从MapActivity
开始对活动进行子类化之外,对于初学者来说,这几乎就是你所需要的全部了。如果你什么都不做,构建这个项目并把它扔进模拟器,你会得到一个漂亮的世界地图。然而,请注意,MapActivity
是抽象的——您需要实现isRouteDisplayed()
来表明您是否在提供某种驾驶方向。由于当前版本的服务条款不支持显示行驶方向,您应该让isRouteDisplayed()
返回false
。
可选地图
虽然大多数主流 Android 设备都有谷歌地图,但有一小部分没有,因为他们的制造商没有选择从谷歌获得许可。因此,您需要决定 Google Maps 对于您的应用的运行是否至关重要。
如果谷歌地图是必不可少的,那么在你的应用中包含<uses-library>
元素,如前所示,因为这将要求任何运行你的应用的设备都要有谷歌地图。
如果谷歌地图不是必不可少的,你可以通过<uses-library>
上的android:required
属性使其成为可选的。设置为false
,如果谷歌地图可用的话,它会被加载到你的应用中,但是不管怎样,你的应用都会运行良好。然后,您将需要使用类似Class.forName("com.google.android.maps.MapView")
的东西来查看您的应用是否可以使用 Google Maps。如果不是,你可以禁用它的菜单项,或者任何可以引导用户到你的MapActivity
的东西。
注意: 在旧的文档中,android:required
属性是“未记录的”,它的使用和支持是有问题的。谷歌现在已经正式记录了它,它在 Android 4.0 冰淇淋三明治和未来的版本中可用。
运用你的控制力
你可以通过findViewById()
找到你的MapView
部件,就像其他部件一样。小部件本身提供了一个getController()
方法。在MapView
和MapController
之间,你有相当多的能力来决定地图显示什么以及它如何表现。缩放和居中是您可能想要使用的两个功能,因此它们将在接下来讨论。
缩放
你开始时的世界地图相当广阔。通常,在手机上看地图的人会期待范围稍微窄一点的东西,比如几个城市街区。
您可以通过MapController
上的setZoom()
方法直接控制缩放级别。这需要一个代表缩放级别的整数,其中1
是世界视图,21
是你能得到的最紧密的缩放。每一级都是有效分辨率的两倍:1
的赤道宽度为 256 像素,而21
的赤道宽度为 268,435,456 像素。由于手机的显示屏可能在两个维度上都没有 268,435,456 像素,用户看到的是聚焦在地球一个微小角落的小地图。一个17
的关卡会在每个维度显示几个城市街区,这可能是你尝试的一个合理起点。
如果您希望允许用户更改缩放级别,调用setBuiltInZoomControls(true);
,用户将能够通过地图底部中心的缩放控件放大和缩小地图。
居中
通常,除了缩放级别之外,您还需要控制地图显示的内容,例如用户的当前位置或与活动中的一些数据一起保存的位置。要改变地图的位置,调用MapController
上的setCenter()
。
setCenter()
方法将一个GeoPoint
作为参数。一个GeoPoint
通过纬度和经度代表一个位置。问题是GeoPoint
将纬度和经度存储为整数,表示微度数 (度数乘以 1E6)中的实际纬度和经度。与存储一个float
或double
相比,这节省了一点内存,并且大大加快了 Android 将GeoPoint
转换成地图位置所需的一些内部计算。但是,这确实意味着您必须记住将真实世界的纬度和经度乘以 1E6。
层层叠叠
如果你曾经使用过全尺寸版的谷歌地图,你可能会习惯于看到覆盖在地图上的东西,比如图钉指示被搜索位置附近的企业。在地图术语中(就此而言,在许多严肃的图形编辑器中),图钉位于与地图本身不同的图层上,您看到的是地图图层上图钉图层的组合。
Android 的地图也允许你创建图层,所以你可以根据用户输入和你的应用的目的来标记你需要的地图。例如,NooYawk
使用一个层来显示选择的建筑物在曼哈顿岛上的位置。
叠加类
任何想要添加到地图上的覆盖图都需要作为Overlay
的子类来实现。如果你想添加图钉之类的东西,可以使用一个ItemizedOverlay
子类;ItemizedOverlay
简化了这个过程。
要给你的地图添加一个覆盖类,只需调用你的MapView
上的getOverlays()
和你的Overlay
实例上的add()
,就像我们在这里使用自定义的SitesOverlay
一样:
`marker.setBounds (0, 0, marker.getIntrinsicWidth (),
marker.getIntrinsicHeight ());
map.getOverlays ().add (new SitesOverlay (marker));`
我们将在下一节中讨论marker
。
绘制明细 Overlay
顾名思义,ItemizedOverlay
允许您提供要在地图上显示的兴趣点列表——特别是OverlayItem
的实例。然后,覆盖层会为您处理大部分绘图逻辑。以下是实现这一目标的最基本步骤:
覆盖ItemizedOverlay<OverlayItem>
作为自己的子类(在这个例子中是SitesOverlay
)。
在构造器中,构建您的OverlayItem
实例花名册,当它们准备好供覆盖使用时,调用populate()
。
实现size()
以返回覆盖要处理的项目数。
重写createItem()
以返回给定索引的OverlayItem
实例。
当你实例化你的ItemizedOverlay
子类时,给它提供一个Drawable
来表示为每个项目显示的默认图标(例如,图钉),在这个图标上你调用boundCenterBottom()
来启用投影效果。
来自NooYawk
构造函数的marker
是用于步骤 5 的Drawable
,它显示了一个图钉。
比如这里是SitesOverlay
:
`private class SitesOverlay extends ItemizedOverlay {
private List items=new ArrayList();
private Drawable marker=null;
public SitesOverlay (Drawable marker) {
super(marker);
this.marker=marker;
boundCenterBottom (marker);
items.add (new OverlayItem (getPoint (40.748963847316034,
-73.96807193756104),
"UN", "United Nations"));
items.add (new OverlayItem (getPoint (40.76866299974387,
-73.98268461227417),
"Lincoln Center",
"Home of Jazz at Lincoln Center"));
items.add (new OverlayItem (getPoint (40.765136435316755,
-73.97989511489868),
"Carnegie Hall",
"Where you go with practice, practice, practice"));
items.add (new OverlayItem (getPoint (40.70686417491799,
-74.01572942733765),
"The Downtown Club",
"Original home of the Heisman Trophy"));
populate ();
}
@Override
protected OverlayItem createItem (int i) {
return(items.get (i));
}
@Override
protected boolean onTap (int i) {
Toast.makeText (NooYawk.this,
items.get (i).getSnippet (),
Toast.LENGTH_SHORT).show ();
return(true);
}
@Override
public int size () {
return(items.size ());
}
}`
处理屏幕点击
一个Overlay
子类也可以实现onTap()
,当用户点击地图时得到通知,这样覆盖图可以调整它所绘制的内容。例如,在全尺寸的谷歌地图中,点击一个图钉会弹出一个气泡,显示图钉所在位置的企业信息。有了onTap()
,你可以在 Android 上做同样的事情。
用于ItemizedOverlay
的onTap()
方法接收被点击的OverlayItem
的索引。这取决于你做一些有意义的事情。
在SitesOverlay
的情况下,如前一节所示,onTap()
看起来像这样:
`@Override
protected boolean onTap (int i) {
Toast.makeText (NooYawk.this,
items.get(i).getSnippet (),
Toast.LENGTH_SHORT).show ();
return(true);
}`
这里,我们只是用来自OverlayItem
的片段抛出一个简短的Toast
,返回true
来表示我们处理了点击。
我的、我自己的和我的位置覆盖
Android 有一个内置的覆盖来处理两种常见的情况:
基于 GPS 或其他位置提供逻辑,显示您在地图上的位置
根据内建的指南针传感器显示您被指向的位置(如果有)
你所需要做的就是创建一个MyLocationOverlay
实例,将其添加到你的MapView
的覆盖列表中,并在适当的时候启用和禁用所需的功能。
“在适当的时候”的概念是为了最大限度地延长电池寿命。当活动暂停时,更新位置或方向没有意义,因此建议您在onResume()
中启用这些功能,在onPause()
中禁用它们。
例如,NooYawk
将使用MyLocationOverlay
显示罗盘。为此,我们首先需要创建覆盖图,并将其添加到覆盖图列表中(其中me
是作为私有数据成员的MyLocationOverlay
实例):
me=new **MyLocationOverlay**(this, map); map.**getOverlays**().**add**(me);
然后,我们根据需要启用和禁用罗盘:
`@Override
public void onResume () {
super.onResume ();
me.enableCompass ();
}
@Override
public void onPause () {
super.onPause ();
me.disableCompass ();
}`
当活动在屏幕上时,这给了我们一个罗盘,如图 Figure 40–1 所示。
图 40–1。 诺亚克地图,显示一个罗盘和两个重叠的物品
崎岖的地形
正如你在全尺寸电脑上使用的谷歌地图可以显示卫星图像一样,安卓地图也可以。
MapView
提供toggleSatellite()
,顾名思义,它可以打开和关闭正在查看的区域的卫星视角。您可以允许用户通过选项菜单来触发,或者在使用NooYawk
的情况下,通过按键来触发:
` @Override
public boolean onKeyDown (int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_S) {
map.setSatellite (!map.isSatellite ());
return(true);
}
else if (keyCode == KeyEvent.KEYCODE_Z) {
map.displayZoomControls (true);
return(true);
}
return(super.onKeyDown (keyCode, event));
}`
图 40–2 显示了点击S
键后NooYawk
中的卫星视图。
图 40–2。 NooYawk 地图,显示一个罗盘和两个重叠的项目,重叠在卫星视图上
地图和片段
您可能认为地图是使用片段的理想场所。毕竟,在一个大的平板电脑屏幕上,你可以将大部分空间分配给地图,但旁边仍然有其他东西。唉,在 Android 的最后两个主要版本中,地图和片段仍然是两种很好的口味,但合在一起就不那么好了。
首先,MapView
要求你继承MapActivity
。这有几个后果:
不能使用 Android 兼容库(ACL),因为那需要你从FragmentActivity
继承,而 Java 不支持多重继承。因此,你只能在 Android 3.0 和更高版本上使用分段地图,这需要你在旧版本的 Android 上使用一些替代实现。
任何可能在片段中托管地图的活动都必须从MapActivity
继承,即使在某些情况下它可能不在片段中托管地图。
另外,MapView
对各种事件的时间做了一些假设,这使得建立一个基于地图的片段变得更加复杂。
完全有可能有一天这些问题会得到解决,通过一个更新的 Google APIs Android 插件与片段支持的结合,可能还有一个更新的 ACL。与此同时,这是让地图尽可能地分段工作的方法。
限制自己使用最新的安卓版本
在清单中,确保将您的android:minSdkVersion
和android:targetSdkVersion
都设置为至少11
,这样您的应用只能在 Android 3.0 和更新版本上运行。例如,下面是来自Maps/NooYawkFragments
示例项目的清单:
`
`
使用 onCreateView()和 onActivityCreated()
基于地图的片段只是一个显示一个MapView
的Fragment
。总的来说,这段代码看起来和工作起来很像一个MapActivity
,配置MapView
,设置ItemizedOverlay
,等等。
然而,有一个时间问题:您不能可靠地从onCreateView()
返回一个MapView
小部件,或者包含这样一个小部件的膨胀布局。不管出于什么原因,第一次运行正常,但是在配置改变时(例如屏幕旋转),它会失败。
解决方案是从onCreateView()
返回一个容器,比如一个FrameLayout
,如这里的NooYawkFragments
的MapFragment
类所示:
@Override public View **onCreateView**(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return(new **FrameLayout**(**getActivity**())); }
然后,在onActivityCreated()
——一旦onCreate()
在托管MapActivity
中完成——您可以向该容器添加一个MapView
,并继续您的正常设置:
`@Override
public void onActivityCreated (Bundle savedInstanceState) {
super.onActivityCreated (savedInstanceState);
map=new MapView (getActivity (), "00yHj0k7_7vxbuQ9zwyXI4bNMJrAjYrJ9KKHgbQ");
map.setClickable (true);
map.getController ().setCenter (getPoint (40.76793169992044,
-73.98180484771729));
map.getController ().setZoom(17);
map.setBuiltInZoomControls (true);
Drawable marker=getResources ().getDrawable (R.drawable.marker);
marker.setBounds (0, 0, marker.getIntrinsicWidth (),
marker.getIntrinsicHeight ());
map.getOverlays ().add (new SitesOverlay (marker));
me=new MyLocationOverlay (getActivity (), map);
map.getOverlays ().add (me);
((ViewGroup)getView ()).addView (map);
}`
注意,我们在 Java 代码中创建了一个MapView
,这意味着我们的 Maps API 键驻留在 Java 代码中(或者可以从 Java 代码中获得的东西,比如一个字符串资源)。如果您愿意,您可以在这里展开包含一个MapView
的布局——MapFragment
的变化只是为了说明从 Java 代码创建一个MapView
。
在 MapActivity 中托管片段
您必须确保托管支持地图的片段的任何活动都是一个MapActivity
。因此,即使NooYawk
活动不再与映射有太大关系,它仍然是一个MapActivity
:
`package com.commonsware.android.maps;
import android.os.Bundle;
import com.google.android.maps.MapActivity;
public class NooYawk extends MapActivity {
@Override
public void onCreate (Bundle savedInstanceState) {
super.onCreate (savedInstanceState);
setContentView (R.layout.main);
}
@Override
protected boolean isRouteDisplayed () {
return(false);
}
}`
布局现在指向一个<fragment>
而不是一个MapView
:
<?xml version="1.0" encoding="utf-8"?> <fragment xmlns:android="http://schemas.android.com/apk/res/android" class="com.commonsware.android.maps.MapFragment" android:id="@+id/map_fragment" android:layout_width="fill_parent" android:layout_height="fill_parent" />
最终的应用,如 Figure 40–3 所示,看起来像大屏幕上的原始NooYawk
活动,因为我们没有对片段系统做任何其他事情(例如,在风景布局中有其他片段)。
图 40–3。 摩托罗拉 XOOM 上渲染的 NooYawkFragments 地图
地图和片段的定制替代方案
可以理解,Android 开发者社区对地图在片段中的限制感到沮丧,并努力应对MapView
需要在MapActivity
中的限制。你可以在 StackOverflow Android 开发者论坛上看到实验和讨论的历史,这导致了贡献者之一 Pete Doyle 发布了android-support-v4-googlemaps
自定义兼容性库。
Doyle 的自定义兼容性库是一个使FragmentActivity
扩展MapActivity
的工作解决方案。这反过来允许你在一个片段中使用一个MapView
对象。
你可以在[
github.com/petedoyle/android-support-v4-googlemaps](https://github.com/petedoyle/android-support-v4-googlemaps)
从 GitHub 下载support-v4-googlemaps
自定义兼容性库。
四十一、处理电话
许多,如果不是大多数,Android 设备将是手机。因此,不仅用户会期待使用 Android 拨打和接听电话,而且如果你愿意,你将有机会帮助他们拨打电话。
为什么你会想呢?
也许你正在为一个销售管理应用(a la Salesforce.com)编写一个 Android 界面,你想让用户只需点击一个按钮就能给潜在客户打电话,而不必让他们在你的应用和手机的联系人应用中都保留这些联系人。
也许您正在编写一个社交网络应用,您可以访问的电话号码列表会不断变化,因此与其尝试将社交网络联系人与手机的联系人数据库同步,不如让人们直接从您的应用拨打电话。
也许你正在创建一个现有联系人系统的替代界面,也许是为运动控制能力下降的用户(例如,老年人),运动大按钮等,使他们更容易拨打电话。
不管是什么原因,Android 有办法让你像操作 Android 系统的任何其他部分一样操作手机。
向经理汇报
为了获得更多的电话 API,可以使用TelephonyManager
类。该类允许您执行如下操作:
通过getCallState()
确定电话是否在使用中,返回值为CALL_STATE_IDLE
(电话未使用)CALL_STATE_RINGING
(已请求通话但仍在连接中)CALL_STATE_OFFHOOK
(通话进行中)
通过getSubscriberId()
找到 SIM ID (IMSI)
通过getPhoneType()
查找电话类型(如 GSM)或通过getNetworkType()
查找数据连接类型(如 GPRS 或 EDGE)
你打电话吧!
您还可以从您的应用发起呼叫,例如从您通过自己的 web 服务获得的电话号码。要做到这一点,只需制作一个带有形式为tel:NNNNN
的Uri
的ACTION_DIALIntent
(其中NNNNN
是要拨打的电话号码)并使用带有startActivity()
的Intent
。这实际上不会拨打电话;相反,它激活了拨号器活动,用户可以点击一个按钮发出呼叫。
例如,让我们看一下Phone/Dialer
示例应用。以下是简单但有效的布局:
<?xml version="1.0" encoding="utf-8"?> <LinearLayoutxmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent" > <LinearLayout android:orientation="horizontal" android:layout_width="fill_parent" android:layout_height="wrap_content" > <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Number to dial:" /> <EditTextandroid:id="@+id/number" android:layout_width="fill_parent" android:layout_height="wrap_content" android:cursorVisible="true" android:editable="true" android:singleLine="true" /> </LinearLayout> <Button android:id="@+id/dial" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_weight="1" android:text="Dial It!" android:onClick="dial" /> </LinearLayout>
我们有一个用于输入电话号码的标签栏,还有一个用于拨打该号码的按钮。
Java 代码只是使用字段中的电话号码启动拨号器:
packagecom.commonsware.android.dialer;
`importandroid.app.Activity;
importandroid.content.Intent;
importandroid.net.Uri;
importandroid.os.Bundle;
importandroid.view.View;
importandroid.widget.EditText;
public class DialerDemo extends Activity {
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
}
public void dial(View v) {
EditText number=(EditText)findViewById (R.id.number);
String toDial="tel:"+number.getText().toString();
startActivity (new Intent(Intent.ACTION_DIAL, Uri.parse(toDial)));
}
}`
活动本身的 UI 并没有那么令人印象深刻,如图 Figure 41–1 所示。
图 41–1。 dialer demo 示例应用,最初启动时
不过你点击拨号键得到的拨号器更好,显示你要拨的号码,如图 Figure 41–2 所示。
图 41–2。 安卓拨号器活动,从拨号器发起
不,真的,你来打电话!
好消息是ACTION_DIAL
不需要任何特殊权限就可以工作。坏消息是它只带用户到拨号器。用户仍然需要采取行动(按绿色的呼叫按钮)来实际拨打电话。
另一种方法是使用ACTION_CALL
而不是ACTION_DIAL
。在一个ACTION_CALLIntent
上调用startActivity()
将立即发出电话呼叫,不需要任何其他的 UI 步骤。然而,你需要得到CALL_PHONE
的许可才能使用ACTION_CALL
(见第三十八章)。
四十二、字体
在开发任何类型的应用时,你都会不可避免地遇到这样的问题:“嘿,我们能改变这种字体吗?”答案取决于平台自带的字体,是否可以添加其他字体,以及字体如何应用到小部件或任何需要改变字体的地方。安卓也不例外。它附带了一些字体,以及添加新字体的方法。然而,如同任何新环境一样,有一些特质需要处理,如本章所述。
爱和你在一起的人
Android 本身就知道三种字体,简写为 sans、serif 和 monospace。这些字体实际上是 Droid 系列字体,由 Monotype Imaging 的一个部门 Ascender Corp. ( [www.ascendercorp.com/](http://www.ascendercorp.com/)
)为开放手机联盟创建。要使用这三种字体,您可以在您的布局 XML 中引用它们,例如下面来自Fonts/FontSampler
示例项目的布局:
<?xml version="1.0" encoding="utf-8"?> <TableLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:stretchColumns="1"> <TableRow> <TextView android:text="sans:" android:layout_marginRight="4dip" android:textSize="20sp" /> <TextView android:id="@+id/sans" android:text="Hello, world!" android:typeface="sans" android:textSize="20sp" /> </TableRow> <TableRow> <TextView
android:text="serif:" android:layout_marginRight="4dip" android:textSize="20sp" /> <TextView android:id="@+id/serif" android:text="Hello, world!" android:typeface="serif" android:textSize="20sp" /> </TableRow> <TableRow> <TextView android:text="monospace:" android:layout_marginRight="4dip" android:textSize="20sp" /> <TextView android:id="@+id/monospace" android:text="Hello, world!" android:typeface="monospace" android:textSize="20sp" /> </TableRow> <TableRow> <TextView android:text="Custom:" android:layout_marginRight="4dip" android:textSize="20sp" /> <TextView android:id="@+id/custom" android:text="Hello, world!" android:textSize="20sp" /> </TableRow> <TableRowandroid:id="@+id/filerow"> <TextView android:text="Custom from File:" android:layout_marginRight="4dip" android:textSize="20sp" /> <TextView android:id="@+id/file" android:text="Hello, world!" android:textSize="20sp" /> </TableRow> </TableLayout>
此布局构建了一个显示五种字体的简短示例的表格。注意前三个有android:typeface
属性,它的值是三个内置字体之一(例如“sans"”
)。
附加字体
三个内置字体都很好看。然而,设计师、经理或客户可能想要不同的字体。或者,您可能希望使用一种用于特殊目的的字体,例如,用丁巴特字体代替一系列 PNG 图形。实现这一点最简单的方法是将所需的字体与应用打包在一起。为此,只需在项目根目录下创建一个assets/
文件夹,并将您的 TrueType (TTF)字体放入该文件夹。例如,您可以创建assets/fonts
/并将您的 TTF 文件放在那里。
然后,您需要告诉您的小部件使用该字体。不幸的是,您不能再为此使用布局 XML,因为 XML 不知道您可能已经作为应用素材隐藏起来的任何字体。相反,您需要在 Java 代码中进行更改:
`importandroid.widget.TextView;
importjava.io.File;
public class FontSampler extends Activity {
@Override
public void onCreate (Bundle icicle) {
super.onCreate (icicle);
setContentView (R.layout.main);
TextViewtv=(TextView)findViewById (R.id.custom);
Typeface face=Typeface.createFromAsset (getAssets (),
"fonts/HandmadeTypewriter.ttf");
tv.setTypeface (face);
File font=new File(Environment.getExternalStorageDirectory (),
"MgOpenCosmeticaBold.ttf");
if (font.exists ()) {
tv=(TextView)findViewById (R.id.file);
face=Typeface.createFromFile (font);
tv.setTypeface (face);
}
else {
findViewById (R.id.filerow).setVisibility(View.GONE);
}
}
}`
这里,我们为我们的定制样本获取了TextView
,然后通过静态的createFromAsset()
构建器方法创建了一个Typeface
对象。这需要应用的AssetManager
(来自getAssets()
)和你的assets/
目录中的一个路径到你想要的字体。
然后,只需要告诉TextView
到setTypeface()
,提供我们刚刚创建的Typeface
。在这种情况下,我们使用手工打字机字体。您也可以从本地文件中加载字体并使用它。好处是你可以在你的应用发布后定制你的字体。另一方面,你必须想办法把字体放到设备上。但是,正如你可以通过createFromAsset()
得到一个Typeface
,你也可以通过createFromFile()
得到一个Typeface
。在我们的FontSampler
中,我们在“外部存储器”(通常是 SD 卡)的根目录中寻找MgOpenCosmeticaBold
TrueType 字体文件,如果找到了,我们将它用于表的第五行。否则,我们隐藏该行。
图 42–1 显示了结果。
图 42–1。 ??【font sampler】应用
我们将在接下来的章节中详细介绍素材和本地文件。
注意,Android 似乎并不喜欢所有的 TrueType 字体。当 Android 不喜欢某个自定义字体时,它似乎会悄悄地用 Droid Sans ( "sans"
)代替,而不是升起一个Exception
。因此,如果你试图使用不同的字体,但它似乎没有工作,它可能与 Android 不兼容,无论出于什么原因。
这里一字形,那里一字形
TrueType 字体可能会很笨重,尤其是当它们支持大量可用的 Unicode 字符时。上一节用的手工打字机字体运行 70KB 以上;DejaVu 免费字体每个可以运行 500KB 以上。即使是压缩的,也会增加你的应用的体积,所以注意不要过度使用自定义字体,以免你的应用在用户的手机上占据太多空间。
相反,请记住,字体可能没有您需要的所有字形。举个例子,我们来说说省略号。
Android 的TextView
类具有“省略”文本的内置功能,如果文本长度超过可用空间,它会截断文本并添加省略号。例如,您可以通过android:ellipsize
属性来使用它。至少对于单行文本来说,这样做效果很好。
安卓用的省略号不是三个句号。相反,它使用了一个实际的省略号字符,其中三个点包含在一个字形中。因此,您使用的任何也使用“椭圆化”功能的字体都需要省略号标志符号。
然而,除此之外,Android 填充了呈现在屏幕上的字符串,使得长度(以字符为单位)在椭圆化前后是相同的。为了做到这一点,Android 用省略号替换一个字符,用 Unicode 字符ZERO WIDTH NO-BREAK SPACE (U+FEFF)
替换所有其他被删除的字符。因此,省略号后面的额外字符不会占用屏幕上的任何可见空间,但它们可以是字符串的一部分。然而,这意味着任何用于与android:ellipsize
一起使用的TextView
部件的定制字体也必须支持这种特殊的 Unicode 字符。并不是所有的字体都是这样,如果你的字体缺少这个字符(例如,流氓 X s 出现在行尾),你会在屏幕上看到你缩短的字符串。这种方法的另一个副作用是,预测任何字符串长度算法(比如一个简单的length()
Java 调用)是否会返回您期望的值是有风险的。这几乎是反直觉的,但 Android 试图给你一个一致的答案,而你以前使用 Java 和字体的经验可能会让你期望不同的结果。
当然,Android 的国际部署意味着你的字体必须处理用户可能希望输入的任何语言,也许是通过特定语言的输入法编辑器。
因此,虽然在 Android 中使用自定义字体是非常可能的,但也有许多潜在的问题,所以你必须仔细权衡自定义字体的好处和潜在的成本。
四十三、更多开发工具
Android SDK 不仅仅是一个 Java 类和 API 调用库。它还包括许多有助于应用开发的工具。当然,Eclipse 倾向于主导讨论。然而,这并不是您可以使用的唯一工具,所以让我们快速浏览一下您还可以使用哪些工具。
层次查看器:你的代码有多深?
Android 附带了一个层次查看器工具,旨在帮助您可视化您的布局,就像在运行的仿真器中的运行活动中看到的一样。因此,举例来说,您可以确定某个小部件占用了多少空间,或者尝试找到没有出现在屏幕上的小部件隐藏在哪里。
要使用 Hierarchy Viewer,首先需要启动仿真器,安装应用,启动“活动”,并导航到要检查的位置。请注意,您不能在生产 Android 设备上使用层次查看器。
您可以通过 Android SDK 安装中的tools/
目录下的hierarchyviewer
程序启动层次结构查看器,或者从 Eclipse 内部启动。主窗口如图 43–1 所示。
图 43–1。 层级查看器主窗口
该表的根显示了当前在您的开发机器上运行的模拟器实例。树叶代表在特定仿真器上运行的应用。您的活动将通过应用包和类别(如com.commonsware.android.files/...
)来识别。
当你选择一个窗口并点击 Load View Hierarchy 时,事情变得有趣了。几秒钟后,细节映入眼帘,如图 43–2 所示。
图 43–2。 层级查看器布局视图
布局视图的主要区域显示了一个由各种小部件和组成您的活动的东西组成的树,从整个系统窗口开始,一直到用户将与之交互的各个 UI 小部件。这包括您的应用定义的小部件和容器,以及系统提供的其他小部件和容器,包括标题栏。
单击其中一个视图可为该透视图添加更多信息,如图 Figure 43–3 所示。
图 43–3。 层次查看器查看属性
现在,在层次查看器的中右区域,您可以看到所选小部件或容器的属性,以及渲染该容器及其子容器所用时间的计时细节。
此外,小部件在活动的线框中以红色突出显示,显示在属性下面(默认情况下,视图显示为黑色背景上的白色轮廓)。这可以帮助您确保选择了正确的小部件,比如说,如果您有几个按钮,并且不能从树中轻易地判断出哪个按钮是哪个按钮。
您还可以在层次结构查看器主窗口中执行以下操作:
将树形图保存为 PNG 文件
将 UI 保存为 Photoshop PSD 文件,不同的小部件和容器使用不同的图层
如果您对数据库或应用的内容进行了更改,并且需要一个新的图表,请强制 UI 在模拟器中重新绘制或重新加载层次结构
您可以单击检查屏幕截图,而不是在主窗口中单击加载视图层次。这将层次查看器置于一个全新的视角,称为像素完美视图,如图 Figure 43–4 所示。
图 43–4。 层次浏览器像素完美视图
在左侧,您会看到一个树,表示您活动中的小部件和其他View
。在中间,您可以看到您的活动的放大视图,它以正常大小显示在右侧。
覆盖在活动上的十字光标显示被缩放的位置。只需点击一个新的区域来改变你所看到的。有一个控制缩放级别的滑块。单击像素还会指示该像素的位置和颜色。
如果您选中工具栏中的“自动刷新”复选框,层次查看器将定期轮询并从您的活动中重新加载 UI,频率由另一个滑块控制。
DDMS:在安卓的引擎盖下
Android 开发者的另一个工具是 Dalvik 调试监控服务(DDMS)。这就像一把瑞士军刀,允许您做任何事情,从浏览日志文件,更新模拟器提供的 GPS 位置,模拟来电和消息,以及浏览模拟器上的存储以推送和拉取文件。
要启动 DDMS,运行 Android SDK 发行版中tools/
目录下的ddms
程序,或者在 Eclipse 中打开 DDMS 透视图。它最初会在左边显示一个模拟器和运行程序的树,如图 Figure 43–5 所示。
图 43–5。 DDMS 初始视图
单击仿真器允许您浏览底部的事件日志,并通过右侧的选项卡操纵仿真器,如 Figure 43–6 所示。
图 43–6。 DDMS,用模拟器选中
伐木
DDMS 让你在一个可滚动的表格中查看你的日志信息,而不是使用adb logcat
。只需突出显示您想要监控的仿真器或设备,屏幕的下半部分就会显示日志。
此外,您可以执行以下操作:
按五个日志记录级别中的任何一个筛选日志选项卡,显示为工具栏按钮 V 到 E。
创建一个自定义过滤器,这样您就可以通过点击+工具栏按钮并完成表格(如图 Figure 43–7 所示)来查看那些带有您的应用标签的条目。您在表单中输入的名称将被用作 DDMS 主窗口底部的另一个日志输出选项卡的名称。
将日志信息保存到一个文本文件中,以便以后阅读或搜索。
图 43–7。 ??【DDMS】测井过滤器
文件推拉
虽然你可以使用adb pull
和adb push
从模拟器或设备获取文件,但 DDMS 可以让你直观地做到这一点。只需突出显示您希望使用的仿真器或设备,然后从主菜单中选择Device
File Explorer
。这将打开典型的目录浏览器,如图 Figure 43–8 所示。
图 43–8。 DDMS 文件浏览器
只需浏览到所需的文件,然后单击工具栏上的“拉”(最左边)或“推”(中间)按钮,将文件传输到开发机器或从开发机器传输文件。要删除文件,请单击删除(最右边)工具栏按钮。
使用文件资源管理器有一些注意事项:
您不能通过此工具创建目录。您将需要使用adb shell
或者从您的应用中创建它们。
虽然你可以在模拟器上浏览大部分文件,但由于 Android 的安全限制,你只能在实际设备上访问/sdcard
之外的很少内容。
截图
要获取 Android 模拟器或设备的屏幕截图,请切换到 Eclipse 中的 DDMS 透视图,并按下屏幕截图工具栏按钮(显示为相机)。这将弹出一个包含当前屏幕图像的对话框,如 Figure 43–9 所示。
图 43–9。 DDMS 截屏
在这里,您可以单击“保存”将图像作为 PNG 文件保存在开发计算机上的某个位置,单击“刷新”根据模拟器或设备的当前状态更新图像,或者单击“完成”关闭对话框。
位置更新
要使用 DDMS 为您的应用提供位置更新,您必须做的第一件事就是让您的应用使用gpsLocationProvider
,因为这是 DDMS 将要更新的。然后,单击“模拟器控件”选项卡,向下滚动到“位置控件”部分。在这里,您会发现一个更小的选项卡窗格,其中有三个选项用于指定位置:手动、GPX 和 KML,如图 Figure 43–10 所示。
图 43–10。 DDMS 位置控件
要使用 Manual 选项卡,请提供纬度和经度,然后单击 Send 按钮将该位置提交给模拟器。仿真器将依次通知任何位置监听器新的位置。
拨打电话和发送信息
如果你想在 Android 模拟器上模拟来电或短信,DDMS 也可以处理。在仿真器控制选项卡上,位置控制组上方是电话操作组,如图 Figure 43–11 所示。
图 43–11。 ??【DDMS】电话控制
要模拟来电,请填写电话号码,选择语音单选按钮,然后单击呼叫。此时,模拟器将显示来电,允许您接听或拒绝,如图 Figure 43–12 所示。
图 43–12。 模拟来电
要模拟收到的文本消息,请填写电话号码,选择 SMS 单选按钮,在提供的文本区域中输入消息,然后单击发送。文本消息将作为通知出现,如图 Figure 43–13 所示。
图 43–13。 模拟短信
当然,您可以点击通知,在成熟的消息应用中查看消息,如图 Figure 43–14 所示。
图 43–14。 模拟短信,在短信应用中
内存管理
DDMS 还可以帮助您诊断与应用如何使用内存(尤其是堆空间)相关的问题。
在 Sysinfo 选项卡上,您可以看到仿真器整体内存分配的饼图,如图 Figure 43–15 所示。
图 43–15。 DDMS 内存使用图表
在 Allocation Tracker 选项卡上,您可以记录您的代码(或您在 Android 内部调用的代码)每次分配内存的时间。只需在树表中突出显示您的应用的进程,然后单击 Start Tracking 按钮。当您想查看自单击开始跟踪以来您已经分配了什么时,单击获取分配按钮,这将填写一个表格,显示每次分配、分配了多少内存以及内存在代码中的分配位置,如图 Figure 43–16 所示。
图 43–16。 DDMS 分配追踪器
此外,您甚至可以通过 Dump HPROF 选项为您的应用转储整个堆,这是一个工具栏按钮,看起来像一个半空的罐子,右边有一个红色的向下箭头。产生的 HPROF 文件可以与 MAT(Eclipse 的一个插件)一起使用,以查看哪些对象仍然在堆上,以及是谁导致它们停留在堆上。
在转储 HPROF 文件之前,您可能希望在您的进程上强制运行垃圾收集。您可以通过单击看起来像经典金属垃圾桶的工具栏按钮来实现。
亚行:像 DDMS,打字多
Android 调试桥或adb
实用程序有两个作用:
在幕后,它充当模拟器/设备和其他工具之间的桥梁。例如,ADT、层次结构查看器和 DDMS 都通过adb
桥与仿真器通信。这个桥以守护进程的形式出现,在您上次重启后第一次尝试使用这些工具时产生。
它为其他工具的许多功能提供了命令行等价物,特别是 DDMS。
您可以使用adb
做的一些事情包括:
启动(adb start-server
)或停止(adb kill-server
)上述守护进程
列出目前可见的所有已识别的 Android 设备和模拟器(adb devices
)
在您的设备或模拟器中访问 Linux 外壳(adb shell
)
在您的设备或模拟器上安装或卸载 Android 应用(adb install
)
将文件复制到模拟器中(adb push
)或从模拟器中(adb pull
),很像 DDMS 的文件浏览器
检查 LogCat ( adb logcat
)
获取图形
与 Android 4.0 (ADT 版本 14 和 15)一起发布的最新版本的 Android 开发者工具引入了新的 Android Asset Studio。这个配套工具旨在让您快速轻松地创建图形素材,如启动器图标、操作栏和选项卡图标等,并处理创建一组连贯图像的许多繁琐方面,这些图像在每个可能的设备大小、分辨率等方面都很好看。
至少有两种方法可以访问和使用 Android Asset Studio。对于铁杆开发者来说,这些代码是开源的,可以在 Google Code 的[
code.google.com/p/android-ui-utils/](http://code.google.com/p/android-ui-utils/)
上获得。您可以下载代码,并构建您的解决方案,为您提供在您的环境中运行的 Android Asset Studio 实例。这种方法的缺点是 Android Asset Studio 仍然被标记为测试版,您很可能会发现自己遇到的问题仍然在开发中得到纠正。
注意: 作为一个尚未纠正的问题的例子,作者可以通过试图在 Firefox 下的启动器图标上添加文本来不断地发送他的图形卡混乱。
另一种方法是从您最喜欢的浏览器中使用 Android Asset Studio 的托管版本。Android Asset Studio 的开发者建议你使用 Chrome 作为浏览器,但是你也可以使用其他浏览器。
Android Asset Studio 的托管版本目前在[
android-ui-utils.googlecode.com/hg/asset-studio/dist/index.html](http://android-ui-utils.googlecode.com/hg/asset-studio/dist/index.html)
。在您的浏览器中打开该页面,您会看到 Android Asset Studio 当前可用的选项,如图 Figure 43–17 所示。
图 43–17。 安卓素材工作室首页
例如,选择创建一个启动器图标会将你带到一个非常简单的调色板,在这里你可以看到 Android 大小的图标,如ldpi
、mdpi
、hdpi
和xhdpi
,当你向新图标添加图形和文本时,它们会变得栩栩如生。图 43–18 展示了一些立竿见影的效果(以及你的作者并非下一个毕加索的事实)。
图 43–18。 在安卓素材工作室 创建启动器图标集
当你完成杰作的创作后,你可以下载这些资源并把它们放在项目的相关res/
文件夹中。
四十四、替代环境的作用
你可能会认为 Android 全是 Java。官方的 Android 软件开发工具包(SDK)是用于 Java 开发的,构建工具是用于 Java 开发的,Android 讨论组和博客帖子都是关于 Java 的,是的,大多数 Android 书籍都是用于 Java 开发的。见鬼,这本书的大部分都是关于 Java 的。
然而(向威廉姆·高德曼道歉),Android 只是大部分是 Java。大部分 Java 和全 Java 有很大的区别。大多 Java 稍微不是 Java。
因此,虽然 Android 的“最佳点”在短期内仍将是基于 Java 的应用,但您仍然可以使用其他技术创建应用。本章和接下来的三章将介绍一些替代技术。
本章首先分析了 Android 以 Java 为中心的策略的利弊。然后,它列举了一些原因,为什么你可能想使用其他东西的 Android 应用。还讨论了替代 Android 应用环境的缺点——缺乏支持和技术挑战。
起初,有 Java...
Android 核心团队在选择 Java 的时候,做了一个相当合理的语言选择。它是一种非常流行的语言,在移动社区中,它的前身是 Java 2 Platform,Micro Edition (J2ME)。由于缺乏对内存地址(所谓的指针)的直接访问,基于 Java 的应用不太容易出现开发人员的错误,这些错误可能会导致缓冲区溢出,并使应用暴露在可能的攻击之下。围绕 Java 有一个相当健壮的生态系统,包括教育材料、现有的代码库、集成开发环境(ide)等等。
然而,虽然你可以用 Java 语言编写 Android 程序,但 Android 设备不能运行 Java 应用。相反,您的 Java 代码被转换成在 Dalvik 虚拟机上运行的东西。这类似于用于常规 Java 应用的技术,但 Dalvik 是专门为 Android 环境调整的。此外,它将 Android 对 Java 本身的依赖限制在少数编程工具上,这一点很重要,因为 Java 的管理权从 Sun 转移到 Oracle,再到 anywhere。
Dalvik 虚拟机还能够运行来自其他编程语言的代码,这一特性使得本书涵盖的大部分内容成为可能。
...这很好
没有一个移动开发环境是完美的,Java 和 Android 的结合也不例外。
一开始,Java 是为 Dalvik 虚拟机实现的,它是被解释的,没有任何常规 Java 用来提高性能的实时(JIT)编译器技巧。这在移动领域是一个更大的问题,因为运行 Android 的设备往往不如普通的台式机、笔记本电脑或网络服务器强大。Android 2.3 增加了一个 JIT 编译器,帮助很大,但是相比原生编译代码还是比较慢。因此,有些事情你不能在装有 Java 的 Android 上做,因为它太慢了。
Java 使用垃圾收集使人们不必跟踪所有的内存分配。这在很大程度上是可行的,通常有利于开发人员的生产力。然而,它不是解决所有内存和资源分配问题的灵丹妙药。即使这些泄漏的精确机制不同于 C、C++ 和其他语言中的经典泄漏,Java 中仍然可能存在所谓的“内存泄漏”。
然而,最重要的是,并不是每个人都喜欢 Java。这可能是因为他们缺乏这方面的经验,或者他们有过这方面的经验,但并不喜欢这种经验。当然,Java 通常被视为大型企业系统的语言,因此不一定“酷”其他语言的拥护者对 Java 也有自己的烦恼(例如,对于 Ruby 开发人员来说,Java 实在是太冗长了)。
因此,尽管 Java 对 Android 来说是个不错的选择,但它也不是完美的。
逆势而为
仅仅因为 Java 是为 Android 构建应用的主要方式,这并不意味着它是唯一的方式,对你来说,它甚至可能不是最好的方式。
也许 Java 不在你现有的技能范围内。您可能是一名 web 开发人员,更熟悉 HTML、CSS 和 JavaScript。有一些框架可以帮助你。或者,也许你已经开始接触服务器端脚本语言,比如 Perl 或 Python——也有办法将这些代码移植到 Android 上。或者也许你已经有了一堆 C/C++ 代码,比如游戏物理算法,用 Java 重写会很痛苦。您也应该能够重用这些代码。
即使你愿意学习 Java,你对 Java 和 Android APIs 的缺乏经验也可能会拖你的后腿。您也许能够用另一个框架更快地构建一些东西,即使将来您最终用一个基于 Java 的实现来替换它。快速开发和原型制作通常是很重要的,以便用最少的时间投入获得早期反馈。
当然,你可能会觉得 Java 编程很烦人。你不会是第一个,也不会是最后一个有这种想法的人。如果你把 Android 当成一种爱好,而不是你“日常工作”的一部分,享受乐趣将对你特别重要,你可能不会觉得 Java 很有趣。
幸运的是,与一些移动平台不同,Android 对构建应用的替代方式很友好。
支架,结构
但是,“友好”和“全力支持”是两回事。基于 Java 的开发的一些替代方案得到了核心 Android 团队的官方支持,例如通过原生开发工具包(NDK)的 C/C++ 开发和通过 HTML5 的 web 风格开发。一些公司支持基于 Java 的开发的替代方案。Adobe 支持 Adobe Integrated Runtime (AIR),最近还收购了以支持 PhoneGap 而闻名的 Nitobi(详见第四十六章),Rhomobile 支持 Rhodes,等等。标准机构支持其他替代方案。例如,万维网联盟(W3C)支持 HTML5。还有一些只是由几个开发商支持的小项目。
您需要自己决定这些支持级别中的哪一个能够满足您的要求。对于许多开发活动来说,支持不是大问题,但是在某些情况下,支持可能是最重要的(例如,企业应用开发)。
警告开发者
当然,在传统的 Java 环境之外进行 Android 开发也有它的问题,不仅仅是可以获得多少支持。
就处理器时间、内存或电池寿命而言,有些环境可能不如 Java 有效。C/C++ 总体来说可能比 Java 好,但是比如 HTML5 可能就差一些。根据你写的内容和它的使用量将决定这种低效率有多严重。
有些环境可能无法在所有设备上使用。现在,Flash 就是最好的例子;一些设备提供一定量的闪存支持,而另一些设备根本没有闪存支持。类似地,HTML5 支持仅从 Android 2.0 开始添加到 Android,因此运行旧版本 Android 的设备没有 HTML5 作为内置选项。
当新版本的 Android 出现时,你和官方支持的环境之间的每一层都使你更难确保与新版本的 Android 兼容。例如,如果您使用 PhoneGap 创建一个应用,并且一个新的 Android 版本变得可用,那么可能会出现不兼容的问题,只有 PhoneGap 团队能够解决。虽然他们可能会很快解决这些问题——并且他们可能会为您提供一些隔离这些不兼容性的措施——但响应时间是您无法控制的。在某些情况下,这不是问题,但在其他情况下,这可能对您的项目不利。
因此,仅仅因为你在 Java 之外开发并不意味着一切都是完美的。您只需在这些问题和基于 Java 的开发可能给您带来的问题之间进行权衡。平衡点在哪里取决于每个开发商或公司。
四十五、HTML5
在当前对移动应用的兴趣浪潮之前,当前的技术是 web 应用。很多注意力都放在了 AJAX、Ruby on Rails 和其他技巧和技术上,这些技巧和技术使得使用 web 应用的体验接近,有时甚至优于使用桌面应用的体验。
web 应用的爆炸最终推动了 web 标准的下一轮增强,统称为 HTML5。Android 2.0 是第一个支持这些 HTML5 增强功能的版本。值得注意的是,Android 支持离线应用和网络存储,这意味着 HTML5 成为创建 Android 应用的相关技术,而无需处理 Java。
离线应用
在 Android 或其他地方使用 HTML5 进行离线应用的关键是,当没有互联网连接时,可以在客户端(例如,在没有 Wi-Fi 的飞机上)或服务器端(例如,由于 web 服务器维护)使用这些应用。
这是什么意思?
从历史上看,web 应用有这种讨厌的趋势,需要 web 服务器。这导致了离线使用的各种变通方法,包括运送 web 服务器并将其部署到桌面。
HTML5 通过允许网页指定自己的缓存规则解决了这个问题。web 应用可以发布一个缓存清单 ,描述哪些资源
可以安全地缓存,这样,如果 web 服务器不可用,浏览器可以使用缓存的副本。
不能被安全地缓存,这样,如果 web 服务器不可用,浏览器就会像往常一样失败。
拥有一个“后备”资源,这样,如果 web 服务器不可用,应该使用缓存的后备资源。
对于移动设备来说,这意味着一个完全支持 HTML5 的浏览器应该能够预先加载所有的资源并保持缓存。如果用户失去连接,应用仍将运行。在这方面,web 应用的行为几乎与常规应用相同。
你如何使用它?
这一章,我们将使用亚历克斯·吉普森创造的Checklist
“迷你应用”。虽然可以在 MiniApps 网站([
miniapps.co.uk/](http://miniapps.co.uk/)
)上找到该应用的最新版本,但本章将回顾在 Apress 网站([www.apress.com](http://www.apress.com)
)的源代码/下载区中找到的HTML5/Checklist
副本。这份拷贝也在线托管在 CommonsWare 网站上,你可以很容易地通过简短的网址[
bit.ly/cw-html5](http://bit.ly/cw-html5)
直接找到它。
关于示例应用
Checklist
顾名思义,是一个简单的清单应用。当您第一次启动它时,列表将是空的,如图 Figure 45–1 所示。
图 45–1。 清单 app,初始启动
您可以在顶部字段中输入一些文本,然后点击添加按钮将其添加到列表中,如图 Figure 45–2 所示。
图 45–2。 检查表,增加一项
您可以“勾选”个别项目,然后以删除线显示,如图 Figure 45–3 所示。
图 45–3。 检查表,其中一项标记为已完成
您也可以删除选中的条目(通过删除选中的按钮)或所有条目(通过删除全部按钮),在继续之前会弹出一个确认对话框,如 Figure 45–4 所示。
图 45–4。 清单删除确认对话框
在您的 Android 设备上“安装”清单
要在您的 Android 设备上访问Checklist
,请访问位于[
bit.ly/cw-html5](http://bit.ly/cw-html5)
的托管版。然后你可以为它添加一个书签(在浏览器的选项菜单中选择更多 添加书签),以便以后返回。
如果你愿意,你甚至可以在主屏幕上设置书签的快捷方式——只需长按背景,选择书签,然后选择你之前设置的Checklist
书签。
检查 HTML
Checklist
应用中的所有功能都是通过几行 HTML 代码实现的:
`
Checklist
`
`