安卓 4 入门指南(五)
三十一、使用偏好设置
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>:
`<header android:icon="@drawable/something" android:title="Fancy Stuff" android:summary="Click here to transcend your plane of existence">
`然后,只要您有一个带有指定您想要的动作(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="schemas.android.com/apk/res/and…" android:layout_width="fill_parent" android:layout_height="fill_parent"
<TextView android:text="Checkbox:" android:paddingRight="5dip" /> <TextView android:id="@+id/checkbox" /> <TextView android:text="Ringtone:" android:paddingRight="5dip" /> <TextView android:id="@+id/ringtone" /> `
该表的字段位于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-linkURL,并将它们填充到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("commonsware.com/misc/test.m…");
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()相同的两部分作用:
- 确定要与之通信的服务
- 以
Intentextras 的形式提供参数,告诉服务它应该做什么
对于一个本地服务(本书的重点),最简单的Intent形式是标识实现Intent的类(例如,new Intent(this, MyService.class);)。
对startService()的调用是异步的,所以客户端不会阻塞。如果服务还没有运行,它将被创建,并通过调用onStartCommand()生命周期方法来接收Intent。在onStartCommand()中,服务可以做任何它需要做的事情,但是因为onStartCommand()是在主应用线程上被调用的,所以它应该很快完成它的工作。任何可能需要一段时间的事情都应该委托给后台线程。
onStartCommand()方法可以返回几个值中的一个,主要是向 Android 指示如果服务的进程在运行时被终止会发生什么。最有可能的返回值如下:
START_STICKY:服务应该被移回启动状态(就像onStartCommand()被调用一样),但是Intent不应该被重新交付给onStartCommand()START_REDELIVER_INTENT:应该通过调用onStartCommand()来重启服务,提供与这次交付的相同的IntentSTART_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("commonsware.com/Android/exc…")); 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); } }`